#215: trusted reverse-proxy header / mTLS client-certificate path
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
For perimeters that authenticate before the application (ADR 0021 §4). Off unless BOTH AUTH_PROXY_HEADER and AUTH_PROXY_TRUSTED_PEERS are set — nothing about the header is guessed. The peer check runs against the TCP peer address only (a forwarded header is attacker-influenced): a request carrying the header from any other peer is rejected outright and audited as auth.proxy_rejected (catalogue v1.4) — that is a spoof attempt, not a misconfiguration — even when a valid session cookie rides along. From a trusted peer the header IS the identity; a session cookie never escalates beyond it; with the feature off the header is inert. Mapping is explicit (AUTH_PROXY_MAP: username or e-mail); deliberately no just-in-time creation — the header carries no verified address. The mTLS variant (AUTH_PROXY_MODE=mtls-dn) maps the configured attribute (default CN) out of the certificate subject DN the TLS terminator forwards, under the same peer rules. Session-less proxy requests key the read trail per user (user:<id>). The trust boundary is stated in security.md (the section an assessor reads closest), the VS-NfD security documentation and the hardening guide's deploy table. Tests cover all four decisions: off = inert, trusted peer authenticates (username and DN mapping), untrusted peer rejected + audited, no escalation past a session cookie. Refs #215. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
This commit is contained in:
parent
5796b7a5dd
commit
4c7f001cab
@ -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' },
|
||||
|
||||
@ -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<boolean> {
|
||||
@ -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;
|
||||
|
||||
@ -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 },
|
||||
|
||||
157
apps/api/src/auth/proxy-identity.e2e.db.test.ts
Normal file
157
apps/api/src/auth/proxy-identity.e2e.db.test.ts
Normal file
@ -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<Record<(typeof PROXY_ENV)[number], string>>) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
102
apps/api/src/auth/proxy-identity.service.ts
Normal file
102
apps/api/src/auth/proxy-identity.service.ts
Normal file
@ -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<User | null> {
|
||||
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();
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.*`)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<typeof apiEnvSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user