phase-5/security-hardening #14

Merged
Zayin_Admin merged 21 commits from phase-5/security-hardening into main 2026-07-30 03:04:37 +00:00
5 changed files with 363 additions and 24 deletions
Showing only changes of commit 6912680113 - Show all commits
+36 -10
View File
@@ -94,20 +94,46 @@ export const MIGRATION_CONNECTION_TIMEOUT_MS = 10_000;
export const MIGRATION_OPERATION_TIMEOUT_MS = 10_000;
/**
* Connection-string parameters the pinned `[email protected]` would let silently override the timeouts
* selected above. Its `ConnectionParameters` merges as
* `Object.assign({}, config, parse(config.connectionString))`, so the **URL wins** over explicitly
* passed options — verified directly against the pinned driver, where an explicit
* `statement_timeout: 10000` alongside `?statement_timeout=0` produced a server-reported
* `statement_timeout` of `0`. Rejecting these is therefore not stylistic: without it the policy
* above is advisory.
* Connection-string parameters that must not reach the pinned driver, established by measuring the
* installed `[email protected]`/`[email protected]` against a real PostgreSQL and a real
* blackhole listener rather than by reading the merge and assuming.
*
* Deliberately narrow — only settings that actually conflict with the timeouts this boundary
* selects. `options` is included solely because it can carry `-c statement_timeout=...`, and is
* rejected only when it does.
* `ConnectionParameters` merges as `Object.assign({}, config, parse(config.connectionString))` and
* `pg-connection-string` retains *arbitrary* query keys, so any of these lands in the driver's
* configuration. What each one actually does differs, and only the first genuinely weakens policy:
*
* - `statement_timeout` — **wins outright.** `?statement_timeout=0` produced a server-reported
* `statement_timeout` of `0` (disabled) and `?statement_timeout=900000` produced `15min`, against
* an explicit `statement_timeout: 10000`. This is the one real downgrade.
* - `query_timeout` — overrides the explicit field, but does not weaken the bound; it breaks the
* client. `client.query` computes its read timer as
* `queryConfig.query_timeout || connectionParameters.query_timeout`, and the parsed URL value is
* the *string* `"0"` — truthy — so every query gets a 0 ms read timer and fails
* nondeterministically with `Query read timeout`. Rejected so an operator gets a fixed
* configuration error instead of intermittent, unexplained query failures.
* - `connectionTimeoutMillis` — pollutes `ConnectionParameters.connect_timeout` (10 → `0` or `900`
* seconds), but that field is only consumed by `getLibpqConnectionString()`, which the pure-JS
* client never calls. Measured against a blackhole listener, the effective connect timer is
* **unchanged**: an explicit 500 ms still expired at ~500 ms with `?connectionTimeoutMillis=0`
* and with `=900000`. Rejected as unsupported and as defense in depth — no legitimate
* `DATABASE_URL` carries a driver option, and the polluted value would take effect on the native
* binding — not because it currently defeats the deadline.
* - `connect_timeout` — no effect at all: `ConnectionParameters` recomputes it from
* `connectionTimeoutMillis`, so `?connect_timeout=0` left it at the derived `10`. Rejected as
* unsupported; earlier revisions of this comment wrongly claimed it overrode the explicit field.
* - `options` carrying `statement_timeout` — measured **not** to override the named startup
* parameter: `?options=-c statement_timeout=0`, `=900s`, and a backslash-escaped spelling all
* still produced `10s`, as did an inherited `PGOPTIONS`. Rejected as unsupported rather than
* because it wins.
*
* Deliberately narrow: unrelated parameters (`application_name`, and anything else that does not
* name a timeout this boundary selects) are untouched. `PGCONNECT_TIMEOUT` needs no handling — the
* driver consults it only when `connectionTimeoutMillis` is `undefined`, and this boundary always
* sets it; an inherited `PGCONNECT_TIMEOUT=0` was measured to change nothing.
*/
const CONFLICTING_TIMEOUT_PARAMETERS = Object.freeze([
"connect_timeout",
"connectiontimeoutmillis",
"query_timeout",
"statement_timeout",
]);
@@ -219,3 +219,80 @@ describe("PostgreSQL migration timeout policy", () => {
await session.close();
});
});
/**
* Pass 4 correction: `pg-connection-string` retains *arbitrary* query keys, so the driver's own
* configuration key `connectionTimeoutMillis` reaches `ConnectionParameters` from a URL. It was not
* on the rejected list. These pin the corrected boundary; the measured effect of each key on the
* real driver and a real server lives in `core/tests/postgres-migration.integration.ts`.
*/
describe("PostgreSQL migration timeout precedence", () => {
it.each([
"postgresql://example.test/ekklesia?connectionTimeoutMillis=0",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=900000",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=-1",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=abc",
"postgresql://example.test/ekklesia?CONNECTIONTIMEOUTMILLIS=0",
"postgresql://example.test/ekklesia?connectiontimeoutmillis=0",
])("refuses the driver's own connectionTimeoutMillis key in a URL (%s)", (databaseUrl) => {
const constructionsBefore = clientConstructor.mock.calls.length;
let captured: unknown;
try {
createPostgresMigrationDatabase(databaseUrl);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
// Rejected before any client exists, so no connection is ever attempted.
expect(clientConstructor.mock.calls.length).toBe(constructionsBefore);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("example.test");
expect(publicText).not.toContain("connectionTimeoutMillis");
expect(publicText).not.toContain("900000");
});
it("also refuses it alongside a narrower explicit test override", () => {
let captured: unknown;
try {
createPostgresMigrationDatabase(
"postgresql://example.test/ekklesia?connectionTimeoutMillis=0",
{ connectionTimeoutMs: 300, operationTimeoutMs: 300 },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
});
it("still accepts unrelated parameters and produces the exact expected client configuration", async () => {
const client = {
connect: vi.fn().mockResolvedValue(undefined),
end: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
};
clientConstructor.mockImplementation(
class {
readonly connect = client.connect;
readonly end = client.end;
readonly on = client.on;
} as unknown as () => typeof client,
);
const url = "postgresql://example.test/ekklesia?application_name=ekklesia&lock_timeout=5000";
const session = await createPostgresMigrationDatabase(url).openSession();
expect(clientConstructor).toHaveBeenLastCalledWith({
connectionString: url,
connectionTimeoutMillis: MIGRATION_CONNECTION_TIMEOUT_MS,
query_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
});
await session.close();
});
});
@@ -1635,3 +1635,219 @@ describe.sequential("live PostgreSQL migration command hardening", () => {
});
});
});
/**
* Pass 4 correction: what each timeout-shaped connection-string key and inherited environment
* variable *actually* does to the pinned `[email protected]`, measured rather than inferred from its merge.
*
* The distinction matters: the boundary rejects several keys, but only one of them genuinely
* weakens the selected policy. Documenting a key as "overrides the explicit field" when it does not
* is the kind of claim this suite exists to prevent.
*
* Every listener, socket, client, and disposable database is closed in `finally`.
*/
describe.sequential("live PostgreSQL migration timeout precedence", () => {
const productionClientFields = Object.freeze({
connectionTimeoutMillis: 10_000,
query_timeout: 10_000,
statement_timeout: 10_000,
});
it("does not let a URL weaken the effective connect timer, whichever spelling is used", async () => {
const sockets: net.Socket[] = [];
const listener = net.createServer((socket) => {
sockets.push(socket);
});
try {
await new Promise<void>((resolve, reject) => {
listener.once("error", reject);
listener.listen(0, "127.0.0.1", () => resolve());
});
const address = listener.address();
if (!address || typeof address !== "object") {
throw harnessError("The blackhole listener has no bound address.");
}
const base = `postgresql://ekklesia:[email protected]:${address.port}/ekklesia`;
// A short explicit deadline stands in for the production 10 s so the measurement is fast.
for (const query of [
"",
"?connectionTimeoutMillis=0",
"?connectionTimeoutMillis=900000",
"?connect_timeout=0",
]) {
const client = new Client({
connectionString: `${base}${query}`,
connectionTimeoutMillis: 500,
});
client.on("error", () => undefined);
const startedAt = Date.now();
let settled = false;
try {
await client.connect();
} catch {
settled = true;
}
const elapsed = Date.now() - startedAt;
try {
await client.end();
} catch {
// Already unusable.
}
// The pure-JS client reads `connectionTimeoutMillis` from the object handed to its
// constructor, before any connection-string merge; the merged value only reaches
// `ConnectionParameters.connect_timeout`, which solely feeds `getLibpqConnectionString()`.
expect(settled, `expected the connect timer to fire for ${JSON.stringify(query)}`).toBe(
true,
);
expect(elapsed, `unexpected timer for ${JSON.stringify(query)}`).toBeLessThan(3_000);
}
// ...but the driver-visible field really is polluted, which is why the boundary rejects the
// key rather than relying on the pure-JS path staying this way. `connectionParameters` is
// real on the pinned client but absent from its published types, so it is read through a
// narrow structural view rather than asserted away.
const connectTimeoutOf = (connectionString: string): string => {
const client = new Client({ connectionString, ...productionClientFields }) as unknown as {
readonly connectionParameters: { readonly connect_timeout: unknown };
};
(client as unknown as Client).on("error", () => undefined);
return String(client.connectionParameters.connect_timeout);
};
expect(connectTimeoutOf(`${base}?connectionTimeoutMillis=0`)).toBe("0");
expect(connectTimeoutOf(base)).toBe("10");
} finally {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => {
listener.close(() => resolve());
});
}
});
it("records what each timeout source does to the real server's statement_timeout", async () => {
await withIsolatedDatabase("timeout_sources", async (database) => {
async function effectiveStatementTimeout(
query: string,
environment: Readonly<Record<string, string>> = {},
): Promise<string> {
const saved = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(environment)) {
saved.set(key, process.env[key]);
process.env[key] = value;
}
const client = new Client({
connectionString: `${database.url}${query}`,
...productionClientFields,
});
client.on("error", () => undefined);
try {
await client.connect();
const result = await client.query<{ statement_timeout: string }>(
"show statement_timeout",
);
return result.rows[0]?.statement_timeout ?? "unknown";
} catch {
return "connection failed";
} finally {
try {
await client.end();
} catch {
// Already unusable.
}
for (const [key, value] of saved) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
// The baseline the policy selects.
expect(await effectiveStatementTimeout("")).toBe("10s");
// The one key that genuinely wins, and the reason it must stay rejected.
expect(await effectiveStatementTimeout("?statement_timeout=0")).toBe("0");
expect(await effectiveStatementTimeout("?statement_timeout=900000")).toBe("15min");
// `query_timeout` from the URL *does* override the explicit field, but it does not weaken the
// bound — it breaks the client. `client.query` computes its read timer as
// `queryConfig.query_timeout || connectionParameters.query_timeout`, and the parsed URL value
// is the *string* `"0"`, which is truthy, so every query gets a 0 ms read timer and fails
// nondeterministically ("Query read timeout") depending on which wins the race. The
// deterministic, assertable fact is the parameter the URL installed.
const racyClient = new Client({
connectionString: `${database.url}?query_timeout=0`,
...productionClientFields,
}) as unknown as { readonly connectionParameters: { readonly query_timeout: unknown } };
(racyClient as unknown as Client).on("error", () => undefined);
expect(racyClient.connectionParameters.query_timeout).toBe("0");
// `options` does *not* override the named startup parameter — including a backslash-escaped
// spelling — so rejecting it is unsupported-configuration hygiene, not a precedence fix.
expect(
await effectiveStatementTimeout(`?options=${encodeURIComponent("-c statement_timeout=0")}`),
).toBe("10s");
expect(
await effectiveStatementTimeout(
`?options=${encodeURIComponent("-c statement_timeout=900s")}`,
),
).toBe("10s");
expect(
await effectiveStatementTimeout(
`?options=${encodeURIComponent("-c statement_timeout\\=0")}`,
),
).toBe("10s");
// Inherited environment cannot reach the selected policy either.
expect(await effectiveStatementTimeout("", { PGOPTIONS: "-c statement_timeout=0" })).toBe(
"10s",
);
expect(await effectiveStatementTimeout("", { PGCONNECT_TIMEOUT: "0" })).toBe("10s");
// An unrelated parameter changes nothing.
expect(await effectiveStatementTimeout("?application_name=ekklesia")).toBe("10s");
});
});
it("refuses every rejected key at the shared boundary before a client is constructed", async () => {
await withIsolatedDatabase("timeout_boundary", async (database) => {
for (const query of [
"?connectionTimeoutMillis=0",
"?connectionTimeoutMillis=900000",
"?connect_timeout=0",
"?query_timeout=0",
"?statement_timeout=0",
`?options=${encodeURIComponent("-c statement_timeout=0")}`,
]) {
let captured: unknown;
try {
createPostgresMigrationDatabase(`${database.url}${query}`);
} catch (error) {
captured = error;
}
expect(captured, `expected ${query} rejected`).toBeInstanceOf(
PostgresMigrationDatabaseError,
);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
expect(publicText).not.toContain(database.name);
expect(publicText).not.toContain("127.0.0.1");
}
// The clean URL the commands actually use still works, end to end, with the real defaults.
const applied = await applyModuleMigrations({
aggregate: aggregate({
id: "people",
migrations: [migration("people", 0, { sql: "SELECT 1;", tag: "0000_ok" })],
}),
database: createPostgresMigrationDatabase(database.url),
});
expect(applied.appliedCount).toBe(1);
});
});
});
@@ -144,15 +144,32 @@ lock-wait deadline and error codes, and the Pass 2 TLS/hostname/certificate poli
as are Doctor's and the bootstrap CLI's own timeouts. No timeout environment variable or flag was
added.
Explicit constructor fields do **not** win under the pinned driver. `[email protected]` builds parameters as
`Object.assign({}, config, parse(config.connectionString))`, so the connection string overrides
passed options — verified directly: an explicit `statement_timeout: 10000` alongside
`?statement_timeout=0` produced a server-reported `statement_timeout` of `0`, while a clean URL
produced `10s`. A `DATABASE_URL` carrying `connect_timeout`, `query_timeout`, `statement_timeout`,
or an `options` value that sets `statement_timeout` is therefore refused with the existing fixed
`POSTGRES_MIGRATION_CONFIGURATION_INVALID`, before any client is constructed and without echoing the
URL, parameter, or value. The rejection is deliberately narrow — only settings that actually conflict
with the selected policy; unrelated parameters are untouched.
A connection string can reach those values, because `[email protected]` builds parameters as
`Object.assign({}, config, parse(config.connectionString))` and `pg-connection-string` retains
*arbitrary* query keys. What each key actually does was measured against the installed driver, a
real blackhole listener, and a real PostgreSQL rather than inferred from that merge, and only one of
them genuinely weakens the policy:
| Source | Measured effect on the selected policy |
| --- | --- |
| `?statement_timeout=0` / `=900000` | **Wins.** Server reported `0` (disabled) and `15min` against an explicit `statement_timeout: 10000`. The one real downgrade. |
| `?query_timeout=0` | Overrides the explicit field, but breaks the client rather than loosening it: the parsed value is the string `"0"`, which is truthy, so every query gets a 0 ms read timer and fails nondeterministically with `Query read timeout`. |
| `?connectionTimeoutMillis=0` / `=900000` | Pollutes `ConnectionParameters.connect_timeout` (10 → `0`/`900` s), but that field feeds only `getLibpqConnectionString()`, which the pure-JS client never calls. The **effective** connect timer is unchanged — an explicit 500 ms still expired at ~500 ms with either value. |
| `?connect_timeout=0` | No effect: `ConnectionParameters` recomputes it from `connectionTimeoutMillis`. |
| `?options=-c statement_timeout=…` (incl. a backslash-escaped spelling) | No effect: the named startup parameter wins. Server still reported `10s`. |
| inherited `PGOPTIONS=-c statement_timeout=0` | No effect. Server still reported `10s`. |
| inherited `PGCONNECT_TIMEOUT=0` | No effect: consulted only when `connectionTimeoutMillis` is `undefined`, which this boundary never leaves unset. |
| unrelated parameters (`application_name`, …) | No effect; deliberately untouched. |
A `DATABASE_URL` carrying `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with the existing
fixed `POSTGRES_MIGRATION_CONFIGURATION_INVALID`, before any client is constructed and without
echoing the URL, parameter, or value. Only the first is a precedence fix; the rest are rejected as
**unsupported configuration and defense in depth** — no legitimate `DATABASE_URL` carries a driver
option, `connectionTimeoutMillis` would take effect on the native binding, and `query_timeout`
produces intermittent failures an operator would struggle to diagnose. Stating that honestly matters:
an earlier revision of this ADR claimed `connect_timeout` overrode the explicit field, which
measurement disproved.
### Each migration is one PostgreSQL-parsed batch
+8 -5
View File
@@ -85,11 +85,14 @@ pnpm doctor # includes Core's migration in its discovery/plan chec
Both `migrations:plan` and `migrations:apply` connect with fixed, finite defaults — a 10-second
connection deadline, a 10-second client query timeout, and a 10-second PostgreSQL
`statement_timeout` — applied at the one shared construction boundary so no command can omit them.
A `DATABASE_URL` that tries to set `connect_timeout`, `query_timeout`, `statement_timeout`, or an
`options` value carrying `statement_timeout` is refused with a sanitized configuration error,
because the pinned driver lets the connection string override explicitly passed options. This bounds
each individual operation; it does **not** promise that a migration completes within ten seconds
across retries or cleanup. Migrations remain explicit operator work and never run at application
A `DATABASE_URL` that tries to set `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with a sanitized
configuration error. Only `statement_timeout` actually overrides the selected policy under the
pinned driver (measured: `?statement_timeout=0` made the server report `0`); the others are rejected
as unsupported configuration and defense in depth — `query_timeout` breaks the client rather than
loosening it, and `connectionTimeoutMillis` pollutes a driver field the pure-JS client never
consults. Unrelated parameters are untouched. This bounds each individual operation; it does **not**
promise that a migration completes within ten seconds across retries or cleanup. Migrations remain explicit operator work and never run at application
startup.
Each pending migration's SQL is sent to PostgreSQL as **one complete batch**, inside the existing