From 810716e4e4aef2cebd3454d61d68e050db702faa Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 16:12:32 +0200 Subject: [PATCH] Fehlende Umgebungsvariablen durchreichen - und dauerhaft absichern Der Dienst "app" zaehlt seine Umgebungsvariablen einzeln auf; ein env_file gibt es nicht. Drei Werte, die die Anwendung ausliest, standen dort nicht: ERSTEINRICHTUNG_GEHEIMNIS, VERTRAUE_PROXY (beide seit v1.11.0) und S3_SSE (seit v1.11.1). Wer sie laut .env.example und INSTALLATION.md eintrug, brachte sie damit nicht in den Behaelter - die Anleitung war an dieser Stelle schlicht falsch. Besonders unangenehm bei VERTRAUE_PROXY: Der Wert fehlt still, die Anmeldesperre zaehlt dann nur nach E-Mail-Adresse statt zusaetzlich nach Anschluss, und im Betrieb faellt nichts auf. VERTRAUE_PROXY bekommt bewusst KEINE Vorgabe "true". Die strengere Einstellung bleibt die Voreinstellung; wer einen Proxy davorstellt, sagt es ausdruecklich. Ohne Proxy ist der weitergereichte Kopf frei erfunden. Der eigentliche Ertrag ist der Test: Er liest, welche Variablen env.ts ausliest - ueber process.env ebenso wie ueber pflicht(), zahl(), schalter() und schluessel32() -, vergleicht das mit dem, was die Compose-Datei dem Dienst mitgibt, und meldet jede Luecke. Ausnahmen sind aufgezaehlt und begruendet. Damit wiederholt sich der Fehler beim naechsten neuen Wert nicht: Wer ihn einfuehrt und das Durchreichen vergisst, sieht es sofort. Gegen drei Mutationen geprueft: Wert wieder entfernt, Vorgabe "true" gesetzt, S3_SSE entfernt - jede wird rot. Refs #43 --- docker-compose.yml | 12 +++ docs/INSTALLATION.md | 7 ++ tests/umgebung-durchreichen.test.ts | 113 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 tests/umgebung-durchreichen.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index 5125fa4..3512a2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,15 @@ services: AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env} AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env} AUTH_TRUST_HOST: 'true' + # Ohne diesen Wert laesst sich der erste Zugang nicht anlegen - siehe + # src/server/actions/ersteinrichtung.ts. Leer heisst: gesperrt. + ERSTEINRICHTUNG_GEHEIMNIS: ${ERSTEINRICHTUNG_GEHEIMNIS:-} + # Bewusst OHNE Vorgabe "true": Die strengere Einstellung bleibt die + # Voreinstellung. Wer einen Reverse Proxy davorstellt - der + # dokumentierte Betrieb tut das -, sagt es in der .env ausdruecklich. + # Ohne den Wert zaehlt die Anmeldesperre nur nach E-Mail-Adresse. + VERTRAUE_PROXY: ${VERTRAUE_PROXY:-} + VERTRAUE_PROXY_STUFEN: ${VERTRAUE_PROXY_STUFEN:-} ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar} # Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt # leere Schluesselvariablen - siehe src/lib/env.ts. @@ -110,6 +119,9 @@ services: S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} + # Leer lassen. Das mitgelieferte MinIO laeuft ohne KMS und lehnt jede + # Ablage ab, sobald hier etwas steht - siehe .env.example. + S3_SSE: ${S3_SSE:-} UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400} OCR_AKTIV: ${OCR_AKTIV:-true} SMTP_HOST: ${SMTP_HOST:-} diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 1eab7c7..9aed948 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -184,6 +184,13 @@ cp .env.example .env nano .env ``` +> **Die `.env` allein genügt nicht.** Die `docker-compose.yml` reicht die +> Werte einzeln an den Behälter durch — sie hat kein `env_file:`. Ein +> selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die +> Anwendung nicht, auch wenn er in der `.env` steht. Alle hier +> beschriebenen Werte sind eingetragen; ein Test wacht darüber +> (`tests/umgebung-durchreichen.test.ts`). + Diese Werte **musst** du setzen: ```bash diff --git a/tests/umgebung-durchreichen.test.ts b/tests/umgebung-durchreichen.test.ts new file mode 100644 index 0000000..1107f15 --- /dev/null +++ b/tests/umgebung-durchreichen.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +/** + * Was die Anwendung liest, muss der Behälter auch bekommen. + * + * Die `docker-compose.yml` zählt die Umgebungsvariablen des Dienstes `app` + * einzeln auf — sie hat kein `env_file:`. Wer einen neuen Wert einführt, + * ihn in `.env.example` dokumentiert und in `env.ts` ausliest, hat damit + * noch nichts erreicht: Im Behälter kommt er nicht an. + * + * Genau das ist mit v1.11.0 passiert. `ERSTEINRICHTUNG_GEHEIMNIS` und + * `VERTRAUE_PROXY` standen in der Anleitung, in der Beispieldatei und im + * Code — nur nicht in der Compose-Datei. Die Anleitung war damit schlicht + * falsch, und der Fehler war von außen nicht zu sehen: `VERTRAUE_PROXY` + * fehlt still, die Anmeldesperre zählt dann eine Dimension weniger. + * + * Dieser Test liest beide Seiten und vergleicht sie. Er prüft kein + * Verhalten, sondern die Verpackung — wie `skripte.test.ts`. + */ + +const compose = readFileSync('docker-compose.yml', 'utf8') +const envDatei = readFileSync('src/lib/env.ts', 'utf8') +const beispiel = readFileSync('.env.example', 'utf8') + +/** + * Alles, was `env.ts` über seine Helfer ausliest. + * + * `process.env.X` direkt wird mitgenommen, ebenso `pflicht('X')`, + * `zahl('X', …)`, `schalter('X', …)` und `schluessel32('X')`. + */ +function gelesen(): Set { + const namen = new Set() + + for (const treffer of envDatei.matchAll(/process\.env\.([A-Z_0-9]+)/g)) { + namen.add(treffer[1]!) + } + for (const treffer of envDatei.matchAll( + /(?:pflicht|zahl|schalter|schluessel32)\('([A-Z_0-9]+)'/g, + )) { + namen.add(treffer[1]!) + } + + return namen +} + +/** Die Variablen, die der Dienst `app` durchreicht. */ +function durchgereicht(): Set { + const abschnitt = compose.slice(compose.indexOf('\n app:'), compose.indexOf('\n sicherung:')) + const namen = new Set() + for (const treffer of abschnitt.matchAll(/^ {6}([A-Z_0-9]+):/gm)) { + namen.add(treffer[1]!) + } + return namen +} + +/** + * Was absichtlich nicht durchgereicht wird - jeweils mit Grund. + * + * Wer hier etwas ergänzt, nimmt es bewusst aus der Prüfung. Das ist + * erlaubt, aber es soll dabeistehen, warum. + */ +const AUSNAHMEN = new Map([ + ['NODE_ENV', 'wird im Dienst gesetzt, kommt nicht aus der .env'], + ['NEXT_PUBLIC_GIT_COMMIT', 'Bauargument, steckt schon im Abbild'], + ['NEXT_PUBLIC_BUILD_ZEIT', 'Bauargument, steckt schon im Abbild'], + ['DATEI_SPEICHER_PFAD', 'nur fuer den Dateitreiber; im Behaelter laeuft s3'], + ['TESSERACT_BEFEHL', 'Standard "tesseract" stimmt im Abbild immer'], +]) + +describe('Umgebungsvariablen erreichen den Behälter', () => { + it('reicht jede Variable durch, die die Anwendung ausliest', () => { + const fehlend = [...gelesen()] + .filter((name) => !durchgereicht().has(name)) + .filter((name) => !AUSNAHMEN.has(name)) + // ENC_KEY_V2, V3 ... werden dynamisch gesucht; nur V1 ist Pflicht. + .filter((name) => !/^ENC_KEY_V\d+$/.test(name)) + .sort() + + expect( + fehlend, + 'Diese Werte liest die Anwendung, aber die docker-compose.yml gibt sie ' + + 'dem Dienst "app" nicht mit. In der .env stehen sie dann wirkungslos.', + ).toEqual([]) + }) + + it('erklärt jede Ausnahme', () => { + for (const [name, grund] of AUSNAHMEN) { + expect(grund.length, `Ausnahme ${name} ohne Begründung`).toBeGreaterThan(10) + } + }) + + it('dokumentiert die beiden Werte aus v1.11.0 in der Beispieldatei', () => { + // Sie waren der Anlass. Verschwinden sie aus .env.example, merkt es + // sonst niemand, bis jemand eine Neuinstallation versucht. + expect(beispiel).toMatch(/ERSTEINRICHTUNG_GEHEIMNIS=/) + expect(beispiel).toMatch(/VERTRAUE_PROXY=/) + }) + + it('lässt VERTRAUE_PROXY ohne Vorgabewert', () => { + // Die strengere Einstellung bleibt die Voreinstellung. Ein Vorgabewert + // "true" waere bequem und falsch: Ohne Proxy ist der weitergereichte + // Kopf frei erfunden. + expect(compose).toMatch(/VERTRAUE_PROXY: \$\{VERTRAUE_PROXY:-\}/) + }) + + it('findet überhaupt etwas - sonst prüft der Test nichts', () => { + // Ein stiller Ausfall der Erkennung waere schlimmer als eine Luecke: + // Der Test staende gruen da und pruefte nichts. + expect(gelesen().size).toBeGreaterThan(15) + expect(durchgereicht().size).toBeGreaterThan(15) + }) +})