mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(http): adopt an incoming x-request-id only when it is a plain token
The header is echoed on the response and written into every log line. Both
readers (the proxy and apiHandler) now accept it only when it matches
^[A-Za-z0-9-]{8,64}$ and otherwise use a fresh id.
This commit is contained in:
1 parent
f72ec02484
commit
9ee89f2455
4 files changed
+127
-7
No files matched your search
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* An incoming `x-request-id` is client-controlled. It is echoed on the
|
||||
* response and written into every log line of the request, so it is adopted
|
||||
* only when it is a plain correlation token — `^[A-Za-z0-9-]{8,64}$` — and
|
||||
* replaced with a fresh id otherwise. Both places that read it are covered:
|
||||
* the proxy (every proxied path) and `apiHandler` (which also serves the
|
||||
* routes the proxy matcher leaves out).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
shouldRunWorker: () => true,
|
||||
getProcessType: () => "all",
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
const VALID = ["3f9a2c1b", "abcDEF12-3456-7890", "a".repeat(64)];
|
||||
const INVALID = [
|
||||
"short",
|
||||
"a".repeat(65),
|
||||
"id with spaces",
|
||||
"id_underscore_x",
|
||||
"<script>alert(1)</script>",
|
||||
'x" level="error',
|
||||
"ümläut-id-1234",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function proxied(requestId: string): NextRequest {
|
||||
return new NextRequest("http://localhost/api/version", {
|
||||
headers: { "x-request-id": requestId },
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy: incoming x-request-id", () => {
|
||||
it.each(VALID)("adopts the well-formed id %s", (id) => {
|
||||
const res = proxy(proxied(id));
|
||||
expect(res.headers.get("x-request-id")).toBe(id);
|
||||
});
|
||||
|
||||
it.each(INVALID)("replaces the malformed id %j with a fresh one", (id) => {
|
||||
const res = proxy(proxied(id));
|
||||
const echoed = res.headers.get("x-request-id");
|
||||
expect(echoed).not.toBe(id);
|
||||
expect(echoed).toMatch(UUID);
|
||||
// The rewritten request header the route sees carries the same fresh id.
|
||||
expect(res.headers.get("x-middleware-request-x-request-id")).toBe(echoed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiHandler: incoming x-request-id", () => {
|
||||
function call(id: string) {
|
||||
let logged: string | undefined;
|
||||
const wrapped = apiHandler(async (_request: NextRequest) => {
|
||||
logged = getEvent()?.getRequestId();
|
||||
return NextResponse.json({ data: "ok" });
|
||||
});
|
||||
return wrapped(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "x-request-id": id },
|
||||
}),
|
||||
).then((res) => ({ res, logged }));
|
||||
}
|
||||
|
||||
it.each(VALID)("logs and echoes the well-formed id %s", async (id) => {
|
||||
const { res, logged } = await call(id);
|
||||
expect(logged).toBe(id);
|
||||
expect(res.headers.get("x-request-id")).toBe(id);
|
||||
});
|
||||
|
||||
it.each(INVALID)("never logs or echoes the malformed id %j", async (id) => {
|
||||
const { res, logged } = await call(id);
|
||||
expect(logged).not.toBe(id);
|
||||
expect(logged).toMatch(UUID);
|
||||
expect(res.headers.get("x-request-id")).toBe(logged);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
attachRecordContextEcho,
|
||||
} from "./sharing/record-session-fence";
|
||||
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
|
||||
import { acceptIncomingRequestId } from "./http/request-id";
|
||||
import {
|
||||
capturedRateLimit,
|
||||
rateLimitResponseHeaders,
|
||||
@@ -229,11 +230,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
|
||||
const evt = new WideEventBuilder("http");
|
||||
|
||||
// Propagate x-request-id if present
|
||||
const incomingRequestId = safeRequestProp(
|
||||
request,
|
||||
(r) => r.headers.get("x-request-id"),
|
||||
null,
|
||||
// Propagate x-request-id when it is a well-formed token. A malformed one
|
||||
// never reaches the log or the response; the event keeps its fresh id.
|
||||
const incomingRequestId = acceptIncomingRequestId(
|
||||
safeRequestProp(request, (r) => r.headers.get("x-request-id"), null),
|
||||
);
|
||||
if (incomingRequestId) evt.setRequestId(incomingRequestId);
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Incoming `x-request-id` acceptance.
|
||||
*
|
||||
* The header is client-controlled, and the value is echoed on the response
|
||||
* and written into every log line of the request. Only a plain correlation
|
||||
* token is adopted — letters, digits and hyphens, 8 to 64 characters, which
|
||||
* covers a UUID and every proxy-generated id in use — so a caller cannot
|
||||
* inject quotes, markup or log-shaped text through it. Anything else is
|
||||
* dropped and the request gets a fresh id.
|
||||
*
|
||||
* Edge-safe: no Node imports, because `src/proxy.ts` reads it too.
|
||||
*/
|
||||
export const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/;
|
||||
|
||||
/** The incoming id when it is well-formed, else `null`. */
|
||||
export function acceptIncomingRequestId(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return REQUEST_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** The incoming id when it is well-formed, else a fresh UUID. */
|
||||
export function resolveRequestId(value: string | null | undefined): string {
|
||||
return acceptIncomingRequestId(value) ?? crypto.randomUUID();
|
||||
}
|
||||
+5
-2
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/lib/http/retired-routes";
|
||||
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
|
||||
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
||||
import { resolveRequestId } from "@/lib/http/request-id";
|
||||
|
||||
/**
|
||||
* Paths that do NOT require a session cookie (public pages + external webhooks).
|
||||
@@ -568,8 +569,10 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate or propagate x-request-id for request correlation
|
||||
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
||||
// Generate or propagate x-request-id for request correlation. An incoming
|
||||
// id is adopted only when it is a plain token (`resolveRequestId`); it is
|
||||
// echoed and logged, so anything else is replaced with a fresh one.
|
||||
const requestId = resolveRequestId(request.headers.get("x-request-id"));
|
||||
|
||||
// 128 bits of random, base64-encoded. randomUUID().toString() only carries
|
||||
// ~122 bits and has a predictable structure that base64-encodes to a
|
||||
|
||||
Reference in new issue
Block a user