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

+15
View File
@@ -20,6 +20,7 @@
* als ungeprueft gemeldet statt geraten.
*/
import "dotenv/config";
import path from "node:path";
import { prisma } from "../src/lib/prisma";
import { getContentValue } from "../src/lib/content";
import { locales } from "../src/i18n/config";
@@ -130,6 +131,20 @@ should(
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
);
// --- Rechnungsablage -------------------------------------------------------
// Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert
// (app/uploads/[...path]). Liegt die Rechnungsablage darin, sind fremde
// Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar, der den
// Pfad erraet — und im Betrieb faellt das nicht auf, weil weiterhin alles
// funktioniert.
const invoiceDir = path.resolve(value("INVOICE_DIR") || "./invoices");
const uploadDir = path.resolve(value("UPLOAD_DIR") || "./uploads");
must(
"INVOICE_DIR liegt ausserhalb von UPLOAD_DIR",
invoiceDir !== uploadDir && !invoiceDir.startsWith(uploadDir + path.sep),
`Rechnungen (${invoiceDir}) duerfen nicht unterhalb des oeffentlich ausgelieferten Upload-Verzeichnisses (${uploadDir}) liegen. Eigenes Verzeichnis waehlen, siehe .env.example.`
);
// --- Datenbank -------------------------------------------------------------
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");