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 finding — method=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.
Summary
check_no_admin_idor.py's_GUARD_BODY_REacceptsHttp::STATUS_UNAUTHORIZEDas 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: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 onpipelinq/lib/Controller/LoyaltyController.php@206db4be(a copy in a scratch dir; no checkout modified).getAccount,getAccountHistory,getRedemptionOptions,initiateRedemption)plantBareUnguarded(string $accountId):#[NoAdminRequired], reads the account by the URL id, no auth of any kindmethod=plantBareUnguarded rule=no-auth-guard-in-bodyplantAuthenticatedButUnauthorized(string $accountId): byte-identical body, preceded only by the 401 preamble aboveArm 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: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]andHttp::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
UNAUTHORIZEDand401from_GUARD_BODY_RE, keepFORBIDDENand403. A 403 genuinely is an authorisation decision; a 401 is not, by definition..github#347,#356and#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.