fix(gate-48): a net-zero MOVE is not a removal, and 'no co-change in the diff' is not 'no co-change needed' - #318
Merged
Conversation
…the diff' is not 'no co-change needed'
Two defects, both measured on larpingapp.
1. A NET-ZERO MOVE COUNTED AS A REMOVAL
check_csrf_removal.py scanned only ^- lines and never asked whether an
identical ^+ line existed, so relocating a docblock line inside a file read as
deleting the annotation. larpingapp#297 moved one comment line while reordering
SettingsController's docblocks; the removed line is byte-identical to an added
one, and gate-48 kept Hydra Gates red on that repo's development branch from
then on, over a commit that changed no auth posture at all.
Cancellation is per FILE and by MULTISET: per file because a line deleted from
one controller and added to another is a real posture change for the first;
by multiset because removing a tag twice and restoring it once has removed it
once. Whitespace is NOT normalised — a re-indented line is not the same line,
and treating it as a move would let a reformat swallow a genuine deletion.
pre-fix removals() on the #297 diff -> 1
post-fix removals() on the #297 diff -> 0
2. THE GATE COULD NOT SEE A CALLER THAT WAS ALREADY COMPLIANT
The co-change question was asked of the DIFF. A PR whose callers have always
sent a token has no signal to add, so it could not pass; the only exits were a
waiver or staying red.
Measured on larpingapp#298, which closes a LIVE CSRF-forgery hole.
SettingsController::create() and reimport() carried
* @NoCSRFRequired removed to close the CSRF-forgery surface (closes #206).
at docblock-tag position, where Nextcloud's ControllerMethodReflector regex
/^\h+\*\h+@(?P<annotation>[A-Z]\w+)((?P<parameter>.*))?$/m reads it as the
annotation being PRESENT — the sentence announcing the removal was what kept
CSRF disabled on two state-mutating admin POSTs. Deleting it is the fix, and
all three frontend callers already sent requesttoken while the shared
CnAdminSettingsShell uses @nextcloud/axios. The cheapest way to green would
have been a cosmetic edit under src/ containing the word requesttoken:
precisely the prose-satisfaction #191 warns against.
So ask the STATE instead of the diff, via a new scripts/lib/check_csrf_callers.py:
is any mutating caller unprotected right now? Conservative in the direction
that matters — every caller protected means the endpoint's caller is protected
too; any caller unprotected means we cannot show it is not this endpoint's, so
the removal still blocks. A green states which claim it is making, on stdout,
rather than passing silently.
BOTH ARMS, end-to-end through the runner on larpingapp#298:
arm 1 every caller protected
[gate-48] no CSRF signal was ADDED by this diff, and none was needed...
[gate-48] csrf-cochange: PASS
arm 2 one unprotected fetch() DELETE added (the opencatalogi#79 shape)
[gate-48] csrf-cochange: FAIL
UNPROTECTED mutating call site(s) - these are why the removal blocks:
src/services/__armtwo_probe.js:3 - fetch() DELETE with no CSRF signal
Arm 2 is the one that proves this did not switch the gate off: the defect
gate-48 was built for is still caught, and the log now names the call site.
Helper suites: 63 -> 64 discovered, test_check_csrf_callers.py auto-discovered
and passing; passed 62, quarantined 2, failed 0.
ShellCheck SC2181 on the `if [ $? -eq 0 ]` after the command substitution. Moved the assignment into the `if` so the helper's own exit status is tested directly, and cleared `_csrf_unprotected` on the failure path. That second part is not cosmetic: without it a crashed interpreter leaves the variable holding whatever partial stdout it emitted, and an EMPTY result from a helper that died reads identically to "no unprotected callers found" — the fail-open shape this gate has been bitten by before. `_csrf_callers_ran` stays 0 in that case, so the gate falls back to the diff-only question and says so in the log rather than passing on an unanswered question. Arm 1 re-verified end-to-end on larpingapp#298 after the refactor: PASS with the reason line intact.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #303.
Two defects, both measured on larpingapp.
1. A net-zero MOVE counted as a removal
check_csrf_removal.pyscanned only^-lines and never asked whether an identical^+line existed, so relocating a docblock line inside a file read as deleting the annotation.larpingapp#297 moved one comment line while reordering
SettingsController's docblocks:Byte-identity check on that diff:
identical line also ADDED: True. Nothing about CSRF changed, and gate-48 keptHydra Gatesred on that repo'sdevelopmentfrom then on.Cancellation is per file and by multiset:
Seven new tests pin this, including the cross-file arm, the remove-twice-restore-once arm, the re-indent arm, and the
+++ b/...header (which starts with+and must not be pooled as an added line).2. The gate could not see a caller that was already compliant
The co-change question was asked of the diff. A PR whose callers have always sent a token has no signal to add, so it could not pass — the only exits were a waiver or staying red.
Measured on larpingapp#298, which closes a live CSRF-forgery hole.
SettingsController::create()andreimport()carriedat docblock-tag position, where Nextcloud's
ControllerMethodReflectorparses annotations with— so the sentence announcing the removal was the annotation, and
SecurityMiddleware::isValidCSRF()returned early. CSRF was never enforced on either state-mutating admin POST. Deleting that line is the fix.Every frontend caller already sent a token (
requesttokenon all threefetchcalls;CnAdminSettingsShelluses@nextcloud/axios, which injects it), andOC.requestTokenis kept current rather than snapshotted —core/src/OC/index.jssubscribes tocsrf-token-updateand reassigns it. So the co-change did not exist to be made, and the cheapest route to green would have been a cosmetic edit undersrc/containing the wordrequesttoken.The question it asks instead
New
scripts/lib/check_csrf_callers.py: is any mutating caller unprotected right now? Conservative in the direction that matters:Protection means, within the call expression itself:
requesttoken/OCS-APIRequest(both case-insensitive — HTTP header names are) orgetRequestToken; or the call goes through@nextcloud/axios, imported in that file. Extraction is paren-balanced, so a correct call cannot vouch for an incorrect one beside it — pinned by a test.Both arms, end-to-end through the runner on larpingapp#298
Arm 2 is the one that proves this did not switch the gate off. opencatalogi#79 — the delete-modal
fetch()with no CSRF header that gate-48 was built for — is still caught, and its shape is pinned as a test.Suites
scripts/lib/test_*files 63 → 64;test_check_csrf_callers.pyis auto-discovered byrun-helper-suites.shand passes. Full run: passed 62, quarantined 2, failed 0.Fallback behaviour
If
check_csrf_callers.pyis absent or the app has nosrc/, the gate falls back to the previous diff-only question and says so in the log — a missing helper must not report pass (#147).