diff --git a/src/__tests__/no-server-actions-guard.test.ts b/src/__tests__/no-server-actions-guard.test.ts new file mode 100644 index 000000000..8dd2b7700 --- /dev/null +++ b/src/__tests__/no-server-actions-guard.test.ts @@ -0,0 +1,37 @@ +/** + * HealthLog defines no Server Actions. + * + * The proxy refuses every request carrying `Next-Action` on the strength of + * that (`src/proxy.ts`): the only callers that send one are scanners. A module + * that declares `"use server"` would make the refusal break a real feature, so + * adding one has to come with revisiting it. This guard is the reminder. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(path).isDirectory()) out.push(...sourceFiles(path)); + else if (/\.(ts|tsx)$/.test(name)) out.push(path); + } + return out; +} + +describe("no Server Actions", () => { + it('has no module or function that declares "use server"', () => { + const files = sourceFiles(SRC); + expect(files.length).toBeGreaterThan(100); + const declaring = files + .filter((file) => + /^\s*["']use server["'];?\s*$/m.test(readFileSync(file, "utf8")), + ) + .map((file) => relative(SRC, file)); + expect(declaring).toEqual([]); + }); +}); diff --git a/src/__tests__/proxy-server-action.test.ts b/src/__tests__/proxy-server-action.test.ts new file mode 100644 index 000000000..a41e8a129 --- /dev/null +++ b/src/__tests__/proxy-server-action.test.ts @@ -0,0 +1,77 @@ +/** + * A Server Action call is refused at the proxy. + * + * HealthLog defines no Server Actions, so the only requests that carry + * `Next-Action` are scanners probing for them. Next used to answer each with + * an unstructured "Failed to find Server Action" at error level; the proxy now + * refuses it with a 404 and one structured `warn` line, and a request without + * the header is untouched. + */ +import { NextRequest } from "next/server"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from "vitest"; + +vi.mock("@/lib/process-type", () => ({ shouldRunWeb: () => true })); + +import { proxy } from "../proxy"; + +let warn: MockInstance<(...args: unknown[]) => void>; + +beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + warn.mockRestore(); +}); + +function probe(path: string, headers: Record = {}) { + return new NextRequest(`https://health.example.test${path}`, { + method: "POST", + headers: { "content-type": "text/plain", ...headers }, + body: "[]", + }); +} + +describe("proxy on a Server Action call", () => { + it("refuses it with 404 and one warn line", async () => { + const response = proxy( + probe("/", { "next-action": "7f3a9c", "user-agent": "scanner" }), + ); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + data: null, + error: "Not found", + meta: { errorCode: "server_action.unsupported" }, + }); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(warn).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(warn.mock.calls[0]![0]))).toMatchObject({ + level: "warn", + action: { name: "proxy.server_action.refused" }, + http: { method: "POST", path: "/", status: 404, user_agent: "scanner" }, + }); + }); + + it("never logs a secret path segment the probe aimed at", () => { + proxy(probe("/api/withings/webhook/s3cr3t-value", { "next-action": "x" })); + + const line = String(warn.mock.calls[0]![0]); + expect(line).not.toContain("s3cr3t-value"); + }); + + it("leaves a request without the header alone", () => { + const response = proxy(probe("/api/health")); + + expect(response.status).not.toBe(404); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 1d54b9f39..d2f8e2e81 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,6 +8,7 @@ import { import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes"; import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal"; import { resolveRequestId } from "@/lib/http/request-id"; +import { redactSecrets } from "@/lib/logging/redact"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). @@ -321,6 +322,9 @@ function applyBaselineSecurityHeaders(response: NextResponse): NextResponse { */ const NOTIFICATION_ACTION_PREFIX = "/api/notifications/action/"; +/** The request header that marks a Next.js Server Action call. */ +const SERVER_ACTION_HEADER = "next-action"; + type NotificationActionRefusal = "method_not_allowed" | "worker_only" | "demo_mode"; @@ -371,6 +375,42 @@ export function proxy(request: NextRequest) { ); } + // A Server Action call. HealthLog defines no Server Actions (no module + // carries `"use server"`, which `no-server-actions-guard.test.ts` holds), so + // no bundle of this app, old or new, ever sends `Next-Action`: the requests + // that do are scanners probing the Server Action surface, nightly and in + // bursts. Left to Next, each one printed "Failed to find Server Action" at + // error level, unstructured, an error with no user behind it. Refused here + // instead, with one countable `warn` line: a refusal by design. + if (request.headers.has(SERVER_ACTION_HEADER)) { + console.warn( + JSON.stringify({ + timestamp: new Date().toISOString(), + level: "warn", + kind: "http", + message: "Server Action call refused: this app defines none.", + action: { name: "proxy.server_action.refused" }, + http: { + method: request.method, + // A probe may aim at a path that carries a secret segment. + path: redactSecrets(pathname), + status: 404, + user_agent: request.headers.get("user-agent") ?? undefined, + }, + }), + ); + return applyBaselineSecurityHeaders( + NextResponse.json( + { + data: null, + error: "Not found", + meta: { errorCode: "server_action.unsupported" }, + }, + { status: 404 }, + ), + ); + } + // 301 redirects for renamed routes const redirect = LEGACY_REDIRECTS[pathname]; if (redirect) {