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:
@@ -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