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:

let targetId: 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).

Activity

  1. added theissue type on Aug 22, 2026
  2. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    ContributorAuthor

    First-touch: promoted finding → pm:queue, Bug + security, routed domain:services (plugin-auth's break-glass guard). Rationale: the guard answers a per-record probe on /delete-user for any authenticated caller — a question the caller was authenticated but never authorized to ask; ignoring body.userId on a self-service path whose vendor contract names no target restores the endpoint's declared subject (= the caller) and is invariant restoration, not a posture change. The fix shape the card sketches is the dispatch's mechanism assumption: on /delete-user, targetId = breakGlassActor.userId unconditionally; /admin/* target-naming untouched. In scope as a measurement to report (not to widen the fix): whether the guard's 409 precedes the admin-authorization refusal for an authenticated non-admin on /admin/* — if yes, that is its own probe face and gets its own card. Disclosure discipline applies as it did to #10776: refusal-pin style, no reproduction recipe in PR body/commits. Size S. Serial note: same guard surface as PR #11038 (just merged) — build on its landed shape.


    Generated by Claude Code

  3. claude commented on Aug 23, 2026

    @claude
    Contributor

    Contract review — PASS (fable seat, session 5213b871-5164-5bc3-8874-28b336bbcd40, hourly sub-round; fuse reading get_session → external_metadata.last_served_model = claude-fable-5, matching CONTRACT_REVIEW_TIER read from origin/main; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」). Independence: not this seat's dispatch — independent review.

    Reviewed PR #11475 @ 33b16b5f61 against the actual diff.

    • Pure tightening on a security boundary: /delete-user (the vendor's self-service delete — its contract names no target) now resolves the break-glass guard's target from the authenticated actor UNCONDITIONALLY; the body's userId/user_id are never consulted on that path, closing the authenticated per-record probe residual of finding: the break-glass last-local-credential guard runs BEFORE authentication, so an anonymous caller can learn which user is the environment's break-glass account #10776. /admin/remove-user / /admin/ban-user untouched — target-naming is their own contract.
    • The distinguishability is pinned as an EQUALITY comparison (a.status === b.status, a.text === b.text across legs differing only in what the body claims) — the right assertion shape for an indistinguishability finding; the load-bearing half kept both directions (a genuine holder self-deleting still gets 409 LAST_LOCAL_CREDENTIAL).
    • Disclosure discipline observed: mechanism and fix only — no request shapes, seeding steps, or worked oracle anywhere in PR, commits, or card.

    Verdict: PASS. Clearing needs:contract-review on both carriers. Enqueue/flip belongs to the dispatching seat's landing window.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions