diff --git a/deploy/README.md b/deploy/README.md index a236d31..05e3310 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -4,7 +4,7 @@ Die Anwendung startet ohne Schemaänderungen und ohne Demo-Seeding. Datenbankmig ## Konfiguration -Werte aus `production.env.example` als Hosting-Secrets hinterlegen. Im Compose-Stack erzeugt der Dienst `migrate` automatisch ein separates Runtime-Passwort im privaten Secret-Volume, wendet die Migrationen an, vergibt die eingeschränkten DB-Rechte und bereitet die Upload-/Cache-Volumes vor. Danach startet `app` mit UID/GID 1001. Das Eigentümerpasswort steht nur PostgreSQL und dem Initialisierungsdienst zur Verfügung. Für manuelle CLI-Befehle außerhalb von Compose bleibt `DATABASE_URL` die jeweilige administrative oder eingeschränkte URL. Keine echten Secrets im Repository speichern. +Werte aus `production.env.example` als Hosting-Secrets hinterlegen. Im Compose-Stack erzeugt der Dienst `migrate` automatisch ein separates Runtime-Passwort im privaten Secret-Volume, wendet die Migrationen an, vergibt die eingeschränkten DB-Rechte und bereitet die Upload-/Cache-Volumes vor. Danach startet `app` mit UID/GID 1001. Administrative Zugangsdaten werden im App-Service ausdrücklich leer überschrieben, damit eine zusätzliche globale `env_file`-Weitergabe durch das Hosting sie nicht in den App-Container einschleust. Der Start entfernt diese Variablen zusätzlich vor dem App- und Worker-Prozess. Das Eigentümerpasswort steht nur PostgreSQL und dem Initialisierungsdienst zur Verfügung. Für manuelle CLI-Befehle außerhalb von Compose bleibt `DATABASE_URL` die jeweilige administrative oder eingeschränkte URL. Keine echten Secrets im Repository speichern. `AUTH_URL`, `NEXTAUTH_URL` und `NEXT_PUBLIC_SITE_URL` müssen dieselbe kanonische HTTPS-Origin verwenden. Der Reverse Proxy terminiert TLS, routet intern auf App-Port 3000 und überschreibt weitergeleitete Host-/IP-Header. `TRUSTED_CLIENT_IP_HEADER` nur setzen, wenn der Proxy den gewählten Header (`x-real-ip` oder `cf-connecting-ip`) zuverlässig überschreibt. Ohne diese Einstellung gelten weiterhin gemeinsame globale und identitätsbezogene Datenbanklimits. Proxyseitig Body-Limit von höchstens 26 MiB, Timeouts und zusätzliche Request-Limits setzen. diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index cbced6b..e84a951 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -27,6 +27,12 @@ services: NODE_ENV: production PORT: 3000 HOSTNAME: 0.0.0.0 + # Override host-wide env_file injection: administrative credentials belong to migrate only. + DATABASE_URL: "" + MIGRATION_DATABASE_URL: "" + APP_DATABASE_PASSWORD: "" + POSTGRES_PASSWORD: "" + PGADMIN_DEFAULT_PASSWORD: "" APP_DATABASE_PASSWORD_FILE: /app/runtime-secrets/database-password APP_DATABASE_NAME: ${POSTGRES_DB:-kita_planer} AUTH_SECRET: ${AUTH_SECRET:?} diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs index 57b77fa..1b9319b 100644 --- a/scripts/prepare-standalone.mjs +++ b/scripts/prepare-standalone.mjs @@ -5,3 +5,4 @@ mkdirSync('.next/standalone/scripts', { recursive: true }); cpSync('scripts/start-production.sh', '.next/standalone/scripts/start-production.sh'); cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs'); cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs'); +cpSync('scripts/verify-runtime-isolation.mjs', '.next/standalone/scripts/verify-runtime-isolation.mjs'); diff --git a/scripts/start-production.sh b/scripts/start-production.sh index 27c573a..f6eb204 100755 --- a/scripts/start-production.sh +++ b/scripts/start-production.sh @@ -5,6 +5,7 @@ if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then DATABASE_URL="$(node scripts/runtime-database-url.mjs)" export DATABASE_URL fi +unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL # Schema changes are applied by the maintenance image before runtime startup. if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then node scripts/notdienst-alarm-worker.mjs & diff --git a/scripts/verify-runtime-isolation.mjs b/scripts/verify-runtime-isolation.mjs new file mode 100644 index 0000000..faeff87 --- /dev/null +++ b/scripts/verify-runtime-isolation.mjs @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { PrismaClient } from '@prisma/client'; +let prisma; +try { + const main = Object.fromEntries(readFileSync('/proc/1/environ', 'utf8').split('\0').filter(Boolean).map(line => { + const index = line.indexOf('='); return [line.slice(0, index), line.slice(index + 1)]; + })); + for (const key of ['POSTGRES_PASSWORD', 'PGADMIN_DEFAULT_PASSWORD', 'APP_DATABASE_PASSWORD', 'MIGRATION_DATABASE_URL']) { + if (process.env[key] || main[key]) throw new Error('Administrative credential reached the app.'); + } + // Configured global/legacy URLs must not expose an administrative login either. + if (process.env.DATABASE_URL) throw new Error('Hosting injected a legacy database URL.'); + if (new URL(main.DATABASE_URL).username !== 'kita_runtime') throw new Error('Wrong application database role.'); + prisma = new PrismaClient({ datasourceUrl: main.DATABASE_URL }); + const [row] = await prisma.$queryRawUnsafe('SELECT current_user'); + if (row.current_user !== 'kita_runtime') throw new Error('Wrong effective database role.'); + console.log('Runtime isolation verified: no administrative credentials in container/app environment; current_user=kita_runtime.'); +} catch { + console.error('Runtime isolation verification failed. Credential values were not logged.'); + process.exitCode = 1; +} finally { + await prisma?.$disconnect(); +}