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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user