mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(http): let operators name their proxy, and survive a bad TRUST_PROXY_HOPS
Forwarding headers were believed from any connection on a private address. Behind Docker Desktop, OrbStack and docker-proxy every connection to a published port arrives from the gateway, a private address, so a caller reaching the port directly could again choose the client address that rate limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS (comma-separated CIDRs) now names the proxies: when set, only those peers may report a client address. The private-range default stays when it is unset, the boot log states the rule in force in one line, and the reverse-proxy guide says what to set for a proxy on the same host, on a Docker network, or on a public address (which, unlisted, puts all clients in one bucket). An invalid TRUST_PROXY_HOPS threw on every request that resolved the client address, so every route answered 500, the health probe included. It now resolves no address, warns once, and is named at boot.
This commit is contained in:
1 parent
0b830e6b02
commit
d87718407e
10 files changed
+501
-38
No files matched your search
@@ -15,6 +15,18 @@ async function registerOfflineGeoNoticeAtBoot(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which connections may report a client address (TRUSTED_PROXY_CIDRS,
|
||||
* TRUST_PROXY_HOPS), said once, with a bad value named.
|
||||
*/
|
||||
async function logProxyTrustAtBootSafely(): Promise<void> {
|
||||
try {
|
||||
(await import("@/lib/http/proxy-trust")).logProxyTrustAtBoot();
|
||||
} catch {
|
||||
// A log line must never break boot.
|
||||
}
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
@@ -54,6 +66,7 @@ export async function register() {
|
||||
}
|
||||
|
||||
await registerOfflineGeoNoticeAtBoot();
|
||||
await logProxyTrustAtBootSafely();
|
||||
|
||||
// Does the configured encryption key open this database? A process whose
|
||||
// key does not refuses every API request with one clear 503 and starts
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* A typo in `TRUST_PROXY_HOPS` used to make every request that asked for the
|
||||
* client address throw, and `apiHandler` asks for it on every request to
|
||||
* record it: every route answered 500, the health probe included, so the
|
||||
* container looked dead for a configuration slip. It now resolves no address
|
||||
* and the boot log names the value.
|
||||
*/
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("apiHandler with an invalid TRUST_PROXY_HOPS", () => {
|
||||
it("answers the route instead of 500, with no client address", async () => {
|
||||
vi.stubEnv("TRUST_PROXY_HOPS", "two");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const seen: Array<string | null> = [];
|
||||
const route = apiHandler(async (request: NextRequest) => {
|
||||
seen.push(getClientIp(request));
|
||||
return Response.json({ data: { status: "ok" }, error: null });
|
||||
});
|
||||
const response = await route(
|
||||
new NextRequest("https://health.example.com/api/health", {
|
||||
headers: { "x-forwarded-for": "203.0.113.4" },
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(seen).toEqual([null]);
|
||||
});
|
||||
});
|
||||
@@ -10,16 +10,22 @@ import {
|
||||
import {
|
||||
getClientIp,
|
||||
getClientIpOrTrustWarning,
|
||||
_resetInvalidHopsWarningForTests,
|
||||
_resetPublicPeerForwardingWarningForTests,
|
||||
_resetTrustViolationWarningForTests,
|
||||
_resetUnreadProxyHeadersWarningForTests,
|
||||
} from "../api-response";
|
||||
import { describeProxyTrust } from "../http/proxy-trust";
|
||||
|
||||
const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS;
|
||||
const ORIGINAL_CF_FLAG = process.env.TRUST_CF_CONNECTING_IP;
|
||||
const ORIGINAL_CIDRS = process.env.TRUSTED_PROXY_CIDRS;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.TRUST_PROXY_HOPS;
|
||||
delete process.env.TRUST_CF_CONNECTING_IP;
|
||||
delete process.env.TRUSTED_PROXY_CIDRS;
|
||||
_resetPublicPeerForwardingWarningForTests();
|
||||
_resetTrustViolationWarningForTests();
|
||||
_resetUnreadProxyHeadersWarningForTests();
|
||||
});
|
||||
@@ -29,6 +35,8 @@ afterEach(() => {
|
||||
else process.env.TRUST_PROXY_HOPS = ORIGINAL_HOPS;
|
||||
if (ORIGINAL_CF_FLAG === undefined) delete process.env.TRUST_CF_CONNECTING_IP;
|
||||
else process.env.TRUST_CF_CONNECTING_IP = ORIGINAL_CF_FLAG;
|
||||
if (ORIGINAL_CIDRS === undefined) delete process.env.TRUSTED_PROXY_CIDRS;
|
||||
else process.env.TRUSTED_PROXY_CIDRS = ORIGINAL_CIDRS;
|
||||
});
|
||||
|
||||
function makeRequest(headers: Record<string, string>): Request {
|
||||
@@ -64,11 +72,23 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
expect(ip).toBeNull();
|
||||
});
|
||||
|
||||
it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => {
|
||||
it("resolves no address for an unparseable TRUST_PROXY_HOPS instead of failing the request", () => {
|
||||
// It used to throw on every request that asked for the client address,
|
||||
// so every route answered 500, the health probe included. Reading the
|
||||
// headers anyway would silently trust a chain the operator never
|
||||
// described (review L-1), so: no address, a trust violation, one warning.
|
||||
process.env.TRUST_PROXY_HOPS = "garbage";
|
||||
expect(() =>
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })),
|
||||
).toThrow(/TRUST_PROXY_HOPS/);
|
||||
_resetInvalidHopsWarningForTests();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
onTestFinished(() => warn.mockRestore());
|
||||
const request = makeRequest({ "x-forwarded-for": "5.6.7.8" });
|
||||
expect(getClientIpOrTrustWarning(request)).toEqual({
|
||||
ip: null,
|
||||
trustViolation: true,
|
||||
});
|
||||
expect(getClientIp(request)).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]![0]).toMatch(/TRUST_PROXY_HOPS="garbage"/);
|
||||
});
|
||||
|
||||
it.each(["", " "])(
|
||||
@@ -480,3 +500,93 @@ describe("getClientIp against the connection's own peer", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Behind Docker Desktop, OrbStack or `docker-proxy`, every connection to a
|
||||
* published port arrives from the gateway, a private address, so the
|
||||
* private-range default believed a direct caller's forwarding headers again.
|
||||
* `TRUSTED_PROXY_CIDRS` names the proxies; no other peer may forward.
|
||||
*/
|
||||
describe("getClientIp with TRUSTED_PROXY_CIDRS", () => {
|
||||
const stamped = (peer: string, headers: Record<string, string> = {}) =>
|
||||
makeRequest({ ...headers, "x-healthlog-peer-address": peer });
|
||||
|
||||
it("reads forwarding headers only from a peer inside the list", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, 127.0.0.1";
|
||||
expect(
|
||||
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
|
||||
).toBe("203.0.113.5");
|
||||
expect(
|
||||
getClientIp(stamped("127.0.0.1", { "x-real-ip": "203.0.113.6" })),
|
||||
).toBe("203.0.113.6");
|
||||
});
|
||||
|
||||
it("uses the gateway's own address for a direct caller from a private network outside the list", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const forged = ["203.0.113.1", "203.0.113.2"].map((ip) =>
|
||||
getClientIp(
|
||||
stamped("192.168.65.1", { "x-forwarded-for": ip, "x-real-ip": ip }),
|
||||
),
|
||||
);
|
||||
expect(forged).toEqual(["192.168.65.1", "192.168.65.1"]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("believes a proxy on a public address once it is listed", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "198.51.100.7/32";
|
||||
expect(
|
||||
getClientIp(
|
||||
stamped("198.51.100.7", { "x-forwarded-for": "203.0.113.9" }),
|
||||
),
|
||||
).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("takes IPv6 ranges and bare addresses", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "fd00::/8,2001:db8::1";
|
||||
expect(
|
||||
getClientIp(stamped("fd12::3", { "x-forwarded-for": "203.0.113.1" })),
|
||||
).toBe("203.0.113.1");
|
||||
expect(
|
||||
getClientIp(stamped("2001:db8::1", { "x-forwarded-for": "203.0.113.2" })),
|
||||
).toBe("203.0.113.2");
|
||||
expect(
|
||||
getClientIp(stamped("2001:db8::2", { "x-forwarded-for": "203.0.113.3" })),
|
||||
).toBe("2001:db8::2");
|
||||
});
|
||||
|
||||
it("lets no peer forward when an entry is not a CIDR", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, not-a-range";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(
|
||||
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
|
||||
).toBe("172.18.0.5");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the boot line naming the proxy rule", () => {
|
||||
it("names the private-range default, and what it lets in behind a port forwarder", () => {
|
||||
expect(describeProxyTrust()).toMatchObject({
|
||||
level: "info",
|
||||
message: expect.stringMatching(/TRUSTED_PROXY_CIDRS unset.*docker-proxy/),
|
||||
});
|
||||
});
|
||||
|
||||
it("names the list when one is set", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "127.0.0.1/32";
|
||||
expect(describeProxyTrust()).toMatchObject({
|
||||
level: "info",
|
||||
message: expect.stringContaining("only connections from 127.0.0.1/32"),
|
||||
});
|
||||
});
|
||||
|
||||
it("names a bad TRUST_PROXY_HOPS and a bad CIDR as errors", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "one";
|
||||
process.env.TRUSTED_PROXY_CIDRS = "10.0.0.0/33";
|
||||
const line = describeProxyTrust();
|
||||
expect(line.level).toBe("error");
|
||||
expect(line.message).toContain('TRUST_PROXY_HOPS="one"');
|
||||
expect(line.message).toContain('"10.0.0.0/33"');
|
||||
});
|
||||
});
|
||||
+54
-29
@@ -7,7 +7,11 @@ import {
|
||||
peerAddressStampInstalled,
|
||||
readPeerAddress,
|
||||
} from "@/lib/http/peer-address";
|
||||
import { isPublicIp } from "@/lib/validations/notifications";
|
||||
import {
|
||||
parseTrustProxyHops,
|
||||
parseTrustedProxyCidrs,
|
||||
peerMayForward,
|
||||
} from "@/lib/http/proxy-trust";
|
||||
|
||||
export function apiSuccess<T>(data: T, status = 200) {
|
||||
return NextResponse.json({ data, error: null }, { status });
|
||||
@@ -508,13 +512,16 @@ export async function safeJson<T = unknown>(
|
||||
* observed when the request arrived.
|
||||
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
|
||||
*
|
||||
* Forwarding headers are read only from a connection whose peer could be a
|
||||
* proxy: loopback, a private or unique-local network, CGNAT (Tailscale). A
|
||||
* connection from a public address reached the server directly, so whatever
|
||||
* forwarding headers it sends are its own, and its own address is the client
|
||||
* IP. The peer comes from `@/lib/http/peer-address`. Without that check, the
|
||||
* default of one trusted hop let any direct caller name a fresh address per
|
||||
* request and step around every per-IP limit.
|
||||
* Forwarding headers are read only from a connection whose peer may be a
|
||||
* proxy (`peerMayForward` in `@/lib/http/proxy-trust`): one inside
|
||||
* `TRUSTED_PROXY_CIDRS` when that is set, otherwise any loopback, private,
|
||||
* unique-local or CGNAT (Tailscale) address. Any other connection reached the
|
||||
* server directly, so whatever forwarding headers it sends are its own, and
|
||||
* its own address is the client IP. The peer comes from
|
||||
* `@/lib/http/peer-address`. Without that check, the default of one trusted
|
||||
* hop let any direct caller name a fresh address per request and step around
|
||||
* every per-IP limit. An invalid `TRUST_PROXY_HOPS` resolves no address
|
||||
* (and is named at boot) instead of failing the request.
|
||||
*
|
||||
* Cloudflare opt-in (`TRUST_CF_CONNECTING_IP=1`): when the env flag is
|
||||
* set, the helper prefers the `cf-connecting-ip` header before walking
|
||||
@@ -561,22 +568,24 @@ function readCfConnectingIp(request: Request): string | null {
|
||||
return looksLikeIp(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseTrustProxyHops(raw: string | undefined): number {
|
||||
// Empty is unset. The shipped docker-compose.yml passes
|
||||
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
|
||||
// every operator who never set it; reading that as invalid threw on every
|
||||
// request that asked for the client IP.
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (trimmed === "") return 1;
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
// Reject explicitly-invalid values so an operator typo doesn't silently
|
||||
// switch a real-proxy deployment to "no XFF trust" mode (review
|
||||
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
|
||||
throw new Error(
|
||||
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
return parseInt(trimmed, 10);
|
||||
/**
|
||||
* Once per process: `TRUST_PROXY_HOPS` is not a number. Every request that
|
||||
* asks for the client address used to throw on it, so every route answered
|
||||
* 500, `/api/health` included. It resolves no address instead, and says so.
|
||||
*/
|
||||
let invalidHopsWarned = false;
|
||||
|
||||
function warnInvalidHopsOnce(raw: string | undefined): void {
|
||||
if (invalidHopsWarned) return;
|
||||
invalidHopsWarned = true;
|
||||
console.warn(
|
||||
`[getClientIp] TRUST_PROXY_HOPS=${JSON.stringify(raw)} is not a non-negative integer; no client address is read from forwarding headers until it is fixed, and every anonymous caller shares one rate-limit bucket.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only reset for {@link warnInvalidHopsOnce}. */
|
||||
export function _resetInvalidHopsWarningForTests(): void {
|
||||
invalidHopsWarned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,12 +655,23 @@ let publicPeerForwardingWarned = false;
|
||||
function warnPublicPeerForwardingOnce(): void {
|
||||
if (publicPeerForwardingWarned) return;
|
||||
publicPeerForwardingWarned = true;
|
||||
const listed =
|
||||
parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS).kind !== "unset";
|
||||
console.warn(
|
||||
"[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
|
||||
"If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it.",
|
||||
listed
|
||||
? "[getClientIp] a connection from outside TRUSTED_PROXY_CIDRS sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
|
||||
"If it was your proxy, add its address to TRUSTED_PROXY_CIDRS; otherwise the published port is reachable beyond the proxy."
|
||||
: "[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
|
||||
"If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it. " +
|
||||
"If the proxy connects from a public address, set TRUSTED_PROXY_CIDRS to it.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only reset for {@link warnPublicPeerForwardingOnce}. */
|
||||
export function _resetPublicPeerForwardingWarningForTests(): void {
|
||||
publicPeerForwardingWarned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged return shape so a caller can apply a tighter universal
|
||||
* rate-limit when the trust chain is misconfigured. F-6 (mobile security
|
||||
@@ -681,6 +701,10 @@ export function getClientIpOrTrustWarning(request: Request): {
|
||||
if (cfIp) return { ip: cfIp, trustViolation: false };
|
||||
|
||||
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
||||
if (hops === null) {
|
||||
warnInvalidHopsOnce(process.env.TRUST_PROXY_HOPS);
|
||||
return { ip: null, trustViolation: true };
|
||||
}
|
||||
|
||||
// The connection's own peer, stamped by the server for every request it
|
||||
// receives. `undefined` only for a request the server did not receive (a
|
||||
@@ -693,9 +717,10 @@ export function getClientIpOrTrustWarning(request: Request): {
|
||||
if (peer !== undefined) {
|
||||
// No proxy declared: the peer is the client.
|
||||
if (hops === 0) return { ip: peer, trustViolation: false };
|
||||
// A peer on a public address reached the server directly; its
|
||||
// forwarding headers are its own choice, not a proxy's report.
|
||||
if (isPublicIp(peer)) {
|
||||
// A peer that is not a proxy (outside TRUSTED_PROXY_CIDRS when it is
|
||||
// set, on a public address when it is not) reached the server directly;
|
||||
// its forwarding headers are its own choice, not a proxy's report.
|
||||
if (!peerMayForward(peer)) {
|
||||
if (
|
||||
request.headers.has("x-real-ip") ||
|
||||
request.headers.get("x-forwarded-for")?.trim() !== peer
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Which connections may tell the server who the client is.
|
||||
*
|
||||
* A reverse proxy reports the client's address in `X-Forwarded-For` or
|
||||
* `X-Real-IP`. Anyone else who sends those headers is choosing the address
|
||||
* every per-IP rate limit, the audit log and the sign-in notices see, so the
|
||||
* client-IP resolver (`getClientIpOrTrustWarning` in `@/lib/api-response`)
|
||||
* reads them only from a peer this module says may forward.
|
||||
*
|
||||
* Two rules:
|
||||
*
|
||||
* - `TRUSTED_PROXY_CIDRS` set (comma-separated CIDRs, IPv4 or IPv6): only
|
||||
* a peer inside one of them may forward. Every other connection is its
|
||||
* own client, whatever address it comes from.
|
||||
* - unset: any peer whose address is not public (loopback, private,
|
||||
* CGNAT, unique-local, link-local) may forward. This is the long-standing
|
||||
* default and stays for compatibility, but it is only as good as the
|
||||
* network: behind Docker Desktop, OrbStack or `docker-proxy` every
|
||||
* connection to a published port arrives from the gateway address, a
|
||||
* private one, so a caller that reaches the port directly is believed
|
||||
* again. Setting the list closes that.
|
||||
*
|
||||
* `TRUST_PROXY_HOPS` says how many proxies the `X-Forwarded-For` chain
|
||||
* passed through. A value that is not a non-negative integer used to throw
|
||||
* on every request that asked for the client address, `/api/health`
|
||||
* included; it now resolves no address and is named once at boot.
|
||||
*
|
||||
* Both are read per call, so the suites can change them; the parsed CIDR
|
||||
* list is cached by its raw value.
|
||||
*/
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
import { isPublicIp } from "@/lib/validations/notifications";
|
||||
|
||||
// ── TRUST_PROXY_HOPS ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The configured hop count, `1` when unset or empty, or `null` when the
|
||||
* value is not a non-negative integer.
|
||||
*/
|
||||
export function parseTrustProxyHops(raw: string | undefined): number | null {
|
||||
// Empty is unset. The shipped docker-compose.yml passes
|
||||
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
|
||||
// every operator who never set it.
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (trimmed === "") return 1;
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
return parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
// ── TRUSTED_PROXY_CIDRS ───────────────────────────────────────────────────
|
||||
|
||||
export type TrustedProxyList =
|
||||
/** Unset: the private-address default applies. */
|
||||
| { kind: "unset" }
|
||||
| { kind: "list"; entries: string[]; blockList: BlockList }
|
||||
/** Set, but an entry is not a CIDR. No peer may forward. */
|
||||
| { kind: "invalid"; invalid: string[] };
|
||||
|
||||
/** `10.0.0.0/8`, `127.0.0.1`, `fd00::/8`; a bare address is a /32 or /128. */
|
||||
function parseCidr(
|
||||
entry: string,
|
||||
): { address: string; prefix: number; family: "ipv4" | "ipv6" } | null {
|
||||
const [address = "", prefixText, extra] = entry.split("/");
|
||||
if (extra !== undefined) return null;
|
||||
const version = isIP(address);
|
||||
if (version === 0) return null;
|
||||
const max = version === 4 ? 32 : 128;
|
||||
if (prefixText !== undefined && !/^\d{1,3}$/.test(prefixText)) return null;
|
||||
const prefix = prefixText === undefined ? max : Number(prefixText);
|
||||
if (prefix > max) return null;
|
||||
return { address, prefix, family: version === 4 ? "ipv4" : "ipv6" };
|
||||
}
|
||||
|
||||
let cached: { raw: string; list: TrustedProxyList } | null = null;
|
||||
|
||||
export function parseTrustedProxyCidrs(
|
||||
raw: string | undefined,
|
||||
): TrustedProxyList {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (cached && cached.raw === value) return cached.list;
|
||||
let list: TrustedProxyList;
|
||||
if (value === "") {
|
||||
list = { kind: "unset" };
|
||||
} else {
|
||||
const entries = value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const blockList = new BlockList();
|
||||
const invalid: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const cidr = parseCidr(entry);
|
||||
if (!cidr) {
|
||||
invalid.push(entry);
|
||||
continue;
|
||||
}
|
||||
blockList.addSubnet(cidr.address, cidr.prefix, cidr.family);
|
||||
}
|
||||
list =
|
||||
invalid.length > 0 || entries.length === 0
|
||||
? { kind: "invalid", invalid: invalid.length > 0 ? invalid : [value] }
|
||||
: { kind: "list", entries, blockList };
|
||||
}
|
||||
cached = { raw: value, list };
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Whether a connection from `peer` may report the client's address. */
|
||||
export function peerMayForward(peer: string): boolean {
|
||||
const list = parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS);
|
||||
if (list.kind === "unset") return !isPublicIp(peer);
|
||||
if (list.kind === "invalid") return false;
|
||||
const family = isIP(peer) === 6 ? "ipv6" : "ipv4";
|
||||
return list.blockList.check(peer, family);
|
||||
}
|
||||
|
||||
// ── The boot line ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One line naming the rule in force, for the boot log: which peers may
|
||||
* forward, how many hops are read, and what is wrong with a value that
|
||||
* cannot be used.
|
||||
*/
|
||||
export function describeProxyTrust(): {
|
||||
level: "info" | "error";
|
||||
message: string;
|
||||
} {
|
||||
const hopsRaw = process.env.TRUST_PROXY_HOPS;
|
||||
const hops = parseTrustProxyHops(hopsRaw);
|
||||
const list = parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS);
|
||||
const problems: string[] = [];
|
||||
if (hops === null) {
|
||||
problems.push(
|
||||
`TRUST_PROXY_HOPS=${JSON.stringify(hopsRaw)} is not a non-negative integer, so no client address is read from forwarding headers`,
|
||||
);
|
||||
}
|
||||
if (list.kind === "invalid") {
|
||||
problems.push(
|
||||
`TRUSTED_PROXY_CIDRS has entries that are not CIDRs (${list.invalid.map((e) => JSON.stringify(e)).join(", ")}), so no connection may report a client address`,
|
||||
);
|
||||
}
|
||||
const peers =
|
||||
list.kind === "list"
|
||||
? `only connections from ${list.entries.join(", ")} (TRUSTED_PROXY_CIDRS)`
|
||||
: list.kind === "unset"
|
||||
? "connections from any loopback, private, CGNAT, unique-local or link-local address (TRUSTED_PROXY_CIDRS unset; behind Docker Desktop, OrbStack or docker-proxy that includes every caller of a published port)"
|
||||
: "no connection";
|
||||
if (problems.length > 0) {
|
||||
return {
|
||||
level: "error",
|
||||
message: `[proxy-trust] ${problems.join("; ")}. Fix the value; see docs/self-hosting/reverse-proxy.md.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: "info",
|
||||
message:
|
||||
hops === 0
|
||||
? "[proxy-trust] TRUST_PROXY_HOPS=0: forwarding headers are never read; the client address is the connection's own."
|
||||
: `[proxy-trust] forwarding headers are read from ${peers}, ${hops} hop${hops === 1 ? "" : "s"} deep (TRUST_PROXY_HOPS).`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the rule in force once, at boot (`src/instrumentation.ts`). The
|
||||
* logger is a parameter, as in the readiness summary: the production build
|
||||
* strips direct `console.log` calls.
|
||||
*/
|
||||
export function logProxyTrustAtBoot(
|
||||
log: Pick<Console, "log" | "error"> = console,
|
||||
): void {
|
||||
const { level, message } = describeProxyTrust();
|
||||
if (level === "error") log.error(message);
|
||||
else log.log(message);
|
||||
}
|
||||
Reference in new issue
Block a user