feat: HOST validated at the adapter boundary and controls the actual bind interface (E00-S04-T04)
The environment adapter now resolves HOST through resolveHost, validating it at the adapter boundary as a hostname (RFC 1123) or IP address (IPv4/IPv6, node:net isIP); an invalid HOST throws a field-specific ConfigStartupError naming host, so arbitrary env content is never used for binding or echoed verbatim into the startup log (issue acceptance criterion, resolving security review finding SEC-3). The server passes config.host to server.listen(config.port, config.host, ...), so a configured HOST binds exactly that interface and the startup log never claims a bind the process does not enforce (resolving SEC-2).
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user