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
+26 -7
View File
@@ -126,12 +126,28 @@ intentionally contains no real values — configure them per environment, never
| Value | Requirement |
| --- | --- |
| Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. |
| Base URL | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. |
| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. |
| Base URL | Exactly one canonical `http`/`https` **origin** — scheme, host, and non-default port only. In production, `https` is required and loopback/unspecified hosts are rejected. |
| Trusted origins | One or more canonical `http`/`https` origins. Wildcards are rejected in this slice. In production, `https` is required and loopback/unspecified hosts are rejected. |
Local development origins (for example `http://localhost:3000`) are only accepted when the caller
explicitly passes `environment: "development"` — never silently. Every configuration failure
raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value.
**"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin
serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash
(`https://id.example.test/`), a query, a fragment, embedded credentials, an explicitly written
default port (`https://id.example.test:443`), or non-canonical case is rejected with
`BASE_URL_INVALID`/`TRUSTED_ORIGIN_INVALID` rather than normalized behind your back — so the stored
value always matches what a browser actually sends in an `Origin` header.
Production rejects loopback and unspecified hosts by classifying the address, not by comparing it
against a short list: `localhost` and the whole reserved `.localhost` namespace (RFC 6761), all of
`127.0.0.0/8` (not just `127.0.0.1`), `0.0.0.0`, `::1` in both compressed and expanded spellings,
`::`, and IPv4-mapped forms such as `::ffff:127.0.0.1`. Local development origins (for example
`http://localhost:3000`) are only accepted when the caller explicitly passes
`environment: "development"` — never silently. Every configuration failure raises a sanitized
`IdentityConfigError` carrying only a stable code, never the offending value.
This is a guarantee about the *shape* of the configured value and nothing more. It does not resolve
DNS and does not prove reachability: a public hostname that resolves to a loopback or private
address passes this validation, and a firewall, network policy, or reverse proxy — not URL
parsing — is what actually determines who can reach a deployment.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010.
@@ -165,7 +181,9 @@ pinned to `8`/`128` instead of left on Better Auth's fallback default.
`operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a
hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing
the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is
rejected the same sanitized way an invalid connection string already is.
rejected the same sanitized way an invalid connection string already is. It also accepts an optional
`transport` policy — the PostgreSQL TLS decision described in
[API § Database transport](api.md#database-transport-tls); every production executable passes one.
## How a principal is resolved
@@ -194,7 +212,8 @@ internal failures and returns `null` for them exactly as it does for a genuinely
A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable
database, and a query timeout therefore all produce a sanitized
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
logs, and error carry no relation name, column name, SQL, driver message, host, or stack.
logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## What is not operational yet