mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
This commit is contained in:
1 parent
2c295bbea8
commit
4783711651
8 files changed
+285
-16
No files matched your search
@@ -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<string, string> = {
|
||||
"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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
// 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<Response> {
|
||||
const refusal = refuseOAuthOnKeyMismatch();
|
||||
if (refusal) return ensureAuthorizeCsp(refusal);
|
||||
return ensureAuthorizeCsp(await authorizePost(request));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Response> {
|
||||
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",
|
||||
|
||||
@@ -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<Response> {
|
||||
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())) {
|
||||
|
||||
@@ -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<Response> {
|
||||
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" } });
|
||||
|
||||
+4
-16
@@ -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<T extends (...args: any[]) => Promise<Response>>(
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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" } },
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user