fix: harden identity HTTP transport
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s

This commit is contained in:
ekklesia
2026-07-21 23:50:41 -05:00
parent c91f0c8c6d
commit d9b73fd81a
15 changed files with 967 additions and 72 deletions
+1
View File
@@ -29,6 +29,7 @@
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
"better-auth": "1.6.23",
"pg": "8.22.0",
"typescript": "7.0.2",
"vitest": "4.1.10"
+44 -22
View File
@@ -12,6 +12,7 @@ import { createApiApp } from "./app.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000;
export interface RunningApi {
readonly app: Hono;
@@ -65,6 +66,7 @@ export async function startApi(
): Promise<RunningApi> {
const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, {
connectionTimeoutMs,
operationTimeoutMs,
});
let runtime: ReturnType<typeof createIdentityRuntime>;
@@ -89,21 +91,53 @@ export async function startApi(
throw error;
}
let shuttingDown = false;
const shutdown = async (): Promise<void> => {
if (shuttingDown) {
return;
// Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second*
// cleanup from starting, but a caller that arrives while the first cleanup is still in flight
// would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once.
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shutdownPromise = (async () => {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await safeCloseDatabase(databaseHandle);
})();
}
shuttingDown = true;
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
await safeCloseDatabase(databaseHandle);
return shutdownPromise;
};
return Object.freeze({ app, server, shutdown });
}
/**
* Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()`
* so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real
* process signal or invoking the real `process.exit` (which would terminate the test runner
* itself). Production composition (`main()`, below) calls this with real `process.exit`.
*/
export function installShutdownSignalHandlers(
running: Pick<RunningApi, "shutdown">,
exit: (code: number) => void = (code) => process.exit(code),
): void {
let signalReceived = false;
const handleSignal = (): void => {
if (signalReceived) {
return;
}
signalReceived = true;
running
.shutdown()
.then(() => exit(0))
.catch(() => exit(1));
};
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
}
function readConfigFromEnvironment(): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production";
@@ -138,19 +172,7 @@ export async function main(): Promise<void> {
const boundPort = address && typeof address === "object" ? address.port : config.port;
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
let signalReceived = false;
const handleSignal = (): void => {
if (signalReceived) {
return;
}
signalReceived = true;
running
.shutdown()
.then(() => process.exit(0))
.catch(() => process.exit(1));
};
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
installShutdownSignalHandlers(running);
}
function isDirectExecution(): boolean {
+199 -21
View File
@@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
@@ -9,7 +10,7 @@ import {
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { startApi, type RunningApi } from "../src/server.js";
@@ -245,7 +246,12 @@ async function signUp(
): Promise<SignUpResult> {
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({ email, name: "Integration Test User", password }),
headers: { "content-type": "application/json", ...headers },
// A trusted Origin by default, matching signIn below: Node's own fetch() (and every browser)
// automatically sends `Sec-Fetch-Mode: cors`, which Better Auth's first-login CSRF protection
// treats as "this is a Fetch-API call" and requires a trusted Origin/Referer for regardless of
// whether a cookie is present — see the dedicated no-Origin scenario below, which uses a raw
// `http.request` instead of `fetch()` to exercise a genuinely Fetch-Metadata-free caller.
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
});
return { body: (await response.clone().json()) as SignUpResult["body"], response };
@@ -283,6 +289,45 @@ async function fetchPrincipal(
return { body: await response.clone().json(), response };
}
/**
* A raw `node:http` POST, deliberately not `fetch()`. Node's own `fetch()` (undici) — like every
* browser — automatically sends `Sec-Fetch-Mode: cors` on every request, which Better Auth's
* first-login CSRF protection (`formCsrfMiddleware`) treats as "this came from a Fetch-API
* client" and requires a trusted Origin/Referer for regardless of cookie presence. A genuine
* non-browser, non-Fetch server-to-server integration (curl, most backend HTTP client libraries,
* or this raw socket) sends none of the Fetch Metadata headers, so it is unaffected by that
* specific protection and needs no Origin header at all.
*/
function rawPostRequest(
origin: string,
requestPath: string,
body: unknown,
): Promise<{ body: string; status: number }> {
return new Promise((resolve, reject) => {
const url = new URL(requestPath, origin);
const payload = JSON.stringify(body);
const req = httpRequest(
url,
{
headers: {
"content-length": Buffer.byteLength(payload),
"content-type": "application/json",
},
method: "POST",
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk as Buffer));
res.on("end", () => {
resolve({ body: Buffer.concat(chunks).toString("utf8"), status: res.statusCode ?? 0 });
});
},
);
req.on("error", reject);
req.end(payload);
});
}
describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
let database: { name: string; url: string };
let running: RunningApi;
@@ -495,9 +540,13 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
}
});
it("supports a server-to-server sign-up request that carries no Origin header at all", async () => {
const { response } = await signUp(origin, "[email protected]", password);
expect(response.status).toBe(200);
it("supports a genuine server-to-server sign-up request that carries no Origin header and no Fetch Metadata headers at all", async () => {
const { status } = await rawPostRequest(origin, "/api/auth/sign-up/email", {
email: "[email protected]",
name: "Integration Test User",
password,
});
expect(status).toBe(200);
});
it("allows a trusted browser origin for the principal route with credentials", async () => {
@@ -517,6 +566,84 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
expect(principalResponse.headers.get("access-control-allow-origin")).toBeNull();
});
// Production-equivalent Better Auth origin/CSRF protection (ADR 0012). Explicitly setting
// `advanced: { disableCSRFCheck: false, disableOriginCheck: false }` (`core/src/identity/runtime.ts`)
// removes Better Auth's own `isTest()`-driven default that otherwise silently skips origin/CSRF
// validation under a test runner, so the same behavior verified below is also what production
// enforces.
it("accepts a no-cookie first authentication attempt from a trusted browser origin", async () => {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: emailOne, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
});
it("rejects a no-cookie first authentication attempt from an untrusted browser origin", async () => {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: emailOne, password }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
});
const text = await response.text();
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(text).not.toContain(database.url);
expect(text.toLowerCase()).not.toContain("stack");
});
it("rejects a cookie-bearing state-changing request from an untrusted browser origin, and does not invalidate the session", async () => {
const { cookiePair } = await signIn(origin, emailOne, password);
expect(cookiePair).toBeDefined();
const response = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: {
"content-type": "application/json",
cookie: cookiePair as string,
origin: untrustedOrigin,
},
method: "POST",
});
const text = await response.text();
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(text).not.toContain(database.url);
expect(text.toLowerCase()).not.toContain("stack");
// The forged/rejected sign-out must never have reached sign-out logic: the session presented
// alongside it must still be valid.
const { body } = await fetchPrincipal(origin, { cookie: cookiePair as string });
expect(body).toMatchObject({ kind: "authenticated" });
});
it("rejects a 7-character password as too short", async () => {
const { response } = await signUp(origin, "[email protected]", "1234567");
expect(response.status).toBe(400);
});
it("accepts an 8-character password", async () => {
const { response, body } = await signUp(origin, "[email protected]", "12345678");
expect(response.status).toBe(200);
expect(body.user.email).toBe("[email protected]");
});
it("accepts a 128-character password", async () => {
const { response } = await signUp(origin, "[email protected]", "a".repeat(128));
expect(response.status).toBe(200);
});
it("rejects a 129-character password as too long", async () => {
const { response } = await signUp(origin, "[email protected]", "a".repeat(129));
expect(response.status).toBe(400);
});
it("leaves Better Auth's handler unavailable outside /api/auth/*", async () => {
const response = await fetch(`${origin}/sign-in/email`, { method: "POST" });
expect(response.status).toBe(404);
@@ -533,22 +660,6 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
});
});
// Better Auth's own server-side rejection of a cookie-bearing state-changing request from an
// untrusted origin (403 INVALID_ORIGIN) was verified directly and repeatedly against this exact
// pinned release and this exact compiled `apps/api` server — signup, sign-in, then a sign-out
// attempt with the valid session cookie and an untrusted Origin header reliably returns 403, and
// the presented session remains valid afterward — but only when driven by a plain Node script
// against the running server. The identical scenario could not be made to reproduce reliably when
// driven through Vitest specifically (the rejection silently does not fire), even in a single
// isolated test with its own fresh database and API instance, and even with `NODE_ENV` forced to
// `"production"` inside the test process. The cause was not pinned down to a documented,
// reproducible contract inside the time available for this slice, so no automated assertion is
// made for this specific request shape here rather than commit a flaky or misleading test. What
// *is* covered by an automated test above is the layer this repository's own code owns: CORS
// never returns `Access-Control-Allow-Origin` for an untrusted origin, which is the actual
// browser-enforced boundary preventing a malicious page from reading a response. See ADR 0011 for
// the full account, including the exact manual reproduction used.
describe.sequential("live API — startup and migration guarantees", () => {
it("does not create any tables or migration history when started against an unmigrated database", async () => {
const database = await createDisposableDatabase("unmigrated");
@@ -629,3 +740,70 @@ describe.sequential("live API — principal infrastructure failure is never down
await running.shutdown().catch(() => undefined);
});
});
describe.sequential("live API — Better Auth logging never leaks operational detail", () => {
it("emits no raw exception, SQL, connection detail, origin header, or stack trace when a real identity database failure occurs", async () => {
const database = await createDisposableDatabase("logging_leak");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
const origin = apiOrigin(running);
await signUp(origin, "[email protected]", "correct horse battery staple");
// Force the database itself unreachable out from under the running API pool, then drive a
// request through Better Auth's own database-backed handler (not Ekklesia's own principal
// route) so Better Auth's internal logging — configured in `core/src/identity/runtime.ts` —
// is the thing actually being exercised here, not Core's own error handling. Without
// `onAPIError: { throw: true }`, the pinned release's own `better-call` transport dependency
// would catch this internally and print it raw via an unconditional
// `console.error("# SERVER_ERROR: ", error)` that no `logger` configuration can reach; with
// it, the failure instead propagates out of `auth.handler()` and becomes a sanitized
// `IdentityRuntimeError` at `handleAuthRequest`'s own boundary, and nothing is logged at all.
await dropDatabase(database.name);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: "[email protected]",
password: "correct horse battery staple",
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const body = await response.clone().json();
expect(response.status).not.toBe(200);
expect(body).toEqual({ code: "AUTH_REQUEST_FAILED" });
const emitted = [...errorSpy.mock.calls, ...warnSpy.mock.calls, ...logSpy.mock.calls]
.flat()
.map((value) =>
value instanceof Error ? `${value.message}\n${value.stack ?? ""}` : String(value),
)
.join("\n");
// The primary guarantee: onAPIError.throw routes this exact failure out to Core's own
// sanitized boundary before Better Auth (or its own transport dependency) ever gets a
// chance to log anything about it, so nothing is emitted at all.
expect(emitted).toBe("");
// Defense in depth, kept even though the assertion above is currently sufficient: if a
// future change causes something to be logged after all, it must still never contain any
// of this.
expect(emitted).not.toContain(database.url);
expect(emitted).not.toContain(database.name);
expect(emitted.toLowerCase()).not.toContain("password");
expect(emitted.toLowerCase()).not.toContain("stack");
expect(emitted).not.toMatch(/select|insert|update|delete|drizzle/iu);
expect(emitted).not.toContain(dashboardOrigin);
expect(emitted).not.toContain(identityBaseUrl);
} finally {
errorSpy.mockRestore();
warnSpy.mockRestore();
logSpy.mockRestore();
await running.shutdown().catch(() => undefined);
}
});
});
+190
View File
@@ -11,6 +11,84 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
import { createApiRuntimeConfig } from "../src/config.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() {
return createApiRuntimeConfig(
{
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
},
{ environment: "production" },
);
}
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
function fakeServer() {
let errorHandler: ((error: Error) => void) | undefined;
const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => {
callback?.();
return server;
}),
listening: true,
once: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") {
errorHandler = handler;
}
return server;
}),
};
return {
server,
triggerError: (error: Error) => errorHandler?.(error),
};
}
/** Resolves the listening callback asynchronously, matching real `@hono/node-server` behavior
* (the callback fires on the `listening` event, after `serve()` itself has already returned) —
* resolving synchronously would reference `bindServer`'s own `server` const before it is
* initialized. */
function mockSuccessfulServe() {
const { server, triggerError } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.());
return server;
});
return { server, triggerError };
}
function mockFailingServe(error: Error) {
const { server, triggerError } = fakeServer();
serveMock.mockImplementation(() => {
queueMicrotask(() => triggerError(error));
return server;
});
return server;
}
/** Captures signal handlers registered via `process.on` without ever installing a real one, so a
* test can trigger "SIGINT"/"SIGTERM" by calling the captured function directly. */
function mockProcessOn(): { handlers: Record<string, () => void>; restore: () => void } {
const handlers: Record<string, () => void> = {};
const fake = vi.fn((event: string, handler: () => void) => {
handlers[event] = handler;
return process;
});
const onSpy = vi.spyOn(process, "on").mockImplementation(fake as unknown as typeof process.on);
return { handlers, restore: () => onSpy.mockRestore() };
}
describe("Ekklesia API import safety", () => {
const originalEnv = { ...process.env };
const originalProcessOn = process.on.bind(process);
@@ -73,3 +151,115 @@ describe("Ekklesia API import safety", () => {
expect(serveMock).not.toHaveBeenCalled();
});
});
describe("Ekklesia API shutdown lifecycle", () => {
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", 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() });
const { server } = mockSuccessfulServe();
const running = await startApi(validConfig());
await Promise.all([running.shutdown(), running.shutdown(), running.shutdown()]);
expect(server.close).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("keeps shutdown idempotent for calls made after the first has already resolved", 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() });
const { server } = mockSuccessfulServe();
const running = await startApi(validConfig());
await running.shutdown();
await running.shutdown();
expect(server.close).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("closes an already-created database pool when identity construction fails during startup", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockImplementation(() => {
throw new Error("postgresql://admin:[email protected]/x");
});
await expect(startApi(validConfig())).rejects.toMatchObject({
code: "IDENTITY_CONSTRUCTION_FAILED",
});
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes an already-created database pool when the server fails to bind its listen address", 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() });
mockFailingServe(new Error("EADDRINUSE"));
await expect(startApi(validConfig())).rejects.toThrow();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("wires SIGINT/SIGTERM to the same shutdown lifecycle and only calls the injected exit, never the real process.exit", async () => {
const { handlers, restore } = mockProcessOn();
const shutdown = vi.fn().mockResolvedValue(undefined);
const exit = vi.fn();
installShutdownSignalHandlers({ shutdown }, exit);
expect(handlers.SIGINT).toEqual(expect.any(Function));
expect(handlers.SIGTERM).toEqual(expect.any(Function));
handlers.SIGINT?.();
await Promise.resolve();
await Promise.resolve();
expect(shutdown).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(0);
// A second signal after the first must not trigger a second shutdown.
handlers.SIGTERM?.();
await Promise.resolve();
expect(shutdown).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledTimes(1);
restore();
});
it("exits with a failure code, without throwing, when signal-triggered shutdown itself fails", async () => {
const { handlers, restore } = mockProcessOn();
const shutdown = vi.fn().mockRejectedValue(new Error("pool close failed"));
const exit = vi.fn();
installShutdownSignalHandlers({ shutdown }, exit);
handlers.SIGTERM?.();
await Promise.resolve();
await Promise.resolve();
expect(exit).toHaveBeenCalledWith(1);
restore();
});
});