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
4 changed files with 247 additions and 70 deletions
Showing only changes of commit 27088fc054 - Show all commits
+7 -6
View File
@@ -191,12 +191,13 @@ export function assertNoConnectionStringTlsParameters(databaseUrl: string): void
*
* `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`).
* `options.servername` for non-IP hosts (`pg/lib/connection.js`) and passes `tls.connect` a bare
* socket carrying no host, so for an IP endpoint Node has no identity to check and falls back to
* the fixed name `localhost` — never the address actually dialled. A certificate issued for an
* entirely different name is therefore 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,
+221 -60
View File
@@ -1,5 +1,7 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -15,13 +17,48 @@ const run = promisify(execFile);
*
* 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.
* The container, the isolated network, and any published loopback port are likewise disposable and
* removed in `afterAll` even when an assertion fails.
*
* ## Two supported topologies
*
* `EKKLESIA_TEST_JOB_CONTAINER` is the single topology signal, produced by
* `compose_job_network_job_container` in `scripts/lib/compose-job-network.sh` and exported by
* `scripts/test-migrations-integration.sh`. This harness never re-derives the topology itself.
*
* - **Direct host** (signal empty): the container publishes a random port bound to `127.0.0.1`
* only, and the suite connects through `localhost`.
* - **Containerized job** (signal set): the job executes inside a container and drives *sibling*
* containers through the runner's Docker daemon. Two host assumptions break there, and both are
* avoided rather than worked around:
* 1. A port published by the daemon lands on the **runner host's** loopback, which is a
* different network namespace from the job container's. Proven: a container published at
* `127.0.0.1:32907` by the daemon is `ECONNREFUSED` at that address from inside the job
* container. So no port is published at all; both containers join one isolated user-defined
* network and talk over container DNS on the standard port.
* 2. A bind-mount source path is resolved by the daemon against the **runner host's**
* filesystem. A directory created in the job container's `/tmp` does not exist there, so the
* daemon silently creates an empty one, the entrypoint's `cp` fails, the container exits,
* and `docker port` then reports `No public port '5432' published`. So the certificate and
* key are transferred with `docker cp` into the created-but-not-yet-started container, which
* crosses the daemon boundary correctly in both topologies.
*/
const containerName = `ekklesia-database-tls-${process.pid}`;
const instanceId = `${process.pid}-${randomBytes(4).toString("hex")}`;
/** Also the container's DNS name on the isolated network, hence the certificate identity there. */
const containerName = `ekklesia-database-tls-${instanceId}`;
const networkName = `ekklesia-database-tls-net-${instanceId}`;
const jobContainer = (process.env.EKKLESIA_TEST_JOB_CONTAINER ?? "").trim();
const containerizedJob = jobContainer !== "";
let certificateDirectory: string | undefined;
let containerStarted = false;
let boundPort: number | undefined;
let containerCreated = false;
let networkCreated = false;
let jobContainerAttached = false;
/** The host the certificate is issued for, and the only host that may verify successfully. */
let identityHost: string | undefined;
/** A reachable address of the *same* server that the certificate deliberately does not cover. */
let mismatchHost: string | undefined;
let databasePort: number | undefined;
class TlsHarnessError extends Error {
constructor(message: string) {
@@ -40,10 +77,42 @@ async function openssl(args: readonly string[], cwd: string): Promise<void> {
}
}
/** 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> {
async function docker(args: readonly string[]): Promise<string> {
const { stdout } = await run("docker", [...args]);
return stdout.trim();
}
/**
* The identity Node checks when nothing supplies one. Measured against the pinned driver, not
* assumed: for an IP host `pg` skips `options.servername` (`pg/lib/connection.js`) and hands
* `tls.connect` a bare socket with no `host`, and `net.Socket._host` is `null` for an IP literal —
* so Node falls back to the fixed string `localhost`. A certificate whose only SAN is
* `DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected with
* `Host: localhost. is not in the cert's altnames` — proof that chain-only verification checks this
* name rather than the endpoint.
*/
const NODE_FALLBACK_IDENTITY = "localhost";
/**
* Generates a private CA, a server certificate signed by it, and an unrelated second CA.
*
* The certificate's SANs are exactly two DNS names and **no IP SAN**:
*
* - the host this run actually connects through, so the verify-full success case matches a real
* identity rather than a coincidence;
* - {@link NODE_FALLBACK_IDENTITY}, so the chain-only control below reproduces the same outcome in
* both topologies.
*
* Neither covers the server's IP address, which is what keeps the hostname-mismatch assertion
* meaningful: reaching the very same server through its address must fail under the production
* policy, whose pinned `checkServerIdentity` demands an `IP:` SAN that is deliberately absent.
* On a direct host the two names coincide and the SAN set is the single `DNS:localhost` entry this
* suite has always used.
*/
async function generateCertificates(directory: string, host: string): Promise<void> {
const subjectAltNames = [...new Set([host, NODE_FALLBACK_IDENTITY])]
.map((name) => `DNS:${name}`)
.join(",");
await openssl(
[
"req",
@@ -71,11 +140,11 @@ async function generateCertificates(directory: string): Promise<void> {
"-keyout",
"server.key",
"-subj",
"/CN=localhost",
`/CN=${host}`,
],
directory,
);
await writeFile(path.join(directory, "san.cnf"), "subjectAltName=DNS:localhost\n");
await writeFile(path.join(directory, "san.cnf"), `subjectAltName=${subjectAltNames}\n`);
await openssl(
[
"x509",
@@ -113,32 +182,40 @@ async function generateCertificates(directory: string): Promise<void> {
],
directory,
);
// Only the server leaf and its key are ever transferred into the container. Both CA private keys
// stay in the harness temporary directory and are deleted with it.
const material = path.join(directory, "server-material");
await mkdir(material);
for (const file of ["server.crt", "server.key"]) {
await writeFile(path.join(material, file), await readFile(path.join(directory, file)));
}
}
async function docker(args: readonly string[]): Promise<string> {
const { stdout } = await run("docker", [...args]);
return stdout.trim();
}
/**
* Creates the container, transfers the runtime-generated material into it, and starts it. 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 `docker cp`
* lands the files as root.
*/
async function startTlsPostgres(directory: string): Promise<void> {
const publication = containerizedJob
? // No published port: the runner host's loopback is not the job container's loopback.
["--network", networkName]
: // Docker's random-port form, pinned to loopback so PostgreSQL is never offered on any
// other interface.
["-p", "127.0.0.1::5432"];
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",
"create",
"--name",
containerName,
...publication,
"-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",
@@ -148,8 +225,19 @@ async function startTlsPostgres(directory: string): Promise<number> {
"exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/tls/server.crt " +
"-c ssl_key_file=/tls/server.key",
]);
containerStarted = true;
containerCreated = true;
// `docker cp` is executed by the daemon against the container filesystem, so it is correct
// whether the source directory lives on the daemon host or only inside the job container. The
// destination does not exist, so the source directory's *contents* become `/certs-src`.
await docker(["cp", path.join(directory, "server-material"), `${containerName}:/certs-src`]);
await docker(["start", containerName]);
}
/** Resolves the endpoint this run connects through, plus a reachable address of the same server
* that the certificate identity deliberately does not cover. */
async function resolveEndpoint(): Promise<void> {
if (!containerizedJob) {
const published = await docker(["port", containerName, "5432"]);
const match = /^127\.0\.0\.1:(\d+)$/u.exec(published.split("\n")[0]?.trim() ?? "");
if (!match) {
@@ -157,19 +245,53 @@ async function startTlsPostgres(directory: string): Promise<number> {
"The disposable TLS PostgreSQL port is not bound safely to loopback.",
);
}
return Number(match[1]);
databasePort = Number(match[1]);
mismatchHost = "127.0.0.1";
return;
}
if (!(await connectJobContainerToNetwork())) {
throw new TlsHarnessError("The TLS test job could not join the isolated PostgreSQL network.");
}
const address = await docker([
"inspect",
"--format",
`{{ with index .NetworkSettings.Networks "${networkName}" }}{{ .IPAddress }}{{ end }}`,
containerName,
]);
if (!net.isIPv4(address)) {
throw new TlsHarnessError(
"The disposable TLS PostgreSQL has no address on the isolated test network.",
);
}
// The same server, reached by address instead of by name — a genuine identity mismatch, not a
// weakened assertion.
mismatchHost = address;
databasePort = 5432;
}
async function connectJobContainerToNetwork(): Promise<boolean> {
jobContainerAttached = true;
try {
await docker(["network", "connect", networkName, jobContainer]);
return true;
} catch {
await docker(["network", "disconnect", networkName, jobContainer]).catch(() => undefined);
jobContainerAttached = false;
return false;
}
}
/** 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> {
async function waitForTlsReadiness(host: string, 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",
host,
password: "ekklesia_tls",
port,
ssl: { ca, rejectUnauthorized: true },
@@ -223,9 +345,32 @@ async function connectWith(
}
}
/** The trusted CA for the disposable server, read from the harness directory. */
async function readCertificateAuthority(file: "ca.crt" | "other-ca.crt"): Promise<string> {
if (certificateDirectory === undefined) {
throw new TlsHarnessError("The disposable TLS material was never generated.");
}
return await readFile(path.join(certificateDirectory, file), "utf8");
}
function requireEndpoint(): { identity: string; mismatch: string; port: number } {
if (identityHost === undefined || mismatchHost === undefined || databasePort === undefined) {
throw new TlsHarnessError("The disposable TLS PostgreSQL endpoint was never resolved.");
}
return { identity: identityHost, mismatch: mismatchHost, port: databasePort };
}
// Deterministic teardown: every disposable resource this harness created is removed after both
// successful and failed runs, container first so the network has no remaining endpoint.
afterAll(async () => {
if (containerStarted) {
await docker(["rm", "-f", containerName]).catch(() => undefined);
if (containerCreated) {
await docker(["rm", "-f", "-v", containerName]).catch(() => undefined);
}
if (jobContainerAttached) {
await docker(["network", "disconnect", networkName, jobContainer]).catch(() => undefined);
}
if (networkCreated) {
await docker(["network", "rm", networkName]).catch(() => undefined);
}
if (certificateDirectory) {
// Removes the generated CA, server certificate, and every private key.
@@ -236,63 +381,79 @@ afterAll(async () => {
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);
if (containerizedJob) {
await docker(["network", "create", networkName]);
networkCreated = true;
}
// The certificate identity is generated from the host this run will actually connect through.
identityHost = containerizedJob ? containerName : "localhost";
await generateCertificates(certificateDirectory, identityHost);
await startTlsPostgres(certificateDirectory);
await resolveEndpoint();
const endpoint = requireEndpoint();
await waitForTlsReadiness(
endpoint.identity,
endpoint.port,
await readCertificateAuthority("ca.crt"),
);
expect(endpoint.port).toBeGreaterThan(0);
expect(endpoint.mismatch).not.toBe(endpoint.identity);
});
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 { identity, port } = requireEndpoint();
const ca = await readCertificateAuthority("ca.crt");
const ssl = databaseTlsConnectionOptions(
`postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`,
`postgresql://ekklesia_tls@${identity}:${port}/ekklesia_tls`,
{ certificateAuthority: ca, mode: "verify-full" },
);
const { encrypted } = await connectWith("localhost", boundPort as number, ssl);
const { encrypted } = await connectWith(identity, port, 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 { identity, port } = requireEndpoint();
const otherCa = await readCertificateAuthority("other-ca.crt");
const ssl = databaseTlsConnectionOptions(
`postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`,
`postgresql://ekklesia_tls@${identity}:${port}/ekklesia_tls`,
{ certificateAuthority: otherCa, mode: "verify-full" },
);
await expect(connectWith("localhost", boundPort as number, ssl)).rejects.toMatchObject({
await expect(connectWith(identity, port, 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");
// The certificate carries no IP SAN, so reaching the identical server via its 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 with it unset Node checks
// `NODE_FALLBACK_IDENTITY` instead of the endpoint — so `rejectUnauthorized: true` alone would
// accept this connection, as the control below demonstrates.
const { mismatch, port } = requireEndpoint();
const ca = await readCertificateAuthority("ca.crt");
const ssl = databaseTlsConnectionOptions(
`postgresql://ekklesia_tls@127.0.0.1:${boundPort}/ekklesia_tls`,
`postgresql://ekklesia_tls@${mismatch}:${port}/ekklesia_tls`,
{ certificateAuthority: ca, mode: "verify-full" },
);
await expect(connectWith("127.0.0.1", boundPort as number, ssl)).rejects.toMatchObject({
await expect(connectWith(mismatch, port, 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");
const { identity, mismatch, port } = requireEndpoint();
const ca = await readCertificateAuthority("ca.crt");
// 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,
});
// `checkServerIdentity`. It connects to an address the certificate does not cover, because the
// only identity Node had to check was `NODE_FALLBACK_IDENTITY`. The endpoint is never verified
// at all, which is why the policy supplies its own check.
expect(mismatch).not.toBe(identity);
expect(net.isIP(mismatch)).toBeGreaterThan(0);
const { encrypted } = await connectWith(mismatch, port, { ca, rejectUnauthorized: true });
expect(encrypted).toBe(true);
});
@@ -303,7 +464,7 @@ describe.sequential("production PostgreSQL TLS policy against a real TLS endpoin
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 ca = await readCertificateAuthority("ca.crt");
const ssl = databaseTlsConnectionOptions(plaintextUrl, {
certificateAuthority: ca,
mode: "verify-full",
+11
View File
@@ -22,6 +22,17 @@ compose_job_network_initialize() {
COMPOSE_JOB_NETWORK_CURRENT_CONTAINER="$HOSTNAME"
}
# Emits the identifier of the container this job is executing in, or an empty string when the job
# runs directly on a host. This is the single topology signal: harnesses that provision their own
# containers instead of a Compose project (the TLS integration suite) consume it through the
# environment rather than re-deriving the topology, so there is exactly one detector.
compose_job_network_job_container() {
if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" != "true" ]]; then
return 0
fi
printf '%s' "$COMPOSE_JOB_NETWORK_CURRENT_CONTAINER"
}
compose_job_network_connect() {
if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" != "true" ]]; then
return 1
+4
View File
@@ -96,5 +96,9 @@ else
fi
fi
# The TLS suite provisions its own disposable PostgreSQL rather than a Compose service, so it
# cannot call `compose_job_network_connect`. It receives the same topology decision this script
# already made, instead of detecting the topology a second time.
EKKLESIA_TEST_JOB_CONTAINER="$(compose_job_network_job_container)" \
EKKLESIA_MIGRATION_TEST_DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$database_host:$database_port/$POSTGRES_DB" \
pnpm exec vitest run --config vitest.migrations.integration.config.ts