Skip to content
DemoA made-up account, acme. Its deep scan of acme/api replays on a loop, sped up. Nothing here is real or saved.Back to the siteGet started
Agent Cernis
criticalhigh confidenceCWE-639broken access controlOpen

Invoice PDFs can be downloaded across tenants by changing the id

src/routes/invoices.ts:42Exploit verified

Impact

Any signed-in user, on any plan, can download another company's invoice PDFs: billing address, line items, tax id and the last four digits of the card on file. Invoice ids are sequential, so walking the whole table takes minutes.

AttackerA signed-in user of any tenant
Crossesthe tenant boundary in the invoices route
Getsevery tenant's invoice PDFs

Attack path

3 steps

Entry point: GET /invoices/:id/pdf, reachable by any authenticated session.

  1. Step 1: src/routes/invoices.ts:38Entry
    The route only requires a session. It never reads req.session.orgId.
    37router.get("/invoices/:id/pdf", requireSession, async (req, res) => {
    38 const invoice = await invoices.byId(req.params.id);
  2. Step 2: src/db/invoices.ts:17
    byId looks the row up by primary key alone; there is no org_id predicate.
    16export async function byId(id: string) {
    17 return db.one("select * from invoices where id = $1", [id]);
    18}
  3. Step 3: src/routes/invoices.ts:42Sink
    The PDF is streamed straight back to whoever asked.
    41 const pdf = await storage.get(invoice.pdf_key);
    42 pdf.pipe(res.type("application/pdf"));

Preconditions

A valid session for any tenant. Free trial accounts qualify.

Proof of concept

Did not reproduce

Sign in as a user of tenant B, then request GET /invoices/1041/pdf, an invoice that belongs to tenant A. The response is tenant A's PDF with status 200.

it("does not serve another tenant's invoice", async () => {
  const a = await seedTenant();
  const b = await seedTenant();
  const inv = await seedInvoice(a.org);
  const res = await request(app).get(`/invoices/${inv.id}/pdf`).set(b.session);
  expect(res.status).toBe(404);
});

Exploit verified via runtime replay: Fetched a second tenant's invoice with a fresh trial session.

Fix

Scope the lookup to the caller's organisation and return 404 (not 403) when the invoice belongs to someone else, so ids can't be probed.

The fix must guarantee

  • An invoice is served only to members of the organisation that owns it
  • Unknown and foreign ids get the same 404

It must not break

  • Admins of the owning organisation can still download every invoice
  • The billing email job still attaches PDFs

Regression test

Seed two tenants, request tenant A's invoice with tenant B's session, expect 404.

Suggested patch

--- a/src/db/invoices.ts
+++ b/src/db/invoices.ts
@@ -15,5 +15,5 @@
-export async function byId(id: string) {
- return db.one("select * from invoices where id = $1", [id]);
+export async function byId(orgId: string, id: string) {
+ return db.oneOrNone("select * from invoices where id = $1 and org_id = $2", [id, orgId]);
}
Fix prompt for your coding agent

Paste into Claude Code, Cursor, or any coding agent. It carries the attack path, the guarantees, and the regression test.

In acme/api, GET /invoices/:id/pdf (src/routes/invoices.ts:38) loads the invoice by id only (src/db/invoices.ts:17), so any signed-in user can fetch another tenant's PDF. Change byId to take the caller's orgId and filter on org_id; return 404 when no row matches. Add the regression test in test/invoices.test.ts that seeds two tenants and expects 404 across them.