mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user