Skip to content

gate-7 accepts a 401 authentication check as an authorisation guard — 4 real IDORs read as PASS (fleet-wide false negative) #365

Description

@rubenvdlinde

Summary

check_no_admin_idor.py's _GUARD_BODY_RE accepts Http::STATUS_UNAUTHORIZED as an authorisation guard. But a 401 is an authentication signal ("is anyone logged in?"), not an authorisation one ("may this caller touch this object?").

Nextcloud's idiomatic preamble for a #[NoAdminRequired] endpoint is exactly that check:

$user = $this->userSession->getUser();
if ($user === null) {
    return new JSONResponse(['error' => 'Authentication required'], Http::STATUS_UNAUTHORIZED);
}

Any method that opens this way is exempt from gate-7 for free, however unguarded the direct object reference that follows is. Since the preamble is the house style, this is not a rare shape.

Three-arm control on ONE file, changing only the guard shape

Canonical package ConductionNL/.github @ 81c8c97, checker invoked directly on
pipelinq/lib/Controller/LoyaltyController.php @ 206db4be (a copy in a scratch dir; no checkout modified).

arm file gate-7
A — unmodified controller, containing four real IDORs (getAccount, getAccountHistory, getRedemptionOptions, initiateRedemption) as shipped 0 findings
B — plus plantBareUnguarded(string $accountId): #[NoAdminRequired], reads the account by the URL id, no auth of any kind +14 lines 1 findingmethod=plantBareUnguarded rule=no-auth-guard-in-body
C — plus plantAuthenticatedButUnauthorized(string $accountId): byte-identical body, preceded only by the 401 preamble above +23 lines 0 findings

Arm B proves the gate is live on this exact file and this exact shape. Arm C, differing from B only by the 401 preamble, proves the preamble is what silences it.

Root cause

hydra-gates/scripts/lib/check_no_admin_idor.py:384-392:

_GUARD_BODY_RE = re.compile(
    r"OCSForbiddenException"
    r"|isAdmin\s*\("
    r"|->\s*(?:authorize|require|ensure)[A-Z][A-Za-z0-9_]*\s*\("
    r"|Http::STATUS_(?:UNAUTHORIZED|FORBIDDEN)"          # <-- UNAUTHORIZED
    r"|(?:statusCode:\s*|,\s*)(?:401|403)\b"             # <-- 401
    ...
)

The real IDORs this masked

All four are in pipelinq/lib/Controller/LoyaltyController.php, all #[NoAdminRequired], all take {accountId} straight off the URL and never scope it to the caller. getAccount's own docblock (:80) states "the caller MUST own the underlying klantId" — a contract written down and never implemented. LoyaltyAccountService::findAccountByKlantAndProgramme() exists as the means to scope it and is never called from the controller.

The worst is not a read leak: POST /api/loyalty/redemption/initiate/{accountId}/{optionId} returns 201, debits the victim's balance, and hands the caller the reward code. Filed against pipelinq separately.

Blast radius

In pipelinq alone: gate-7 reports 0 over the whole controller tree, and 49 of 82 controller files contain both #[NoAdminRequired] and Http::STATUS_UNAUTHORIZED. Every #[NoAdminRequired] method in those files that opens with the preamble is silenced regardless of what it does next. Other apps in the fleet use the same preamble.

Proposed fix — and why it should NOT be merged yet

Minimal and well-motivated: drop UNAUTHORIZED and 401 from _GUARD_BODY_RE, keep FORBIDDEN and 403. A 403 genuinely is an authorisation decision; a 401 is not, by definition.

⚠️ This has the false-RED shape: it will turn gate-7 red in many repos simultaneously, and it is the same sequencing hazard as .github#347, #356 and #358. Filing, not merging. The coordinator should sequence it, and every repo's existing "gate-7 clean" should be treated as unverified until it lands.

🔑 Related, and the reason this went unnoticed: gate-7's known problems so far (.github#353) were all false positives — a guard the regex failed to recognise. This is the opposite and worse shape: a non-guard the regex accepts. A gate whose known failure mode is "too noisy" gets its silences trusted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions