fix: harden identity HTTP transport
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user