Let's talk
security

Can one customer see another's prices in your B2B portal?

A portal asked for one customer's prices and got everyone's, because the server ignored the request. How to check yours, and how we closed it.

· · updated

A price sheet kept private

If you run a customer or dealer portal with prices agreed per customer, one customer seeing another’s prices usually comes from a request the server does not understand and quietly ignores, so it answers a wider question than the one asked. The first check is to sign in as a test customer and look for any price you did not agree with that customer.

Here is how that looked at one business, and what fixing it took.

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.

How to check your own portal

  1. Sign in as a test customer with its own agreed prices, and look for any price or customer that should not be there.
  2. Ask your developers what the system does with a request option it does not recognise: ignore it, or refuse it. Ignoring it can give a wrong answer that looks right.
  3. Ask how far a mistake like that could reach: between customers of one company, or between whole companies. You want to know which before anybody changes anything.
  4. Ask for a test that proves a filter includes a known match and excludes a known non-match.

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.