Let's talk
security

A filter your API ignores is worse than a filter that returns an error

A pricing panel in the web portal showed the prices agreed with one customer. It was fetching them by passing that customer’s identifier as a query parameter. The endpoint did not declare that parameter, so the framework discarded it, and the endpoint returned every customer-specific price in the tenant. The panel rendered whatever came back.

Nothing failed. No error, no warning, no log line. The request looked correct on the client side and the response looked correct on the server side, because from the server’s point of view nobody had asked for a filter.

This is documented behaviour in most web frameworks and it is a reasonable default in isolation — ignoring unknown parameters is what lets a URL carry tracking junk without breaking. It is also the mechanism by which a typo in a filter name silently widens a query, and widening a query is the definition of a data leak.

Establish the blast radius before touching anything

The first instinct on finding this was to fix it. That was the wrong first move, and the discipline that mattered here was refusing it.

Before any code changed, the question that needed an answer was how far the exposure went: was this a cross-tenant leak, which is the failure that ends the product, or an intra-tenant one? Those need completely different responses — one is a customer notification and an incident, the other is a defect.

It was answered by test rather than by reasoning. A caller authenticated into one tenant issued the call with an identifier belonging to a different tenant, and got an empty list. Row-level security held. The leak was between customers inside a single tenant: a portal user who was entitled to see some customer pricing saw all of it.

That is a real defect and it is not a P0, and knowing which one you have before you start typing changes what you do for the next two hours. Fixing first and measuring afterwards means measuring a system you have already changed.

Fix the class, not the instance

Two endpoints were affected. Fixing two endpoints would have left the mechanism in place and guaranteed a third one later, because nothing about the codebase prevented it — any new filter added to a client before it exists on the server behaves exactly this way.

The fix was a guard applied across the whole application at startup. Every mounted route’s handler is wrapped so that any query parameter the route does not declare produces a rejection with a clear code, rather than being ignored.

The important design property is where the allow-list comes from. It is not a list anybody maintains. It is read off the route’s own resolved dependency tree — the parameters the handler already declares are the permitted set, by construction. That means a route added next year is covered on the day it is added, by someone who has never heard of the guard, and there is no second list to fall out of date.

One route opts out through an explicit decorator: the report runner, whose filters are per-report data that it validates against its own catalogue. An opt-out that has to be written deliberately, on the route, is a very different thing from an omission.

The rule: when a defect is caused by a framework default, the fix belongs at the framework boundary, and the allow-list should be derived from something the developer already had to write. Any guard that needs its own registry will eventually disagree with the thing it guards.

What the sweep found

Turning the guard on before sweeping the clients would have broken screens in a way that looked like a regression. So the clients were swept first.

The web client had one more live instance. The mobile client had one — a date filter on a list endpoint that had never actually filtered. And the sweep surfaced an adjacent defect that had nothing to do with query parameters: a tab in the portal was calling an endpoint that does not exist. That one had presumably been returning an error and rendering an empty state that looked like “no data”.

Later, the mobile client’s forty-eight call sites were checked against the running API’s own schema document, and the check was committed as a script rather than done once. It works two ways: it compares the parameters used against the parameters declared, and it also issues the real requests against a running API. It includes a canary — a request with a deliberately invalid parameter, asserting that the server rejects it — so that the check fails loudly if the guard is ever turned off. A verification that passes when the thing it verifies has been disabled is not a verification.

The contract change is the cost

This was not a free fix. It is a behaviour change every client has to absorb: a request that used to succeed now returns an error.

That is the right trade, because the alternative is a system where a client can be wrong and look right indefinitely. But it has to be stated as a contract change and communicated as one, not slipped in as a bug fix. Somebody’s integration will break, and the difference between a good release and a bad one is whether they were told.

The same class of strictness had already been applied to two other inputs and was worth the same argument: an unknown field in a request body is rejected rather than ignored, and a sort parameter naming a column outside the route’s allow-list is rejected rather than defaulted. In every case the principle is identical. An input the server did not understand must produce an error, because the alternative is the server answering a different question from the one that was asked, and returning a confident answer to it.

Rules

Measure the blast radius before you fix anything. Cross-tenant or intra-tenant is a different incident, and you can only tell on the unmodified system.

Reject undeclared query parameters, unknown body fields and unlisted sort columns. Silent tolerance of unknown input is how a client-side typo becomes a server-side leak.

Derive the allow-list from the code that already exists. A hand-maintained list of permitted parameters is a second source of truth with a worse update rate than the first.

Sweep the clients before turning the guard on, and commit the sweep as a script with a canary.

The honest limit: this guard catches parameters the server does not know about. It does nothing about a parameter the server does declare, does read, and applies to the wrong column. That is a different problem and it needs tests that prove a filter both includes a known match and excludes a known non-match — which is the only form of filter test worth writing, and the one the follow-up work adopted.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch