Fremde Rechnungen über die API: eine fehlende Eigentumsprüfung
Ein Kundenportal prüfte, wer angemeldet war, aber nicht, wem die Rechnung gehörte. Wie der Fehler gefunden wurde, woher er kam und wie er behoben bleibt.
- Firma
- Großhandel für Verbrauchsmaterial, 18 Beschäftigte
- Team
- Zwei Entwickler, einer davon in Teilzeit
- Anwendung
- Kundenportal: Bestellungen, Rechnungen als PDF, Lieferscheine
- Technik
- Node.js und Express 5, PostgreSQL, React, ein VPS hinter Nginx
- Rahmen
- Kein Sicherheitsteam, Anwendungslogs werden 30 Tage aufbewahrt
01Ausgangslage
Das Portal nutzen die Geschäftskunden des Großhändlers. Jede Kundenfirma hat einen oder mehrere Benutzer. Die Anmeldung erzeugt eine serverseitige Sitzung mit der Benutzer-ID und der ID seiner Firma. Der Browser hält nur eine zufällige Sitzungskennung in einem Cookie.
Rechnungen liegen in der Tabelle invoices mit der Spalte company_id. Ihre Kennungen sind fortlaufende Ganzzahlen. Die Anwendung protokolliert bei jeder Anfrage Benutzer-ID, Methode und Pfad.
- Internet
- Nginx (TLS, Reverse Proxy)
- Node.js / Express 5 (API, Sitzungen)
- PostgreSQL
02Das Problem
Bevor Zahlungen ins Portal kamen, gab die Firma einen Penetrationstest in Auftrag. Der Tester erhielt zwei Testkonten in zwei verschiedenen Kundenfirmen. Mit dem ersten angemeldet, genügte es, in GET /api/invoices/1841 die Nummer auf eine Rechnung der zweiten Firma zu ändern, und die API lieferte sie vollständig: Empfänger, Adresse, Positionen und Beträge. Der PDF-Download und die Lieferscheinansicht verhielten sich genauso.
Voraussetzung: Der Angreifer braucht irgendein gültiges Portalkonto, muss also Kunde sein oder ein Kundenkonto übernommen haben. Da die Nummern fortlaufend sind, ließen sich fremde Rechnungen der Reihe nach abrufen. Ändern oder löschen ließen sie sich nicht; diese Funktionen bietet das Portal Kunden gar nicht an.
Auswirkung: Offenlegung geschäftlicher und personenbezogener Daten aller Portalkunden. Wäre der Fehler ausgenutzt worden, läge eine Verletzung des Schutzes personenbezogener Daten vor, die nach DSGVO zu bewerten ist. OWASP führt diese Klasse als Broken Object Level Authorization (API1:2023).
03Technische Ursache
Die Authentifizierung, also die Prüfung, wer angemeldet ist, war in Ordnung. Es fehlte die Autorisierung, also die Prüfung, ob dieser Benutzer genau diesen Datensatz sehen darf. Die Middleware requireAuth prüfte nur, dass eine Sitzung existiert.
Die Abfrage wählte die Rechnung allein über die ID aus der URL: SELECT ... FROM invoices WHERE id = $1. Sie war parametrisiert, SQL-Injection war also kein Thema, aber nichts beschränkte das Ergebnis auf die Firma des angemeldeten Benutzers. Die Rechnungsliste filterte nach company_id. Detail, PDF und Lieferschein kamen später hinzu, jeweils etwas anders gebaut, und die Bedingung wurde vergessen.
04Untersuchung
Zuerst wurde jeder Endpunkt, der einen Datensatz über eine ID aus der Anfrage lädt, darauf geprüft, ob seine Abfrage auch die Firma aus der Sitzung enthält. Betroffen waren genau drei: Rechnungsdetail, PDF und Lieferscheindetail.
Dann die Frage, ob jemand den Fehler genutzt hatte. Die Logs der letzten 30 Tage wurden mit der Datenbank abgeglichen, um Anfragen zu finden, bei denen die Rechnung im Pfad einer anderen Firma gehörte als der Benutzer. Außer den Testkonten gab es keine.
Für ältere Zeiträume lässt sich das nicht prüfen, die Logs existieren nicht mehr. So steht es auch im Vorfallsprotokoll. Es behauptet nicht, dass keine Ausnutzung stattfand, sondern nur, was sich belegen lässt.
05Behebung
Jede Abfrage auf einen Datensatz, der einer Firma gehört, hat jetzt eine Firmenbedingung. Die Firmen-ID stammt ausschließlich aus der serverseitigen Sitzung, nie aus Body oder Parametern der Anfrage. Der Zugriff auf diese Tabellen läuft über eine kleine Schicht von Funktionen, die die Firmen-ID als Pflichtargument verlangen.
// data layer: the company is a required argument, not an afterthought
export async function getInvoice(companyId, invoiceId) {
const { rows } = await pool.query(
`SELECT id, number, issued_at, total
FROM invoices
WHERE id = $1 AND company_id = $2`,
[invoiceId, companyId],
);
return rows[0] ?? null;
}
// route (Express 5 passes a rejected promise on to the error handler)
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
if (!/^\d+$/.test(req.params.id)) return res.sendStatus(404);
// companyId comes from the server-side session, never from the request
const invoice = await getInvoice(req.session.companyId, req.params.id);
// "does not exist" and "not yours" must look the same from outside
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});Eine nicht vorhandene und eine fremde Rechnung liefern beide 404. Würde die fremde 403 liefern, ließe sich damit herausfinden, welche Rechnungsnummern existieren.
Damit der Fehler nicht mit dem nächsten Endpunkt zurückkommt, gibt es Integrationstests mit zwei Firmen. Sie laufen in der CI bei jedem Pull Request, und die Regel im Team lautet, nichts zu mergen, was sie bricht.
import { test } from "node:test";
import request from "supertest";
import { app } from "../src/app.js";
import { seedTwoCompanies } from "./fixtures.js";
test("a user of company A cannot read company B's invoice", async () => {
const { userA, invoiceB } = await seedTwoCompanies();
const agent = request.agent(app); // keeps the session cookie
await agent.post("/api/login").send(userA.credentials).expect(200);
await agent.get(`/api/invoices/${invoiceB.id}`).expect(404);
await agent.get(`/api/invoices/${invoiceB.id}/pdf`).expect(404);
});Erwogene Alternativen: Row Level Security in PostgreSQL würde die Prüfung direkt in der Datenbank ergänzen, verlangt aber, den Firmenkontext in jeder Transaktion zu setzen, und die Anwendung darf sich nicht als Tabelleneigentümer verbinden, für den die Richtlinien standardmäßig nicht gelten. Für zwei Entwickler war ein expliziter Filter mit Tests lesbarer. Zufällige UUIDs statt fortlaufender Nummern würden das Raten erschweren, ersetzen aber keine Autorisierung.
06Warum die Lösung wirkt
Die Datenbank liefert eine Zeile nur, wenn Rechnungs-ID und Firma beide passen. Die Firma bestimmt die serverseitige Sitzung, deren Inhalt der Client nicht ändern kann. Eine geänderte Nummer in der URL ergibt daher immer nur 404.
Das Pflichtargument companyId verlagert die Prüfung vom Gedächtnis des Entwicklers in den Code: Ohne Firma lässt sich die Funktion nicht aufrufen. Die Tests mit zwei Firmen fangen den Fall ab, dass jemand die Schicht umgeht und direkt abfragt.
Was das nicht löst: Wer ein Kundenkonto übernimmt, etwa mit einem erratenen Passwort, sieht die Rechnungen dieser Firma, denn dieses Konto hat tatsächlich Zugriff darauf.
07Überprüfung
- Der Tester wiederholte das ursprüngliche Vorgehen mit beiden Konten bei Rechnungsdetail, PDF und Lieferschein. Alle drei lieferten 404.
- Die Integrationstests mit zwei Firmen laufen durch und schlagen fehl, wenn die Firmenbedingung absichtlich entfernt wird. Damit ist belegt, dass sie das Richtige testen.
- Seit dem Deployment erscheinen Anfragen auf fremde Rechnungen in den Logs nur noch mit 404.
08Ergebnisse
Behoben
- Fremde Rechnungen, PDFs und Lieferscheine lassen sich durch Ändern der ID nicht mehr lesen.
Risiko gesenkt
- Das Risiko, dass ein neuer Endpunkt denselben Fehler wiederholt, senken die verpflichtende Datenzugriffsschicht und die Tests mit zwei Firmen.
Weiter offen
- Für den Zeitraum vor mehr als 30 Tagen lässt sich nicht belegen, ob jemand den Fehler ausgenutzt hat.
- Rechnungen haben weiterhin fortlaufende Nummern. Das ist für sich kein Fehler, würde einen künftigen Autorisierungsfehler aber leichter ausnutzbar machen.
09Grenzen
- Tests decken nur die Endpunkte ab, für die jemand einen Test schreibt. Ein neuer Datensatztyp ohne Test kann den Fehler wiederholen.
- Die Lösung schützt nicht vor der Übernahme eines Kundenkontos. Dafür sind Zwei-Faktor-Authentifizierung und eine Begrenzung fehlgeschlagener Anmeldungen da, die das Portal noch nicht hat.
- Die Prüfung sitzt in der Anwendung, nicht in der Datenbank. Wer direkten Datenbankzugriff hat, sieht alles.
10Erkenntnisse
- Angemeldet heißt nicht berechtigt. Jeder Zugriff auf einen Datensatz per ID braucht auch eine Eigentumsprüfung.
- Die Prüfung gehört in die Abfrage, nicht dahinter. Was die Datenbank nicht liefert, kann nicht abfließen.
- Existiert nicht und gehört Ihnen nicht sollten von außen gleich aussehen.
- Ein Test mit zwei Konten in zwei Firmen ist der günstigste Schutz vor dieser ganzen Fehlerklasse.
- Logs mit Benutzer-ID entscheiden darüber, ob sich nach einem Fehler sagen lässt, was passiert ist.
11Nächste Schritte für eine kleine Firma
- 1.Jeden Endpunkt, der per ID liest, um einen Test mit zwei Firmen ergänzen.
- 2.Zwei-Faktor-Authentifizierung mindestens für Portaladministratoren einschalten und fehlgeschlagene Anmeldungen begrenzen.
- 3.Anwendungslogs 90 Tage aufbewahren, außerhalb des Anwendungsservers.
- 4.Row Level Security in PostgreSQL als zweite Schicht erwägen.
Erkennen Sie Ihre eigene Firma darin?
Schreiben Sie uns, worum es geht. Wir antworten innerhalb eines Werktags und sagen Ihnen, ob es Arbeit für uns ist, auch wenn die Antwort nein lautet.