Compare commits

...
2 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Opus 5 319d94b03c Check private-network host before the CA cert when choosing TLS
CI / build-and-test (push) Canceled after 0s
A leftover DATABASE_CA_CERT_PATH (the DigitalOcean CA, still set in the
production env and still committed at certs/ca-certificate.crt) made the `ca`
branch win before the private-host check, so the app forced verified TLS onto
the internal Postgres container and failed with "The server does not support
SSL connections".

Move the private-host branch ahead of the CA branch: a Docker service name,
localhost, or an RFC1918 address connects in plaintext regardless of a stale CA
path. Public hosts are unaffected and still require a CA in production. Set
DATABASE_SSL=require (or =verify) to opt a private host back into TLS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:31:25 -04:00
Leon SerfatyandClaude Opus 5 605c0e0052 Allow plaintext Postgres in production for private-network hosts only
CI / build-and-test (push) Canceled after 0s
The production DB is now a Postgres container on the same Docker/Swarm overlay
network as the app, reachable as a bare service name. It does not speak TLS, and
no managed-DB CA exists for it, so the previous fail-closed rules (CA required in
production; DATABASE_SSL=disable refused in production) made it unusable.

Narrow both rules to distinguish private from public hosts: a Docker service name,
localhost, or an RFC1918 address may connect in plaintext, because that traffic
never leaves the internal network. A routable hostname or public IP in production
still fails closed exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:19:14 -04:00
+38 -6
View File
@@ -20,6 +20,27 @@ function stripSslmode(url: string): string {
} }
} }
// A database reachable only over a private network (a Docker/Swarm service name, localhost, or an
// RFC1918 address) never crosses a link an attacker can sit on, so plaintext there is not the
// man-in-the-middle exposure that plaintext to a public host is. Anything else — a routable
// hostname or public IP — is treated as public and still fails closed in production.
function isPrivateHost(url: string): boolean {
let host: string;
try {
host = new URL(url).hostname;
} catch {
return false;
}
if (host === 'localhost' || host.endsWith('.internal') || host.endsWith('.local')) return true;
// Bare service name (no dots) — Docker/Swarm internal DNS, e.g. "elegalsoftware-db-puhy21".
if (!host.includes('.')) return true;
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (!m) return false;
const a = Number(m[1]);
const b = Number(m[2]);
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
export function getPool(): pg.Pool { export function getPool(): pg.Pool {
if (_pool) return _pool; if (_pool) return _pool;
@@ -35,18 +56,29 @@ export function getPool(): pg.Pool {
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior. // Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
// Without this, pg merges URL-derived settings which can conflict with the options below. // Without this, pg merges URL-derived settings which can conflict with the options below.
let ssl: pg.PoolConfig['ssl']; let ssl: pg.PoolConfig['ssl'];
const wantsTls = process.env.DATABASE_SSL === 'require' || process.env.DATABASE_SSL === 'verify';
if (process.env.DATABASE_SSL === 'disable') { if (process.env.DATABASE_SSL === 'disable') {
// Explicit opt-out for local dev/test databases that don't speak TLS at all (e.g. a // Explicit opt-out for databases that don't speak TLS at all (e.g. a disposable Docker
// disposable Docker Postgres). Refused in production — prod must always verify TLS. // Postgres). In production this is honored ONLY for a private-network host; pointing it at
if (isProd) { // a public database still fails closed.
throw new Error('DATABASE_SSL=disable is not allowed in production'); if (isProd && !isPrivateHost(connectionString)) {
throw new Error(
'DATABASE_SSL=disable is only allowed in production when DATABASE_URL points at a private-network host (Docker service name, localhost, or an RFC1918 address).',
);
} }
ssl = false; ssl = false;
} else if (isPrivateHost(connectionString) && !wantsTls) {
// Database on a private network (app + Postgres on the same Docker/Swarm overlay, or a local
// container). The connection never leaves that network, so plaintext is fine here. This is
// checked BEFORE the CA branch on purpose: a leftover DATABASE_CA_CERT_PATH from a previous
// managed-database provider must not force TLS onto a server that does not speak it. Set
// DATABASE_SSL=require (or =verify) to opt a private host back into TLS.
ssl = false;
} else if (ca) { } else if (ca) {
// Verified TLS against the managed-DB CA — the correct posture everywhere. // Verified TLS against the managed-DB CA — the correct posture for any public host.
ssl = { ca, rejectUnauthorized: true }; ssl = { ca, rejectUnauthorized: true };
} else if (isProd) { } else if (isProd) {
// Never run production against the database over unverified TLS: fail fast so a missing // Public database host in production: never connect over unverified TLS. Fail fast so a missing
// CA cert is a loud deploy error instead of a silent man-in-the-middle exposure. // CA cert is a loud deploy error instead of a silent man-in-the-middle exposure.
throw new Error( throw new Error(
'DATABASE_CA_CERT_PATH is required in production: point it at the managed-DB CA cert so TLS certificates are verified (rejectUnauthorized: true).', 'DATABASE_CA_CERT_PATH is required in production: point it at the managed-DB CA cert so TLS certificates are verified (rejectUnauthorized: true).',