From 4783711651c90452267ff61af864cb12feaf65a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:32:07 +0200 Subject: [PATCH] Refuse MCP and its OAuth endpoints while the encryption key does not match apiHandler answers 503 encryption.key_mismatch before auth, but the remote MCP endpoint and the OAuth authorize, token and register routes wrap in withBackgroundEvent instead and never checked. An MCP write tool could seal new rows under the wrong key, and those rows would open under it at the next boot. The refusal moves into one helper used by apiHandler, /mcp (same envelope) and the OAuth routes (RFC 6749 temporarily_unavailable with the error code as an extension member). A structural test fails when a route outside apiHandler, or a server action, lacks the refusal in every exported handler and is not on a short allowlist of routes that never touch the database. --- .../key-mismatch-refusal-guard.test.ts | 127 ++++++++++++++++++ src/app/api/mcp/oauth/authorize/route.ts | 6 + src/app/api/mcp/oauth/register/route.ts | 5 + src/app/api/mcp/oauth/token/route.ts | 5 + src/app/mcp/route.ts | 7 + src/lib/api-handler.ts | 20 +-- src/lib/boot/key-mismatch-refusal.ts | 67 +++++++++ .../integration/encryption-key-check.test.ts | 64 +++++++++ 8 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/key-mismatch-refusal-guard.test.ts create mode 100644 src/lib/boot/key-mismatch-refusal.ts diff --git a/src/__tests__/key-mismatch-refusal-guard.test.ts b/src/__tests__/key-mismatch-refusal-guard.test.ts new file mode 100644 index 000000000..9d145e319 --- /dev/null +++ b/src/__tests__/key-mismatch-refusal-guard.test.ts @@ -0,0 +1,127 @@ +/** + * Every request surface outside `apiHandler` refuses while the boot key check + * has found a mismatch. + * + * `apiHandler` answers 503 `encryption.key_mismatch` before auth and before + * the handler, so a process holding the wrong key never writes through it. + * A route that does not wrap in `apiHandler` gets none of that: the remote + * MCP endpoint and its OAuth bridge went without it, and an MCP write tool + * would have sealed new rows under the wrong key, rows the next boot's probe + * could then read as evidence that the wrong key is right. + * + * So: a `route.ts` under `src/app` that does not wrap in `apiHandler` must + * call `refuseOnKeyMismatch` / `refuseOAuthOnKeyMismatch`, or sit in the + * allowlist below with the reason it cannot touch encrypted data. A server + * action (`"use server"`) is held to the same rule; there are none today. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * Routes outside `apiHandler` that need no refusal, each with why. A file + * belongs here only if it reads nothing from the database and writes nothing. + */ +const NO_DATA_ROUTES: Record = { + "app/.well-known/apple-app-site-association/route.ts": + "static JSON built from environment variables; no database access", + "app/.well-known/oauth-authorization-server/route.ts": + "RFC 8414 metadata built from the configured origin; no database access", + "app/.well-known/oauth-protected-resource/route.ts": + "RFC 9728 metadata built from the configured origin; no database access", +}; + +const REFUSAL_CALL = /\brefuse(?:OAuth)?OnKeyMismatch\s*\(/; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +/** The text of a top-level function starting at `from`, up to its closing brace. */ +function functionBody(src: string, from: number): string { + const end = src.indexOf("\n}\n", from); + return src.slice(from, end === -1 ? undefined : end); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +describe("key mismatch refusal outside apiHandler", () => { + const routes = sourceFiles().filter( + (p) => p.startsWith("app/") && /\/route\.tsx?$/.test(p), + ); + const outside = routes.filter((p) => !/\bapiHandler\s*\(/.test(read(p))); + + it("finds the route tree and the routes outside apiHandler", () => { + // A matcher that matches nothing would pass every check below. + expect(routes.length).toBeGreaterThan(300); + expect(outside).toEqual( + expect.arrayContaining([ + "app/mcp/route.ts", + "app/api/mcp/oauth/authorize/route.ts", + "app/api/mcp/oauth/register/route.ts", + "app/api/mcp/oauth/token/route.ts", + ]), + ); + }); + + it("every route outside apiHandler refuses, or is allowlisted with a reason", () => { + const missing = outside.filter( + (p) => !(p in NO_DATA_ROUTES) && !REFUSAL_CALL.test(read(p)), + ); + expect(missing).toEqual([]); + }); + + it("an allowlisted route really stays away from the database", () => { + for (const p of Object.keys(NO_DATA_ROUTES)) { + expect(outside, `${p} is no longer outside apiHandler`).toContain(p); + const src = read(p); + expect(src, p).not.toMatch(/@\/lib\/db\b|prisma|\$queryRaw/); + } + }); + + it("every exported handler of a refusing route calls the refusal itself", () => { + // `export async function POST(...) { ... }` must refuse in its own body; + // `export const POST = handleX` must refuse in `handleX`. A refusal in one + // handler of a file proves nothing about its siblings. + let checked = 0; + for (const p of outside.filter((f) => !(f in NO_DATA_ROUTES))) { + const src = read(p); + const bodies: Array<[string, string]> = []; + for (const m of src.matchAll( + /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/g, + )) { + bodies.push([m[1], functionBody(src, m.index)]); + } + for (const m of src.matchAll( + /export\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=\s*(\w+)\s*;/g, + )) { + const def = new RegExp(`function\\s+${m[2]}\\s*\\(`).exec(src); + expect(def, `${p}: ${m[1]} → ${m[2]} not found`).not.toBeNull(); + bodies.push([m[1], functionBody(src, def!.index)]); + } + expect(bodies.length, p).toBeGreaterThan(0); + for (const [name, body] of bodies) { + expect(REFUSAL_CALL.test(body), `${p} ${name}`).toBe(true); + checked += 1; + } + } + expect(checked).toBeGreaterThanOrEqual(6); + }); + + it("server actions refuse too", () => { + const actions = sourceFiles().filter((p) => + /^\s*["']use server["']/m.test(read(p)), + ); + const missing = actions.filter((p) => !REFUSAL_CALL.test(read(p))); + expect(missing).toEqual([]); + }); +}); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index b0ad1ef5e..c8a71d9a8 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -29,6 +29,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { annotate } from "@/lib/logging/context"; +import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; @@ -344,10 +345,15 @@ function ensureAuthorizeCsp(response: Response): Response { } export async function GET(request: NextRequest): Promise { + // The boot key check refused this process: no grant is issued or stored. + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return ensureAuthorizeCsp(refusal); return ensureAuthorizeCsp(await authorizeGet(request)); } export async function POST(request: NextRequest): Promise { + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return ensureAuthorizeCsp(refusal); return ensureAuthorizeCsp(await authorizePost(request)); } diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts index aa5d76a83..7e9e1d13c 100644 --- a/src/app/api/mcp/oauth/register/route.ts +++ b/src/app/api/mcp/oauth/register/route.ts @@ -16,6 +16,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { annotate } from "@/lib/logging/context"; +import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; @@ -68,6 +69,10 @@ function oauthError( export async function POST(request: NextRequest): Promise { return withBackgroundEvent("mcp.oauth.register", async () => { + // The boot key check refused this process: mint nothing, write nothing. + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return refusal; + const rl = await checkAuthSurfaceRateLimit( request, "mcp:oauth:register", diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts index a82b732a5..fbdaf01af 100644 --- a/src/app/api/mcp/oauth/token/route.ts +++ b/src/app/api/mcp/oauth/token/route.ts @@ -32,6 +32,7 @@ import { NextRequest } from "next/server"; import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { auditLog } from "@/lib/auth/audit"; import { issueApiToken } from "@/lib/auth/issue-token"; @@ -201,6 +202,10 @@ async function finalizeTokenPair( export async function POST(request: NextRequest): Promise { return withBackgroundEvent("mcp.oauth.token", async () => { + // The boot key check refused this process: mint nothing, write nothing. + const refusal = refuseOAuthOnKeyMismatch(); + if (refusal) return refusal; + // M1 — fail closed without a pinned origin; M4 — honour the operator's // global API kill-switch. Either off and the bridge mints no tokens. if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) { diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index c71635dc8..30b4b1738 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -50,6 +50,7 @@ import { resolveMcpAuthContext, SCOPE_HEALTH_READ, } from "@/lib/mcp"; +import { refuseOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal"; import { withBackgroundEvent } from "@/lib/logging/background"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -135,6 +136,12 @@ function readBearer(request: Request): string | null { async function handleMcp(request: Request): Promise { return withBackgroundEvent("mcp.request", async () => { + // 00. KEY CHECK — the boot key check found that the configured encryption + // key does not open this database. The write tools would seal new + // rows under it, so refuse before anything else, as apiHandler does. + const refusal = refuseOnKeyMismatch(); + if (refusal) return refusal; + // 0. ORIGIN — refuse a cross-origin browser request before anything else. if (!originAllowed(request)) { annotate({ action: { name: "mcp.origin.rejected" } }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index a47b66ab2..4f00f4b9e 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -53,11 +53,7 @@ import { assertRecordSessionFence, attachRecordContextEcho, } from "./sharing/record-session-fence"; -import { - isKeyMismatch, - KEY_MISMATCH_ERROR_CODE, - KEY_MISMATCH_EXEMPT_PATHS, -} from "./boot/key-mismatch-state"; +import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -266,17 +262,9 @@ export function apiHandler Promise>( // The boot key check found that the configured encryption key // cannot open this database. Refuse before auth, before the // handler: nothing here may read or write encrypted data. - if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) { - annotate({ action: { name: "encryption.key_mismatch.refused" } }); - response = NextResponse.json( - { - data: null, - error: - "The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.", - meta: { errorCode: KEY_MISMATCH_ERROR_CODE }, - }, - { status: 503, headers: { "Retry-After": "300" } }, - ); + const refusal = refuseOnKeyMismatch(url.pathname); + if (refusal) { + response = refusal; } else { response = await handler(...args); } diff --git a/src/lib/boot/key-mismatch-refusal.ts b/src/lib/boot/key-mismatch-refusal.ts new file mode 100644 index 000000000..10533a122 --- /dev/null +++ b/src/lib/boot/key-mismatch-refusal.ts @@ -0,0 +1,67 @@ +/** + * The refusal a request surface answers while the boot key check has found + * that the configured encryption key does not open this database. + * + * `apiHandler` applies it to every API route. A route outside `apiHandler` + * (the remote MCP endpoint, its OAuth bridge) calls one of the two helpers + * below as the first thing it does, before auth and before any read or + * write: a process holding the wrong key must never seal new rows, because + * those rows would open under that key at the next boot and read as evidence + * that it is the right one. `key-mismatch-refusal-guard.test.ts` fails when a + * route outside `apiHandler` does neither. + */ +import { annotate } from "@/lib/logging/context"; +import { + isKeyMismatch, + KEY_MISMATCH_ERROR_CODE, + KEY_MISMATCH_EXEMPT_PATHS, +} from "./key-mismatch-state"; + +export const KEY_MISMATCH_MESSAGE = + "The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database."; + +const RETRY_AFTER = { "Retry-After": "300" }; + +/** The 503 in the standard `{ data, error, meta }` envelope. */ +export function keyMismatchResponse(): Response { + annotate({ action: { name: "encryption.key_mismatch.refused" } }); + return Response.json( + { + data: null, + error: KEY_MISMATCH_MESSAGE, + meta: { errorCode: KEY_MISMATCH_ERROR_CODE }, + }, + { status: 503, headers: RETRY_AFTER }, + ); +} + +/** + * The envelope refusal when the process refuses and `pathname` is not one of + * the exempt paths; otherwise null and the caller carries on. + */ +export function refuseOnKeyMismatch(pathname?: string): Response | null { + if (!isKeyMismatch()) return null; + if (pathname !== undefined && KEY_MISMATCH_EXEMPT_PATHS.has(pathname)) { + return null; + } + return keyMismatchResponse(); +} + +/** + * The same refusal in the RFC 6749 error shape the OAuth endpoints speak: + * `temporarily_unavailable` with a description, plus the HealthLog error + * code as an extension member a client may ignore. + */ +export function refuseOAuthOnKeyMismatch(): Response | null { + if (!isKeyMismatch()) return null; + annotate({ action: { name: "encryption.key_mismatch.refused" } }); + return Response.json( + { + error: "temporarily_unavailable", + error_description: + "The server's encryption key does not match its database.", + error_code: KEY_MISMATCH_ERROR_CODE, + }, + { status: 503, headers: { ...RETRY_AFTER, "Cache-Control": "no-store" } }, + ); +} diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 01f2796af..6e7a99a51 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -439,6 +439,70 @@ describe("retiring a key id after rotation (real Postgres)", () => { }); }); +describe("routes outside apiHandler refuse while the key does not match", () => { + beforeEach(() => { + setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" }); + }); + + it("/mcp answers 503 encryption.key_mismatch before auth", async () => { + const { POST } = await import("@/app/mcp/route"); + const res = await POST( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer hlk_whatever", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + expect(res.status).toBe(503); + expect((await res.json()).meta).toEqual({ + errorCode: "encryption.key_mismatch", + }); + }); + + it("the MCP OAuth endpoints refuse in their RFC shape", async () => { + const { POST: token } = await import("@/app/api/mcp/oauth/token/route"); + const { POST: register } = + await import("@/app/api/mcp/oauth/register/route"); + const { GET: authorizeGet, POST: authorizePost } = + await import("@/app/api/mcp/oauth/authorize/route"); + const responses = [ + await token( + new NextRequest("http://localhost/api/mcp/oauth/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=authorization_code&code=x", + }), + ), + await register( + post("/api/mcp/oauth/register", { + redirect_uris: ["https://client.example/cb"], + }), + ), + await authorizeGet( + new NextRequest( + "http://localhost/api/mcp/oauth/authorize?response_type=code", + ), + ), + await authorizePost( + new NextRequest("http://localhost/api/mcp/oauth/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "decision=approve", + }), + ), + ]; + for (const res of responses) { + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBe("temporarily_unavailable"); + expect(body.error_description).toContain("encryption key"); + } + }); +}); + describe("encryption key backup step (real Postgres)", () => { it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => { const prisma = getPrismaClient();