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,4 +1,11 @@
|
|||||||
# Local development only. Replace every credential outside a developer workstation.
|
# Local development only. Replace every credential outside a developer workstation.
|
||||||
|
|
||||||
|
# Required for a local workstation, and only ever correct there. Anything else — including an
|
||||||
|
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
|
||||||
|
# (EKKLESIA_DATABASE_TLS_MODE defaults to verify-full) and rejects loopback/http identity origins.
|
||||||
|
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
POSTGRES_DB=ekklesia
|
POSTGRES_DB=ekklesia
|
||||||
POSTGRES_USER=ekklesia
|
POSTGRES_USER=ekklesia
|
||||||
POSTGRES_PASSWORD=ekklesia-development-only
|
POSTGRES_PASSWORD=ekklesia-development-only
|
||||||
|
|||||||
@@ -117,13 +117,35 @@ guarantee, and it does not complete Phase 5D** — passes 2–5 (identity/databa
|
|||||||
authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure
|
authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure
|
||||||
plus a final independent re-audit) remain outstanding before Phase 6 (Surface work) may begin.
|
plus a final independent re-audit) remain outstanding before Phase 6 (Surface work) may begin.
|
||||||
|
|
||||||
|
Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy.
|
||||||
|
A request carrying no cookies, or only unrelated cookies, now performs **no** identity database
|
||||||
|
work at all; only a request presenting the configured session cookie that Better Auth resolved to
|
||||||
|
`null` is disambiguated, by a real read of the session relation and the exact columns the adapter
|
||||||
|
depends on (under `where false`, so no row is ever read) rather than the previous `select 1`. A
|
||||||
|
missing table, dropped column, revoked privilege, unreachable database, or query timeout therefore
|
||||||
|
produces a sanitized `503`, never a silent anonymous downgrade. The identity base URL and every
|
||||||
|
trusted origin must now be exactly one canonical origin, and production rejects the full
|
||||||
|
loopback/unspecified address space rather than three literal spellings. **Production PostgreSQL
|
||||||
|
connections now require TLS with certificate *and* hostname verification** — from the API, both
|
||||||
|
migration commands, the bootstrap CLI, and `pnpm doctor` alike — with no encryption-only mode, no
|
||||||
|
`rejectUnauthorized: false` escape hatch, no plaintext exception (not even for a loopback
|
||||||
|
database), and an explicit refusal of a `DATABASE_URL` that tries to carry its own TLS parameters,
|
||||||
|
which the pinned driver would otherwise let silently override the policy. See
|
||||||
|
[Identity](docs/development/identity.md), [API](docs/development/api.md#database-transport-tls), and
|
||||||
|
[ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||||
|
**This pass adds no secret store, no certificate rotation, no client-certificate authentication, and
|
||||||
|
no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary
|
||||||
|
remains the deployment's own responsibility. Phase 5D is still not complete** — passes 3–5
|
||||||
|
(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure
|
||||||
|
plus a final independent re-audit) remain outstanding.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
cp .env.example .env
|
cp .env.example .env # keep its NODE_ENV=development: see below
|
||||||
pnpm infrastructure:up
|
pnpm infrastructure:up
|
||||||
pnpm modules:validate
|
pnpm modules:validate
|
||||||
pnpm migrations:validate
|
pnpm migrations:validate
|
||||||
@@ -135,9 +157,15 @@ pnpm verify
|
|||||||
```
|
```
|
||||||
|
|
||||||
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
||||||
modules and dependency order, schema and migration inventories, optional read-only PostgreSQL
|
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
||||||
planning, and the current object-storage integration state. It never applies migrations, creates a
|
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
|
||||||
history table, starts module servers, or performs storage operations. Schema discovery does import
|
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||||||
|
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||||||
|
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
||||||
|
development infrastructure provides is only usable with it set.
|
||||||
|
|
||||||
|
Doctor never applies migrations, creates a history table, starts module servers, or performs
|
||||||
|
storage operations. Schema discovery does import
|
||||||
compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free.
|
compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free.
|
||||||
|
|
||||||
With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped
|
With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped
|
||||||
@@ -167,6 +195,8 @@ Data definitions
|
|||||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
|
✓ Database TLS verified
|
||||||
|
TLS required with certificate and hostname verification.
|
||||||
○ Connection check skipped
|
○ Connection check skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Migration plan skipped
|
○ Migration plan skipped
|
||||||
|
|||||||
+27
-1
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
type DatabaseTransportPolicyInput,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
} from "@ekklesia/core/database";
|
||||||
import {
|
import {
|
||||||
type CreateIdentityConfigOptions,
|
type CreateIdentityConfigOptions,
|
||||||
createIdentityConfig,
|
createIdentityConfig,
|
||||||
@@ -44,6 +50,8 @@ const defaultPort = 3000;
|
|||||||
export interface ApiRuntimeConfig {
|
export interface ApiRuntimeConfig {
|
||||||
readonly clientAddress: ClientAddressConfig;
|
readonly clientAddress: ClientAddressConfig;
|
||||||
readonly databaseUrl: string;
|
readonly databaseUrl: string;
|
||||||
|
/** The one validated PostgreSQL transport policy this runtime's single pool uses. */
|
||||||
|
readonly databaseTransport: DatabaseTransportPolicy;
|
||||||
readonly host: string;
|
readonly host: string;
|
||||||
readonly identity: IdentityConfig;
|
readonly identity: IdentityConfig;
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
@@ -54,6 +62,9 @@ export interface ApiRuntimeConfigInput {
|
|||||||
* trust unless explicitly configured. See `./client-address.js`. */
|
* trust unless explicitly configured. See `./client-address.js`. */
|
||||||
readonly clientAddress?: ClientAddressConfigInput;
|
readonly clientAddress?: ClientAddressConfigInput;
|
||||||
readonly databaseUrl?: unknown;
|
readonly databaseUrl?: unknown;
|
||||||
|
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
||||||
|
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
||||||
|
readonly databaseTransport?: DatabaseTransportPolicyInput;
|
||||||
readonly host?: unknown;
|
readonly host?: unknown;
|
||||||
readonly identity: IdentityConfigInput;
|
readonly identity: IdentityConfigInput;
|
||||||
readonly port?: unknown;
|
readonly port?: unknown;
|
||||||
@@ -122,6 +133,21 @@ export function createApiRuntimeConfig(
|
|||||||
options.environment === undefined ? {} : { environment: options.environment },
|
options.environment === undefined ? {} : { environment: options.environment },
|
||||||
);
|
);
|
||||||
const clientAddress = createClientAddressConfig(input.clientAddress);
|
const clientAddress = createClientAddressConfig(input.clientAddress);
|
||||||
|
const environment = options.environment ?? "production";
|
||||||
|
const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, {
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
// Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a
|
||||||
|
// listener — if a production deployment would connect without certificate-verified TLS, or if
|
||||||
|
// the URL carries TLS parameters that could override the policy.
|
||||||
|
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
|
||||||
|
|
||||||
return Object.freeze({ clientAddress, databaseUrl, host, identity, port });
|
return Object.freeze({
|
||||||
|
clientAddress,
|
||||||
|
databaseTransport,
|
||||||
|
databaseUrl,
|
||||||
|
host,
|
||||||
|
identity,
|
||||||
|
port,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,10 @@ export async function startApi(
|
|||||||
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMs,
|
||||||
operationTimeoutMs,
|
operationTimeoutMs,
|
||||||
|
// The one validated transport policy reaches this runtime's single physical pool here; the
|
||||||
|
// identity, authorization, and module Drizzle views are all typed views over that same pool,
|
||||||
|
// so TLS never introduces a second connection.
|
||||||
|
transport: config.databaseTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
let services: ModuleRuntimeServices;
|
let services: ModuleRuntimeServices;
|
||||||
@@ -425,6 +429,14 @@ function readConfigFromEnvironment(): {
|
|||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||||
input: {
|
input: {
|
||||||
...(clientAddress === undefined ? {} : { clientAddress }),
|
...(clientAddress === undefined ? {} : { clientAddress }),
|
||||||
|
databaseTransport: {
|
||||||
|
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
||||||
|
? {}
|
||||||
|
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
||||||
|
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||||
|
? {}
|
||||||
|
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
||||||
|
},
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
host: process.env.EKKLESIA_API_HOST,
|
host: process.env.EKKLESIA_API_HOST,
|
||||||
identity: {
|
identity: {
|
||||||
|
|||||||
@@ -347,14 +347,20 @@ async function startConfiguredApi(
|
|||||||
options: StartConfiguredApiOptions = {},
|
options: StartConfiguredApiOptions = {},
|
||||||
): Promise<RunningApi> {
|
): Promise<RunningApi> {
|
||||||
const publicSignupEnabled = options.publicSignupEnabled ?? true;
|
const publicSignupEnabled = options.publicSignupEnabled ?? true;
|
||||||
// Production configuration refuses to enable public signup at all until durable abuse controls
|
// This harness explicitly selects development configuration, for two independent reasons that
|
||||||
// exist (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`), so a scenario
|
// both apply to every scenario in this suite:
|
||||||
// that creates accounts over HTTP must validate its configuration as development — the explicit,
|
//
|
||||||
// intended way to opt in. This changes only Ekklesia's own config-validation strictness:
|
// 1. It connects to the disposable *plaintext* loopback PostgreSQL. Production configuration
|
||||||
// `environment` never reaches Better Auth, and this harness keeps the identical https,
|
// requires certificate-verified TLS with no exception, so a plaintext test database can
|
||||||
// non-loopback base URL and trusted origins either way, so the production-equivalent origin/CSRF
|
// only be used by a deployment that has explicitly declared itself non-production.
|
||||||
// behavior ADR 0012 established is exercised exactly the same in both cases.
|
// 2. Scenarios that create accounts over HTTP need public signup, which production refuses to
|
||||||
const environment = publicSignupEnabled ? "development" : "production";
|
// enable at all until durable abuse controls exist.
|
||||||
|
//
|
||||||
|
// This changes only Ekklesia's own configuration-validation strictness — `environment` never
|
||||||
|
// reaches Better Auth — and the harness keeps the identical https, non-loopback base URL and
|
||||||
|
// trusted origins regardless, so the production-equivalent origin/CSRF behavior ADR 0012
|
||||||
|
// established is exercised exactly the same either way.
|
||||||
|
const environment = "development" as const;
|
||||||
const config = createApiRuntimeConfig(
|
const config = createApiRuntimeConfig(
|
||||||
{
|
{
|
||||||
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
|
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
|
||||||
@@ -2255,7 +2261,10 @@ describe.sequential("live API — Node header/request timeouts and client errors
|
|||||||
},
|
},
|
||||||
port: 0,
|
port: 0,
|
||||||
},
|
},
|
||||||
{ environment: "production" },
|
// Development configuration, for the same reason `startConfiguredApi` documents: this suite
|
||||||
|
// connects to the disposable plaintext loopback PostgreSQL, which production configuration
|
||||||
|
// never permits. Nothing about the transport-level timeout behavior under test depends on it.
|
||||||
|
{ environment: "development" },
|
||||||
);
|
);
|
||||||
// Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never
|
// Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never
|
||||||
// sets this override — so a real timeout is observed deterministically in well under a second
|
// sets this override — so a real timeout is observed deterministically in well under a second
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ describe("API runtime configuration", () => {
|
|||||||
|
|
||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
clientAddress: { mode: "direct" },
|
clientAddress: { mode: "direct" },
|
||||||
|
// Production defaults to certificate-verified TLS with no configuration at all.
|
||||||
|
databaseTransport: { mode: "verify-full" },
|
||||||
databaseUrl: validInput().databaseUrl,
|
databaseUrl: validInput().databaseUrl,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
identity: {
|
identity: {
|
||||||
@@ -185,6 +187,135 @@ describe("API runtime configuration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PostgreSQL transport policy", () => {
|
||||||
|
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
|
||||||
|
expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({
|
||||||
|
mode: "verify-full",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
identity: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
).databaseTransport,
|
||||||
|
).toEqual({ mode: "disable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a production configuration that disables TLS", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{ ...validInput(), databaseTransport: { mode: "disable" } },
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => {
|
||||||
|
for (const mode of [
|
||||||
|
"VERIFY-FULL",
|
||||||
|
" verify-full ",
|
||||||
|
"require",
|
||||||
|
"verify-ca",
|
||||||
|
"prefer",
|
||||||
|
"no-verify",
|
||||||
|
"true",
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
]) {
|
||||||
|
for (const environment of ["production", "development"] as const) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
databaseTransport: { mode },
|
||||||
|
...(environment === "development"
|
||||||
|
? {
|
||||||
|
identity: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
{ environment },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_MODE_INVALID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => {
|
||||||
|
for (const parameter of [
|
||||||
|
"sslmode=disable",
|
||||||
|
"sslmode=require",
|
||||||
|
"sslmode=verify-full",
|
||||||
|
"ssl=true",
|
||||||
|
"sslrootcert=/tmp/ca.crt",
|
||||||
|
"sslcert=/tmp/c.crt",
|
||||||
|
"sslkey=/tmp/c.key",
|
||||||
|
"uselibpqcompat=true",
|
||||||
|
]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
databaseUrl: `postgresql://ekklesia:[email protected]:5432/ekklesia?${parameter}`,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
|
||||||
|
databaseUrl:
|
||||||
|
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
const publicText = `${String(captured)}\n${
|
||||||
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||||
|
}\n${JSON.stringify(captured)}`;
|
||||||
|
expect(publicText).not.toContain("hunter2");
|
||||||
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
|
expect(publicText).not.toContain("SECRET");
|
||||||
|
expect(publicText).not.toContain("sslmode");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
||||||
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
||||||
let captured: unknown;
|
let captured: unknown;
|
||||||
|
|||||||
@@ -460,6 +460,59 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
expect(poolEnd).not.toHaveBeenCalled();
|
expect(poolEnd).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
// Both an explicit production plaintext selection and a URL that would silently downgrade the
|
||||||
|
// policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry
|
||||||
|
// point calls before `startApi` — so no runtime resource is ever constructed to leak.
|
||||||
|
for (const [label, input] of [
|
||||||
|
[
|
||||||
|
"explicit production plaintext",
|
||||||
|
{
|
||||||
|
databaseTransport: { mode: "disable" },
|
||||||
|
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"connection-string TLS downgrade",
|
||||||
|
{
|
||||||
|
databaseUrl:
|
||||||
|
"postgresql://ekklesia:[email protected]:5432/ekklesia?sslmode=disable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: "https://identity.example.test",
|
||||||
|
secret: "correct-horse-battery-staple-0123456789",
|
||||||
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
...input,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error);
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
expect(poolEnd).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { Pool, type PoolConfig } from "pg";
|
import { Pool, type PoolConfig } from "pg";
|
||||||
|
import {
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
databaseTlsConnectionOptions,
|
||||||
|
} from "../database/transport.js";
|
||||||
import { authorizationSchema } from "./schema.js";
|
import { authorizationSchema } from "./schema.js";
|
||||||
|
|
||||||
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
||||||
@@ -27,6 +31,10 @@ export interface AuthorizationDatabaseOptions {
|
|||||||
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
||||||
* connection, not a hung query already running on one. */
|
* connection, not a hung query already running on one. */
|
||||||
readonly operationTimeoutMs?: number;
|
readonly operationTimeoutMs?: number;
|
||||||
|
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
|
||||||
|
* every production executable; omitted by single-domain unit/integration factories that use
|
||||||
|
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
|
||||||
|
readonly transport?: DatabaseTransportPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthorizationDatabaseHandle {
|
export interface AuthorizationDatabaseHandle {
|
||||||
@@ -80,8 +88,13 @@ export function createAuthorizationDrizzleDatabase(
|
|||||||
const validatedConnectionString = validateConnectionString(connectionString);
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||||
|
const ssl =
|
||||||
|
options.transport === undefined
|
||||||
|
? undefined
|
||||||
|
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
|
||||||
const poolConfig: PoolConfig = {
|
const poolConfig: PoolConfig = {
|
||||||
connectionString: validatedConnectionString,
|
connectionString: validatedConnectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
...(validatedConnectionTimeoutMs === undefined
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { formatMigrationApplyReport } from "./migration-report.js";
|
import { formatMigrationApplyReport } from "./migration-report.js";
|
||||||
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
||||||
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
|
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const aggregate = await discoverRepositoryMigrations();
|
const aggregate = await discoverRepositoryMigrations();
|
||||||
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
// Resolved and asserted before any client exists: an unsafe production transport fails
|
||||||
|
// here rather than after opening a connection.
|
||||||
|
const transport =
|
||||||
|
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
|
||||||
|
const database = createPostgresMigrationDatabase(databaseUrl, {
|
||||||
|
...(transport === undefined ? {} : { transport }),
|
||||||
|
});
|
||||||
const result = await applyModuleMigrations({ aggregate, database });
|
const result = await applyModuleMigrations({ aggregate, database });
|
||||||
|
|
||||||
for (const line of formatMigrationApplyReport(result)) {
|
for (const line of formatMigrationApplyReport(result)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createAuthorizationDrizzleDatabase,
|
createAuthorizationDrizzleDatabase,
|
||||||
PlatformOwnerBootstrapError,
|
PlatformOwnerBootstrapError,
|
||||||
} from "../authorization/index.js";
|
} from "../authorization/index.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
|
||||||
class BootstrapCliError extends Error {
|
class BootstrapCliError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -67,9 +68,13 @@ async function main(): Promise<void> {
|
|||||||
throw new BootstrapCliError("DATABASE_URL is required.");
|
throw new BootstrapCliError("DATABASE_URL is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asserted before the connection is constructed, so an unsafe production transport never opens
|
||||||
|
// a client at all.
|
||||||
|
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||||
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
||||||
connectionTimeoutMs: 10_000,
|
connectionTimeoutMs: 10_000,
|
||||||
operationTimeoutMs: 10_000,
|
operationTimeoutMs: 10_000,
|
||||||
|
transport,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
type DatabaseTransportEnvironment,
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
} from "../database/transport.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place every Core-owned executable reads its database transport policy from the process
|
||||||
|
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
|
||||||
|
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
|
||||||
|
*
|
||||||
|
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
|
||||||
|
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
|
||||||
|
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
|
||||||
|
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
|
||||||
|
*/
|
||||||
|
export function readDatabaseTransportFromEnvironment(): {
|
||||||
|
readonly environment: DatabaseTransportEnvironment;
|
||||||
|
readonly transport: DatabaseTransportPolicy;
|
||||||
|
} {
|
||||||
|
const environment: DatabaseTransportEnvironment =
|
||||||
|
process.env.NODE_ENV === "development" ? "development" : "production";
|
||||||
|
const transport = createDatabaseTransportPolicy(
|
||||||
|
{
|
||||||
|
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
||||||
|
? {}
|
||||||
|
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
||||||
|
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||||
|
? {}
|
||||||
|
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
||||||
|
},
|
||||||
|
{ environment },
|
||||||
|
);
|
||||||
|
return { environment, transport };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the policy and asserts it is safe for the given database URL before any client, pool,
|
||||||
|
* or connection is constructed. Throws the sanitized `DatabaseTransportError` otherwise.
|
||||||
|
*/
|
||||||
|
export function resolveExecutableDatabaseTransport(databaseUrl: string): DatabaseTransportPolicy {
|
||||||
|
const { environment, transport } = readDatabaseTransportFromEnvironment();
|
||||||
|
assertProductionDatabaseTransport(databaseUrl, transport, environment);
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
@@ -81,8 +81,19 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
|||||||
const rootPackage = rootPackageDocument(rootValue);
|
const rootPackage = rootPackageDocument(rootValue);
|
||||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||||
|
|
||||||
|
const databaseEnvironment =
|
||||||
|
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
|
||||||
const report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia({
|
||||||
clientAddress: readClientAddressFromEnvironment(),
|
clientAddress: readClientAddressFromEnvironment(),
|
||||||
|
databaseEnvironment,
|
||||||
|
databaseTransport: {
|
||||||
|
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
||||||
|
? {}
|
||||||
|
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
||||||
|
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||||
|
? {}
|
||||||
|
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
||||||
|
},
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import { DatabaseTransportError } from "../database/transport.js";
|
||||||
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
|
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
|
||||||
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
|
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
|
||||||
|
|
||||||
export function reportMigrationCommandError(error: unknown): void {
|
export function reportMigrationCommandError(error: unknown): void {
|
||||||
|
if (error instanceof DatabaseTransportError) {
|
||||||
|
// The message is a fixed string chosen by code; it never carries the connection string, host,
|
||||||
|
// port, credentials, CA material, or the offending mode value, so it is safe to print as-is —
|
||||||
|
// and an operator who misconfigured TLS needs to know that, not "failed unexpectedly".
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (error instanceof ModuleDiscoveryError) {
|
if (error instanceof ModuleDiscoveryError) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
for (const issue of error.issues) {
|
for (const issue of error.issues) {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { formatMigrationPlanReport } from "./migration-report.js";
|
import { formatMigrationPlanReport } from "./migration-report.js";
|
||||||
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
||||||
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js";
|
import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const aggregate = await discoverRepositoryMigrations();
|
const aggregate = await discoverRepositoryMigrations();
|
||||||
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
// Resolved and asserted before any client exists: an unsafe production transport fails
|
||||||
|
// here rather than after opening a connection.
|
||||||
|
const transport =
|
||||||
|
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
|
||||||
|
const database = createPostgresMigrationDatabase(databaseUrl, {
|
||||||
|
...(transport === undefined ? {} : { transport }),
|
||||||
|
});
|
||||||
const plan = await planModuleMigrations({ aggregate, database });
|
const plan = await planModuleMigrations({ aggregate, database });
|
||||||
|
|
||||||
for (const line of formatMigrationPlanReport(plan)) {
|
for (const line of formatMigrationPlanReport(plan)) {
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./pool.js";
|
export * from "./pool.js";
|
||||||
|
export * from "./transport.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolConfig } from "pg";
|
import { Pool, type PoolConfig } from "pg";
|
||||||
|
import { type DatabaseTransportPolicy, databaseTlsConnectionOptions } from "./transport.js";
|
||||||
|
|
||||||
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
|
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
|
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
|
||||||
* establishing a connection, not a hung query already running on one. */
|
* establishing a connection, not a hung query already running on one. */
|
||||||
readonly operationTimeoutMs?: number;
|
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 {
|
export interface PostgresPoolHandle {
|
||||||
@@ -84,8 +90,16 @@ export function createValidatedPostgresPool(
|
|||||||
const validatedConnectionString = validateConnectionString(connectionString);
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
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 = {
|
const poolConfig: PoolConfig = {
|
||||||
connectionString: validatedConnectionString,
|
connectionString: validatedConnectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
...(validatedConnectionTimeoutMs === undefined
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
: { 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 };
|
||||||
@@ -13,7 +13,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
|||||||
section("Platform", ["platform.runtime"]),
|
section("Platform", ["platform.runtime"]),
|
||||||
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
|
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
|
||||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||||
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||||
section("Identity", ["identity.configuration", "identity.client-address"]),
|
section("Identity", ["identity.configuration", "identity.client-address"]),
|
||||||
section("Object storage", ["object-storage.integration"]),
|
section("Object storage", ["object-storage.integration"]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import {
|
|||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import { getTableName } from "drizzle-orm";
|
import { getTableName } from "drizzle-orm";
|
||||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||||
|
import {
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
type DatabaseTransportEnvironment,
|
||||||
|
type DatabaseTransportPolicyInput,
|
||||||
|
DatabaseTransportError,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
describeDatabaseTransport,
|
||||||
|
} from "../database/transport.js";
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
type ClientAddressConfigInput,
|
||||||
ClientAddressConfigError,
|
ClientAddressConfigError,
|
||||||
@@ -41,6 +49,7 @@ import {
|
|||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
|
||||||
export type DoctorCheckId =
|
export type DoctorCheckId =
|
||||||
|
| "database.transport"
|
||||||
| "platform.runtime"
|
| "platform.runtime"
|
||||||
| "modules.discovery"
|
| "modules.discovery"
|
||||||
| "modules.dependencies"
|
| "modules.dependencies"
|
||||||
@@ -78,6 +87,10 @@ export interface DoctorIdentityConfigurationOptions {
|
|||||||
|
|
||||||
export interface DoctorOptions {
|
export interface DoctorOptions {
|
||||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
|
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
||||||
|
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
||||||
|
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
||||||
|
readonly databaseTransport?: DatabaseTransportPolicyInput | undefined;
|
||||||
readonly databaseUrl?: string | undefined;
|
readonly databaseUrl?: string | undefined;
|
||||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||||
@@ -417,6 +430,41 @@ function identityConfigurationCheck(
|
|||||||
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
|
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
|
||||||
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
|
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Pure configuration validation only: never opens a connection, and never prints the database URL,
|
||||||
|
* host, port, database name, credentials, certificate, or CA material. An unsafe production
|
||||||
|
* transport is a blocking `failure`, not a warning — a production deployment that would reach
|
||||||
|
* PostgreSQL without certificate-verified TLS is not "ready with warnings".
|
||||||
|
*/
|
||||||
|
function databaseTransportCheck(
|
||||||
|
input: DatabaseTransportPolicyInput | undefined,
|
||||||
|
databaseUrl: string | undefined,
|
||||||
|
environment: DatabaseTransportEnvironment | undefined,
|
||||||
|
): DoctorCheck {
|
||||||
|
const effectiveEnvironment = environment ?? "production";
|
||||||
|
try {
|
||||||
|
const policy = createDatabaseTransportPolicy(input, { environment: effectiveEnvironment });
|
||||||
|
if (typeof databaseUrl === "string" && databaseUrl.trim() !== "") {
|
||||||
|
// Also rejects a URL carrying TLS parameters that could override the policy.
|
||||||
|
assertProductionDatabaseTransport(databaseUrl, policy, effectiveEnvironment);
|
||||||
|
}
|
||||||
|
return freezeCheck(
|
||||||
|
"database.transport",
|
||||||
|
"pass",
|
||||||
|
policy.mode === "verify-full"
|
||||||
|
? "Database TLS verified"
|
||||||
|
: "Database TLS disabled (development)",
|
||||||
|
[describeDatabaseTransport(policy)],
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof DatabaseTransportError ? error.code : "DATABASE_TRANSPORT_INVALID";
|
||||||
|
return freezeCheck("database.transport", "failure", "Database transport unsafe", [
|
||||||
|
`[${code}] The configured database transport is not permitted.`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||||
try {
|
try {
|
||||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||||
@@ -580,9 +628,16 @@ export async function diagnoseEkklesia(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
// Doctor's own read-only connection uses the exact same validated transport policy every
|
||||||
|
// other executable does, so a Doctor "connection established" can never mean something
|
||||||
|
// weaker than what `pnpm api:start` or `pnpm migrations:apply` would use.
|
||||||
|
const doctorTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||||
|
environment: options.databaseEnvironment ?? "production",
|
||||||
|
});
|
||||||
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
||||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
|
transport: doctorTransport,
|
||||||
});
|
});
|
||||||
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
||||||
postgresConnectionCheck = freezeCheck(
|
postgresConnectionCheck = freezeCheck(
|
||||||
@@ -598,6 +653,11 @@ export async function diagnoseEkklesia(
|
|||||||
|
|
||||||
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
||||||
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
|
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
|
||||||
|
const transportCheck = databaseTransportCheck(
|
||||||
|
options.databaseTransport,
|
||||||
|
options.databaseUrl,
|
||||||
|
options.databaseEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||||
"Core storage integration has not been implemented yet.",
|
"Core storage integration has not been implemented yet.",
|
||||||
@@ -610,6 +670,7 @@ export async function diagnoseEkklesia(
|
|||||||
permissionsCheck,
|
permissionsCheck,
|
||||||
schemaCheck,
|
schemaCheck,
|
||||||
migrationCheck,
|
migrationCheck,
|
||||||
|
transportCheck,
|
||||||
postgresConnectionCheck,
|
postgresConnectionCheck,
|
||||||
postgresPlanCheck,
|
postgresPlanCheck,
|
||||||
identityCheck,
|
identityCheck,
|
||||||
|
|||||||
+142
-20
@@ -18,12 +18,108 @@ export type IdentityConfigErrorCode =
|
|||||||
| "TRUSTED_ORIGINS_MISSING";
|
| "TRUSTED_ORIGINS_MISSING";
|
||||||
|
|
||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
|
|
||||||
"localhost",
|
/**
|
||||||
"127.0.0.1",
|
* Hostnames that are never a legitimate public production origin, matched exactly (after WHATWG
|
||||||
"::1",
|
* URL canonicalization, which already folds `127.1`, `2130706433`, and `0x7f000001` into
|
||||||
"0.0.0.0",
|
* `127.0.0.1`, and lowercases every hostname).
|
||||||
]);
|
*
|
||||||
|
* This is a purely *syntactic* classifier. It rejects literal and reserved loopback/unspecified
|
||||||
|
* spellings; it performs no DNS resolution, so it cannot and does not prove that some arbitrary
|
||||||
|
* public name will never resolve to a loopback address later. Network reachability remains the
|
||||||
|
* deployment's own firewall/DNS responsibility.
|
||||||
|
*/
|
||||||
|
const IPV4_LOOPBACK_BLOCK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u;
|
||||||
|
const UNSPECIFIED_HOSTNAMES: ReadonlySet<string> = new Set(["0.0.0.0", "[::]"]);
|
||||||
|
const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(["localhost"]);
|
||||||
|
|
||||||
|
/** Expands a bracketed IPv6 `URL.hostname` into its 8 canonical 16-bit groups, or `undefined` when
|
||||||
|
* the value is not a bracketed IPv6 literal. Handles `::` zero-compression and the mixed
|
||||||
|
* `::ffff:a.b.c.d` notation the WHATWG parser preserves. */
|
||||||
|
function expandIpv6Hostname(hostname: string): readonly number[] | undefined {
|
||||||
|
if (!hostname.startsWith("[") || !hostname.endsWith("]")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let body = hostname.slice(1, -1);
|
||||||
|
// Mixed notation: translate a trailing dotted-quad into two hex groups.
|
||||||
|
const dotted = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(body);
|
||||||
|
if (dotted) {
|
||||||
|
const octets = [dotted[2], dotted[3], dotted[4], dotted[5]].map((part) => Number(part));
|
||||||
|
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const high = ((octets[0] as number) << 8) | (octets[1] as number);
|
||||||
|
const low = ((octets[2] as number) << 8) | (octets[3] as number);
|
||||||
|
body = `${dotted[1]}${high.toString(16)}:${low.toString(16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, tail, ...extra] = body.split("::");
|
||||||
|
if (extra.length > 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parseGroups = (value: string | undefined): number[] | undefined => {
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const groups: number[] = [];
|
||||||
|
for (const part of value.split(":")) {
|
||||||
|
if (!/^[0-9a-f]{1,4}$/u.test(part)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
groups.push(Number.parseInt(part, 16));
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
const leading = parseGroups(head);
|
||||||
|
const trailing = tail === undefined ? [] : parseGroups(tail);
|
||||||
|
if (!leading || !trailing) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (tail === undefined) {
|
||||||
|
return leading.length === 8 ? leading : undefined;
|
||||||
|
}
|
||||||
|
const missing = 8 - leading.length - trailing.length;
|
||||||
|
if (missing < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [...leading, ...Array.from({ length: missing }, () => 0), ...trailing];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a canonicalized `URL.hostname` is a loopback or unspecified listen address in any
|
||||||
|
* spelling this repository accepts — literal name, reserved `.localhost` namespace, the full IPv4
|
||||||
|
* `127.0.0.0/8` block, IPv6 `::1` compressed or expanded, IPv6 `::` unspecified, and IPv4-mapped
|
||||||
|
* IPv6 forms of both in mixed (`::ffff:127.0.0.1`) and hexadecimal (`::ffff:7f00:1`) notation. */
|
||||||
|
function isLoopbackOrUnspecifiedHostname(hostname: string): boolean {
|
||||||
|
const normalized = hostname.toLowerCase();
|
||||||
|
if (LITERAL_LOOPBACK_HOSTNAMES.has(normalized) || UNSPECIFIED_HOSTNAMES.has(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// RFC 6761 reserves the entire `.localhost` namespace for loopback.
|
||||||
|
if (normalized.endsWith(".localhost")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (IPV4_LOOPBACK_BLOCK.test(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const groups = expandIpv6Hostname(normalized);
|
||||||
|
if (!groups) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isAllZero = groups.every((group) => group === 0);
|
||||||
|
// `::` (unspecified) and `::1` (loopback).
|
||||||
|
if (isAllZero || (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// IPv4-mapped IPv6 (`::ffff:a.b.c.d`): classify the embedded IPv4 address.
|
||||||
|
const isMapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
||||||
|
if (isMapped) {
|
||||||
|
const high = groups[6] as number;
|
||||||
|
const low = groups[7] as number;
|
||||||
|
const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
||||||
|
return IPV4_LOOPBACK_BLOCK.test(embedded) || embedded === "0.0.0.0";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
|
||||||
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
|
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
|
||||||
@@ -90,20 +186,38 @@ export interface CreateIdentityConfigOptions {
|
|||||||
readonly environment?: IdentityEnvironment;
|
readonly environment?: IdentityEnvironment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoopbackHostname(hostname: string): boolean {
|
/**
|
||||||
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
|
* Parses one value that must be exactly a canonical absolute `http:`/`https:` **origin** — the
|
||||||
}
|
* single representation both the identity base URL and every trusted origin use, so no component
|
||||||
|
* of the platform can reinterpret the same deployment differently.
|
||||||
function parseAbsoluteHttpUrl(value: unknown): URL | undefined {
|
*
|
||||||
|
* Canonical means `URL.origin` must equal the configured string byte-for-byte. That one comparison
|
||||||
|
* structurally rejects every non-origin component at once — credentials, any path (including a
|
||||||
|
* bare trailing `/`, since `URL.origin` is slashless), a query, a fragment, surrounding
|
||||||
|
* whitespace, an uppercase hostname, and a redundant default port — because the WHATWG parser
|
||||||
|
* normalizes or preserves each of those in a way that no longer matches `origin`.
|
||||||
|
*
|
||||||
|
* Structural parsing is deliberately separate from environment policy: a caller distinguishes
|
||||||
|
* "this is not an origin" from "this origin is not allowed in production" and reports the
|
||||||
|
* structural code first, so an already-malformed URL is never mislabeled as a production-loopback
|
||||||
|
* failure.
|
||||||
|
*/
|
||||||
|
function parseCanonicalHttpOrigin(value: unknown): URL | undefined {
|
||||||
if (typeof value !== "string" || value.length === 0) {
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
url = new URL(value);
|
||||||
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
|
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// `URL.origin` carries no credentials, path, query, or fragment, and is already lowercased with
|
||||||
|
// any default port elided — so requiring exact equality is the whole canonical-origin rule.
|
||||||
|
return url.origin === value ? url : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSecret(secret: unknown): string {
|
function validateSecret(secret: unknown): string {
|
||||||
@@ -126,7 +240,11 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
|||||||
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
|
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
|
||||||
throw new IdentityConfigError("BASE_URL_MISSING");
|
throw new IdentityConfigError("BASE_URL_MISSING");
|
||||||
}
|
}
|
||||||
const url = parseAbsoluteHttpUrl(baseUrl);
|
// Structural validity first, in every environment: the base URL is the public origin of the
|
||||||
|
// already-fixed `/api/auth` transport, and Ekklesia does not support mounting Better Auth
|
||||||
|
// beneath a configurable URL path. A credentialed, pathed, queried, fragmented, whitespace-
|
||||||
|
// padded, or otherwise non-canonical spelling is structurally invalid, never a policy failure.
|
||||||
|
const url = parseCanonicalHttpOrigin(baseUrl);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new IdentityConfigError("BASE_URL_INVALID");
|
throw new IdentityConfigError("BASE_URL_INVALID");
|
||||||
}
|
}
|
||||||
@@ -134,11 +252,13 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
|||||||
if (url.protocol !== "https:") {
|
if (url.protocol !== "https:") {
|
||||||
throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION");
|
throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION");
|
||||||
}
|
}
|
||||||
if (isLoopbackHostname(url.hostname)) {
|
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
|
||||||
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
|
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return baseUrl;
|
// Store the canonical origin — identical to what `validateTrustedOrigin` stores — so CORS,
|
||||||
|
// Better Auth's own origin checks, and this base URL can never disagree about the same origin.
|
||||||
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
||||||
@@ -152,19 +272,21 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment
|
|||||||
if (origin.includes("*") || origin.includes("?")) {
|
if (origin.includes("*") || origin.includes("?")) {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED");
|
||||||
}
|
}
|
||||||
const url = parseAbsoluteHttpUrl(origin);
|
const url = parseCanonicalHttpOrigin(origin);
|
||||||
if (!url || url.origin !== origin) {
|
if (!url) {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||||
}
|
}
|
||||||
if (environment === "production") {
|
if (environment === "production") {
|
||||||
if (url.protocol !== "https:") {
|
if (url.protocol !== "https:") {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
|
||||||
}
|
}
|
||||||
if (isLoopbackHostname(url.hostname)) {
|
// The identical classifier the base URL uses, so the two can never disagree about whether the
|
||||||
|
// same host is a production-legal origin.
|
||||||
|
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return origin;
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { Pool, type PoolConfig } from "pg";
|
import { Pool, type PoolConfig } from "pg";
|
||||||
|
import {
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
databaseTlsConnectionOptions,
|
||||||
|
} from "../database/transport.js";
|
||||||
import { identityAdapterSchema } from "./schema.js";
|
import { identityAdapterSchema } from "./schema.js";
|
||||||
|
|
||||||
export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>;
|
export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>;
|
||||||
@@ -28,6 +32,10 @@ export interface IdentityDatabaseOptions {
|
|||||||
* indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does
|
* indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does
|
||||||
* nothing once a query is already running on one. */
|
* nothing once a query is already running on one. */
|
||||||
readonly operationTimeoutMs?: number;
|
readonly operationTimeoutMs?: number;
|
||||||
|
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
|
||||||
|
* every production executable; omitted by single-domain unit/integration factories that use
|
||||||
|
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
|
||||||
|
readonly transport?: DatabaseTransportPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdentityDatabaseHandle {
|
export interface IdentityDatabaseHandle {
|
||||||
@@ -81,8 +89,13 @@ export function createIdentityDrizzleDatabase(
|
|||||||
const validatedConnectionString = validateConnectionString(connectionString);
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||||
|
const ssl =
|
||||||
|
options.transport === undefined
|
||||||
|
? undefined
|
||||||
|
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
|
||||||
const poolConfig: PoolConfig = {
|
const poolConfig: PoolConfig = {
|
||||||
connectionString: validatedConnectionString,
|
connectionString: validatedConnectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
...(validatedConnectionTimeoutMs === undefined
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Client, type ClientConfig, type QueryResultRow } from "pg";
|
import { Client, type ClientConfig, type QueryResultRow } from "pg";
|
||||||
|
import {
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
databaseTlsConnectionOptions,
|
||||||
|
} from "../database/transport.js";
|
||||||
import type {
|
import type {
|
||||||
AppliedMigrationRecord,
|
AppliedMigrationRecord,
|
||||||
MigrationDatabase,
|
MigrationDatabase,
|
||||||
@@ -76,6 +80,10 @@ export class PostgresMigrationDatabaseError extends Error {
|
|||||||
export interface PostgresMigrationDatabaseOptions {
|
export interface PostgresMigrationDatabaseOptions {
|
||||||
readonly connectionTimeoutMs?: number;
|
readonly connectionTimeoutMs?: number;
|
||||||
readonly operationTimeoutMs?: number;
|
readonly operationTimeoutMs?: number;
|
||||||
|
/** The validated transport policy (`core/src/database/transport.js`). Every production executable
|
||||||
|
* that plans or applies migrations passes one; omitting it keeps the pre-existing plaintext
|
||||||
|
* behavior used by the disposable loopback PostgreSQL harness. */
|
||||||
|
readonly transport?: DatabaseTransportPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryExistenceRow extends QueryResultRow {
|
interface HistoryExistenceRow extends QueryResultRow {
|
||||||
@@ -178,8 +186,16 @@ function createClientConfig(
|
|||||||
"The PostgreSQL operation timeout is invalid.",
|
"The PostgreSQL operation timeout is invalid.",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Also rejects a URL carrying its own TLS parameters, so `pg`'s connection-string precedence
|
||||||
|
// can never silently replace this policy with a weaker one.
|
||||||
|
const ssl =
|
||||||
|
options?.transport === undefined
|
||||||
|
? undefined
|
||||||
|
: databaseTlsConnectionOptions(connectionString, options.transport);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionString,
|
connectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
...(validatedConnectionTimeoutMs === undefined
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { Client } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { databaseTlsConnectionOptions } from "../src/database/transport.js";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real TLS proof for the production transport policy, exercised through the pinned `pg` client
|
||||||
|
* against a disposable, TLS-enabled PostgreSQL container.
|
||||||
|
*
|
||||||
|
* Every certificate, key, and CA used here is generated at test runtime into an isolated temporary
|
||||||
|
* directory and deleted in `afterAll` — no private-key material is committed to this repository.
|
||||||
|
* The container, its name, and its published loopback port are likewise disposable and removed in
|
||||||
|
* `afterAll` even when an assertion fails.
|
||||||
|
*/
|
||||||
|
const containerName = `ekklesia-database-tls-${process.pid}`;
|
||||||
|
let certificateDirectory: string | undefined;
|
||||||
|
let containerStarted = false;
|
||||||
|
let boundPort: number | undefined;
|
||||||
|
|
||||||
|
class TlsHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TlsHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openssl(args: readonly string[], cwd: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await run("openssl", [...args], { cwd });
|
||||||
|
} catch {
|
||||||
|
// Never surface openssl's own stderr: it can echo paths from the harness environment.
|
||||||
|
throw new TlsHarnessError("The disposable TLS material could not be generated.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a private CA, a server certificate signed by it whose only SAN is `DNS:localhost`,
|
||||||
|
* and an unrelated second CA. The deliberately narrow SAN is what makes the hostname-mismatch
|
||||||
|
* assertion below meaningful: reaching the very same server through `127.0.0.1` must fail. */
|
||||||
|
async function generateCertificates(directory: string): Promise<void> {
|
||||||
|
await openssl(
|
||||||
|
[
|
||||||
|
"req",
|
||||||
|
"-new",
|
||||||
|
"-x509",
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
"-nodes",
|
||||||
|
"-out",
|
||||||
|
"ca.crt",
|
||||||
|
"-keyout",
|
||||||
|
"ca.key",
|
||||||
|
"-subj",
|
||||||
|
"/CN=Ekklesia Disposable Test CA",
|
||||||
|
],
|
||||||
|
directory,
|
||||||
|
);
|
||||||
|
await openssl(
|
||||||
|
[
|
||||||
|
"req",
|
||||||
|
"-new",
|
||||||
|
"-nodes",
|
||||||
|
"-out",
|
||||||
|
"server.csr",
|
||||||
|
"-keyout",
|
||||||
|
"server.key",
|
||||||
|
"-subj",
|
||||||
|
"/CN=localhost",
|
||||||
|
],
|
||||||
|
directory,
|
||||||
|
);
|
||||||
|
await writeFile(path.join(directory, "san.cnf"), "subjectAltName=DNS:localhost\n");
|
||||||
|
await openssl(
|
||||||
|
[
|
||||||
|
"x509",
|
||||||
|
"-req",
|
||||||
|
"-in",
|
||||||
|
"server.csr",
|
||||||
|
"-CA",
|
||||||
|
"ca.crt",
|
||||||
|
"-CAkey",
|
||||||
|
"ca.key",
|
||||||
|
"-CAcreateserial",
|
||||||
|
"-out",
|
||||||
|
"server.crt",
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
"-extfile",
|
||||||
|
"san.cnf",
|
||||||
|
],
|
||||||
|
directory,
|
||||||
|
);
|
||||||
|
await openssl(
|
||||||
|
[
|
||||||
|
"req",
|
||||||
|
"-new",
|
||||||
|
"-x509",
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
"-nodes",
|
||||||
|
"-out",
|
||||||
|
"other-ca.crt",
|
||||||
|
"-keyout",
|
||||||
|
"other-ca.key",
|
||||||
|
"-subj",
|
||||||
|
"/CN=Unrelated Test CA",
|
||||||
|
],
|
||||||
|
directory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function docker(args: readonly string[]): Promise<string> {
|
||||||
|
const { stdout } = await run("docker", [...args]);
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTlsPostgres(directory: string): Promise<number> {
|
||||||
|
// The certificate/key are copied *inside* the container and re-owned before PostgreSQL starts:
|
||||||
|
// PostgreSQL refuses a key file that is not owned by the database user or root, and a bind-
|
||||||
|
// mounted host file keeps the host's ownership.
|
||||||
|
await docker([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
containerName,
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_DB=ekklesia_tls",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_USER=ekklesia_tls",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_PASSWORD=ekklesia_tls",
|
||||||
|
"-v",
|
||||||
|
`${directory}:/certs-src:ro`,
|
||||||
|
"-p",
|
||||||
|
"127.0.0.1:0:5432",
|
||||||
|
"--entrypoint",
|
||||||
|
"/bin/sh",
|
||||||
|
"postgres:18.4-alpine3.24",
|
||||||
|
"-c",
|
||||||
|
"mkdir -p /tls && cp /certs-src/server.crt /certs-src/server.key /tls/ && " +
|
||||||
|
"chown postgres:postgres /tls/server.* && chmod 600 /tls/server.key && " +
|
||||||
|
"exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/tls/server.crt " +
|
||||||
|
"-c ssl_key_file=/tls/server.key",
|
||||||
|
]);
|
||||||
|
containerStarted = true;
|
||||||
|
|
||||||
|
const published = await docker(["port", containerName, "5432"]);
|
||||||
|
const match = /^127\.0\.0\.1:(\d+)$/u.exec(published.split("\n")[0]?.trim() ?? "");
|
||||||
|
if (!match) {
|
||||||
|
throw new TlsHarnessError(
|
||||||
|
"The disposable TLS PostgreSQL port is not bound safely to loopback.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounded readiness polling — never an arbitrary sleep. Resolves once the server accepts a TLS
|
||||||
|
* connection, or fails after the deadline. */
|
||||||
|
async function waitForTlsReadiness(port: number, ca: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + 60_000;
|
||||||
|
let lastError: unknown;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const client = new Client({
|
||||||
|
connectionTimeoutMillis: 3_000,
|
||||||
|
database: "ekklesia_tls",
|
||||||
|
host: "localhost",
|
||||||
|
password: "ekklesia_tls",
|
||||||
|
port,
|
||||||
|
ssl: { ca, rejectUnauthorized: true },
|
||||||
|
user: "ekklesia_tls",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.end();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// The failed client is already unusable; nothing to clean up beyond this.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new TlsHarnessError(
|
||||||
|
`The disposable TLS PostgreSQL did not become ready: ${String(lastError)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWith(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
ssl: ReturnType<typeof databaseTlsConnectionOptions>,
|
||||||
|
): Promise<{ encrypted: boolean }> {
|
||||||
|
const client = new Client({
|
||||||
|
connectionTimeoutMillis: 5_000,
|
||||||
|
database: "ekklesia_tls",
|
||||||
|
host,
|
||||||
|
password: "ekklesia_tls",
|
||||||
|
port,
|
||||||
|
...(ssl === false ? {} : { ssl }),
|
||||||
|
user: "ekklesia_tls",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const result = await client.query<{ encrypted: boolean }>(
|
||||||
|
"SELECT ssl AS encrypted FROM pg_stat_ssl WHERE pid = pg_backend_pid()",
|
||||||
|
);
|
||||||
|
return { encrypted: result.rows[0]?.encrypted === true };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// Closing an already-failed client is not itself a test failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (containerStarted) {
|
||||||
|
await docker(["rm", "-f", containerName]).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (certificateDirectory) {
|
||||||
|
// Removes the generated CA, server certificate, and every private key.
|
||||||
|
await rm(certificateDirectory, { force: true, recursive: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("production PostgreSQL TLS policy against a real TLS endpoint", () => {
|
||||||
|
it("starts a disposable TLS-enabled PostgreSQL with runtime-generated certificates", async () => {
|
||||||
|
certificateDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-tls-"));
|
||||||
|
await generateCertificates(certificateDirectory);
|
||||||
|
boundPort = await startTlsPostgres(certificateDirectory);
|
||||||
|
const ca = await readFile(path.join(certificateDirectory, "ca.crt"), "utf8");
|
||||||
|
await waitForTlsReadiness(boundPort, ca);
|
||||||
|
expect(boundPort).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects successfully, and encrypted, when the certificate is trusted and its identity matches the host", async () => {
|
||||||
|
const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||||
|
const ssl = databaseTlsConnectionOptions(
|
||||||
|
`postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`,
|
||||||
|
{ certificateAuthority: ca, mode: "verify-full" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { encrypted } = await connectWith("localhost", boundPort as number, ssl);
|
||||||
|
expect(encrypted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a certificate that does not chain to the configured CA", async () => {
|
||||||
|
const otherCa = await readFile(
|
||||||
|
path.join(certificateDirectory as string, "other-ca.crt"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const ssl = databaseTlsConnectionOptions(
|
||||||
|
`postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`,
|
||||||
|
{ certificateAuthority: otherCa, mode: "verify-full" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(connectWith("localhost", boundPort as number, ssl)).rejects.toMatchObject({
|
||||||
|
code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a trusted certificate whose identity does not match the host being connected to", async () => {
|
||||||
|
// The server certificate's only SAN is `DNS:localhost`, so reaching the identical server via
|
||||||
|
// its IP address is a genuine hostname mismatch. This is the case the pinned driver does *not*
|
||||||
|
// catch on its own: `pg` sets `servername` only for non-IP hosts, and Node performs no identity
|
||||||
|
// check when it is unset, so `rejectUnauthorized: true` alone would accept this connection.
|
||||||
|
const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||||
|
const ssl = databaseTlsConnectionOptions(
|
||||||
|
`postgresql://[email protected]:${boundPort}/ekklesia_tls`,
|
||||||
|
{ certificateAuthority: ca, mode: "verify-full" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(connectWith("127.0.0.1", boundPort as number, ssl)).rejects.toMatchObject({
|
||||||
|
code: "ERR_TLS_CERT_ALTNAME_INVALID",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proves the gap the policy closes: chain-only verification would accept that same mismatch", async () => {
|
||||||
|
const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||||
|
// Exactly what `ssl: { ca, rejectUnauthorized: true }` alone does — no pinned
|
||||||
|
// `checkServerIdentity`. It connects, which is why the policy supplies one.
|
||||||
|
const { encrypted } = await connectWith("127.0.0.1", boundPort as number, {
|
||||||
|
ca,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
});
|
||||||
|
expect(encrypted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot satisfy verify-full against a plaintext-only PostgreSQL endpoint", async () => {
|
||||||
|
// The disposable plaintext PostgreSQL the rest of the integration suite uses.
|
||||||
|
const plaintextUrl = process.env.EKKLESIA_MIGRATION_TEST_DATABASE_URL;
|
||||||
|
if (typeof plaintextUrl !== "string" || plaintextUrl.trim() === "") {
|
||||||
|
throw new TlsHarnessError("The plaintext integration database is not configured.");
|
||||||
|
}
|
||||||
|
const parsed = new URL(plaintextUrl);
|
||||||
|
const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||||
|
const ssl = databaseTlsConnectionOptions(plaintextUrl, {
|
||||||
|
certificateAuthority: ca,
|
||||||
|
mode: "verify-full",
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
connectionTimeoutMillis: 5_000,
|
||||||
|
database: parsed.pathname.slice(1),
|
||||||
|
host: parsed.hostname,
|
||||||
|
password: decodeURIComponent(parsed.password),
|
||||||
|
port: Number(parsed.port),
|
||||||
|
...(ssl === false ? {} : { ssl }),
|
||||||
|
user: decodeURIComponent(parsed.username),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(client.connect()).rejects.toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// Already unusable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import tls from "node:tls";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
|
||||||
|
import {
|
||||||
|
assertNoConnectionStringTlsParameters,
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
databaseHostFromUrl,
|
||||||
|
DatabaseTransportError,
|
||||||
|
databaseTlsConnectionOptions,
|
||||||
|
describeDatabaseTransport,
|
||||||
|
} from "../src/database/transport.js";
|
||||||
|
|
||||||
|
const productionUrl = "postgresql://ekklesia:[email protected]:5432/ekklesia";
|
||||||
|
|
||||||
|
describe("createDatabaseTransportPolicy", () => {
|
||||||
|
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
|
||||||
|
expect(createDatabaseTransportPolicy()).toEqual({ mode: "verify-full" });
|
||||||
|
expect(createDatabaseTransportPolicy({}, { environment: "production" })).toEqual({
|
||||||
|
mode: "verify-full",
|
||||||
|
});
|
||||||
|
expect(createDatabaseTransportPolicy({}, { environment: "development" })).toEqual({
|
||||||
|
mode: "disable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses plaintext in production", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toBeInstanceOf(DatabaseTransportError);
|
||||||
|
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects every loose, aliased, or coercible mode spelling in both environments", () => {
|
||||||
|
for (const mode of [
|
||||||
|
"VERIFY-FULL",
|
||||||
|
"Verify-Full",
|
||||||
|
" verify-full",
|
||||||
|
"verify-full ",
|
||||||
|
"require", // encryption-only: deliberately unsupported
|
||||||
|
"verify-ca", // no hostname verification: deliberately unsupported
|
||||||
|
"prefer",
|
||||||
|
"no-verify",
|
||||||
|
"allow",
|
||||||
|
"true",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
]) {
|
||||||
|
for (const environment of ["production", "development"] as const) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createDatabaseTransportPolicy({ mode }, { environment });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${JSON.stringify(mode)} to be rejected`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_MODE_INVALID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an optional private CA and rejects an empty one", () => {
|
||||||
|
expect(
|
||||||
|
createDatabaseTransportPolicy({ certificateAuthority: "-----BEGIN CERTIFICATE-----" })
|
||||||
|
.certificateAuthority,
|
||||||
|
).toBe("-----BEGIN CERTIFICATE-----");
|
||||||
|
for (const certificateAuthority of ["", " ", 42]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createDatabaseTransportPolicy({ certificateAuthority });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toBeInstanceOf(DatabaseTransportError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never carries the offending mode or CA material in the thrown error", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createDatabaseTransportPolicy({ certificateAuthority: "SUPER-SECRET-CA", mode: "sslmode=x" });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
const publicText = `${String(captured)}\n${
|
||||||
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||||
|
}\n${JSON.stringify(captured)}`;
|
||||||
|
expect(publicText).not.toContain("SUPER-SECRET-CA");
|
||||||
|
expect(publicText).not.toContain("sslmode=x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertNoConnectionStringTlsParameters", () => {
|
||||||
|
it("rejects every TLS parameter the pinned connection-string parser recognizes", () => {
|
||||||
|
for (const parameter of [
|
||||||
|
"ssl=true",
|
||||||
|
"sslmode=disable",
|
||||||
|
"sslmode=require",
|
||||||
|
"sslmode=verify-full",
|
||||||
|
"sslcert=/tmp/c.crt",
|
||||||
|
"sslkey=/tmp/c.key",
|
||||||
|
"sslrootcert=/tmp/ca.crt",
|
||||||
|
"sslpassword=x",
|
||||||
|
"sslnegotiation=direct",
|
||||||
|
"uselibpqcompat=true",
|
||||||
|
"SSLMODE=disable", // case-insensitive
|
||||||
|
]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
assertNoConnectionStringTlsParameters(`${productionUrl}?${parameter}`);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an unrelated parameter", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertNoConnectionStringTlsParameters(`${productionUrl}?application_name=ekklesia`),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-PostgreSQL or malformed URL", () => {
|
||||||
|
for (const url of ["", "not-a-url", "mysql://db.example.test/x", "http://db.example.test"]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
assertNoConnectionStringTlsParameters(url);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "DATABASE_URL_INVALID" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("databaseTlsConnectionOptions", () => {
|
||||||
|
it("produces no TLS options at all when the policy disables TLS", () => {
|
||||||
|
expect(
|
||||||
|
databaseTlsConnectionOptions("postgresql://127.0.0.1:5432/ekklesia", { mode: "disable" }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables chain verification and pins hostname verification to the configured host", () => {
|
||||||
|
const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" });
|
||||||
|
expect(options).not.toBe(false);
|
||||||
|
if (options === false) {
|
||||||
|
throw new Error("expected TLS options");
|
||||||
|
}
|
||||||
|
// There is no configuration path that produces `rejectUnauthorized: false`.
|
||||||
|
expect(options.rejectUnauthorized).toBe(true);
|
||||||
|
expect(typeof options.checkServerIdentity).toBe("function");
|
||||||
|
expect(options.ca).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the certificate against the configured host, not against whatever SNI the driver set", () => {
|
||||||
|
// The pinned driver sets `servername` only for non-IP hosts, and Node skips identity checking
|
||||||
|
// entirely when it is unset — so the policy must pin the host itself. This asserts the
|
||||||
|
// supplied callback ignores the passed servername and uses the configured host.
|
||||||
|
const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" });
|
||||||
|
if (options === false || !options.checkServerIdentity) {
|
||||||
|
throw new Error("expected a checkServerIdentity callback");
|
||||||
|
}
|
||||||
|
const spy = vi.spyOn(tls, "checkServerIdentity").mockReturnValue(undefined);
|
||||||
|
try {
|
||||||
|
options.checkServerIdentity("attacker-supplied.example", {} as tls.PeerCertificate);
|
||||||
|
expect(spy).toHaveBeenCalledWith("db.example.test", expect.anything());
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the bare address for an IPv6 host so identity checking sees a usable value", () => {
|
||||||
|
expect(databaseHostFromUrl("postgresql://[2001:db8::1]:5432/ekklesia")).toBe("2001:db8::1");
|
||||||
|
expect(databaseHostFromUrl("postgresql://db.example.test:5432/ekklesia")).toBe(
|
||||||
|
"db.example.test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes an explicit private CA through unchanged", () => {
|
||||||
|
const certificateAuthority =
|
||||||
|
"-----BEGIN CERTIFICATE-----\nprivate-ca\n-----END CERTIFICATE-----";
|
||||||
|
const options = databaseTlsConnectionOptions(productionUrl, {
|
||||||
|
certificateAuthority,
|
||||||
|
mode: "verify-full",
|
||||||
|
});
|
||||||
|
if (options === false) {
|
||||||
|
throw new Error("expected TLS options");
|
||||||
|
}
|
||||||
|
expect(options.ca).toBe(certificateAuthority);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to build options for a URL carrying its own TLS parameters", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
databaseTlsConnectionOptions(`${productionUrl}?sslmode=disable`, { mode: "verify-full" });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertProductionDatabaseTransport", () => {
|
||||||
|
it("permits verified TLS in production and any validated policy in development", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertProductionDatabaseTransport(productionUrl, { mode: "verify-full" }, "production"),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
assertProductionDatabaseTransport(
|
||||||
|
"postgresql://127.0.0.1:5432/ekklesia",
|
||||||
|
{ mode: "disable" },
|
||||||
|
"development",
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses production plaintext, including for a loopback database — there is no production exception", () => {
|
||||||
|
for (const url of [
|
||||||
|
productionUrl,
|
||||||
|
"postgresql://127.0.0.1:5432/ekklesia",
|
||||||
|
"postgresql://localhost:5432/ekklesia",
|
||||||
|
"postgresql://[::1]:5432/ekklesia",
|
||||||
|
]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
assertProductionDatabaseTransport(url, { mode: "disable" }, "production");
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected production plaintext to ${url} to be refused`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migration CLI transport reporting", () => {
|
||||||
|
it("reports the stable transport code instead of a generic 'failed unexpectedly', and leaks nothing", () => {
|
||||||
|
const captured: string[] = [];
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||||
|
captured.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const url of [
|
||||||
|
`${productionUrl}?sslmode=disable`,
|
||||||
|
"postgresql://ekklesia:[email protected]:5432/ekklesia?ssl=true",
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
databaseTlsConnectionOptions(url, { mode: "verify-full" });
|
||||||
|
} catch (error) {
|
||||||
|
reportMigrationCommandError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" });
|
||||||
|
} catch (error) {
|
||||||
|
reportMigrationCommandError(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = captured.join("\n");
|
||||||
|
expect(output).toContain("[DATABASE_TLS_CONNECTION_STRING_CONFLICT]");
|
||||||
|
expect(output).toContain("[DATABASE_TLS_REQUIRED_IN_PRODUCTION]");
|
||||||
|
expect(output).not.toContain("failed unexpectedly");
|
||||||
|
// The connection string, its credentials, and its host never reach operator output.
|
||||||
|
for (const secret of ["hunter2", "db.example.test", "secret", "sslmode=disable"]) {
|
||||||
|
expect(output).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeDatabaseTransport", () => {
|
||||||
|
it("describes the effective mode without revealing any connection detail", () => {
|
||||||
|
expect(describeDatabaseTransport({ mode: "verify-full" })).toBe(
|
||||||
|
"TLS required with certificate and hostname verification.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }),
|
||||||
|
).toBe(
|
||||||
|
"TLS required with certificate and hostname verification (explicit private CA supplied).",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }),
|
||||||
|
).not.toContain("SECRET-CA");
|
||||||
|
expect(describeDatabaseTransport({ mode: "disable" })).toBe(
|
||||||
|
"TLS disabled (development/test plaintext).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,8 @@ Data definitions
|
|||||||
0 migrations · checksums valid
|
0 migrations · checksums valid
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
|
✓ Database TLS verified
|
||||||
|
TLS required with certificate and hostname verification.
|
||||||
○ Connection check skipped
|
○ Connection check skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Migration plan skipped
|
○ Migration plan skipped
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
"modules.permissions",
|
"modules.permissions",
|
||||||
"schemas.discovery",
|
"schemas.discovery",
|
||||||
"migrations.discovery",
|
"migrations.discovery",
|
||||||
|
"database.transport",
|
||||||
"postgresql.connection",
|
"postgresql.connection",
|
||||||
"postgresql.plan",
|
"postgresql.plan",
|
||||||
"identity.configuration",
|
"identity.configuration",
|
||||||
@@ -255,9 +256,15 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
dependencies({ createMigrationDatabase, planMigrations }),
|
dependencies({ createMigrationDatabase, planMigrations }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Doctor's own read-only connection carries the same validated transport policy every other
|
||||||
|
// executable uses — it defaults to certificate-verified TLS, exactly like production.
|
||||||
expect(createMigrationDatabase).toHaveBeenCalledWith(
|
expect(createMigrationDatabase).toHaveBeenCalledWith(
|
||||||
"postgresql://not-rendered.invalid/ekklesia",
|
"postgresql://not-rendered.invalid/ekklesia",
|
||||||
{ connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000 },
|
{
|
||||||
|
connectionTimeoutMs: 10_000,
|
||||||
|
operationTimeoutMs: 10_000,
|
||||||
|
transport: { mode: "verify-full" },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(planMigrations).toHaveBeenCalledOnce();
|
expect(planMigrations).toHaveBeenCalledOnce();
|
||||||
expect(check(report, "postgresql.connection")).toMatchObject({
|
expect(check(report, "postgresql.connection")).toMatchObject({
|
||||||
@@ -702,6 +709,57 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(report.result).toBe("not-ready");
|
expect(report.result).toBe("not-ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes database transport by default, reporting verified TLS without revealing any connection detail", async () => {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{ ...options("postgresql://admin:[email protected]:5432/ekklesia") },
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = check(report, "database.transport");
|
||||||
|
expect(transport.status).toBe("pass");
|
||||||
|
expect(transport.summary).toBe("Database TLS verified");
|
||||||
|
const rendered = JSON.stringify(transport);
|
||||||
|
expect(rendered).not.toContain("hunter2");
|
||||||
|
expect(rendered).not.toContain("db.internal.example");
|
||||||
|
expect(rendered).not.toContain("postgresql://");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks readiness when a production deployment would reach PostgreSQL without verified TLS", async () => {
|
||||||
|
for (const [label, extra] of [
|
||||||
|
["explicit production plaintext", { databaseTransport: { mode: "disable" } }],
|
||||||
|
[
|
||||||
|
"connection-string downgrade",
|
||||||
|
{ databaseUrl: "postgresql://db.example.test/ekklesia?sslmode=disable" },
|
||||||
|
],
|
||||||
|
["malformed mode", { databaseTransport: { mode: "require" } }],
|
||||||
|
] as const) {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{ ...options("postgresql://db.example.test/ekklesia"), ...extra },
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = check(report, "database.transport");
|
||||||
|
expect(transport.status, `expected ${label} to block readiness`).toBe("failure");
|
||||||
|
expect(transport.summary).toBe("Database transport unsafe");
|
||||||
|
expect(report.result).toBe("not-ready");
|
||||||
|
expect(doctorExitCode(report)).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes development plaintext only when development is explicitly selected", async () => {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{
|
||||||
|
...options("postgresql://127.0.0.1:5432/ekklesia"),
|
||||||
|
databaseEnvironment: "development",
|
||||||
|
},
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = check(report, "database.transport");
|
||||||
|
expect(transport.status).toBe("pass");
|
||||||
|
expect(transport.summary).toContain("disabled");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes client-address configuration with direct mode when nothing is configured", async () => {
|
it("passes client-address configuration with direct mode when nothing is configured", async () => {
|
||||||
const report = await diagnoseEkklesia(options(), dependencies());
|
const report = await diagnoseEkklesia(options(), dependencies());
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,126 @@ describe("Core identity configuration", () => {
|
|||||||
expect(config.baseUrl).toBe("http://localhost:3000");
|
expect(config.baseUrl).toBe("http://localhost:3000");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires the base URL to be exactly one canonical origin, rejecting every non-origin component", () => {
|
||||||
|
for (const baseUrl of [
|
||||||
|
"https://id.example.test/", // a bare trailing slash is a path; `URL.origin` is slashless
|
||||||
|
"https://id.example.test/auth", // Better Auth is never mounted beneath a configurable path
|
||||||
|
"https://id.example.test?a=1",
|
||||||
|
"https://id.example.test#fragment",
|
||||||
|
"https://user:[email protected]",
|
||||||
|
"https://:[email protected]",
|
||||||
|
" https://id.example.test ",
|
||||||
|
"https://ID.EXAMPLE.TEST", // non-canonical hostname case
|
||||||
|
"https://id.example.test:443", // redundant default port
|
||||||
|
"http://id.example.test:80",
|
||||||
|
"ftp://id.example.test",
|
||||||
|
"id.example.test",
|
||||||
|
]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${JSON.stringify(baseUrl)} to be rejected`).toMatchObject({
|
||||||
|
code: "BASE_URL_INVALID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the base URL and trusted origins as one identical canonical origin representation", () => {
|
||||||
|
const config = createIdentityConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
baseUrl: "https://id.example.test",
|
||||||
|
trustedOrigins: ["https://id.example.test", "https://dashboard.example.test:8443"],
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
expect(config.baseUrl).toBe("https://id.example.test");
|
||||||
|
expect(config.trustedOrigins).toEqual([
|
||||||
|
"https://id.example.test",
|
||||||
|
"https://dashboard.example.test:8443",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects every loopback and unspecified-host spelling in production, for the base URL and trusted origins alike", () => {
|
||||||
|
// Canonical spellings the WHATWG parser preserves — these must be rejected as production
|
||||||
|
// policy failures, by the identical classifier on both fields.
|
||||||
|
const canonicalLoopback = [
|
||||||
|
"https://127.0.0.1",
|
||||||
|
"https://127.0.0.2", // the full 127.0.0.0/8 block, not just .1
|
||||||
|
"https://127.255.255.254",
|
||||||
|
"https://[::1]", // IPv6 loopback, compressed
|
||||||
|
"https://[::ffff:7f00:1]", // IPv4-mapped loopback, hexadecimal notation
|
||||||
|
"https://localhost",
|
||||||
|
"https://app.localhost", // RFC 6761 reserved namespace
|
||||||
|
"https://0.0.0.0",
|
||||||
|
"https://[::]", // IPv6 unspecified
|
||||||
|
"https://[::ffff:0:0]", // IPv4-mapped unspecified
|
||||||
|
];
|
||||||
|
for (const value of canonicalLoopback) {
|
||||||
|
let baseCaptured: unknown;
|
||||||
|
try {
|
||||||
|
createIdentityConfig({ ...validInput(), baseUrl: value }, { environment: "production" });
|
||||||
|
} catch (error) {
|
||||||
|
baseCaptured = error;
|
||||||
|
}
|
||||||
|
expect(baseCaptured, `expected base URL ${value} to be rejected`).toMatchObject({
|
||||||
|
code: "BASE_URL_LOOPBACK_IN_PRODUCTION",
|
||||||
|
});
|
||||||
|
|
||||||
|
let originCaptured: unknown;
|
||||||
|
try {
|
||||||
|
createIdentityConfig(
|
||||||
|
{ ...validInput(), trustedOrigins: [value] },
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
originCaptured = error;
|
||||||
|
}
|
||||||
|
expect(originCaptured, `expected trusted origin ${value} to be rejected`).toMatchObject({
|
||||||
|
code: "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-canonical loopback spellings structurally, never as a misleading production-policy failure", () => {
|
||||||
|
// WHATWG canonicalization folds some of these into `127.0.0.1`; others simply are not the
|
||||||
|
// canonical origin text. Either way the honest answer is "this is not a canonical origin".
|
||||||
|
for (const baseUrl of [
|
||||||
|
"https://127.1",
|
||||||
|
"https://2130706433",
|
||||||
|
"https://0x7f000001",
|
||||||
|
"https://[0:0:0:0:0:0:0:1]", // expanded IPv6 loopback
|
||||||
|
"https://[::ffff:127.0.0.1]", // mixed IPv4-mapped notation
|
||||||
|
"https://LOCALHOST",
|
||||||
|
]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${baseUrl} to be structurally rejected`).toMatchObject({
|
||||||
|
code: "BASE_URL_INVALID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still accepts canonical loopback development origins", () => {
|
||||||
|
const config = createIdentityConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:5173"],
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
expect(config.baseUrl).toBe("http://localhost:3000");
|
||||||
|
expect(config.trustedOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:5173"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults publicSignupEnabled to false when entirely omitted", () => {
|
it("defaults publicSignupEnabled to false when entirely omitted", () => {
|
||||||
const config = createIdentityConfig(validInput(), { environment: "production" });
|
const config = createIdentityConfig(validInput(), { environment: "production" });
|
||||||
expect(config.publicSignupEnabled).toBe(false);
|
expect(config.publicSignupEnabled).toBe(false);
|
||||||
|
|||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
# 0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-26
|
||||||
|
- Amends: [0010](0010-core-identity-and-better-auth-adoption.md), [0011](0011-identity-http-transport-and-public-enrollment.md)
|
||||||
|
- Extends: [0017](0017-public-request-perimeter-hardening.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 5D pass 1 ([ADR 0017](0017-public-request-perimeter-hardening.md), commits `d3c6bd73`,
|
||||||
|
`1161f5a076b66548767d67b58853bf83ab8f24b4`, `3d6c85b0`) closed the public request perimeter's
|
||||||
|
default-open exposures. Pass 2 addresses the layer immediately behind it: how a request's principal
|
||||||
|
is actually resolved, what a "public origin" is allowed to be, and how Core reaches PostgreSQL at
|
||||||
|
all. Five defects were reproduced against the pass-1 tree before anything was changed:
|
||||||
|
|
||||||
|
1. **Any cookie triggered identity database work.** `resolvePrincipal`'s ambiguity check ran
|
||||||
|
whenever the request carried *a* `Cookie` header. `Cookie: x=y` on an otherwise anonymous
|
||||||
|
request caused a database round trip on every request — an unauthenticated caller could drive
|
||||||
|
identity database load with a header they chose themselves.
|
||||||
|
2. **The readiness probe proved almost nothing.** It issued `select 1`. A dropped session table, a
|
||||||
|
drifted or removed column, or a revoked `SELECT` privilege on the session relation all still
|
||||||
|
answered "reachable", so Better Auth's ambiguous `null` was reported as a genuinely anonymous
|
||||||
|
request. A deployment whose session store was broken in any of those ways served every request
|
||||||
|
as anonymous instead of failing.
|
||||||
|
3. **The base URL / trusted-origin boundary accepted non-origins.** Validation accepted any
|
||||||
|
absolute `http`/`https` URL, so a value carrying a path, a trailing slash, credentials, a
|
||||||
|
default port written explicitly, or non-canonical case became configuration that no longer
|
||||||
|
compared equal to the origin a browser would send. The loopback rejection was also a short
|
||||||
|
literal list (`localhost`, `127.0.0.1`, `::1`), missing `127.0.0.2`, the rest of `127.0.0.0/8`,
|
||||||
|
`0.0.0.0`, `::`, the expanded `0:0:0:0:0:0:0:1` spelling, IPv4-mapped forms such as
|
||||||
|
`::ffff:127.0.0.1`, and the RFC 6761 `.localhost` namespace.
|
||||||
|
4. **Every PostgreSQL connection was plaintext, in every environment.** No connection site set
|
||||||
|
`ssl` at all. Credentials, session tokens, and row data crossed the network in the clear, and
|
||||||
|
nothing in configuration, startup, or `pnpm doctor` said so.
|
||||||
|
5. **A connection string could silently downgrade an explicit TLS decision.** The pinned
|
||||||
|
`pg` 8.22.0 builds its parameters as
|
||||||
|
`Object.assign({}, config, parse(config.connectionString))`, so a `?sslmode=disable` in
|
||||||
|
`DATABASE_URL` *overrides* an explicit `ssl` object passed alongside it. A policy that only set
|
||||||
|
`ssl` would have been silently defeated by the URL it was given.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Only the configured session cookie makes `null` ambiguous
|
||||||
|
|
||||||
|
`core/src/identity/runtime.ts` exports `hasConfiguredSessionCookie(headers, cookieName)`. It parses
|
||||||
|
the `Cookie` header the way RFC 6265's `cookie-string` grammar (and Better Auth's own parser)
|
||||||
|
requires — split on `;`, take each pair's name as the text before its first `=`, trim surrounding
|
||||||
|
whitespace — and compares the whole name with an exact string comparison. Never `includes`, never a
|
||||||
|
substring test, never an unanchored regular expression: `better-auth.session_token_x`,
|
||||||
|
`x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are all
|
||||||
|
correctly *not* the session cookie (cookie names are case-sensitive per RFC 6265 §4.1.1 and are
|
||||||
|
never percent-decoded). Ambiguity fails safe *towards* checking: a duplicated session-cookie name
|
||||||
|
still reports `true`, because reporting `false` is what would let a broken session store be served
|
||||||
|
as an anonymous request. Reporting `true` never grants access on its own — it only means the
|
||||||
|
readiness check runs, and Better Auth's own result still governs the principal.
|
||||||
|
|
||||||
|
The cookie name is not a second, hard-coded copy. One `betterAuthOptions` object feeds both
|
||||||
|
`betterAuth()` and Better Auth's own supported `getCookies` helper (`better-auth/cookies`), so the
|
||||||
|
name this runtime detects — including the `__Secure-` prefix an `https` `baseURL` implies — is
|
||||||
|
derived from the exact configuration Better Auth itself uses. Drift is structurally impossible
|
||||||
|
rather than merely tested.
|
||||||
|
|
||||||
|
The practical effect: a request with no cookies, or with only unrelated cookies, performs **zero**
|
||||||
|
identity database work. Better Auth never attempted to resolve an Ekklesia session for it, so its
|
||||||
|
`null` is unambiguous and the anonymous principal is returned directly.
|
||||||
|
|
||||||
|
### The readiness check reads the real session relation
|
||||||
|
|
||||||
|
`probeSessionStore` replaces `select 1` with a planned read of the exact session relation and the
|
||||||
|
exact columns the Better Auth adapter depends on (`id`, `token`, `userId`, `expiresAt`), under a
|
||||||
|
`where false` predicate. PostgreSQL still resolves the relation, every named column, and the
|
||||||
|
caller's `SELECT` privilege at plan time, while guaranteeing zero rows are matched, read, or
|
||||||
|
returned — so no session row, token, or user id can be enumerated, logged, or exposed by the check
|
||||||
|
itself. It uses the injected Identity Drizzle view (the API's one shared pool) and never constructs
|
||||||
|
a second pool, client, connection string, or Drizzle instance, and it stays bounded by the existing
|
||||||
|
3-second timeout whose timer is always cleared.
|
||||||
|
|
||||||
|
A missing relation, a dropped column, a revoked privilege, an unreachable connection, and a query
|
||||||
|
timeout all surface at this seam identically — as a rejected query — and all produce the same
|
||||||
|
outcome: a sanitized `PRINCIPAL_RESOLUTION_FAILED`, which `apps/api` already maps to a
|
||||||
|
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` with no relation name, column name, SQL, driver text,
|
||||||
|
host, or stack in it. A genuinely missing, expired, revoked, or malformed session still resolves to
|
||||||
|
the anonymous principal exactly as ADR 0011 specifies. This is the distinction the pass-1 tree
|
||||||
|
could not make.
|
||||||
|
|
||||||
|
### The public identity URL boundary is exactly one canonical origin
|
||||||
|
|
||||||
|
`parseCanonicalHttpOrigin` (`core/src/identity/config.ts`) requires `url.origin === value` — the
|
||||||
|
WHATWG URL parser's own canonical serialization must equal the supplied string exactly. A path,
|
||||||
|
a trailing slash, a query, a fragment, embedded credentials, an explicitly written default port, or
|
||||||
|
non-canonical case therefore fails with the existing `BASE_URL_INVALID` /
|
||||||
|
`TRUSTED_ORIGIN_INVALID` codes rather than being normalized behind the operator's back, and both
|
||||||
|
`validateBaseUrl` and `validateTrustedOrigin` return that canonical origin, so what is stored is
|
||||||
|
what Better Auth compares against a browser's `Origin` header. The base URL is now an origin, not a
|
||||||
|
URL that merely happens to have one.
|
||||||
|
|
||||||
|
`isLoopbackOrUnspecifiedHostname` replaces the literal list. It classifies `localhost` and the whole
|
||||||
|
reserved `.localhost` namespace (RFC 6761), all of `127.0.0.0/8`, `0.0.0.0`, `::1` in both its
|
||||||
|
compressed and fully expanded spellings, `::`, and IPv4-mapped forms such as `::ffff:127.0.0.1` and
|
||||||
|
`::ffff:7f00:1` — by expanding the address to its eight numeric groups rather than by comparing
|
||||||
|
strings. Production rejects every one of them for both the base URL and every trusted origin
|
||||||
|
(`BASE_URL_LOOPBACK_IN_PRODUCTION` / `TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION`), and development
|
||||||
|
composition must still opt in explicitly with `environment: "development"`.
|
||||||
|
|
||||||
|
This is a configuration-shape guarantee and nothing more. **It does not resolve DNS and does not
|
||||||
|
prove reachability.** A public hostname that resolves to a loopback or private address still passes
|
||||||
|
this validation, and a firewall, network policy, or reverse proxy — not URL parsing — is what
|
||||||
|
actually determines who can reach the deployment.
|
||||||
|
|
||||||
|
### Production PostgreSQL requires TLS with certificate *and* hostname verification
|
||||||
|
|
||||||
|
`core/src/database/transport.ts` is the one typed transport policy. `DatabaseTlsMode` has exactly
|
||||||
|
two values:
|
||||||
|
|
||||||
|
- `verify-full` — TLS required, certificate chain verified, and the server's identity verified
|
||||||
|
against the configured host. **The default in production.**
|
||||||
|
- `disable` — no TLS. **The default in development/test, and refused outright in production.**
|
||||||
|
|
||||||
|
There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no
|
||||||
|
`rejectUnauthorized: false` escape hatch anywhere in the configuration surface: encryption without
|
||||||
|
identity verification is exactly what an interception attack defeats, so offering it as a
|
||||||
|
supported setting would offer a switch whose safe position is the only one worth having. Every
|
||||||
|
loose, aliased, or coercible spelling (`VERIFY-FULL`, `" verify-full"`, `require`, `prefer`,
|
||||||
|
`no-verify`, `true`, `1`, `""`, `null`) is rejected with `DATABASE_TLS_MODE_INVALID` 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.**
|
||||||
|
`assertProductionDatabaseTransport` refuses `mode: "disable"` in production whether the URL points
|
||||||
|
at `db.example.test`, `127.0.0.1`, `localhost`, or `[::1]`, with the stable
|
||||||
|
`DATABASE_TLS_REQUIRED_IN_PRODUCTION` code. A "same host, therefore safe" carve-out would be a
|
||||||
|
claim about the deployment's process isolation, container networking, and socket namespace that
|
||||||
|
this repository cannot verify from a URL string.
|
||||||
|
|
||||||
|
`databaseTlsConnectionOptions` builds the actual `ssl` object: `rejectUnauthorized: true`, an
|
||||||
|
optional explicit private CA (`certificateAuthority`, supplied as PEM text — a path, a filesystem
|
||||||
|
read, and a secret store are all out of scope here), and — critically — an explicit
|
||||||
|
`checkServerIdentity` pinned to the host parsed from the connection string. That last part closes a
|
||||||
|
real gap in the pinned driver rather than restating a default: `pg` sets TLS `servername` only for
|
||||||
|
non-IP hosts (`connection.js`), and Node performs **no** identity check when `servername` is unset,
|
||||||
|
so `rejectUnauthorized: true` alone accepts a certificate from any host the configured CA ever
|
||||||
|
signed when connecting by IP address. Both halves of that claim are proven against a real
|
||||||
|
TLS-enabled PostgreSQL in `core/tests/database-tls.integration.ts`: the policy rejects the mismatch
|
||||||
|
with `ERR_TLS_CERT_ALTNAME_INVALID`, and the same connection *without* the pinned callback succeeds.
|
||||||
|
|
||||||
|
### A connection string may not carry its own TLS decision
|
||||||
|
|
||||||
|
`assertNoConnectionStringTlsParameters` rejects a `DATABASE_URL` carrying any TLS parameter the
|
||||||
|
pinned `pg-connection-string` 2.14.0 recognizes — `ssl`, `sslmode`, `sslcert`, `sslkey`,
|
||||||
|
`sslrootcert`, `sslpassword`, `sslnegotiation`, `uselibpqcompat`, case-insensitively — with
|
||||||
|
`DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic. The pinned driver's
|
||||||
|
`Object.assign({}, config, parse(config.connectionString))` gives the *connection string* the last
|
||||||
|
word, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object;
|
||||||
|
`sslmode=require` is additionally aliased to verify-full behavior with a deprecation warning in this
|
||||||
|
release, so even the "stricter-looking" spellings do not mean what an operator would assume. There
|
||||||
|
is exactly one place TLS is decided, and it is the policy — never the URL. `databaseTlsConnectionOptions`
|
||||||
|
performs this assertion itself, so no connection site can forget it.
|
||||||
|
|
||||||
|
### One policy, every executable, and Doctor
|
||||||
|
|
||||||
|
`core/src/cli/database-transport-environment.ts` is the single environment reader
|
||||||
|
(`EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`), keeping `process.env` out of Core's
|
||||||
|
reusable constructors: `createValidatedPostgresPool`, `createIdentityDrizzleDatabase`,
|
||||||
|
`createAuthorizationDrizzleDatabase`, and `createPostgresMigrationDatabase` all take an explicit
|
||||||
|
optional `transport` policy instead. Every executable that opens a PostgreSQL connection resolves
|
||||||
|
the policy the same way — `apps/api` (via `ApiRuntimeConfig.databaseTransport`, validated in
|
||||||
|
`createApiRuntimeConfig` *before* any pool, Better Auth instance, module runtime, or listener
|
||||||
|
exists), `pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
|
||||||
|
`pnpm doctor`'s own connection.
|
||||||
|
|
||||||
|
`pnpm doctor` gains a `database.transport` check reported in its PostgreSQL section. A verified
|
||||||
|
production policy passes; an unsafe or invalid one is a blocking `NOT READY` **failure**, never a
|
||||||
|
warning, and its output names the effective mode only — never the URL, host, port, credentials, or
|
||||||
|
CA material.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keeping `select 1` and adding a separate table-existence query** was rejected: it would be a
|
||||||
|
second, drifting description of what the adapter actually needs. Planning the real read against
|
||||||
|
the real relation and the real columns is the only check that fails when any of them changes.
|
||||||
|
- **Probing the session store on every request** was rejected: it makes an unauthenticated caller
|
||||||
|
able to drive database load, which is the defect being fixed, not a stricter version of it.
|
||||||
|
- **Normalizing a non-canonical base URL instead of rejecting it** was rejected: silently rewriting
|
||||||
|
an operator's origin is how a deployment ends up with a trusted-origin list that does not match
|
||||||
|
what browsers actually send, and the failure then appears as an unexplained CSRF rejection.
|
||||||
|
- **Resolving DNS during configuration validation to catch a public hostname pointing at a private
|
||||||
|
address** was rejected: it makes startup depend on resolver state, is trivially defeated by a
|
||||||
|
later DNS change, and would invite exactly the false confidence this ADR explicitly disclaims.
|
||||||
|
- **Supporting `sslmode=require` (encryption without identity verification)** was rejected: it is
|
||||||
|
the mode that looks secure and is not, and having it available makes it the one an operator picks
|
||||||
|
when verify-full is inconvenient.
|
||||||
|
- **A production plaintext exception for a loopback database** was rejected: the URL cannot prove
|
||||||
|
the database is actually process-local, and the exception would be the first thing a
|
||||||
|
containerized deployment mistakenly claimed.
|
||||||
|
- **Trusting `rejectUnauthorized: true` alone** was rejected once the reproduction showed the pinned
|
||||||
|
driver leaves `servername` unset for IP hosts and Node then performs no identity check at all.
|
||||||
|
- **Reading `EKKLESIA_DATABASE_*` inside Core's database constructors** was rejected: it would put
|
||||||
|
`process.env` inside reusable library code and give every test and future embedder an implicit,
|
||||||
|
invisible input.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A request without the configured session cookie performs no identity database work at all; a
|
||||||
|
request that presents it, and that Better Auth resolved to `null`, is disambiguated by a real
|
||||||
|
session-store read, so a broken store produces a sanitized `503` instead of a silent anonymous
|
||||||
|
downgrade. The identity base URL and every trusted origin are now exactly one canonical origin
|
||||||
|
each, with a complete loopback/unspecified classifier enforced in production — a shape guarantee
|
||||||
|
only, which does not resolve DNS, does not prove reachability, and does not replace a firewall or
|
||||||
|
reverse proxy. Production PostgreSQL connections require TLS with both certificate and hostname
|
||||||
|
verification, from every executable, with no plaintext exception and no way to express a weaker
|
||||||
|
mode; a connection string that tries to decide TLS for itself is refused rather than honored.
|
||||||
|
|
||||||
|
None of this makes an internet-exposed database safe: TLS protects the connection, not the exposure,
|
||||||
|
and the network boundary remains the deployment's own responsibility. This pass adds no secret
|
||||||
|
store, no CA file loading, no certificate rotation, no client-certificate authentication, no
|
||||||
|
connection-level authorization change, and no distributed rate limiting. Phase 5D passes 3–5
|
||||||
|
(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain
|
||||||
|
closure) remain outstanding — Phase 5D is not complete.
|
||||||
@@ -26,3 +26,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
|
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
|
||||||
- [0016: Module database access](0016-module-database-access.md) — Accepted
|
- [0016: Module database access](0016-module-database-access.md) — Accepted
|
||||||
- [0017: Public request perimeter hardening — client address, signup gating, body limits, timeouts](0017-public-request-perimeter-hardening.md) — Accepted
|
- [0017: Public request perimeter hardening — client address, signup gating, body limits, timeouts](0017-public-request-perimeter-hardening.md) — Accepted
|
||||||
|
- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted
|
||||||
|
|||||||
+60
-3
@@ -49,12 +49,14 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
|
|
||||||
| Variable | Requirement |
|
| Variable | Requirement |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. |
|
| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. It must **not** carry a TLS parameter (`sslmode`, `ssl`, `sslrootcert`, …) — see [Database transport (TLS)](#database-transport-tls). |
|
||||||
|
| `EKKLESIA_DATABASE_TLS_MODE` | Optional. Exactly `"verify-full"` or `"disable"`. Defaults to `verify-full` in production and `disable` in development/test. Production refuses `disable`. |
|
||||||
|
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
|
||||||
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
||||||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||||
| `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. |
|
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
||||||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||||||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||||||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||||||
@@ -499,6 +501,61 @@ authorization, and module domains all read from this one pool through independen
|
|||||||
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
|
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
|
||||||
one pool, not two.
|
one pool, not two.
|
||||||
|
|
||||||
|
## Database transport (TLS)
|
||||||
|
|
||||||
|
`core/src/database/transport.ts` is the one typed policy deciding how *every* Ekklesia executable
|
||||||
|
reaches PostgreSQL. It has exactly two modes:
|
||||||
|
|
||||||
|
| Mode | Meaning | Default in |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `verify-full` | TLS required, certificate chain verified, **and** the server's identity verified against the configured host. | production |
|
||||||
|
| `disable` | No TLS. Refused outright in production. | development/test |
|
||||||
|
|
||||||
|
There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no
|
||||||
|
`rejectUnauthorized: false` escape hatch: encryption without identity verification is exactly what
|
||||||
|
an interception attack defeats. Every loose or aliased spelling — `VERIFY-FULL`, `" verify-full"`,
|
||||||
|
`require`, `prefer`, `no-verify`, `true`, `1`, empty — is rejected with `DATABASE_TLS_MODE_INVALID`
|
||||||
|
in *both* environments, so a typo can never quietly become a weaker mode.
|
||||||
|
|
||||||
|
**Production has no plaintext exception — not even for a loopback database.** `disable` in
|
||||||
|
production fails with `DATABASE_TLS_REQUIRED_IN_PRODUCTION` whether `DATABASE_URL` points at a
|
||||||
|
remote host, `127.0.0.1`, `localhost`, or `[::1]`. A connection string cannot prove the database is
|
||||||
|
actually process-local, so no "same host, therefore safe" carve-out exists.
|
||||||
|
|
||||||
|
Verification is certificate *and* hostname, not encryption alone. The policy sets
|
||||||
|
`rejectUnauthorized: true` and an explicit `checkServerIdentity` pinned to the host parsed from
|
||||||
|
`DATABASE_URL`. That second part closes a real gap rather than restating a default: the pinned `pg`
|
||||||
|
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 you connect by IP address. Both halves are proven against a real TLS-enabled
|
||||||
|
PostgreSQL in `core/tests/database-tls.integration.ts`.
|
||||||
|
|
||||||
|
**`DATABASE_URL` may not carry its own TLS parameters.** `sslmode`, `ssl`, `sslcert`, `sslkey`,
|
||||||
|
`sslrootcert`, `sslpassword`, `sslnegotiation`, and `uselibpqcompat` are all rejected
|
||||||
|
(case-insensitively) with `DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic: 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. TLS is
|
||||||
|
decided in exactly one place, and it is never the URL.
|
||||||
|
|
||||||
|
For a database behind an internal CA, supply the CA's PEM text in `EKKLESIA_DATABASE_CA`; it is
|
||||||
|
added to the trusted set for verification and never appears in any error, log, or `pnpm doctor`
|
||||||
|
output. Loading it from a file path or a secret store is out of scope here, as are certificate
|
||||||
|
rotation and client-certificate authentication.
|
||||||
|
|
||||||
|
The same policy is resolved identically by every executable that opens a connection — `apps/api`,
|
||||||
|
`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
|
||||||
|
`pnpm doctor`'s own connection — through one shared environment reader
|
||||||
|
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable
|
||||||
|
constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
|
||||||
|
instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in
|
||||||
|
its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a
|
||||||
|
blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL,
|
||||||
|
host, port, credentials, or CA material.
|
||||||
|
|
||||||
|
TLS protects the *connection*, not the exposure: it does not make an internet-reachable database
|
||||||
|
safe, and the network boundary remains the deployment's own responsibility. See
|
||||||
|
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||||
|
|
||||||
## Graceful shutdown
|
## Graceful shutdown
|
||||||
|
|
||||||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||||
|
|||||||
@@ -416,6 +416,14 @@ unaffected and remain available for standalone/testable use — the CLI bootstra
|
|||||||
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs
|
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs
|
||||||
the authorization schema and has no reason to share a pool with anything else.
|
the authorization schema and has no reason to share a pool with anything else.
|
||||||
|
|
||||||
|
Both the shared pool and the per-domain factories accept the same optional `transport` policy —
|
||||||
|
Ekklesia's single PostgreSQL TLS decision, described in
|
||||||
|
[API § Database transport (TLS)](api.md#database-transport-tls). Every executable that opens a
|
||||||
|
connection (the API, both migration commands, the platform-owner bootstrap CLI, and `pnpm doctor`)
|
||||||
|
resolves it the same way, so production connections are TLS-verified regardless of which factory
|
||||||
|
built them. Omitting it is a development/test affordance only: no `ssl` option is set and the
|
||||||
|
connection is plaintext, which a production configuration refuses outright.
|
||||||
|
|
||||||
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
|
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
|
||||||
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
|
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
|
||||||
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
|
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
|
||||||
|
|||||||
@@ -126,12 +126,28 @@ intentionally contains no real values — configure them per environment, never
|
|||||||
| Value | Requirement |
|
| 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. |
|
| 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. |
|
| 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 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. |
|
| 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
|
**"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin
|
||||||
explicitly passes `environment: "development"` — never silently. Every configuration failure
|
serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash
|
||||||
raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value.
|
(`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
|
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
|
||||||
ADR 0010.
|
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
|
`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
|
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
|
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
|
## 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
|
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
|
database, and a query timeout therefore all produce a sanitized
|
||||||
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
|
`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
|
## What is not operational yet
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ module migration discovery, identity schema drift detection, formatting, linting
|
|||||||
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
|
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
|
||||||
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
|
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
|
||||||
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
|
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
|
||||||
configuration validation (secret, base URL, and trusted-origin rules), bounded/validated
|
configuration validation (secret rules, canonical-origin-only base URL and trusted origins, and the
|
||||||
|
full loopback/unspecified classifier production rejects — `.localhost`, all of `127.0.0.0/8`,
|
||||||
|
`0.0.0.0`, `::1` compressed and expanded, `::`, and IPv4-mapped forms), bounded/validated
|
||||||
connection and operation (`query_timeout`/`statement_timeout`) database timeouts, the Core identity
|
connection and operation (`query_timeout`/`statement_timeout`) database timeouts, the Core identity
|
||||||
runtime (explicit construction, exact schema mapping, only email/password and rate limiting
|
runtime (explicit construction, exact schema mapping, only email/password and rate limiting
|
||||||
enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit
|
enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit
|
||||||
@@ -22,9 +24,12 @@ error-only sanitized logger that strips Better Auth's own message and arguments
|
|||||||
they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name
|
they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name
|
||||||
session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike,
|
session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike,
|
||||||
and that a broken session store during principal resolution is never silently reported as anonymous
|
and that a broken session store during principal resolution is never silently reported as anonymous
|
||||||
while an unrelated cookie performs no identity database work at all), and Core migration discovery
|
while an unrelated cookie performs no identity database work at all), the PostgreSQL transport
|
||||||
(deterministic ordering, the reserved `core` identity, and collision rejection) — all without a
|
policy (`core/tests/database-transport.test.ts`: mode validation including every loose or aliased
|
||||||
live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module
|
spelling, production's plaintext refusal with no loopback exception, connection-string
|
||||||
|
TLS-parameter rejection, and that no configuration path yields `rejectUnauthorized: false` or leaks
|
||||||
|
CA material into an error), and Core migration discovery (deterministic ordering, the reserved
|
||||||
|
`core` identity, and collision rejection) — all without a live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module
|
||||||
route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
|
route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
|
||||||
and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle
|
and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle
|
||||||
(concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a
|
(concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a
|
||||||
@@ -125,6 +130,20 @@ completes and is independently verifiable, through the harness's own administrat
|
|||||||
never the API's own pool — before that pool closes. See
|
never the API's own pool — before that pool closes. See
|
||||||
[ADR 0016](../architecture/decisions/0016-module-database-access.md).
|
[ADR 0016](../architecture/decisions/0016-module-database-access.md).
|
||||||
|
|
||||||
|
A fourth dedicated suite (`core/tests/database-tls.integration.ts`, included in the same
|
||||||
|
`pnpm test:migrations:integration` run) proves the production PostgreSQL transport policy against a
|
||||||
|
real TLS-enabled PostgreSQL rather than against the shape of an options object: a trusted
|
||||||
|
certificate whose identity matches the host connects and is confirmed encrypted through
|
||||||
|
`pg_stat_ssl`; a certificate that does not chain to the configured CA is refused
|
||||||
|
(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`); the same server reached through an address its certificate does
|
||||||
|
not cover is refused (`ERR_TLS_CERT_ALTNAME_INVALID`); the identical connection *without* the
|
||||||
|
policy's pinned `checkServerIdentity` succeeds, which is the concrete gap the policy closes; and
|
||||||
|
`verify-full` cannot be satisfied by a plaintext-only endpoint. Every CA, certificate, and private
|
||||||
|
key is generated at test runtime into a temporary directory and deleted afterwards — **no private
|
||||||
|
key material is ever committed to this repository** — and the disposable container is removed in
|
||||||
|
`afterAll` even when an assertion fails. See
|
||||||
|
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||||
|
|
||||||
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
||||||
the following pull-request status context to `main` branch protection:
|
the following pull-request status context to `main` branch protection:
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
hookTimeout: 30_000,
|
hookTimeout: 30_000,
|
||||||
include: [
|
include: [
|
||||||
"core/tests/postgres-migration.integration.ts",
|
"core/tests/postgres-migration.integration.ts",
|
||||||
|
"core/tests/database-tls.integration.ts",
|
||||||
"core/tests/authorization-store.integration.ts",
|
"core/tests/authorization-store.integration.ts",
|
||||||
"core/tests/authorization-bootstrap.integration.ts",
|
"core/tests/authorization-bootstrap.integration.ts",
|
||||||
"apps/api/tests/api-http.integration.ts",
|
"apps/api/tests/api-http.integration.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user