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:
1 parent
92ac826baa
commit
6135c1f9c0
3 files changed
+59
No files matched your search
@@ -20,6 +20,7 @@
|
|||||||
* als ungeprueft gemeldet statt geraten.
|
* als ungeprueft gemeldet statt geraten.
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import path from "node:path";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { prisma } from "../src/lib/prisma";
|
||||||
import { getContentValue } from "../src/lib/content";
|
import { getContentValue } from "../src/lib/content";
|
||||||
import { locales } from "../src/i18n/config";
|
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.`
|
`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 -------------------------------------------------------------
|
// --- Datenbank -------------------------------------------------------------
|
||||||
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
|
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
|
||||||
|
|
||||||
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
|
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", () => {
|
describe("resolveInvoicePath", () => {
|
||||||
test("loest innerhalb des Ablageverzeichnisses auf", () => {
|
test("loest innerhalb des Ablageverzeichnisses auf", () => {
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ export const INVOICE_DIR = path.resolve(
|
|||||||
/*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices"
|
/*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
|
* Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute
|
||||||
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben
|
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben
|
||||||
|
|||||||
Reference in new issue
Block a user