fix(gate-7): an authentication check is not an authorisation guard (#365) - #373
fix(gate-7): an authentication check is not an authorisation guard (#365)#373rubenvdlinde wants to merge 1 commit into
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. |
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.