Broken object-level authorization in invoice export API
- CVSS v3.1 vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
- Weakness
- CWE-639 · Authorization Bypass Through User-Controlled Key
- Affected assets
- api.example.corp — GET /v2/invoices/{id}/export, PATCH /v2/invoices/{id}
- app.example.corp (consumer of the API)
- MITRE ATT&CK
- T1190 · Exploit Public-Facing Application
Description
The invoice export endpoint accepts a sequential numeric invoice ID and returns the invoice as PDF or JSON. The API validates that the bearer token is genuine, but never checks that the invoice belongs to the tenant encoded in that token.
The same missing check applies to PATCH on the same resource, which lets the caller change the billing e-mail address of another tenant’s invoice. Because invoices are numbered sequentially, every record can be enumerated in minutes.
Impact
Any customer, including one on a free trial, can read every other customer’s invoices (names, postal addresses, tax IDs, line items, last four digits of payment cards) and redirect future invoice e-mails. Scope is changed because the flaw crosses the tenant boundary that the platform promises to enforce.
Steps to reproduce
Steps to reproduce
- 01Log in to app.example.corp as the test user of tenant A (tester-a@example.corp) and export one of tenant A’s own invoices; note its ID (e.g. 104 220).
- 02Replay the request with the invoice ID of a record belonging to tenant B (provided test tenant), keeping tenant A’s bearer token.
- 03Observe HTTP 200 with tenant B’s invoice in the response body.
- 04Send a PATCH with a new billing_email to the same tenant B invoice; observe HTTP 200 and the changed value when tenant B views the invoice.
- 05Stop. Per the rules of engagement, enumeration was limited to the two test tenants plus a count of reachable IDs (HEAD requests only).
Evidence
Evidence
GET /v2/invoices/104221/export?format=json HTTP/2
Host: api.example.corp
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…[REDACTED]
X-Tenant-Hint: tnt_0a91f ← tenant A
HTTP/2 200 OK
Content-Type: application/json
{
"invoice_id": 104221,
"tenant_id": "tnt_7c3e2", ← tenant B
"bill_to": {
"name": "[REDACTED]",
"tax_id": "[REDACTED]",
"address": "[REDACTED]"
},
"card_last4": "••••",
"total": "1,240.00",
"currency": "USD"
}PATCH /v2/invoices/104221 HTTP/2
Host: api.example.corp
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…[REDACTED] ← tenant A
Content-Type: application/json
{"billing_email": "canary+sc001@example.corp"}
HTTP/2 200 OK
{"invoice_id": 104221, "billing_email": "canary+sc001@example.corp"}Tokens, secrets and personal data redacted. All hosts and data belong to a fictional organization.
Remediation
- Resolve the invoice through the caller’s tenant: query by (tenant_id from the verified token, invoice_id) and return 404 when no row matches, so existence is not disclosed.
- Move the ownership check into the shared authorization middleware (policy-as-code) so new endpoints inherit it by default instead of re-implementing it.
- Replace sequential IDs in public URLs with random identifiers (UUIDv4 or similar). This slows enumeration but is defence in depth, not the fix.
- Add automated negative tests (tenant A token against tenant B objects) for every object-returning route in CI.
- Review API access logs for the last 12 months for tokens that accessed invoices of more than one tenant.
Retest result
Retest result
Fixed and verified on 2026-10-06. Cross-tenant GET and PATCH now return 404; IDs in export URLs are UUIDs. Log review by Example Corp found no evidence of prior abuse.