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
+53 -3
View File
@@ -285,6 +285,39 @@
ujson.Obj("kind" -> "context_summary") :: (rows ++ carriedRows ++ boundaries)
}
// ---- The identity a destructuring spelled away -------------------------------------------------------
//
// `const { userId } = req.session` lowers to `_tmp_1 = req.session` and then `userId = _tmp_1.userId`, so by
// the time the value reaches `dao.getByUserId(userId, ...)` nothing in the graph spells `session.userId` and
// the constraint the handler really applies is invisible. Measured on NodeGoat: two of three corroborated
// claims were this, which is correct code being put to a judge.
//
// Resolved by walking the method's OWN assignments back from the names the operation is given, rather than
// by accepting the token anywhere in the handler. What discharges an object-level obligation is that the
// value PASSED to the operation comes from the authenticated context; 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.
// Bounded at three hops, which is two more than the lowering needs and far short of a dataflow query.
def definingText(
method: io.shiftleft.codepropertygraph.generated.nodes.Method,
names: Set[String],
hops: Int
): List[String] = {
val assignments = method.ast.isCall.nameExact("<operator>.assignment").l
var seen = names
var frontier = names
var texts = List.empty[String]
var level = 0
while (level < hops && frontier.nonEmpty) {
val defining = assignments.filter(a => frontier.exists(n => a.code.startsWith(n + " =")))
texts = texts ++ defining.map(_.code)
val next = defining.flatMap(_.argument.argumentIndexGt(1).ast.isIdentifier.name.l).toSet -- seen
seen = seen ++ next
frontier = next
level += 1
}
texts.distinct
}
// `via` is the call that carried the obligation here, absent when the operation is in the region's own file.
def rowFor(op: io.shiftleft.codepropertygraph.generated.nodes.Call, via: Option[io.shiftleft.codepropertygraph.generated.nodes.Call]): ujson.Obj = {
val method = op.method
@@ -299,7 +332,14 @@
val inCondition = method.controlStructure.condition.code.l.filter(c => mentions(c, applicable))
// 3. dominating guard: a call naming a discharger that actually dominates the operation.
val dominating = op.dominatedBy.isCall.code.l.filter(c => mentions(c, applicable))
//
// A CALL, so operators are excluded, and that exclusion is load-bearing rather than tidy. `const { userId }
// = req.session` lowers to an assignment, and an assignment that dominates the query is a READ of the
// authenticated context, not a check applied to it. Counting it discharged NodeGoat's memo listing, which
// reads every memo in the collection and only renders the session's user -- the leak that page exists to
// demonstrate. What a session read does discharge is decided by clause 5, where the value has to reach the
// operation's own arguments.
val dominating = op.dominatedBy.isCall.filterNot(_.name.startsWith("<operator")).code.l.filter(c => mentions(c, applicable))
// 4. a guard at the CARRIER. When the obligation crossed a module boundary the check that discharges it is
// overwhelmingly on this side of it: the handler establishes who is asking and passes the result down.
@@ -307,10 +347,20 @@
val atCarrier = via.toList.flatMap { call =>
call.argument.code.l.filter(c => mentions(c, applicable)) ++
call.method.controlStructure.condition.code.l.filter(c => mentions(c, applicable)) ++
call.dominatedBy.isCall.code.l.filter(c => mentions(c, applicable))
call.dominatedBy.isCall.filterNot(_.name.startsWith("<operator")).code.l.filter(c => mentions(c, applicable))
}
val guards = (inArguments ++ inCondition ++ dominating ++ atCarrier).distinct.map(_.take(120))
// 5. identity that reaches the operation's arguments through a local, which is the shape a destructuring
// leaves behind. Asked at the site the arguments are written: the carrier when there is one.
val site = via.getOrElse(op)
// A CALLBACK is not a constraint, so its body's names are not the operation's arguments. NodeGoat's memo
// listing is the measurement: `memosDAO.getAllMemos((err, docs) => ... userId ...)` reads every memo in the
// collection and only RENDERS the session's user, and taking identifiers from the whole argument subtree
// credited that render with discharging the obligation. The leak is the point of that page.
val argumentNames = site.argument.argumentIndexGt(0).filterNot(_.label == "METHOD_REF").ast.isIdentifier.name.l.toSet
val throughLocals = definingText(site.method, argumentNames, 3).filter(c => mentions(c, applicable))
val guards = (inArguments ++ inCondition ++ dominating ++ atCarrier ++ throughLocals).distinct.map(_.take(120))
val attributed = via.map(call => attributedName(call.method)).getOrElse(attributedName(method))
ujson.Obj(
@@ -75,10 +75,15 @@ kind = "path_guard"
calls = ["authenticate", "requireAuth", "ensureAuthenticated", "isAuthenticated", "verifyToken", "checkAuth", "protect", "authorize", "requireUser", "getServerSession", "auth", "withAuth", "jwt.verify", "passport.authenticate"]
decorators = ["UseGuards", "Authorized", "Roles"]
# `req.session` is the authenticated context itself, and it is listed because a destructuring spells the
# dotted forms away: `const { userId } = req.session` lowers to a temporary, so nothing in the graph says
# `session.userId` by the time the value reaches the query. The token is only safe because the arbiter
# requires the value to REACH the operation's arguments -- 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.
[[discharger]]
id = "identity-from-context"
kind = "identity_constraint"
identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "getServerSession("]
identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "req.session", "request.session", "getServerSession("]
constraint_params = ["userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "accountId", "tenantId", "createdBy", "user", "owner"]
request_sources = ["req.body", "req.query", "req.params", "request.body", "request.query", "req.headers", "req.cookies", "searchParams", "formData("]
@@ -75,10 +75,15 @@ kind = "path_guard"
calls = ["authenticate", "requireAuth", "ensureAuthenticated", "isAuthenticated", "verifyToken", "checkAuth", "protect", "authorize", "requireUser", "getServerSession", "auth", "withAuth", "jwt.verify", "passport.authenticate"]
decorators = ["UseGuards", "Authorized", "Roles"]
# `req.session` is the authenticated context itself, and it is listed because a destructuring spells the
# dotted forms away: `const { userId } = req.session` lowers to a temporary, so nothing in the graph says
# `session.userId` by the time the value reaches the query. The token is only safe because the arbiter
# requires the value to REACH the operation's arguments -- 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.
[[discharger]]
id = "identity-from-context"
kind = "identity_constraint"
identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "getServerSession("]
identity_sources = ["req.user", "request.user", "ctx.user", "session.user", "locals.user", "auth.uid", "auth().userId", "currentUser", "user.id", "token.sub", "decoded.sub", "session.userId", "req.session", "request.session", "getServerSession("]
constraint_params = ["userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "accountId", "tenantId", "createdBy", "user", "owner"]
request_sources = ["req.body", "req.query", "req.params", "request.body", "request.query", "req.headers", "req.cookies", "searchParams", "formData("]