diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index ac09c72..7ebf176 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -21,6 +21,7 @@ export const AUDIT_EVENTS = { 'auth.login_failed': { severity: 'warning' }, 'auth.login_succeeded': { severity: 'info' }, 'auth.password_reset': { severity: 'notice' }, + 'auth.proxy_rejected': { severity: 'warning' }, 'auth.signup': { severity: 'info' }, 'backup.restore_requested': { severity: 'warning' }, 'backup.run_triggered': { severity: 'info' }, diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index 787779e..e154ea9 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -13,6 +13,7 @@ import type { User } from '@prisma/client'; import type { Request, Response } from 'express'; import { AppConfig } from '../config/app-config.service'; +import { ProxyIdentityService } from './proxy-identity.service'; import { SessionsService } from './sessions.service'; export const SESSION_COOKIE = 'dt_session'; @@ -84,6 +85,7 @@ export class AuthGuard implements CanActivate { private readonly reflector: Reflector, private readonly sessions: SessionsService, private readonly config: AppConfig, + private readonly proxyIdentity: ProxyIdentityService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -94,9 +96,16 @@ export class AuthGuard implements CanActivate { this.assertSameOrigin(request); } - // Attach the user whenever the cookie is valid — public routes may - // still want to know who is asking. - if (rawToken) { + // Trusted-proxy identity first (issue #215): when the perimeter + // authenticates, its header IS the identity for this request — a + // session cookie riding along never escalates beyond it, and an + // untrusted peer carrying the header is rejected inside resolve(). + const proxyUser = await this.proxyIdentity.resolve(request); + if (proxyUser) { + request.user = proxyUser; + } else if (rawToken) { + // Attach the user whenever the cookie is valid — public routes may + // still want to know who is asking. const validated = await this.sessions.validate(rawToken); if (validated) { request.user = validated.user; diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 75b32b7..397d285 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -10,6 +10,7 @@ import { AuthService } from './auth.service'; import { AuthTokensService } from './auth-tokens.service'; import { OidcController } from './oidc.controller'; import { OidcService } from './oidc.service'; +import { ProxyIdentityService } from './proxy-identity.service'; import { SessionsModule } from './sessions.module'; @Module({ @@ -19,6 +20,7 @@ import { SessionsModule } from './sessions.module'; AuthService, AuthTokensService, OidcService, + ProxyIdentityService, // Global default-protected: every route needs a session unless it // opts out with @Public(). { provide: APP_GUARD, useClass: AuthGuard }, diff --git a/apps/api/src/auth/proxy-identity.e2e.db.test.ts b/apps/api/src/auth/proxy-identity.e2e.db.test.ts new file mode 100644 index 0000000..4246adb --- /dev/null +++ b/apps/api/src/auth/proxy-identity.e2e.db.test.ts @@ -0,0 +1,157 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +const HEADER = 'x-auth-user'; + +/** + * Trusted reverse-proxy authentication (issue #215, ADR 0021): off by + * default (header fully ignored), identity only from a trusted TCP peer, a + * spoofing peer rejected AND audited, no privilege escalation past a + * riding-along session cookie, and the mTLS variant mapping a forwarded + * certificate DN attribute. + */ +describe.skipIf(!hasTestDb)('trusted-proxy identity (e2e, issue #215)', () => { + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'proxy identitaet 123'; + + const PROXY_ENV = ['AUTH_PROXY_HEADER', 'AUTH_PROXY_TRUSTED_PEERS', 'AUTH_PROXY_MODE'] as const; + + async function bootApp(env: Partial>) { + for (const key of PROXY_ENV) delete process.env[key]; + Object.assign(process.env, env); + return createTestApp(); + } + + async function makeUser(app: INestApplication, handle: string) { + const users = app.get(UsersService); + const user = await users.createUser({ + username: `${handle}-${suffix}`, + email: `${handle}-${suffix}@example.test`, + displayName: handle, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + return user; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + }); + + afterAll(async () => { + for (const key of PROXY_ENV) delete process.env[key]; + await prisma.auditEntry.deleteMany({ where: { action: 'auth.proxy_rejected' } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + }); + + it('ignores the header entirely while the feature is off', async () => { + const app = await bootApp({}); + try { + await makeUser(app, 'off'); + await request(app.getHttpServer()) + .get('/api/v1/auth/me') + .set(HEADER, `off-${suffix}`) + .expect(401); + } finally { + await app.close(); + } + }); + + it('authenticates a trusted peer, maps by username, and never escalates past a session cookie', async () => { + const app = await bootApp({ + AUTH_PROXY_HEADER: HEADER, + AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1', + }); + try { + const alice = await makeUser(app, 'alice'); + const bob = await makeUser(app, 'bob'); + const api = () => request(app.getHttpServer()); + + const me = await api().get('/api/v1/auth/me').set(HEADER, alice.username).expect(200); + expect(me.body.id).toBe(alice.id); + + // Unknown identity: authenticated by nobody. + await api().get('/api/v1/auth/me').set(HEADER, `ghost-${suffix}`).expect(401); + + // A session cookie riding along never escalates beyond the header + // identity: bob's cookie plus alice's header acts as alice. + const login = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: bob.username, password }) + .expect(200); + const both = await api() + .get('/api/v1/auth/me') + .set('Cookie', sessionCookieOf(login)) + .set(HEADER, alice.username) + .expect(200); + expect(both.body.id).toBe(alice.id); + // Without the header the same cookie still works normally. + const cookieOnly = await api() + .get('/api/v1/auth/me') + .set('Cookie', sessionCookieOf(login)) + .expect(200); + expect(cookieOnly.body.id).toBe(bob.id); + } finally { + await app.close(); + } + }); + + it('rejects and audits the header from an untrusted peer — even with a valid session', async () => { + const app = await bootApp({ + AUTH_PROXY_HEADER: HEADER, + AUTH_PROXY_TRUSTED_PEERS: '203.0.113.9', + }); + try { + const carol = await makeUser(app, 'carol'); + const api = () => request(app.getHttpServer()); + await api().get('/api/v1/auth/me').set(HEADER, carol.username).expect(403); + const audit = await prisma.auditEntry.findFirst({ + where: { action: 'auth.proxy_rejected' }, + orderBy: { at: 'desc' }, + }); + expect(audit?.details).toMatchObject({ header: HEADER }); + + const login = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: carol.username, password }) + .expect(200); + // The spoofed header poisons the request even alongside a valid + // cookie — rejecting is safer than guessing which identity wins. + await api() + .get('/api/v1/auth/me') + .set('Cookie', sessionCookieOf(login)) + .set(HEADER, carol.username) + .expect(403); + } finally { + await app.close(); + } + }); + + it('maps the configured DN attribute in mtls-dn mode', async () => { + const app = await bootApp({ + AUTH_PROXY_HEADER: HEADER, + AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1', + AUTH_PROXY_MODE: 'mtls-dn', + }); + try { + const dana = await makeUser(app, 'dana'); + const me = await request(app.getHttpServer()) + .get('/api/v1/auth/me') + .set(HEADER, `CN=${dana.username},OU=unit,O=example`) + .expect(200); + expect(me.body.id).toBe(dana.id); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/api/src/auth/proxy-identity.service.ts b/apps/api/src/auth/proxy-identity.service.ts new file mode 100644 index 0000000..e746f1e --- /dev/null +++ b/apps/api/src/auth/proxy-identity.service.ts @@ -0,0 +1,102 @@ +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { AuditService } from '../audit/audit.service'; +import { AppConfig } from '../config/app-config.service'; +import { UsersService } from '../users/users.service'; + +import type { AuthedRequest } from './auth.guard'; + +/** + * Trusted reverse-proxy authentication (issue #215, ADR 0021): the + * perimeter (proxy or mTLS terminator) authenticates and forwards the + * identity in a configured header; the application trusts that header ONLY + * when the request's TCP peer is on the configured allowlist. + * + * The trust boundary, stated plainly (security.md §External + * authentication): everything upstream of the configured peers is the + * operator's responsibility; the application's contribution is that the + * header is worthless from anywhere else — a header from an untrusted peer + * rejects the request outright and lands in the audit trail + * (`auth.proxy_rejected`), because someone is attempting a spoof. + * + * Deliberately NO just-in-time creation here: the header carries no + * verified e-mail, so accounts must already exist (the IdP/OIDC path or an + * admin creates them) and are mapped by username or e-mail — explicit + * configuration, never guessed. + */ +@Injectable() +export class ProxyIdentityService { + constructor( + private readonly users: UsersService, + private readonly audit: AuditService, + private readonly config: AppConfig, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(ProxyIdentityService.name); + } + + /** Enabled only with BOTH the header name and a non-empty allowlist. */ + get enabled(): boolean { + return Boolean( + this.config.env.AUTH_PROXY_HEADER && this.config.env.AUTH_PROXY_TRUSTED_PEERS.length > 0, + ); + } + + /** + * Resolves the request's proxy identity, or null when the feature is off + * or the header is absent. Throws 403 (audited) for an untrusted peer + * carrying the header, 401 for an unknown identity. + */ + async resolve(request: AuthedRequest): Promise { + if (!this.enabled) return null; + const headerName = this.config.env.AUTH_PROXY_HEADER!.toLowerCase(); + const raw = request.headers[headerName]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (!value) return null; + + const peer = normalizePeer(request.socket.remoteAddress ?? ''); + const trusted = this.config.env.AUTH_PROXY_TRUSTED_PEERS.map(normalizePeer); + if (!trusted.includes(peer)) { + // A spoof attempt, not a misconfiguration: reject and evidence it. + await this.audit.record({ + action: 'auth.proxy_rejected', + details: { peer, header: headerName }, + }); + throw new ForbiddenException({ code: 'proxy_peer_untrusted' }); + } + + const identity = this.extractIdentity(value); + if (!identity) throw new UnauthorizedException({ code: 'proxy_identity_unknown' }); + const user = + this.config.env.AUTH_PROXY_MAP === 'email' + ? await this.users.findByEmail(identity) + : await this.users.findByUsernameOrEmail(identity); + if (!user || user.status !== 'ACTIVE') { + throw new UnauthorizedException({ code: 'proxy_identity_unknown' }); + } + return user; + } + + /** `plain`: the value is the identity. `mtls-dn`: the value is a client + * certificate subject DN as forwarded by the TLS terminator; the identity + * is the configured attribute (default CN). */ + private extractIdentity(value: string): string | null { + if (this.config.env.AUTH_PROXY_MODE === 'plain') return value.trim() || null; + const attribute = this.config.env.AUTH_PROXY_DN_ATTRIBUTE.toLowerCase(); + for (const part of value.split(/[,/]/)) { + const [key, ...rest] = part.split('='); + if (key?.trim().toLowerCase() === attribute) { + const extracted = rest.join('=').trim(); + return extracted || null; + } + } + return null; + } +} + +/** `::ffff:127.0.0.1` and `127.0.0.1` are the same peer. */ +function normalizePeer(address: string): string { + return address.replace(/^::ffff:/i, '').trim(); +} diff --git a/apps/api/src/read-trail/read-actor.ts b/apps/api/src/read-trail/read-actor.ts index b832ca9..84de32b 100644 --- a/apps/api/src/read-trail/read-actor.ts +++ b/apps/api/src/read-trail/read-actor.ts @@ -10,8 +10,13 @@ export function readActorOf(request: { user?: { id: string } | null; sessionId?: string; }): ReadActor { - return { - actorId: request.user?.id ?? null, - sessionKey: request.sessionId ? `session:${request.sessionId}` : 'anon', - }; + // Session-less authenticated requests (trusted-proxy identity, #215) key + // per user — the proxy re-authenticates every request, so the user is + // the closest thing to a session the channel has. + const sessionKey = request.sessionId + ? `session:${request.sessionId}` + : request.user + ? `user:${request.user.id}` + : 'anon'; + return { actorId: request.user?.id ?? null, sessionKey }; } diff --git a/docs/architecture/adr/0021-external-authentication.md b/docs/architecture/adr/0021-external-authentication.md index 52e877d..0c65e94 100644 --- a/docs/architecture/adr/0021-external-authentication.md +++ b/docs/architecture/adr/0021-external-authentication.md @@ -67,6 +67,20 @@ expect the application to trust a header or a client certificate. plus the explicit `GET /auth/oidc/link` flow (audited `auth.identity_linked`) is the documented linking rule. +## Decisions taken in #215 + +- **Peer check against the TCP peer address only** — a forwarded + `X-Forwarded-For` is attacker-influenced and never consulted. +- **Untrusted peer + header ⇒ reject the whole request (403) and audit** + (`auth.proxy_rejected`), even when a valid session cookie rides along: + a poisoned request is rejected, not partially trusted. +- **Trusted peer + header ⇒ the header is the identity**; a session + cookie never escalates beyond it. No just-in-time creation — the header + carries no verified e-mail, so accounts come from OIDC or an admin. +- **mTLS is proxy-terminated**: the application never touches TLS; the + terminator forwards the certificate subject DN and the configured + attribute (default CN) is the identity, under the same peer rules. + ## Consequences - Bootstrapping needs a documented answer: the first-run wizard creates a diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md index 4adf246..d4784fe 100644 --- a/docs/architecture/audit-events.md +++ b/docs/architecture/audit-events.md @@ -1,8 +1,9 @@ # Audit event catalogue -**Catalogue version 1.3 (2026-07-31; 1.3 adds `auth.identity_linked`, -issue #214; 1.2 added `read_trail.pruned`, issue #224; 1.1 added -`page.classification_*`, issue #205).** +**Catalogue version 1.4 (2026-07-31; 1.4 adds `auth.proxy_rejected`, +issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added +`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`, +issue #205).** This is the operator-facing contract for the audit trail: every event id the application can emit, with its trigger, severity, actor/target @@ -63,14 +64,15 @@ failure), `warning` = feeds detection (suspicious or destructive), ### Authentication (`auth.*`) -| Id | Trigger | Severity | Actor | Target | Fields | -| ---------------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------ | ------ | ------------------------------------- | -| `auth.signup` | Account created via self-registration or OIDC just-in-time (issue #214) | info | the new user | — | `provider` (optional; absent = local) | -| `auth.email_verified` | E-mail double-opt-in completed | info | the verified user | — | — | -| `auth.login_failed` | Login rejected (bad credentials) | warning | matched user, `null` if unknown name | — | — | -| `auth.login_succeeded` | Session created | info | the user | — | `provider` (optional; absent = local) | -| `auth.password_reset` | Password changed via reset token | notice | the user | — | — | -| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` | +| Id | Trigger | Severity | Actor | Target | Fields | +| ---------------------- | ----------------------------------------------------------------------------------------- | -------- | ------------------------------------ | ------ | ------------------------------------- | +| `auth.signup` | Account created via self-registration or OIDC just-in-time (issue #214) | info | the new user | — | `provider` (optional; absent = local) | +| `auth.email_verified` | E-mail double-opt-in completed | info | the verified user | — | — | +| `auth.login_failed` | Login rejected (bad credentials) | warning | matched user, `null` if unknown name | — | — | +| `auth.login_succeeded` | Session created | info | the user | — | `provider` (optional; absent = local) | +| `auth.password_reset` | Password changed via reset token | notice | the user | — | — | +| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` | +| `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` | ### Access & membership (`grant.*`, `member.*`) diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 72f9cec..cad29f4 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -73,6 +73,24 @@ or sloppy plugin authors, compromised dependencies. is deliberately NOT implemented: sessions are short-bounded, and the claim-mapping revocation path (#217) plus the account-disable flag cover the leaver case — recorded in ADR 0021. +- **Trusted-proxy / mTLS path (issue #215)** — for perimeters that + authenticate before the application. **The trust boundary, precisely:** + the identity header (`AUTH_PROXY_HEADER`) is honoured if and only if + the request's **TCP peer address** — never a forwarded header — is on + `AUTH_PROXY_TRUSTED_PEERS`. Off unless both are set; nothing about the + header is ever guessed. A request carrying the header from any other + peer is rejected outright (403) and audited (`auth.proxy_rejected`) — + that is a spoof attempt, not a misconfiguration. A session cookie + riding alongside the header never escalates beyond the header identity; + with the feature off the header is inert. Mapping is explicit + (`AUTH_PROXY_MAP`: the value is the local username or e-mail; no + just-in-time creation — the header carries no verified address). The + mTLS variant (`AUTH_PROXY_MODE=mtls-dn`) expects the TLS terminator to + forward the client-certificate subject DN in the same header and maps + the configured attribute (`AUTH_PROXY_DN_ATTRIBUTE`, default CN). + Everything upstream of the trusted peers — TLS termination, certificate + validation, header hygiene (the proxy MUST strip the header from + incoming traffic) — is the operator's platform responsibility. - **Keycloak verification procedure** (repeatable): run `docker run --name keycloak-local -p 8089:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0 start-dev`; via `kcadm.sh`: create realm `dorfteich`, a public client diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 06c1e9f..97c77af 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -38,7 +38,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_ - [x] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007 ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214 -- [ ] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS- +- [x] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS- Client-Zertifikat · 2 AT · #215 - [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216 diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index e4c51da..74bf718 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -46,15 +46,16 @@ Settings-Cache ist in-process (operations.md). ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) -| Variable | Referenzwert | Warum | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | -| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | -| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | -| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | -| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | -| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | -| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | +| Variable | Referenzwert | Warum | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | +| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | +| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | +| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | +| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | +| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | +| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). | ### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR) diff --git a/docs/vs-nfd/60-sicherheitsdokumentation.md b/docs/vs-nfd/60-sicherheitsdokumentation.md index e17766b..5459789 100644 --- a/docs/vs-nfd/60-sicherheitsdokumentation.md +++ b/docs/vs-nfd/60-sicherheitsdokumentation.md @@ -210,10 +210,14 @@ einzelne Sync-Frames. Anonyme Leser teilen sich den Marker `anon` 'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS); Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189). 2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen; - der Proxy ist die einzige Eintrittsstelle. Der geplante - Proxy-Header-/mTLS-Authentisierungspfad (#215) verschiebt die - Authentisierungs-Vertrauensgrenze an genau diese Stelle — bis dahin - trägt der Proxy nur Transport. + der Proxy ist die einzige Eintrittsstelle. Der optionale + Proxy-Header-/mTLS-Authentisierungspfad (#215, Default aus) legt die + Authentisierungs-Vertrauensgrenze an genau diese Stelle: der + konfigurierte Header gilt NUR vom TCP-Peer der Allowlist + (`AUTH_PROXY_TRUSTED_PEERS`); von jedem anderen Peer wird die + Anfrage abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy + MUSS den Header aus eingehendem Verkehr strippen (Betreiberpflicht; + Details: security.md §External authentication). 3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup` sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei Zugang dorthin. diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 2d10890..81d58db 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -168,6 +168,32 @@ export const apiEnvSchema = z.object({ OIDC_SCOPES: z.string().min(1).default('openid profile email'), /** Button label the login page shows, e.g. the agency SSO's name. */ OIDC_PROVIDER_LABEL: z.string().min(1).default('Single Sign-On'), + /** + * Trusted reverse-proxy authentication (ADR 0021, issue #215) — for + * environments that terminate authentication (or mTLS) at the perimeter. + * OFF unless BOTH the header name and the peer allowlist are set: a + * trusted header is a loaded gun, so nothing about it is guessed. The + * peer check runs against the TCP peer address (never a forwarded + * header); a request carrying the header from any other peer is + * rejected and audited. + */ + AUTH_PROXY_HEADER: z.string().min(1).optional(), + AUTH_PROXY_TRUSTED_PEERS: z + .string() + .optional() + .transform((value) => + (value ?? '') + .split(',') + .map((peer) => peer.trim()) + .filter(Boolean), + ), + /** How the header value maps to a local account: as its username or its + * e-mail address. No just-in-time creation — the account must exist. */ + AUTH_PROXY_MAP: z.enum(['username', 'email']).default('username'), + /** `mtls-dn`: the header carries a client-certificate subject DN (as the + * proxy forwards it) and the identity is the configured attribute. */ + AUTH_PROXY_MODE: z.enum(['plain', 'mtls-dn']).default('plain'), + AUTH_PROXY_DN_ATTRIBUTE: z.string().min(1).default('CN'), }); export type ApiEnv = z.infer;