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:
co-authored by
Claude Opus 5
parent
7a0f624fc3
commit
6b6b77c105
@@ -1 +1,2 @@
|
||||
export * from "./pool.js";
|
||||
export * from "./transport.js";
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user