Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

The one test that proves no user can see another user's data

Every multi-tenant app has the same nightmare bug: a route that forgets to check who owns the thing it returns. One missing where owner_id = ? and user B can read user A's projects by changing an id in the URL. Code rev

Every multi-tenant app has the same nightmare bug: a route that forgets to check who owns the thing it returns. One missing where owner_id = ? and user B can read user A's projects by changing an id in the URL.

Code review doesn't reliably catch it. Neither do the tests most of us write, because we test the routes we remember, and the leaky one is always the route we forgot.

So I stopped writing per-route authorisation tests and wrote one test that walks every route the API has.

The idea

  1. Collect every route the server registers, automatically.
  2. Make two accounts. Alice creates real data: a project, a file, a secret, an API key.
  3. For every route that takes an id, call it as Mallory using Alice's ids.
  4. Every single one must answer 404 (not 403, which confirms the thing exists). With no session at all, every one must answer 401.
  5. The only exceptions are routes that show something an owner chose to publish, and each exception is written down with its reason.

A route added next month that forgets the owner check fails this test on day one, without anyone remembering to test it.

Collecting the routes

In Fastify there is a hook for exactly this. Register it before your routes:

const routeList: { method: string; url: string }[] = [];
app.addHook('onRoute', (r) => {
  for (const m of [r.method].flat()) {
    if (m !== 'HEAD' && m !== 'OPTIONS') routeList.push({ method: m, url: r.url });
  }
});
app.decorate('routeList', routeList);

In Express 4 you can walk app._router.stack; most frameworks have an equivalent.

The test

const SHARED: Record<string, string> = {
  'GET /showcase/:id': 'an app its owner shared publicly',
  'GET /shared-chats/:slug': 'a chat its owner published with a link',
};

const withIds = routes.filter((r) => /:|\*/.test(r.url));

// Alice's real ids wherever a route names one of hers.
const fill = (url: string) => url
  .replace(/^\/projects\/:id/, `/projects/${project.id}`)
  .replace(':gid', generation.id)
  .replace(':name', 'ALICE_KEY')
  .replace(/:id/g, randomUUID())
  .replace('*', 'web/secret.ts');

const leaks: string[] = [];
for (const r of withIds) {
  const id = `${r.method} ${r.url}`;
  if (SHARED[id]) continue;
  const url = fill(r.url);
  const res = await app.inject({ method: r.method, url, cookies: mallory.jar, payload: plausibleBody(r.method) });
  if (res.statusCode !== 404) leaks.push(`${id} as another account: ${res.statusCode}`);
  if (res.body.includes('alice only')) leaks.push(`${id} showed Alice's data`);
  const anon = await app.inject({ method: r.method, url, payload: plausibleBody(r.method) });
  if (anon.statusCode !== 401) leaks.push(`${id} with no session: ${anon.statusCode}`);
}
expect(leaks).toEqual([]);

Two details matter more than they look.

Send a plausible body. If a route validates the body before it checks ownership, an empty body gets a 400 and the test learns nothing about the owner check. Give every request a body that would pass validation, so the 404 you get back is the owner check speaking.

Collect leaks, then assert once. A list of every failing route is far more useful than the first failure. On my codebase the list was empty, which brings me to the part most people skip.

Prove the test can fail

An empty list is only good news if the test can actually catch a leak. So I broke one route on purpose: I removed the owner check from the route that lists a project's secrets, and ran the test again.

- "GET /projects/:id/secrets as another account: 200 {"secrets":[{"name":"ALICE_KEY"...

It failed, naming the route and showing the leaked secret names. I put the check back. Now I trust the green.

The lists of "mine"

Routes without an id leak too: /me/projects, /me/exports, /me/api-keys. A second test signs in as Mallory and checks that none of those responses contain Alice's ids, email or names anywhere in the JSON.

What it costs

76 routes with ids, two requests each, about 400 ms with Fastify's inject. It runs with the rest of the test suite before every commit.

I built this for spoe.ai, an AI app builder where people prompt full-stack apps and export the code. When an AI writes a lot of your routes, "we'll remember to test authorisation" stops being a plan. A test that walks every route does not need anyone to remember.

If your app has more than one user, steal the idea. It's an afternoon of work and it closes the most common serious bug in multi-tenant software.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.