fix(access-control): decide the sibling scope, and see identity a destructuring spelled away

Two of NodeGoat's three corroborated claims were correct code. `const { userId } = req.session`
lowers to a temporary and then a field read, so nothing in the graph spells session.userId by the
time the value reaches dao.getByUserId(userId, ...), and the constraint the handler really applies
was invisible.

The arbiter now walks back three hops over the method's own assignments from the names the
operation is GIVEN, and req.session joins the identity sources. The token is only safe because of
that requirement: what discharges an object-level obligation is that the value passed to the
operation comes from the authenticated context, not that a session is mentioned nearby. A handler
that reads the session for logging and keys its query on a path parameter is the bug this family
exists for, and it stays unguarded.

Two exclusions the measurement forced, each caught by a page whose whole purpose is the leak it
demonstrates.

- A callback is not a constraint. Taking identifiers from the whole argument subtree credited
  NodeGoat's memo listing with a discharge, because memosDAO.getAllMemos((err, docs) => ... userId
  ...) reads every memo in the collection and only RENDERS the session's user. Method references
  are excluded from the argument names.
- An assignment is not a dominating guard. The session read dominates the query, so that clause
  credited it instead and the memo listing went quiet again. An assignment is a read of the
  authenticated context, not a check applied to it, so operator calls are excluded there -- the
  same exclusion the sink matcher needed. The leak stays reported.

On NodeGoat displayContributions and displayProfile go silent, the allocations insecure direct
object reference, both benefits pages and the memo listing stay, and the login and signup handlers
become honestly unguarded rather than accidentally cleared by a callback's text. Six corroborated
where there were seven, and a better six. VAmPI is unchanged at two entailed findings.

The sibling scope is decided by measurement and kept at the file. On NodeGoat the region's own
file entails two handlers, both public routes and both false, and no true defect at all. Every
handler in the application entails six: four true, and the same two public routes. Both scopes
carry the same false positives, because neither can tell a public route from a protected one --
a semantic question that is already the corroborated band's residual. With the carried rung capped
the scope changes no verdict, only the witness, so widening it would buy a repository-wide walk
per question and no precision. A public-route discriminator would change that; the file scope is
not what holds this family back.

1,415 tests pass, sixteen skipped; thirteen Joern-gated query tests pass in the image.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-20 15:16:03 +02:00
co-authored by Claude Opus 5
parent c0427e6ceb
commit e6bdac46e2
7 changed files with 297 additions and 7 deletions
+11 -1
View File
@@ -174,6 +174,15 @@ of a query document. Measured on a plugin that ships jQuery, all 88 of its `find
unclaimed. Seven NodeGoat handlers now reach a verdict, all corroborated; the census is
unchanged, because the corroborated band needs a judge it does not configure.
What a session read discharges is now decided by where its value goes. A destructuring spells
`session.userId` away, so the arbiter walks back from the names the query is given; two NodeGoat
handlers that were correct code stop being asked about, while the memo page that reads every memo
and merely renders the session's user stays reported. The sibling scope was decided by
measurement and left at the file: on this subject the file scope entails two handlers, both
public routes and both wrong, and an application-wide scope entails four true defects and the
same two wrong ones. Neither scope can tell a public route from a protected one, so the carried
rung stays capped and that question stays with the judge.
Closing the second gap surfaced four defects, three of which were false positives it would
otherwise have shipped: a permissive-value set shared across settings whose booleans have
opposite polarity, a dotted setting matched on its trailing segment alone, a flow query asked of
@@ -186,4 +195,5 @@ Evidence: [family detection census](../../../benchmarks/measurements/2026-09-19-
[untrusted destination vocabulary](../../../benchmarks/measurements/2026-09-19-untrusted-destination-vocabulary.json),
[output encoding family](../../../benchmarks/measurements/2026-09-19-output-encoding-family.json),
[access control across the module split](../../../benchmarks/measurements/2026-09-20-access-control-module-split.json),
[collection read obligation](../../../benchmarks/measurements/2026-09-20-collection-read-obligation.json).
[collection read obligation](../../../benchmarks/measurements/2026-09-20-collection-read-obligation.json),
[sibling scope and identity](../../../benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json).
+4 -1
View File
@@ -510,11 +510,14 @@ Preserve all completed task evidence below.
- _Boundary: contributor-scan dominance, Ranker Scope Contract_
- _Depends: 14.1_
- _Requirements: 6.2, 6.3, 8.1_
- [ ] 14.7 Decide the sibling scope, and see identity that a destructuring spelled away
- [x] 14.7 Decide the sibling scope, and see identity that a destructuring spelled away
- **Named by the 14.4 measurement.** Two of NodeGoat's three corroborated claims are correct code: `const { userId } = req.session` followed by `dao.getByUserId(userId, ...)` is identity-constrained, and the destructuring erases the `session.userId` spelling the token list holds, so the guard is invisible at the call. A judge would be asked about handlers that are not defects.
- The contradiction that would make NodeGoat's IDOR entailed is cross-file: `allocations` passes a URL parameter while `contributions` and `profile` pass session identity. The sibling set is one file, and widening it is a decision, not an implementation detail: measured, a route file mixes public with authenticated handlers, so application-wide siblings would contradict every login and signup handler there is.
- Record the measured cost of each candidate before choosing, and do not widen the scope to make a finding appear.
- Done when one record states the candidates and the choice, a destructured identity read discharges the obligation it actually discharges, and no public route is reported.
- _Verified 2026-09-20:_ 1,415 tests passed, sixteen skipped; thirteen Joern-gated query tests pass in the image; Ruff/format/mypy passed. A destructured identity read now discharges what it reaches, through a bounded three-hop walk back over the method's own assignments from the names the operation is GIVEN. `req.session` joins the identity sources and is only safe because of that requirement: what discharges an object-level obligation is that the value PASSED to the operation comes from the authenticated context, not that a session is mentioned nearby. On NodeGoat `displayContributions` and `displayProfile` go silent, which is correct code no longer put to a judge, and VAmPI is unchanged.
- _Two exclusions the measurement forced, each caught by a page whose whole purpose is the leak it demonstrates._ Taking identifiers from the whole argument subtree credited NodeGoat's memo listing with a discharge, because `memosDAO.getAllMemos((err, docs) => ... userId ...)` reads every memo and only RENDERS the session's user; a callback is not a constraint, so method references are excluded. Then the session read still dominated the query and the dominating-guard clause credited that instead; an assignment is a read of the authenticated context rather than a check applied to it, so operator calls are excluded from that clause, which is the same exclusion the sink matcher needed. The leak stays reported.
- _The sibling scope is decided by measurement, and the choice is to keep the file._ On NodeGoat the region's own file entails two handlers, both public routes and both false, and no true defect at all. Every handler in the application entails six: four true, and the same two public routes. Both scopes carry the same false positives, because neither can tell a public route from a protected one — which is a semantic question and already the corroborated band's residual. With the carried rung capped at corroborated the scope changes no verdict, only the witness, so widening it would buy a repository-wide walk per question and no precision. What would change the decision is a discriminator for a public route; with one, the wider scope is worth four true entailments on this subject. Evidence: `benchmarks/measurements/2026-09-20-sibling-scope-and-identity.json`.
- _Boundary: contributor-scan dominance, shipped obligation facts_
- _Depends: 14.4_
- _Requirements: 6.2, 6.3, 8.1_