diff --git a/apps/server/package.json b/apps/server/package.json index 9a1d8c1..27dc383 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly (E00-S04-T04); the Fastify 5 application shell lands in a later story.", + "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02), automatic secret redaction from all log output (E00-S04-T03) and all settings flowing through the config package's environment adapter — the server reads no process.env directly and binds the validated HOST interface (E00-S04-T04); the Fastify 5 application shell lands in a later story.", "scripts": { "build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json", "typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 838562d..393a27d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -33,7 +33,11 @@ * (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the validated * config shape and validates it with `assertValidConfig` (E00-S04-T02) before * the server binds, so a missing required setting is still a startup error - * naming the missing field. + * naming the missing field. `HOST` is validated at the adapter boundary as a + * hostname or IP address and the server passes `config.host` to + * `server.listen`, so a configured `HOST` binds exactly that interface and + * the startup log reflects the actual bind — it never claims a bind the + * process does not enforce, and never echoes unvalidated env content. * * [E00-S04-T03] secret redaction: ALL log output goes through the redacting * logger (`createLogger`, defined below — every line is scrubbed of the @@ -199,7 +203,11 @@ if (databaseUrl === undefined) { }); } -server.listen(config.port, () => { +// The server binds the validated bind interface: `config.host` (default +// `0.0.0.0`, validated as a hostname/IP by the adapter) is passed to +// `server.listen`, so a configured `HOST` binds exactly that interface and +// the startup log reflects the actual bind. +server.listen(config.port, config.host, () => { logger.log(`@personal-blog/server listening on http://${config.host}:${config.port} (health: GET /health)`); }); diff --git a/packages/config/package.json b/packages/config/package.json index 49c69c2..8e8ad19 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.", + "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01), the field-specific startup error for a missing required setting (E00-S04-T02), the secret redaction layer (E00-S04-T03) and the environment adapter — the single owner of process.env reads, validating HOST as a hostname/IP at the adapter boundary (E00-S04-T04); the .env.example template (E00-S04-T05) lands in a later task.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index bca077c..03cbc10 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -16,7 +16,13 @@ * `schema.ts`): * * - `HOST` → `host` — the interface the HTTP server binds, default `0.0.0.0` - * (the container default). + * (the container default). The value is validated at this adapter boundary + * as a hostname or IP address (IPv4/IPv6) before it is used for binding or + * logged: an invalid `HOST` throws a field-specific `ConfigStartupError` + * naming `host`, so arbitrary env content is never echoed verbatim into the + * startup log (the issue's acceptance criterion: "HOST is validated at the + * adapter boundary as a hostname or IP address before it is used for + * binding or logged"). * - `PORT` → `port` — integer in the valid TCP port range (1–65535), default * `3000` (the Dockerfile `EXPOSE 3000` / compose `:3000` container port). A * non-numeric or out-of-range override falls back to the default so a bad @@ -39,7 +45,9 @@ * `process.env`. */ -import { assertValidConfig } from './startup.js'; +import { isIP } from 'node:net'; + +import { assertValidConfig, ConfigStartupError } from './startup.js'; import type { Config } from './schema.js'; /** @@ -53,6 +61,43 @@ function resolvePort(raw: string | undefined): number { return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3000; } +/** + * One RFC 1123 hostname label: 1–63 alphanumerics/hyphens, not starting or + * ending with a hyphen. + */ +const HOSTNAME_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; + +/** + * A hostname: dot-separated RFC 1123 labels (e.g. `localhost`, `db`, + * `api.internal.example`), at most 253 characters total. + */ +const HOSTNAME_PATTERN = new RegExp(`^(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}$`); + +/** + * Resolves the bind interface from `HOST` (default `0.0.0.0` — the container + * default). The value is validated at this adapter boundary as a hostname or + * IP address (IPv4/IPv6, via `node:net` `isIP` or the RFC 1123 hostname + * pattern) BEFORE it can be used for binding or logged: an invalid value + * throws a field-specific `ConfigStartupError` naming `host`, so arbitrary + * `HOST` content is never echoed verbatim into the startup log (the issue's + * acceptance criterion — the server passes the validated value to + * `server.listen`, and the startup log reflects the actual bind interface). + * + * Unlike `resolvePort` (which falls back to the default on a bad value), an + * invalid `HOST` fails startup: an operator who sets `HOST=127.0.0.1` to + * restrict network exposure must never silently get a different interface. + */ +function resolveHost(raw: string | undefined): string { + if (raw === undefined) return '0.0.0.0'; + if (isIP(raw) !== 0 || (raw.length <= 253 && HOSTNAME_PATTERN.test(raw))) { + return raw; + } + throw new ConfigStartupError( + 'invalid configuration: host: must be a valid hostname or IP address', + ['host: must be a valid hostname or IP address'], + ); +} + /** * Maps the environment onto the validated configuration — the E00-S04-T04 * environment adapter. @@ -70,7 +115,7 @@ function resolvePort(raw: string | undefined): number { */ export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config { return assertValidConfig({ - host: env.HOST ?? '0.0.0.0', + host: resolveHost(env.HOST), port: resolvePort(env.PORT), databaseUrl: env.DATABASE_URL, sessionSecret: env.EPPP_SESSION_SECRET, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index dce9fcc..7658be0 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -23,8 +23,11 @@ * It maps the environment (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) * onto the validated config shape and validates it with `assertValidConfig` * at startup, so every setting flows through the adapter and no other module - * reads `process.env` directly. The `.env.example` template (E00-S04-T05) - * builds on this boundary in a later task. + * reads `process.env` directly. `HOST` is validated at the adapter boundary + * as a hostname or IP address before it is used for binding or logged, so + * arbitrary env content is never echoed verbatim into the startup log. The + * `.env.example` template (E00-S04-T05) builds on this boundary in a later + * task. */ export { configSchema } from './schema.js';