The `migration-smoke` job fails on the Gitea runner at `docker port ekklesia-database-tls-<pid> 5432` with `no public port '5432' published`; the later invalid-URL and `ECONNREFUSED 127.0.0.1:5432` failures all cascade from it. Gitea executes the job inside a container and drives sibling containers through the runner's Docker daemon. The harness held two host-only assumptions, both reproduced in a nested job container and fixed here. Bind-mount source paths are resolved by the daemon against the runner host's filesystem. The certificate directory is created in the job container's `/tmp`, which does not exist there, so the daemon silently creates an empty directory, the entrypoint's `cp` fails, the container exits, and `docker port` reports no published port for an exited container. The material is now transferred with `docker cp` into the created-but-not-yet-started container, which crosses the daemon boundary correctly in both topologies, and only the server leaf and its key are transferred — both CA private keys stay in the harness directory. A published port lands on the runner host's loopback, a different network namespace from the job container's; measured, a container the daemon published at `127.0.0.1:32907` is `ECONNREFUSED` at that address from inside the job container. Under a containerized job the harness now publishes nothing and joins both containers to one isolated user-defined network, reaching PostgreSQL by container DNS on 5432. Direct-host runs keep a random port pinned to loopback via Docker's `127.0.0.1::5432` form. `compose_job_network_job_container` exports the topology decision the integration script already made, so there is still exactly one detector. Every TLS assertion is preserved, and the certificate identity is generated from the host actually connected to — `localhost` directly, the container's DNS name otherwise. The mismatch and chain-only controls use the TLS container's address on the isolated network rather than a hardcoded one, and the certificate carries no IP SAN, so verify-full still fails with `ERR_TLS_CERT_ALTNAME_INVALID`. Correcting one measurement in passing: chain-only verification does not skip the identity check. For an IP host `pg` omits `servername` and hands `tls.connect` a bare socket with no host, and `net.Socket._host` is null for an IP literal, so Node checks the fixed fallback name `localhost` — never the address dialled. A certificate whose only SAN is `DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected with `Host: localhost. is not in the cert's altnames`. The direct-host control therefore only ever passed because its SAN was `DNS:localhost`. The SAN set now always includes that fallback name so the control proves the same gap in both topologies, and the stale claim is corrected in the transport doc comment. No production behavior changes. Teardown also removes the container's anonymous volume, the isolated network, and the job container's attachment, after success and failure alike. Co-Authored-By: Claude Opus 5 <[email protected]>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import net from "node:net";
|
|
import tls from "node:tls";
|
|
import type { ConnectionOptions } from "node:tls";
|
|
|
|
export type DatabaseTransportEnvironment = "development" | "production";
|
|
|
|
/**
|
|
* The two transport modes this release supports, named after the libpq/PostgreSQL vocabulary an
|
|
* operator already knows:
|
|
*
|
|
* - `verify-full` — TLS is required, the server certificate must chain to a trusted CA, **and**
|
|
* the certificate identity must match the configured database host. This is the production
|
|
* default, and the only mode a production deployment may use.
|
|
* - `disable` — no TLS at all. Available only for the disposable loopback PostgreSQL used by local
|
|
* development and this repository's integration harness.
|
|
*
|
|
* There is deliberately no encryption-only mode (`require`/`verify-ca`) and no
|
|
* `rejectUnauthorized: false` escape hatch: both authenticate nothing, and an unauthenticated
|
|
* encrypted channel to an attacker-controlled endpoint is not a security property worth offering.
|
|
*/
|
|
export type DatabaseTlsMode = "disable" | "verify-full";
|
|
|
|
export type DatabaseTransportErrorCode =
|
|
| "DATABASE_TLS_CONNECTION_STRING_CONFLICT"
|
|
| "DATABASE_TLS_MODE_INVALID"
|
|
| "DATABASE_TLS_REQUIRED_IN_PRODUCTION"
|
|
| "DATABASE_URL_INVALID";
|
|
|
|
const errorMessages: Readonly<Record<DatabaseTransportErrorCode, string>> = Object.freeze({
|
|
DATABASE_TLS_CONNECTION_STRING_CONFLICT:
|
|
"The database URL must not carry TLS parameters; configure the database TLS mode explicitly.",
|
|
DATABASE_TLS_MODE_INVALID: 'The database TLS mode must be exactly "verify-full" or "disable".',
|
|
DATABASE_TLS_REQUIRED_IN_PRODUCTION:
|
|
"A production database connection must use certificate-verified TLS.",
|
|
DATABASE_URL_INVALID: "The database URL must be an absolute postgres:// or postgresql:// URL.",
|
|
});
|
|
|
|
/** Never carries the connection string, host, port, database name, credentials, certificate, CA
|
|
* material, or the offending mode value. */
|
|
export class DatabaseTransportError extends Error {
|
|
readonly code: DatabaseTransportErrorCode;
|
|
|
|
constructor(code: DatabaseTransportErrorCode) {
|
|
super(errorMessages[code]);
|
|
this.name = "DatabaseTransportError";
|
|
this.code = code;
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
export interface DatabaseTransportPolicy {
|
|
readonly mode: DatabaseTlsMode;
|
|
/** PEM trust material for a private PostgreSQL CA. Optional: when omitted, Node's own trusted CA
|
|
* set is used. Never logged, never echoed in an error. */
|
|
readonly certificateAuthority?: string;
|
|
}
|
|
|
|
export interface DatabaseTransportPolicyInput {
|
|
readonly mode?: unknown;
|
|
readonly certificateAuthority?: unknown;
|
|
}
|
|
|
|
export interface CreateDatabaseTransportPolicyOptions {
|
|
/** Defaults to production, so an omitted environment never silently relaxes transport security. */
|
|
readonly environment?: DatabaseTransportEnvironment;
|
|
}
|
|
|
|
/**
|
|
* Every TLS-related parameter `[email protected]` recognizes in a PostgreSQL URL. These
|
|
* are rejected outright rather than merged, because the pinned driver resolves them in a way that
|
|
* silently defeats an explicit policy: `pg`'s own `ConnectionParameters` does
|
|
* `Object.assign({}, config, parse(config.connectionString))`
|
|
* (`pg/lib/connection-parameters.js`), so a URL parameter **overwrites** the `ssl` object a caller
|
|
* passed alongside it. Verified directly against a live TLS PostgreSQL instance: a
|
|
* `?sslmode=disable` URL combined with an explicit certificate-verifying `ssl` object connects in
|
|
* plaintext.
|
|
*
|
|
* The pinned release additionally aliases `prefer`/`require`/`verify-ca` to full verification while
|
|
* printing a deprecation warning that they will adopt weaker libpq semantics in `pg@9`, so even
|
|
* a URL that looks safe today is not a durable guarantee. One typed policy is the whole contract.
|
|
*/
|
|
const CONNECTION_STRING_TLS_PARAMETERS = Object.freeze([
|
|
"ssl",
|
|
"sslcert",
|
|
"sslkey",
|
|
"sslmode",
|
|
"sslnegotiation",
|
|
"sslpassword",
|
|
"sslrootcert",
|
|
"uselibpqcompat",
|
|
]);
|
|
|
|
function parseDatabaseUrl(databaseUrl: string): URL {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(databaseUrl);
|
|
} catch {
|
|
throw new DatabaseTransportError("DATABASE_URL_INVALID");
|
|
}
|
|
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
|
throw new DatabaseTransportError("DATABASE_URL_INVALID");
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/** The host a certificate's identity must match — the exact host the driver will connect to. */
|
|
export function databaseHostFromUrl(databaseUrl: string): string {
|
|
const url = parseDatabaseUrl(databaseUrl);
|
|
// `URL.hostname` keeps IPv6 literals bracketed; TLS identity checking wants the bare address.
|
|
const hostname = url.hostname;
|
|
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
}
|
|
|
|
/** Whether the database endpoint is an unambiguous loopback literal. Used only to decide whether
|
|
* the development plaintext mode is even permissible; it is never a production allowance. */
|
|
function isLoopbackDatabaseHost(host: string): boolean {
|
|
const normalized = host.toLowerCase();
|
|
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
|
|
return true;
|
|
}
|
|
if (net.isIPv4(normalized)) {
|
|
return normalized.startsWith("127.");
|
|
}
|
|
if (net.isIPv6(normalized)) {
|
|
return normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Validates the typed transport policy. Parsing is deliberately separate from environment policy,
|
|
* matching the convention the public-signup gate already established: a malformed mode is reported
|
|
* as malformed in every environment, never relabeled as a production failure.
|
|
*/
|
|
export function createDatabaseTransportPolicy(
|
|
input: DatabaseTransportPolicyInput = {},
|
|
options: CreateDatabaseTransportPolicyOptions = {},
|
|
): DatabaseTransportPolicy {
|
|
const environment = options.environment ?? "production";
|
|
|
|
let mode: DatabaseTlsMode;
|
|
if (input.mode === undefined) {
|
|
// Production fails safe to verified TLS; development keeps the plaintext disposable-PostgreSQL
|
|
// workflow this repository's harness relies on.
|
|
mode = environment === "production" ? "verify-full" : "disable";
|
|
} else if (input.mode === "verify-full" || input.mode === "disable") {
|
|
mode = input.mode;
|
|
} else {
|
|
// No coercion of booleans, `1`, `"TRUE"`, `" verify-full "`, or any other loose spelling.
|
|
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
|
|
}
|
|
|
|
if (environment === "production" && mode !== "verify-full") {
|
|
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
|
|
}
|
|
|
|
if (
|
|
input.certificateAuthority !== undefined &&
|
|
(typeof input.certificateAuthority !== "string" ||
|
|
input.certificateAuthority.trim().length === 0)
|
|
) {
|
|
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
|
|
}
|
|
|
|
return Object.freeze({
|
|
...(typeof input.certificateAuthority === "string"
|
|
? { certificateAuthority: input.certificateAuthority }
|
|
: {}),
|
|
mode,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rejects any database URL carrying its own TLS parameters. Called before a pool or client is ever
|
|
* constructed, so a URL can never silently downgrade or contradict the validated policy — there is
|
|
* exactly one place transport security is decided.
|
|
*/
|
|
export function assertNoConnectionStringTlsParameters(databaseUrl: string): void {
|
|
const url = parseDatabaseUrl(databaseUrl);
|
|
for (const [name] of url.searchParams) {
|
|
if (CONNECTION_STRING_TLS_PARAMETERS.includes(name.toLowerCase())) {
|
|
throw new DatabaseTransportError("DATABASE_TLS_CONNECTION_STRING_CONFLICT");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translates the validated policy into the exact pinned-`pg` options that achieve it, and asserts
|
|
* the URL cannot override them.
|
|
*
|
|
* `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly
|
|
* against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets
|
|
* `options.servername` for non-IP hosts (`pg/lib/connection.js`) and passes `tls.connect` a bare
|
|
* socket carrying no host, so for an IP endpoint Node has no identity to check and falls back to
|
|
* the fixed name `localhost` — never the address actually dialled. A certificate issued for an
|
|
* entirely different name is therefore accepted when connecting to an IP address. Supplying an
|
|
* explicit `checkServerIdentity` pinned to the configured host closes that gap for DNS and IP hosts
|
|
* alike (proven: a certificate valid only for `localhost` is rejected with
|
|
* `ERR_TLS_CERT_ALTNAME_INVALID` when reached via `127.0.0.1`).
|
|
*/
|
|
export function databaseTlsConnectionOptions(
|
|
databaseUrl: string,
|
|
policy: DatabaseTransportPolicy,
|
|
): false | ConnectionOptions {
|
|
assertNoConnectionStringTlsParameters(databaseUrl);
|
|
if (policy.mode === "disable") {
|
|
return false;
|
|
}
|
|
const host = databaseHostFromUrl(databaseUrl);
|
|
return {
|
|
...(policy.certificateAuthority === undefined ? {} : { ca: policy.certificateAuthority }),
|
|
// Chain verification. There is no configuration that can set this to `false`.
|
|
rejectUnauthorized: true,
|
|
// Identity verification against the host actually being connected to — never the SNI value pg
|
|
// may or may not have set, and never skipped for an IP endpoint.
|
|
checkServerIdentity: (_servername: string, certificate: tls.PeerCertificate) =>
|
|
tls.checkServerIdentity(host, certificate),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The one production guard every executable database path shares: a production deployment must use
|
|
* `verify-full`, and its URL must not carry TLS parameters. There is no production plaintext
|
|
* exception — not for loopback, not for an RFC1918 address, not for a Docker service name. A
|
|
* production process that genuinely runs beside its database still gets certificate-verified TLS,
|
|
* which costs nothing and removes an entire class of "it was only internal" misconfiguration.
|
|
*/
|
|
export function assertProductionDatabaseTransport(
|
|
databaseUrl: string,
|
|
policy: DatabaseTransportPolicy,
|
|
environment: DatabaseTransportEnvironment,
|
|
): void {
|
|
assertNoConnectionStringTlsParameters(databaseUrl);
|
|
if (environment !== "production") {
|
|
return;
|
|
}
|
|
if (policy.mode !== "verify-full") {
|
|
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
|
|
}
|
|
}
|
|
|
|
/** A fixed, generic description of the effective transport for diagnostics. Never includes the
|
|
* URL, host, port, database name, credentials, certificate, or CA path. */
|
|
export function describeDatabaseTransport(policy: DatabaseTransportPolicy): string {
|
|
return policy.mode === "verify-full"
|
|
? `TLS required with certificate and hostname verification${
|
|
policy.certificateAuthority === undefined ? "" : " (explicit private CA supplied)"
|
|
}.`
|
|
: "TLS disabled (development/test plaintext).";
|
|
}
|
|
|
|
export { isLoopbackDatabaseHost };
|