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.
Attack path
3 stepsEntry point: GET /invoices/:id/pdf, reachable by any authenticated session.
- Step 1: src/routes/invoices.ts:38EntryThe route only requires a session. It never reads req.session.orgId.
37 router.get("/invoices/:id/pdf", requireSession, async (req, res) => { 38 const invoice = await invoices.byId(req.params.id); - Step 2: src/db/invoices.ts:17byId looks the row up by primary key alone; there is no org_id predicate.
16 export async function byId(id: string) { 17 return db.one("select * from invoices where id = $1", [id]); 18 } - Step 3: src/routes/invoices.ts:42SinkThe 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 reproduceSign 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.