Skip to content

[finding] break-glass guard still answers a per-record probe for ANY authenticated caller via /delete-user body.userId — residual of the #10776 fix, no longer anonymous but not gone #11074

Description

@os-zhuang

Found during the clause-② contract review of PR #11038 (skills seat, session session_01MsbKEG4LtERSLaDrbehM3e, 2026-08-22). Filed unassigned as a finding — recording only, first-touch grading is triage's.

Observation

PR #11038 correctly closes the anonymous disclosure per the maintainer's Option-A ruling: the break-glass guard now runs only after resolveActor(ctx) establishes an identity. But inside the guard, target resolution is unchanged:

lettargetId: string|undefined=ctx?.body?.userId??ctx?.body?.user_id;if(!targetId&&ctx.path==='/delete-user')targetId=breakGlassActor.userId;

On /delete-user — a self-service path whose vendor contract names no target (the subject IS the caller) — the guard still prefers body.userId when present. So any authenticated caller (no admin role required; /delete-user sits outside the /admin/ lane) can POST {"userId": "<target>"} and read the answer off the refusal: 409 LAST_LOCAL_CREDENTIAL ⇒ the named user is the environment's break-glass holder; the vendor's ordinary response ⇒ not.

The guard queries and refuses on a target the endpoint itself would never act on — the lookup answers a question the caller was never authorized to ask, merely authenticated to ask.

Severity framing

Strictly narrower than #10776 (attacker needs any account in the org, e.g. self-signup deployments or any compromised low-privilege user) — which is why this is a residual finding, not a defect in PR #11038: the ruling was "authentication before the guard", implemented faithfully, and the pre-fix code had the same body-first preference with no authentication at all.

Shape of a fix, not a prescription

On /delete-user, ignore body.userId entirely and key the guard on the resolved actor (targetId = breakGlassActor.userId unconditionally for that path) — the endpoint's subject is the caller, so the guard should ask about no one else. The /admin/* paths are unaffected (target-naming is their contract, and adminMiddleware authorizes the caller before anything is served... though note the guard's 409 may still precede the admin-authorization refusal for an authenticated non-admin — worth measuring in the same pass).

Refs: #10776 (the anonymous half, fixed) · PR #11038 (the fix under review when this was found).

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions