Let's talk
security

A customer portal showed one customer the others' prices

A pricing panel asked for one customer's agreed prices and got every customer's. Nothing failed or warned. How far it went, and how we closed it.

· · updated

A laptop on a distributor counter in front of shelves showing the customer outstanding screen of the Sazinga Field portal.

The pricing panel in a web portal was meant to show the prices agreed with one customer. It asked for them by sending that customer’s identifier. The server did not recognise that request option, quietly dropped it, and sent back every customer-specific price in the company’s account. The panel showed whatever arrived.

Nothing failed. There was no error, no warning and no log entry. The request looked right from the screen’s side and the answer looked right from the server’s side, because as far as the server could tell, nobody had asked for a filter.

For a business that agrees different prices with different customers, that is the exposure you most want to avoid: one customer’s negotiated rates in front of someone else, with nothing to say it happened. Most web software ignores options it does not recognise by design, which lets a link carry tracking junk without breaking. It is also how a typo in a filter name silently widens a question, and a widened question is how data leaks.

What was actually going on

Our first instinct was to fix it. The discipline that mattered was not doing so yet.

The question that decided everything was how far the exposure went. Did it cross between separate companies on the system, the failure that ends a product, or only between customers inside one company? One is a customer notification and an incident. The other is a defect.

We answered it by test, not by reasoning. A caller signed into one company sent the same request with an identifier from a different company, and got an empty list. The separation between companies held. The leak was inside a single company: a portal user entitled to see some customer pricing saw all of it.

That was a real defect, and not the worst kind. Knowing which one we had changed what we did for the next two hours, and we could only know it on a system we had not yet touched.

What we changed

Two routes were affected. Fixing two would have left the cause in place and guaranteed a third, because any new filter added to a screen before the server supports it behaves the same way.

So the fix went across the whole application. Any request option that a route does not declare is now refused with a clear code, rather than ignored. The list of allowed options is not something anyone maintains. It is read from what each route already declares, so a route added next year is covered on the day it appears, by someone who has never heard of the rule. One route is deliberately exempt, the report runner, whose filters are checked against its own catalogue.

Before switching it on, we checked every screen that talks to the server, because switching it on first would have broken screens in a way that looked like a regression. The web portal had one more live instance. The phone app had one: a date filter on a list that had never actually filtered. And the check turned up something unrelated: a tab in the portal calling an address that does not exist, and showing an empty state that looked like “no data”.

Later, the phone app’s forty-eight calls were checked against the server’s own description of itself, and the check was kept as a script. It includes a deliberate bad request that must be refused, so that the check fails loudly if the protection is ever switched off. A check that passes when the thing it checks has been disabled is not a check.

What it did not fix

This has a cost. A request that used to succeed now returns an error, and every screen has to absorb that. It has to be announced as a change to how the system behaves, not slipped in as a bug fix, because somebody’s connection will break and the difference between a good release and a bad one is whether they were told.

It also catches only options the server does not know. It does nothing about an option the server does declare, does read, and applies to the wrong thing. That needs tests which prove a filter both includes a known match and excludes a known non-match, which is the only form of filter test worth writing.

The pattern, for anyone with a customer portal

Ask your developers one question: what does the system do when a screen asks for something it does not recognise? If the answer is “ignores it”, you can have a wrong answer that looks right.

Then ask how far a mistake like that could reach. Between customers is one thing. Between whole companies is another. You want to know which before anybody changes anything.

Where this ends up

Sazinga Field is the field-sales system this came from, with a web portal, a phone app and prices agreed per customer. What the server does with a request it does not understand is settled once at the edge, because it is far harder to settle after a hundred routes have each grown their own habit.

This came out of building Sazinga Field

Orders, stock, dispatch and the people on the road, in one place. The problem above is one we met while building it, and what we did about it is in the product.

If you run something like this, there is one thing you can do without a call: send one day's order sheet.