fix: enforce production transport policy

Every PostgreSQL connection this repository opened was plaintext, in every
environment: no connection site set `ssl` at all, so credentials, session
tokens, and row data crossed the network in the clear and nothing in
configuration, startup, or `pnpm doctor` said so. The public identity URL
boundary was also looser than it appeared.

`core/src/database/transport.ts` is now the one typed policy. It has exactly two
modes: `verify-full` (TLS required, certificate chain verified, and server
identity verified against the configured host — the production default) and
`disable` (the development/test default, refused outright in production). There
is deliberately no encryption-only `require` mode, no chain-only `verify-ca`,
and no `rejectUnauthorized: false` escape hatch anywhere in the configuration
surface; every loose or aliased spelling is rejected in both environments, so a
typo never quietly becomes a weaker mode. Production has no plaintext exception
at all, not even for a loopback database — a connection string cannot prove the
database is process-local.

Verification is certificate *and* hostname. The policy supplies an explicit
`checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the
pinned driver sets TLS `servername` only for non-IP hosts and Node performs no
identity check when it is unset — so `rejectUnauthorized: true` alone accepts a
certificate from any host the trusted CA ever signed when connecting by IP.
Both halves are proven against a real TLS-enabled PostgreSQL, including that the
same connection without the pinned callback succeeds. Every CA, certificate, and
key is generated at test runtime and deleted afterwards.

A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver
merges parsed connection-string values over explicitly passed options, so a
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl`
object; that now fails with a stable code, reported by both `pnpm doctor` and
the migration CLIs rather than as "failed unexpectedly".

One shared environment reader keeps `process.env` out of Core's reusable
constructors, and every executable that opens a connection resolves the policy
identically: the API (validated before any pool, Better Auth instance, module
runtime, or listener exists), both migration commands, the bootstrap CLI, and
Doctor's own connection. Doctor reports it as a blocking failure, never a
warning, naming only the effective mode.

Separately, the identity base URL and every trusted origin must now be exactly
one canonical origin — the WHATWG origin serialization must equal the supplied
string — so a path, trailing slash, credentials, explicit default port, or
non-canonical case is rejected rather than normalized into something that no
longer matches what a browser sends. Production's loopback rejection now
classifies the address instead of comparing against three literals, covering
`.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped
forms. This is a configuration-shape guarantee only: it does not resolve DNS or
prove reachability, and TLS protects the connection, not the exposure.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 17:03:52 -05:00
co-authored by Claude Opus 5
parent 7a0f624fc3
commit 6b6b77c105
34 changed files with 2035 additions and 52 deletions
+1
View File
@@ -1 +1,2 @@
export * from "./pool.js";
export * from "./transport.js";
+14
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolConfig } from "pg";
import { type DatabaseTransportPolicy, databaseTlsConnectionOptions } from "./transport.js";
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
@@ -22,6 +23,11 @@ export interface PostgresPoolOptions {
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
* establishing a connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number;
/** The validated transport policy (`./transport.js`). Omitted only by isolated unit-test and
* single-domain factories that connect to a disposable loopback database; every production
* executable passes one explicitly. When omitted, no `ssl` option is set and the connection is
* plaintext — exactly the pre-existing development behavior. */
readonly transport?: DatabaseTransportPolicy;
}
export interface PostgresPoolHandle {
@@ -84,8 +90,16 @@ export function createValidatedPostgresPool(
const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
// `databaseTlsConnectionOptions` also rejects a URL carrying its own TLS parameters, so the
// pinned driver's `Object.assign({}, config, parse(connectionString))` precedence can never
// silently replace the policy's `ssl` object with a weaker one.
const ssl =
options.transport === undefined
? undefined
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
const poolConfig: PoolConfig = {
connectionString: validatedConnectionString,
...(ssl === undefined ? {} : { ssl }),
...(validatedConnectionTimeoutMs === undefined
? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
+252
View File
@@ -0,0 +1,252 @@
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 Node performs no identity
* check at all when `servername` is unset — so a certificate issued for an entirely different name
* is 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 };