From 6135c1f9c03c762aef9f01eede6c48201538d885 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 17 Sep 2026 18:29:54 +0200 Subject: [PATCH] fix(security): Rechnungsablage kann nicht mehr ins oeffentliche Upload-Verzeichnis geraten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- scripts/preflight.ts | 15 +++++++++++++++ src/lib/invoice-storage.test.ts | 14 ++++++++++++++ src/lib/invoice-storage.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/scripts/preflight.ts b/scripts/preflight.ts index 597a62d..358312c 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -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."); diff --git a/src/lib/invoice-storage.test.ts b/src/lib/invoice-storage.test.ts index e5688fc..d331e78 100644 --- a/src/lib/invoice-storage.test.ts +++ b/src/lib/invoice-storage.test.ts @@ -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[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 diff --git a/src/lib/invoice-storage.ts b/src/lib/invoice-storage.ts index 0aba999..b85e277 100644 --- a/src/lib/invoice-storage.ts +++ b/src/lib/invoice-storage.ts @@ -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