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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 20:29:46 +02:00
1 parent 0534123d0d
commit 7759729aca
3 files changed
+154

No files matched your search

@@ -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([]);
});
});
+77
View File
@@ -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<string, string> = {}) {
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();
});
});
+40
View File
@@ -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) {