Toolnotes

Notes on access-control bugs and AI-agent instruction drift, from the people who built entitlement-guard and baton to catch them.

View the Project on GitHub Ayinla10/toolnotes

Mass assignment (CWE-915) in Node/TypeScript APIs: why it survives code review

Mass assignment is an old bug class — it has its own CWE entry (CWE-915: “Improperly Controlled Modification of Dynamically-Determined Object Attributes”) — and it still ships in new code, including code that has an authorization check sitting right next to it. That’s the part worth explaining: this isn’t usually a missing auth check, it’s a field that never goes through one.

The shape of the bug

A request handler takes a JSON body, spreads or assigns most of it onto a model, and persists it:

// naive but common
async function updateProfile(req: Request, res: Response) {
  const user = await getUser(req.session.userId);
  Object.assign(user, req.body); // <-- role, plan, tier all ride along
  await user.save();
  res.json(user);
}

The handler does check that req.session.userId is a real, logged-in user — that check is correct and present. What it doesn’t check is whether any individual field in req.body is one the caller is allowed to set. If the User model has a role or plan column, and the client includes {"role": "admin"} or {"plan": "enterprise"} in the body, it gets written, because nothing in the code path distinguishes “fields the user owns” from “fields only a webhook or an admin panel should ever write.”

This is exactly the shape of a real, filed issue: juice-shop/juice-shop#3353, “Mass Assignment: Privilege Escalation via Registration” — a registration endpoint that let a client set fields beyond what the registration form exposed.

Why it passes review

Reviewers read authorization checks by looking for the presence of a gate — if (!req.session.userId) return 401, a middleware, a role check. Mass assignment bugs have that gate. It’s just gating access to the handler, not gating which fields the handler is allowed to write once inside it. A reviewer scanning for “is there an auth check here” sees one and moves on. The vulnerable line isn’t the missing check — it’s the Object.assign or spread three lines later that nobody thought to re-scope.

Static analysis has a similar blind spot for a related reason. Semgrep and CodeQL both have the taint-tracking machinery to catch this in principle — Semgrep’s mode: taint, CodeQL’s interprocedural dataflow — but neither ships a pre-built rule for the specific shape of “a client-controlled tier/plan/role-shaped field reaches a persistence write without passing through a payment-webhook-verified gate.” That’s a narrow, product-shaped rule, not a generic taint rule, and generic scanners are (reasonably) generic.

What actually closes the gap

Two separate things have to both be true for this bug class to be closed in a codebase: (1) a rule that specifically looks for a client-controlled, entitlement-shaped field reaching a write without webhook-verified gating, and (2) that rule running in CI, not just in a reviewer’s head during a fast-moving PR.

We built entitlement-guard for exactly that: a static-analysis CLI (and GitHub Action) that flags client-controlled tier/plan/role values reaching an entitlement decision without Stripe-webhook-verified gating. It’s deliberately narrow — one bug class, not a general security scanner — and it’s meant to run alongside CodeQL, not replace it; see the README’s “Relationship to CodeQL” section for where the two overlap and where they don’t.

# .github/workflows/entitlement-guard.yml
- uses: Ayinla10/entitlement-guard@v1

MIT-licensed, free, output is JSON on stdout for CI/agent consumption.