The authorization bugs your scanner will never find

A lone figure silhouetted against tall windows in a concrete building at night

Run a decent SAST tool over a mature codebase and it will find injection, unsafe deserialization, weak crypto, hardcoded secrets, path traversal. It will find them because each of those has a shape: tainted data reaching a dangerous sink through a traceable path. The analyzer models sources, sinks, and propagation, and the bug is the path.

Now look at the bug that actually drains data out of production systems: a user requests /api/invoices/8842, the handler fetches invoice 8842, and returns it. No tainted path. No dangerous sink. Every function is safe on its own. The vulnerability is not in the code that exists, it is in a comparison that was never written. There is no pattern to match, because absence has no pattern.

This is why broken object-level authorization keeps topping breach analyses while scanner dashboards stay green. The tooling is not weak. It is being asked to find something that is definitionally invisible to it: the analyzer would have to know that invoice 8842 belongs to a different tenant, and nothing in the source says so.

Why code review misses it too

A reviewer looking at a forty-line diff sees a handler that fetches a record and returns it. To spot the bug they would need to hold in their head the tenancy model, the sharing rules, whether this particular resource is reachable by URL, and whether some middleware three directories away already enforced ownership. Nobody carries that reliably at three in the afternoon on the fourth review of the day.

Worse, the reviewer is usually right that a check exists somewhere. Most codebases do authorize most things. The bugs live in the endpoints that came later: the bulk export added for a customer escalation, the mobile-only route, the internal admin view that quietly became reachable, the GraphQL field someone exposed without noticing it resolves across tenants. Coverage decays at the edges, and the edges are where features get added under pressure.

If your defense is that reviewers will notice, your defense degrades every quarter and nobody will ever tell you it did.

Make the unsafe path impossible to write

The reliable fix is not more diligence. It is to change the shape of the code so the missing check becomes a compile error, a runtime exception, or a query that returns nothing. Three patterns, in rough order of how much they cost to adopt.

Push the tenant into the query, not the guard clause

A guard clause can be forgotten. A query that structurally cannot address another tenant’s rows cannot. The difference is that the first relies on someone remembering and the second relies on the type system or the database.

# Forgettable: the check is a separate statement someone can omit
invoice = Invoice.objects.get(id=invoice_id)
if invoice.tenant_id != request.tenant_id:      # one deleted line = breach
    raise Forbidden()

# Structural: there is no accessor that ignores tenancy
class TenantScoped:
    def __init__(self, tenant_id):
        self._tenant_id = tenant_id
    def invoices(self):
        return Invoice.objects.filter(tenant_id=self._tenant_id)

# Handlers only ever receive a TenantScoped. The unscoped manager is
# private, and CI fails on any use of Invoice.objects outside the
# repository layer. Forgetting the check now returns DoesNotExist.
invoice = scope.invoices().get(id=invoice_id)

The lint rule is not decoration, it is the load-bearing part. Without it the private manager gets reached for the first time someone is in a hurry, and the pattern silently stops being a pattern. In Postgres, row-level security gives you the same guarantee one layer lower, which survives an ORM someone bypassed with raw SQL.

Deny by default at the router

Most frameworks default to open: a route with no decorator is reachable. Invert it. Every route declares its policy, and a route with no declaration fails to register. This turns “someone forgot” from a silent vulnerability into a deployment that will not start.

@route("/api/invoices/<id>", policy=Policy.owner_of("invoice", "id"))
def get_invoice(id, ctx): ...

@route("/api/health", policy=Policy.PUBLIC)   # explicit, greppable
def health(): ...

# Startup check: every registered route has a policy, or the process exits.
for r in router.routes:
    if r.policy is None:
        raise ConfigError(f"{r.path} declares no authorization policy")

A useful side effect: Policy.PUBLIC becomes a grep-able inventory of your entire unauthenticated attack surface. Most teams have never seen that list written down, and reading it for the first time is usually an eventful afternoon.

Stop returning identifiers that reward enumeration

Opaque identifiers are not an access control and should never be described as one. What they do is remove the attacker’s cheap oracle. With sequential integers, one missing check plus a for-loop is a full table dump. With unguessable identifiers, the same missing check requires knowing a value you were already given, which usually collapses the finding from critical to low.

Do it because it shrinks the damage of the bug you have not found yet, not because it prevents the bug. Teams that confuse those two stop doing the work that actually matters.

Testing: two identities, every endpoint, generated

Hand-written authorization tests cover the endpoints someone was already worried about, which are the endpoints least likely to be broken. Generate them instead, from the route table, so that a new endpoint is covered the moment it is registered and an uncovered endpoint is a build failure.

# Two tenants, fixtures seeded in both. For every non-public route,
# assert that tenant A cannot touch tenant B's object.

@pytest.mark.parametrize("route", [r for r in router.routes
                                   if r.policy is not Policy.PUBLIC])
def test_cross_tenant_denied(route, tenant_a, tenant_b, client):
    target = seed_object_for(route, owner=tenant_b)
    resp = client.request(route.method,
                          route.path_for(target),
                          auth=tenant_a.token)
    assert resp.status in (403, 404), (
        f"{route.method} {route.path} leaks across tenants")
    assert tenant_b.secret_marker not in resp.text

The second assertion matters more than the status code. Plenty of endpoints return 200 with an empty envelope and the leaked record buried in an included array, or in an error message that helpfully echoes the record’s name. Assert on the body for a marker string that only exists in the other tenant’s fixture.

Then extend the same generator to the axis everyone forgets: same tenant, lower privilege. A viewer calling the endpoints a manager uses. Cross-tenant leaks get attention because they are dramatic. Vertical escalation inside one customer is more common, quieter, and just as reportable.

Where these bugs actually come from

Almost never from a developer who did not know authorization was necessary. Nearly always from one of four situations, and each has a structural answer rather than a training answer.

  • A second read path. The record is authorized in the detail view, then exposed unfiltered through search, export, a webhook payload, or a GraphQL edge. Authorize at the data layer and every path inherits it.
  • A trusted client. The mobile app or the internal tool is assumed to send only legitimate identifiers. Nothing about a client is trusted; it is a request like any other.
  • A sharing feature. Ownership was binary until someone shipped collaborators, and half the codebase still asks owner_id == user_id. Model permission as a relation from the start, even when there is only one relation.
  • A batch endpoint. The handler validates the first identifier in the array and processes all of them. Validate per element, and write the test that sends a mixed array.

What good looks like

You can measure this without a maturity model. Ask three questions and watch how long the answers take.

  • Can someone produce the complete list of unauthenticated endpoints in under a minute, from code rather than from memory?
  • If a developer forgets the ownership check on a new endpoint, what fails, and does it fail before production?
  • When did a cross-tenant test last fail for a real reason? If never, the tests are probably asserting the wrong thing.

A green scanner dashboard answers none of these, and it was never going to. The bug it cannot see is the one that shows up in the disclosure.

Leave a Reply

Discover more from Nullhaus

Subscribe now to keep reading and get access to the full archive.

Continue reading