mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Robustness sweep, perf hotspots, full localization, coach gate cascade closure. - /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket sparkline + 60 s response cache. - Coach API gate inventory test surfaced and closed two orphan routes (chat[id] GET+DELETE, chat-message feedback POST). - Cross-tz fast-path runtime guard falls back to live for users more than ±3 h from UTC; proper bucket minting stays v1.5. - es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus three medication-cluster placeholder restorations. - IANA timezone validation closes the User.timezone self-DoS regression the new dashboard SQL would have surfaced. - Unit suite 4520 → 4524. See CHANGELOG.md for the full set.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import {
|
|
getClientIp,
|
|
getClientIpOrTrustWarning,
|
|
_resetTrustViolationWarningForTests,
|
|
} from "../api-response";
|
|
|
|
const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS;
|
|
const ORIGINAL_CF_FLAG = process.env.TRUST_CF_CONNECTING_IP;
|
|
|
|
beforeEach(() => {
|
|
delete process.env.TRUST_PROXY_HOPS;
|
|
delete process.env.TRUST_CF_CONNECTING_IP;
|
|
_resetTrustViolationWarningForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_HOPS === undefined) delete process.env.TRUST_PROXY_HOPS;
|
|
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;
|
|
});
|
|
|
|
function makeRequest(headers: Record<string, string>): Request {
|
|
return new Request("https://example.com/", { headers });
|
|
}
|
|
|
|
// V3 audit: client-supplied X-Forwarded-For was being trusted blindly,
|
|
// allowing a per-request `XFF: 1.2.3.<rand>` to rotate the bucket of any
|
|
// per-IP rate-limit. The new contract reads XFF right-to-left with a
|
|
// configurable number of trusted hops (Express semantics).
|
|
describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
|
it("defaults to 1 trusted hop — returns the rightmost XFF entry", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=2 returns the second-from-rightmost", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("1.2.3.4");
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=2 and a chain shorter than 2 returns null (fixes review M-3)", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
// Misconfigured deployment: claims 2 trusted hops but only 1 proxy is
|
|
// in the chain. Falling back to the leftmost (attacker-controlled)
|
|
// entry would re-introduce XFF rotation. Refuse the chain entirely.
|
|
const ip = getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(ip).toBeNull();
|
|
});
|
|
|
|
it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => {
|
|
process.env.TRUST_PROXY_HOPS = "garbage";
|
|
expect(() =>
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })),
|
|
).toThrow(/TRUST_PROXY_HOPS/);
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"x-forwarded-for": "9.9.9.9, 1.2.3.4",
|
|
"x-real-ip": "8.8.8.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("8.8.8.8");
|
|
});
|
|
|
|
it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => {
|
|
process.env.TRUST_PROXY_HOPS = "0";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4" }),
|
|
);
|
|
expect(ip).toBeNull();
|
|
});
|
|
|
|
it("XFF rotation attack — caller cannot bypass per-IP rate-limit by changing leftmost entry", () => {
|
|
// Attacker sends successive requests with rotating leftmost XFF; the
|
|
// proxy still appends the real client IP at the end, so the helper
|
|
// returns the same IP regardless of attacker chosen XFF.
|
|
const a = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "1.1.1.1, 203.0.113.5" }),
|
|
);
|
|
const b = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "8.8.8.8, 203.0.113.5" }),
|
|
);
|
|
expect(a).toBe("203.0.113.5");
|
|
expect(b).toBe("203.0.113.5");
|
|
});
|
|
|
|
it("rejects malformed entries in the chain", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "garbage,, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
// v1.4.38 — looksLikeIp now defers to node:net's `isIP` instead of
|
|
// a hex/dot character-class regex. Structurally invalid candidates
|
|
// (`:::`, `1.2`, `gg:hh::1`) used to slip through the regex and
|
|
// pollute the rate-limit / audit log with non-IP strings; they are
|
|
// now rejected at the helper.
|
|
it("rejects structurally invalid IPv4 candidates (1.2)", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("rejects structurally invalid IPv6 candidates (:::)", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("rejects hex-but-not-IPv6 chains", () => {
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "gg:hh::1, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("accepts well-formed IPv6 addresses", () => {
|
|
process.env.TRUST_PROXY_HOPS = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "2001:db8::1" }),
|
|
);
|
|
expect(ip).toBe("2001:db8::1");
|
|
});
|
|
|
|
it("falls back to x-real-ip if XFF missing", () => {
|
|
const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" }));
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* F-6 (mobile security audit, 2026-05-16): when `TRUST_PROXY_HOPS` and
|
|
* the actual proxy chain don't agree, getClientIp returns null and every
|
|
* caller falls back to a literal `"unknown"` rate-limit bucket. The
|
|
* operator needs a single warning per process so the dashboards reflect
|
|
* the misconfiguration, and a new `getClientIpOrTrustWarning` helper
|
|
* lets future callers route the request to a tighter universal bucket.
|
|
*/
|
|
describe("getClientIp trust-violation warning (F-6, 2026-05-16)", () => {
|
|
it("emits a console.warn once when the chain is shorter than configured hops", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toMatch(/TRUST_PROXY_HOPS=2/);
|
|
expect(warn.mock.calls[0][0]).toMatch(/1 entry/);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does not warn when the chain length matches", () => {
|
|
process.env.TRUST_PROXY_HOPS = "1";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
|
expect(warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.4.37 — Cloudflare's `cf-connecting-ip` carries the visitor IP
|
|
* for every request that lands on its edge. The Coolify-fronted
|
|
* HealthLog stack sits behind Cloudflare; without consulting this
|
|
* header, the admin sign-in geo lookup had no signal to resolve from
|
|
* because XFF stops at the Caddy loopback. The header is honoured
|
|
* only under `TRUST_CF_CONNECTING_IP=1` so a self-hosted deployment
|
|
* without Cloudflare in front cannot be tricked by an attacker
|
|
* setting the header directly.
|
|
*/
|
|
describe("getClientIp Cloudflare cf-connecting-ip branch (v1.4.37)", () => {
|
|
it("returns cf-connecting-ip when the env flag is on and the header is present", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "1.1.1.1, 192.0.2.7",
|
|
}),
|
|
);
|
|
expect(ip).toBe("203.0.113.42");
|
|
});
|
|
|
|
it("ignores cf-connecting-ip when the env flag is off (default)", () => {
|
|
// Default — header set by an attacker on a deployment without
|
|
// Cloudflare in front must NOT be trusted; the helper falls
|
|
// through to the XFF / x-real-ip chain.
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "9.9.9.9, 5.6.7.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("ignores cf-connecting-ip when the env flag is any value other than '1'", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "true";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "9.9.9.9, 5.6.7.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("falls back to the XFF chain when the env flag is on but cf-connecting-ip is missing", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({ "x-forwarded-for": "1.1.1.1, 5.6.7.8" }),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("rejects a malformed cf-connecting-ip when the env flag is on", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const ip = getClientIp(
|
|
makeRequest({
|
|
"cf-connecting-ip": "<<not-an-ip>>",
|
|
"x-forwarded-for": "1.1.1.1, 5.6.7.8",
|
|
}),
|
|
);
|
|
expect(ip).toBe("5.6.7.8");
|
|
});
|
|
|
|
it("getClientIpOrTrustWarning also prefers cf-connecting-ip when the flag is on", () => {
|
|
process.env.TRUST_CF_CONNECTING_IP = "1";
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({
|
|
"cf-connecting-ip": "203.0.113.42",
|
|
"x-forwarded-for": "1.1.1.1",
|
|
}),
|
|
);
|
|
expect(result).toEqual({ ip: "203.0.113.42", trustViolation: false });
|
|
});
|
|
});
|
|
|
|
describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
|
|
it("returns trustViolation=false on a well-formed chain", () => {
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" }),
|
|
);
|
|
expect(result).toEqual({ ip: "5.6.7.8", trustViolation: false });
|
|
});
|
|
|
|
it("returns trustViolation=true when the chain is shorter than hops", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
|
);
|
|
expect(result.trustViolation).toBe(true);
|
|
// ip falls back to x-real-ip (absent here) so it's null
|
|
expect(result.ip).toBeNull();
|
|
});
|
|
|
|
it("returns trustViolation=true and x-real-ip when chain is short but x-real-ip is set", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({
|
|
"x-forwarded-for": "5.6.7.8",
|
|
"x-real-ip": "9.9.9.9",
|
|
}),
|
|
);
|
|
expect(result).toEqual({ ip: "9.9.9.9", trustViolation: true });
|
|
});
|
|
|
|
it("returns trustViolation=false when XFF is absent (no chain to validate)", () => {
|
|
const result = getClientIpOrTrustWarning(
|
|
makeRequest({ "x-real-ip": "9.9.9.9" }),
|
|
);
|
|
expect(result).toEqual({ ip: "9.9.9.9", trustViolation: false });
|
|
});
|
|
|
|
it("emits the same once-per-process warning when triggered via the tagged helper", () => {
|
|
process.env.TRUST_PROXY_HOPS = "2";
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
getClientIpOrTrustWarning(
|
|
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
|
);
|
|
getClientIpOrTrustWarning(
|
|
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
|
);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|