From 7759729aca71d124030e2a121a874476c1c6a638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 20:29:46 +0200 Subject: [PATCH] fix(proxy): refuse Server Action calls the app never makes HealthLog defines no Server Actions, so no bundle of it, current or stale, sends a Next-Action header. The requests that do arrive come from the nightly scan probing that surface, and Next answered each with an unstructured "Failed to find Server Action" at error level. The proxy now refuses them with a 404 and one structured warn line, and a guard keeps "use server" out of the tree so the refusal stays safe. --- src/__tests__/no-server-actions-guard.test.ts | 37 +++++++++ src/__tests__/proxy-server-action.test.ts | 77 +++++++++++++++++++ src/proxy.ts | 40 ++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/__tests__/no-server-actions-guard.test.ts create mode 100644 src/__tests__/proxy-server-action.test.ts 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) {