fix(gate-7): an authentication check is not an authorisation guard (#365) - #373
Conversation
) gate-7 reported 0 in ALL EIGHTEEN fleet apps. Every one of those zeros was produced by a checker that accepts "is anyone logged in?" as an answer to "may THIS caller touch THIS object?". Reproduced with a three-arm committed-plant control — same file, same commit, same run, byte-identical data-access bodies, only the preamble differing: bare unguarded method 1 finding + a no-user 401 preamble, nothing else 0 findings + a real per-object ownership check 0 findings (correct) Scale at the canonical package 57bcb2b: 0 reported fleet-wide while 453 of 791 controller files carry that preamble. Positive-controlled in all 18 apps by planting a bare IDOR into each extracted tree: the checker went 0 to 1 in every one, so each zero is a measurement and not an empty scope. Under NoAdminRequired the framework has already rejected the anonymous caller before the method runs, so the preamble cannot even fail. It was nonetheless silencing the gate through three guard regexes at once. THE FIX IS NOT "DROP 401 FROM THE REGEX", which is what the issue proposes. That repair is wrong in both directions, and both are now pinned by tests: too wide - a real ownership comparison that answers 401 instead of 403 is a genuine guard, and deleting the token makes it a false positive. False positives are how gate-7 lost its credibility and therefore how its silences came to be believed. too narrow - the same authentication clause answering 403 keeps clearing. One token of edit, made by someone chasing a green cell, buys the silence back by making the response worse. So authentication-ness is treated as a property of the CONDITION, not of the status code. An if whose condition tests only whether a caller identity is absent, and whose consequent refuses, is blanked to same-length whitespace before any guard pattern runs. Offsets, spans and line numbers are unchanged. Three controls keep the blanking narrow: absence polarity only, so a presence test wrapping a whole method body is never eaten; argument-free operands only, so a guard call and object data are never read as an identity; and the consequent must refuse. Two companion patterns ship in the same change because removing the clear is what EXPOSES them, and shipping without them would have manufactured 45 false positives in doriath alone - an app whose real gate-7 exposure is zero: Pattern 6 session-identity hand-off. Every call receiving a caller-supplied value must also receive a session-derived identity, so the object reference is resolved under a scope the caller cannot forge. An ALL-quantifier, not ANY: one unscoped data call still reports. Pattern 7 in-body ownership comparison, whatever status it answers with, including the deliberate 404 chosen so a 403 cannot become an existence oracle. That sentence was already in this gate's own FAIL message; it was advice to a human and is now code. Fleet effect, measured over canonical development clones: 0 becomes 327 findings across 18 apps. Pattern 6 suppresses 86 and Pattern 7 suppresses 2 of what the naive repair would have reported. The audit's hand-verified real findings all survive: shillinq arbitrary-file-read download, pipelinq semantic handoff, docudesk cross-register preview, decidesk motion history, opencatalogi unscoped search, zaakafhandelapp object routes. Coverage: a new authn-vs-authz acceptance bundle drives the control through the real wrapper. Both arms KEEP the 401 preamble, so the clean arm pins that the preamble is ignored rather than punished. Its expect.conf names method=preamble rather than the file, because the bare arm would name the file under the old checker too - verified by running the bundle against the pre-fix checker, where it goes red on exactly that assertion. Four unit tests that encoded the defect are inverted with the reason recorded in each; one of them, the decidesk citizenAction case, sits beside staffAction in the same collaborator and only staffAction is an authorisation guard. 113 unit tests pass, 74 helper suites pass, 61 entry-point tests pass. The one red suite, test_gate_45_to_55_acceptance.sh, fails identically on the unmodified canonical clone: ajv is not resolvable in this environment.
|
CI settled: 16 pass, 15 skipping, none failing, none pending — count stable at 31 across eleven consecutive polls, since a check list read too early is trivially all-non-pending. The job that matters is One correction to the description: the gates 45-to-55 acceptance suite, reported above as red locally, is green in CI. It refuses to run when ajv is unresolvable rather than emit a verdict it cannot support, so the local red was the machine and not the change — and CI is what establishes that rather than my having said so. Still deliberately not merged. |
…t can reach is public" The second of gate-7's two blinding exemptions. `#[PublicPage]` was skipped unconditionally, commented "intentionally open to unauthenticated callers" — a claim about the CALLER, where the gate's question is about the OBJECT. It carried no stated reason, so there was nothing to audit. REPRODUCED at package 57bcb2b on a nine-arm rig, one file, one commit, one run: a byte-identical IDOR plant carrying `#[PublicPage]` — including an UNAUTHENTICATED WRITE to an arbitrary `$id` — reported PASS. AND THE EXEMPTION IS THE SMALLER HALF. Deleting it and changing nothing else moves only the arm that carries BOTH attributes. A `#[PublicPage]`-only method never reaches it: the `_NO_ADMIN_RE` scope filter drops it one branch earlier. Across the fleet's lib/Controller trees that is 267 of 357 public methods — 74% — invisible to any edit of the exemption. So the scope filter widens too. It is demonstrably not safe by construction. opencatalogi#856, merged as 963f832a, was exactly this shape: `GET /api/themes/{id}` was `@PublicPage` and its `find()` carried no register and no schema, so it resolved the caller's identifier in ANY register on the instance and served an anonymous caller a municipal `zaak` status record. THE PREDICATE IS NOT THE `#[NoAdminRequired]` ONE. There is no current user to compare an owner against, so ownership guards do not transfer. A public handler must CONSTRAIN THE LOOKUP TO A SCOPE IT DECLARES PUBLIC rather than resolve the identifier globally. Five clears, each derived from a shape read in a fleet app: a non-caller argument that carries a value; a callee declaring publicness as a complete CamelCase segment; a receiver already scoped as state; a container resolution whose result constrains a later lookup and whose miss is refused; and one resolved delegation hop. Plus a publicness or scope predicate answered with a refusal, per selector rather than per call. TWO-DIRECTIONAL CONTROL, on the real endpoint rather than only the rig: opencatalogi `ThemesController::show` is FLAGGED at f6e86ab and SILENT at 963f832 — same file, same method, same checker, the security fix in between. ALSO HERE, and load-bearing: a helper was guard-bearing if its body matched a bare `throw`, matched against source WITH COMMENTS IN IT. hermiq's `loadOwnedAgent()` contains no throw statement at all — the word appears only in a comment explaining why it CATCHES one — and that cleared all four routed methods calling it, and cleared opencatalogi's `getObjectService()` for every caller in the app, which is why Pattern 8 could not see the endpoint that motivates it. The body test now reads comment-free text and a `throw` must name an authorisation exception; an ownership comparison answered with `null` is recognised instead, so no true clear was lost (hermiq stays at 20, doriath at 5). `TemplateResponse` no longer clears a `@PublicPage` method: its stated reason is that NC guarantees a session, and `@PublicPage` is the annotation that turns that guarantee off. Findings get their own rule name, `publicpage-unscoped-object-lookup`, because the remedy differs. `@no-admin-idor-exempt <reason>` still exempts — a reason-bearing exemption replacing a reasonless blanket one. BLAST RADIUS, 18 canonical clones, identical file lists: 307 -> 315. Six `publicpage-unscoped-object-lookup` (openregister 3, pipelinq 1, portaliq 1, softwarecatalog 1) and two from the helper-evidence repair (opencatalogi 1, launchpad 1). doriath — the app that gets scoping right — stays at 5. hermiq stays at 20. Tests: 113 -> 130 unit tests, 9 of the 17 new ones red against the pre-fix checker. New repo-shaped acceptance bundle `publicpage-scope` (#359), verified to go RED on `method=arbitraryId` against the pre-fix checker, so it proves the fix rather than merely passing.
…a subject (#404) Three of the ten acceptance bundles could not detect the regression they exist to prevent. All ten go red on a FULL revert -- which is the test anybody actually runs, and which is why nobody noticed. Three were blind to a PARTIAL one, and one of those was blind to precisely the defect it was built for. `_names` is `grep -qF`. gate-7's log line is `path:LINE method=NAME rule=RULE`, so `method=preamble` is CONTAINED IN `method=preambleForbiddenCode`. Reverting only the 401 half of #365 removed preamble() from the log entirely and the suite printed `PASS -- gate-7 NAMES 'method=preamble'` and `ALL PASS`. #380's remedy -- one mechanism per named file -- would not have caught that: all three plants legitimately belong in one controller, because the fixture's whole argument is that the arms differ only by the preamble. The real invariant is that a subject must be unsatisfiable by every OTHER finding the bundle can produce. A method name is a prefix of every longer method name, so a name alone is never a discriminator. Every subject is now closed on the right by its ` rule=...` / ` calls a service method` field, one row per arm. A trailing space would NOT have worked: `read` strips trailing IFS whitespace from the last field, so `method=preamble ` collapses back and the bundle stays vacuous while looking fixed. The anchor has to be a token. Internal spaces DO survive, which is why the driver needed no change. authn-vs-authz 1 -> 3 rows (bare / 401 preamble / 403 preamble) auth-guards 1 -> 2 rows (was the FILE PATH, which both plants name) exception-translation 1 -> 2 rows (destroy header-binding / purge docblock) comment-silenced-guard NEW 3 mechanisms, 2 files, 2 rows The new bundle closes the third of gate-7's three proven blindnesses, which had no repo-shaped fixture at all -- only unit tests, i.e. exactly the layer this suite's own header argues is insufficient ("gate-7 has 86 unit tests. All 86 passed. The gate was still wrong."). Its three mechanisms revert separately: A the body test reads comment-free text. hermiq's loadOwnedAgent() contains no `throw` statement at all -- the word appears only in a comment explaining why it CATCHES one -- and that cleared all four routed methods. B a `throw` must NAME an authorisation exception. opencatalogi's getObjectService(), in 36 controller files across 7 apps, throws when OpenRegister is absent and so cleared every caller. C an ownership comparison answered with `null` IS recognised, so the narrowing costs no true clear. Visible only in the clean/ arm. Both arms keep the comment and keep the locator verbatim. #373 did not make prose illegal, it made prose uncountable; an arm that also deleted the sentence would have passed against the broken checker too. Twelve probes, twelve predictions written before the run, twelve matched exactly -- count and the identity of every failing assertion. 52 -> 70 assertions, 0 failed. Probes 4 and 7 are the two the vacuity audit measured at 28 passed / 0 failed, green over the exact defect; they are now one failure each, naming the lost plant. Probe 9b is its own result: gate-7's routed-method scan still reads comment-bearing text, so an explanatory docblock in a fixture can clear the very method the fixture plants. With mechanism A reverted, deleting ONLY the two comment lines restored the finding -- so the silencer is the comment and nothing else in the file. Every per-method docblock in the new fixture is austere for that reason. Not closed, and said so in the bundle header: `scan_file` still matches _GUARD_BODY_RE against comment-bearing text, so a comment in a ROUTED method containing Http::STATUS_FORBIDDEN / `, 403)` still clears it. This bundle pins the helper path only. a11y-noise and debug-and-conflict (#380) have not been revert-audited by anyone. Coverage is unchanged at 22 of 65 gates fixtured. Only hydra-gates/scripts/test-fixtures/gate-acceptance/ is touched. No checker, no runner, no driver. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Closes #365.
What was wrong
gate-7 reported zero in all eighteen fleet apps. Every one of those zeros came
from a checker that accepts "is anyone logged in?" as an answer to "may this
caller touch this object?".
Reproduced before touching anything, with a three-arm committed-plant control —
same file, same commit, same run, byte-identical data-access bodies, only the
preamble differing:
no user -> 401preambleScale, measured against the canonical package at
57bcb2b: zero reportedfleet-wide, while 453 of 791 controller files carry that preamble.
Positive-controlled in all 18 apps. A zero from a gate looks exactly like an
empty scope, so a bare
#[NoAdminRequired]IDOR was planted into each extractedtree and the canonical checker re-run: it moved from 0 to 1 in every single one,
then every plant was removed and verified absent. Each zero is a measurement.
Under
#[NoAdminRequired]Nextcloud's middleware has already rejected theanonymous caller before the method runs, so that preamble cannot even fail. It
was nonetheless silencing the gate, through three separate guard patterns.
Why the fix is not "drop 401 from the regex"
That is what #365 proposes, and it is wrong in both directions. Both are now
pinned by tests.
Too wide. A genuine per-object decision that answers with the wrong status —
— is a real guard. Deleting the token turns it into a false positive, and false
positives (#353, #360) are precisely how gate-7 lost its credibility and how its
silences came to be believed.
Too narrow. The same authentication clause answering
403keeps clearing.One token of edit, made by someone chasing a green cell, buys the silence back by
making the response worse.
So this treats authentication-ness as a property of the condition, not of the
status code. An
ifwhose condition tests only whether a caller identity isabsent, and whose consequent refuses, is blanked to same-length whitespace
before any guard pattern runs. Byte offsets, spans and reported line numbers are
unchanged; only the text the guard patterns get to see is.
Three controls keep the blanking narrow, each closing a way it could have eaten a
real guard:
if ($user !== null) { ...whole body... }is a wrapper, and blanking it would erase every guard inside.
$this->access->canAccess($id, $user)and$account['ownerId']can never be read as an identity, however they arespelled. The first is a guard; the second is object data. This one was caught
by the existing suite, which went red on the first draft.
alone.
Identity recognition is deliberately spelling-agnostic. #365's own re-audit built
the opposite mistake into its triage tool — three enumerated spellings of "who is
the caller",
sessionUserId()matching none — and manufactured 19 false positivesfrom that gap.
Two companion patterns, and why they ship in the same PR
Removing the clear is what exposes them. Shipping without them would have
reported 45 findings in doriath alone, an app whose real gate-7 exposure was
hand-verified as zero.
caller-supplied value must also receive a session-derived identity, so the
object reference is resolved under a scope the caller cannot forge. An
ALL-quantifier, not ANY: one unscoped data call still reports, so an audit-log
line cannot clear a real IDOR. Suppresses 86 fleet-wide.
including the 404 chosen deliberately so a 403 cannot become an existence
oracle. That sentence was already printed by this gate's own failure guidance;
it was advice to a human and is now code. Suppresses 2 fleet-wide, and both
are endpoints an earlier audit named specifically as over-reports.
Blast radius
Canonical
developmentclones from GitHub, not local checkouts — three local appdirectories turned out to have no
.gitat all and silently resolved the parentrepository's ref.
bef3a7cccf0ff35d31898347f2a47cd256c67d5201445776bea4dc7edf815c3f6e86ab6772ff6bfd6da6d2a0a1c19e9b499704abd6da5dfb65cafbd8e49697This is a candidate count, not an exposure count. An earlier hand-audit of a
similar candidate list found per-app precision ranging from 95% to 0%. Read the
findings; do not quote 327 as a defect count.
Spot-checked that the fix does not swallow the real ones: shillinq's
arbitrary-file-read download endpoint, its SEPA mandate export, pipelinq's
semantic-handoff routes, docudesk's cross-register preview, decidesk's motion
history, opencatalogi's unscoped search, and zaakafhandelapp's object routes are
all still reported. procest's and openconnector's counts are low because both
landed real guards earlier today; the guards were read and confirmed rather than
assumed.
Coverage added
A new
authn-vs-authzbundle in the gate-acceptance matrix drives the controlthrough the real wrapper. Both arms keep the 401 preamble, so the clean arm
pins that the preamble is ignored, not punished — without that, "flag anything
containing a 401" would pass. Its
expect.confrequires the finding to namemethod=preamblerather than the file path, because the bare arm names the fileunder the old checker too; a file-level assertion would have been satisfied by a
checker with the defect intact.
Proven able to fail: running that bundle against the pre-fix checker turns it
red, on exactly that assertion, with the driver reporting that the gate failed for
some other reason and the fixture therefore proves nothing.
Four existing unit tests encoded the defect as correct behaviour and are inverted,
each carrying the reason. The sharpest is the decidesk
citizenAction()case: itsits beside
staffAction()in one collaborator, and onlystaffAction()is anauthorisation guard — the 401 arm was borrowing credibility from the 403 arm next
door.
Verification
identically on an unmodified canonical clone:
ajvis not resolvable in thisenvironment and the suite refuses to run rather than emit a false verdict. Not
caused by this change.
Not verified
endpoints here rests on reading source.
ignores it. This gate sees one method body by contract, so that residual is
symmetrical with the guidance already printed with its findings, and the
ALL-quantifier narrows it rather than removing it.
Merge
Deliberately not merged. It turns a green cell red in all eighteen apps at
once, and the sequencing is Ruben's call.
Extension, 2026-08-12 — gate-7's SECOND blinding exemption:
#[PublicPage]What was wrong
#[PublicPage]was skipped unconditionally, commented "intentionally open tounauthenticated callers". That is a claim about the caller, where the gate's
question is about the object. The exemption carried no stated reason, so
there was nothing to audit.
Reproduced first, on a nine-arm rig — one file, one commit, one run, predictions
written before the run. A byte-identical IDOR plant carrying
#[PublicPage],including an unauthenticated write to an arbitrary
$id, cleared the gate.It is not safe by construction. opencatalogi#856, merged as
963f832a, wasexactly this shape:
GET /api/themes/{id}was@PublicPageand itsfind()carried no register and no schema, so it resolved the caller's identifier in any
register on the instance and served an anonymous caller a municipal
zaakstatusrecord.
🔑 The exemption is the smaller half of the blinding
Deleting it and changing nothing else moves one arm in nine. A
#[PublicPage]method carrying no#[NoAdminRequired]never reaches theexemption — the scope filter drops it one branch earlier. Across the fleet that
is 267 of 357 public controller methods (74 %), including all 78 of
procest's. So the scope filter widens too.
The predicate
There is no current user, so ownership guards do not transfer. A public handler
must constrain the lookup to a scope it declares public rather than resolve
the identifier globally. In scope: a caller-supplied scalar reaches a selection
call —
array $datais a payload (portaliq's public forms are untouched), and a$token/$secretis the authorisation, not a selector. Cleared when every suchvalue reaches a constrained selection, by five spellings each read out of a fleet
app: a non-caller argument that carries a value (a bare literal such as
_rbac: truedoes not constrain —963f832a's own message says why); acallee declaring publicness as a complete CamelCase segment, so
getPublicationByIdis not mistaken for one; a receiver already scoped as state;a container resolution whose result constrains a later lookup and whose miss is
refused; and one resolved delegation hop. Judged per selector, not per call,
because "verify in scope, then act" is the commonest correct public shape.
Two-directional control, on a real endpoint
opencatalogi
ThemesController::show— same file, same method, same checker,the shipped security fix in between — is reported at
f6e86aband is silent at963f832.PublicationsController::show/uses/used/index, fixed by thesame commit, also go silent.
Also here, and load-bearing
A same-class helper counted as guard-bearing if its body matched a bare
throw,matched against source with comments in it. hermiq's
loadOwnedAgent()contains no
throwstatement at all — the word appears only in a commentexplaining why it catches one — and that cleared all four routed methods
calling it. opencatalogi's
getObjectService(), a service locator that throwswhen OpenRegister is absent, cleared every caller in the app, which is why the
new pattern could not see the endpoint that motivates it. The body test now reads
comment-free text, a
throwmust name an authorisation exception, and anownership comparison answered with
nullis recognised instead — so no trueclear is lost. hermiq is unmoved; so is doriath.
TemplateResponseno longer clears a@PublicPagemethod: its stated reason isthat Nextcloud guarantees a session, and
@PublicPageis the annotation thatturns that guarantee off.
Blast radius
18 canonical clones on current
development, identical file lists. The fleettotal moves by eight: six under the new rule name
publicpage-unscoped-object-lookup(openregister 3, pipelinq 1, portaliq 1,softwarecatalog 1) and two from the helper-evidence repair (opencatalogi 1,
launchpad 1). doriath — the app that gets scoping right — is unmoved, and so is
hermiq.
All eight were hand-read. One is a live exposure: openregister
NamesController::showresolves any identifier anonymously and returns its name,which is #856's shape one field narrower. One is a real pre-existing
#365-classfinding surfaced by the helper repair (opencatalogi
ListingsController::index).The other six are endpoints whose publicness is a claim — a public catalogue
keyed by a public identifier. The gate cannot tell those from an object store
without knowing which schemas are public, and that is the point: the remedy is
@no-admin-idor-exempt <reason>, which turns a reasonless blanket exemption intoan auditable one.
Coverage
Unit tests 113 → 130; nine of the seventeen new ones are red against the
pre-fix checker. New repo-shaped acceptance bundle
publicpage-scopeunder#359's matrix — both arms keep#[PublicPage]on every method, so the cleanarm pins that the annotation is ignored, not punished, and the subject is a
method name rather than a file path. Verified able to fail: run against the
pre-fix checker, the bundle goes red on exactly that assertion.
Not verified
No HTTP probe against a running instance — every app verdict rests on reading
source. Pattern 2's premise (that OpenRegister's RBAC decides for an anonymous
caller) is contradicted in prose by
963f832a's own commit message and remainsunaudited; withholding it from
@PublicPagemethods changes nothing onopenregister today, so it is not what masks those endpoints. Two pre-existing
weaknesses are named but not fixed here:
scan_filestill matches its guardpatterns against comment-bearing text, and an exception-to-status mapping inside
a
catchblock still reads as an authorisation decision.