fix(security): Rechnungsablage kann nicht mehr ins oeffentliche Upload-Verzeichnis geraten

app/uploads/[...path] liefert JEDE Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus — das ist fuer Produktfotos gewollt. Dass Rechnungen nicht
darunter liegen, war bisher aber allein eine Frage der richtigen
Konfiguration: ein INVOICE_DIR="/app/uploads/rechnungen" haette genuegt,
damit fremde Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar
sind, der den Pfad erraet.

Das waere im Betrieb nicht aufgefallen, denn funktioniert haette weiterhin
alles: Ablegen, Versenden und Abrufen ueber /rechnung/<nummer>/pdf haetten
sich genauso verhalten wie vorher. Nur haette daneben eine zweite,
ungeschuetzte Tuer zu denselben Dateien gestanden.

Drei Sicherungen statt einer Anmerkung in der Dokumentation:

- lib/invoice-storage.ts wirft beim Laden des Moduls, wenn INVOICE_DIR
  innerhalb von UPLOAD_DIR liegt. Lieber startet die Anwendung nicht, als
  dass sie Belege oeffentlich ausliefert.
- "pnpm preflight" meldet denselben Fall als Blocker, also bereits vor dem
  Deploy statt erst beim Start.
- Ein Test haelt die Trennung fest, damit sie nicht beim naechsten Umbau
  unbemerkt verloren geht.

Der uebrige Sicherheitsdurchgang ueber die Aenderungen dieser Sitzung ergab
keine ausnutzbaren Befunde: der Admin-Bereich ist dreifach abgesichert
(Proxy, Layout, jede Server Action einzeln), jeweils autoritativ gegen die
Datenbank und mit erzwungener Zwei-Faktor-Authentifizierung; das returnTo
des Sync-Knopfes wird gegen eine feste Liste geprueft; Pfade aus der
Datenbank werden weiterhin gegen ihr Wurzelverzeichnis validiert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 18:29:54 +02:00
1 parent 92ac826baa
commit 6135c1f9c0
3 files changed
+59

No files matched your search

+14
View File
@@ -1,4 +1,5 @@
import { describe, test, expect } from "vitest";
import path from "node:path";
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
@@ -58,6 +59,19 @@ describe("invoiceRelativePath", () => {
});
});
describe("Trennung von der oeffentlichen Upload-Ablage", () => {
test("die Rechnungsablage liegt nicht im oeffentlich ausgelieferten Upload-Verzeichnis", () => {
// Arrange: app/uploads/[...path] liefert JEDE Datei unterhalb von
// UPLOAD_DIR ohne Anmeldung aus.
const uploads = path.resolve(process.env.UPLOAD_DIR || "./uploads");
// Act & Assert: laege INVOICE_DIR darin, waeren fremde Rechnungen samt
// Anschrift und Bestellinhalt fuer jeden abrufbar, der den Pfad erraet.
expect(INVOICE_DIR).not.toBe(uploads);
expect(INVOICE_DIR.startsWith(uploads + path.sep)).toBe(false);
});
});
describe("resolveInvoicePath", () => {
test("loest innerhalb des Ablageverzeichnisses auf", () => {
// Arrange & Act
+30
View File
@@ -24,6 +24,36 @@ export const INVOICE_DIR = path.resolve(
/*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices"
);
/**
* Stellt sicher, dass die Rechnungsablage NICHT innerhalb des oeffentlich
* ausgelieferten Upload-Verzeichnisses liegt.
*
* Die Trennung der beiden Verzeichnisse ist die einzige Sicherung dagegen,
* dass Rechnungen ueber app/uploads/[...path] abrufbar werden — jene Route
* liefert JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Bisher hing
* diese Trennung allein daran, dass jemand INVOICE_DIR richtig setzt. Ein
* INVOICE_DIR="/app/uploads/rechnungen" haette genuegt, damit fremde
* Rechnungen samt Anschrift und Bestellinhalt oeffentlich abrufbar sind — und
* es waere nicht aufgefallen, denn funktioniert haette weiterhin alles.
*
* Deshalb eine harte Pruefung beim Laden des Moduls statt eines blossen
* Hinweises in der Dokumentation: lieber startet die Anwendung nicht, als
* dass sie Belege oeffentlich ausliefert.
*/
function assertInvoicesAreNotPubliclyServed(): void {
const uploads = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
const withSep = uploads.endsWith(path.sep) ? uploads : uploads + path.sep;
if (INVOICE_DIR === uploads || INVOICE_DIR.startsWith(withSep)) {
throw new Error(
`INVOICE_DIR ("${INVOICE_DIR}") liegt innerhalb von UPLOAD_DIR ("${uploads}"). ` +
"Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert — Rechnungen waeren damit " +
"fuer jeden abrufbar, der den Pfad erraet. Bitte ein eigenes Verzeichnis ausserhalb von UPLOAD_DIR waehlen."
);
}
}
assertInvoicesAreNotPubliclyServed();
/**
* Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben