mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge w11/geo into release/v1.18.11
Surface non-ok geo-lookup responses as a warning instead of swallowing them, make the lookup provider/URL override explicit, and whitelist TRUST_CF_CONNECTING_IP + the geo env in compose so the real visitor IP is stored behind Cloudflare.
This commit is contained in:
commit
c100b67eda
4 files changed
+135
-9
No files matched your search
@@ -187,3 +187,31 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG
|
||||
#
|
||||
# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client-IP resolution -- optional (required behind Cloudflare)
|
||||
# -----------------------------------------------------------------------------
|
||||
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
|
||||
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
|
||||
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
|
||||
# address and the geo lookup resolves the wrong location. Set this to "1"
|
||||
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
|
||||
# it unset, or an attacker could forge the header. Off by default.
|
||||
# TRUST_CF_CONNECTING_IP="1"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# IP geolocation lookup -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# The admin login/audit overview enriches auth events with a coarse
|
||||
# "City, CC" location + carrier. The default online provider is ip-api.com.
|
||||
# Override the base URL to point at a different provider; the response must
|
||||
# match either the ipwho.is shape (`success` + `country_code`) or the
|
||||
# ip-api.com shape (`status` + `countryCode`). HTTPS only -- a non-HTTPS URL
|
||||
# is refused (audit-event IPs must never egress over plaintext HTTP). The
|
||||
# `/<ip>` segment is appended automatically.
|
||||
# IP_GEO_LOOKUP_URL="https://ip-api.com/json"
|
||||
#
|
||||
# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress
|
||||
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
|
||||
# IP_GEO_LOOKUP_DISABLED="1"
|
||||
@@ -52,6 +52,17 @@ services:
|
||||
# the container, even though docker-compose reads .env for
|
||||
# `${VAR}` substitution at compose-up time).
|
||||
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
# default — a direct-exposed host must not trust a forgeable header).
|
||||
# IP_GEO_LOOKUP_URL overrides the default ip-api.com provider (HTTPS
|
||||
# only); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
|
||||
# Listed here so operator values in .env reach the container (the
|
||||
# `environment:` block is a whitelist — vars not listed never propagate).
|
||||
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
|
||||
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
|
||||
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// v1.18.11 (W3): capture wide-event warnings so the non-ok-status path can
|
||||
// be asserted. `getEvent()` returns null outside a request context, so
|
||||
// without this mock the `getEvent()?.addWarning(...)` call is a silent
|
||||
// no-op and there is nothing to assert against.
|
||||
const addWarningSpy = vi.fn();
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
getEvent: () => ({ addWarning: addWarningSpy }),
|
||||
};
|
||||
});
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.IP_GEO_LOOKUP_URL;
|
||||
delete process.env.IP_GEO_LOOKUP_DISABLED;
|
||||
addWarningSpy.mockClear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -55,11 +69,14 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("uses HTTPS by default (ipwho.is)", async () => {
|
||||
it("uses the ip-api.com HTTPS endpoint by default", async () => {
|
||||
// v1.18.11 (W3): default provider is ip-api.com (ipwho.is 403s on
|
||||
// datacentre egress). The wire URL must be the ip-api.com JSON path
|
||||
// and the parser must accept its `status`/`countryCode` shape.
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
|
||||
jsonOk({ status: "success", city: "Berlin", countryCode: "DE" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
@@ -67,6 +84,7 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url.startsWith("https://")).toBe(true);
|
||||
expect(url).toBe("https://ip-api.com/json/8.8.8.8");
|
||||
});
|
||||
|
||||
it("refuses to call non-HTTPS configured providers", async () => {
|
||||
@@ -104,6 +122,51 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.18.11 (W3): a non-ok HTTP status (403 free-plan/CORS rejection, 429
|
||||
// rate-limit, 5xx) must surface a wide-event warning instead of being
|
||||
// swallowed as a clean miss. The ipwho.is 403 that produced the prod "—"
|
||||
// never emitted a single signal; this guards against that regression.
|
||||
describe("lookupIpLocation non-ok status warning (v1.18.11 W3)", () => {
|
||||
function nonOk(status: number): Response {
|
||||
return new Response("", {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [403, 429, 503]) {
|
||||
it(`returns null and warns on HTTP ${status}`, async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(nonOk(status));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
|
||||
expect(addWarningSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addWarningSpy.mock.calls[0]?.[0]).toContain(String(status));
|
||||
});
|
||||
}
|
||||
|
||||
it("does not warn on a clean 200 success", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
status: "success",
|
||||
city: "Berlin",
|
||||
countryCode: "DE",
|
||||
}),
|
||||
).buffer as ArrayBuffer,
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
|
||||
expect(addWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.16 A8a: the maintainer spotted "Nrnberg" rendered in /admin/login-overview
|
||||
// for an audit row that should have read "Nürnberg, DE". The geo source
|
||||
// (ipwho.is) returns valid UTF-8, but `Response.json()` defers its
|
||||
|
||||
+31
-7
@@ -30,11 +30,14 @@
|
||||
* skip the offline tier — local dev without the MMDBs still works and
|
||||
* falls straight back to the online provider.
|
||||
*
|
||||
* Default provider for the online lookup is ipwho.is (HTTPS, free,
|
||||
* no key). Both the response shape from ipwho.is and the fallback
|
||||
* ip-api.com pro endpoint are accepted, so swapping providers via
|
||||
* `IP_GEO_LOOKUP_URL` only requires matching one of those response
|
||||
* shapes.
|
||||
* Default provider for the online lookup is ip-api.com (HTTPS JSON
|
||||
* endpoint). ipwho.is was the v1.18.10 default but rejects datacentre /
|
||||
* free-plan server egress with a 403, so it never resolved on prod. Both
|
||||
* the ipwho.is shape (`success`/`country_code`) and the ip-api.com shape
|
||||
* (`status`/`countryCode`) are accepted, so swapping providers via
|
||||
* `IP_GEO_LOOKUP_URL` only requires matching one of those response shapes.
|
||||
* A non-ok HTTP status (403/429/5xx) is surfaced on the wide event rather
|
||||
* than swallowed, so a future provider rejection is visible.
|
||||
*
|
||||
* Setting `IP_GEO_LOOKUP_DISABLED=1` disables the online lookup
|
||||
* entirely — used by deployments that do not want any IP egress to a
|
||||
@@ -87,7 +90,16 @@ type GeoResponse = IpwhoIsResponse & IpApiProResponse;
|
||||
const PRIVATE_IP =
|
||||
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/;
|
||||
|
||||
const DEFAULT_GEO_URL = "https://ipwho.is";
|
||||
// v1.18.11 (W3) — the default online provider is ip-api.com, not ipwho.is.
|
||||
// ipwho.is rejects datacentre / free-plan server egress with a 403
|
||||
// ("CORS is not supported on the Free plan"), which silently produced the
|
||||
// "—" location on prod (every server-side lookup failed). The base is
|
||||
// `https://ip-api.com/json`; `buildLookupUrl` appends `/<ip>` so the wire
|
||||
// URL is `https://ip-api.com/json/<ip>`. The parser already accepts the
|
||||
// ip-api.com shape (`status:"success"` + `countryCode`). Operators who need
|
||||
// a different / keyed provider override via `IP_GEO_LOOKUP_URL`; the HTTPS
|
||||
// egress guard in `buildLookupUrl` still applies to any override.
|
||||
const DEFAULT_GEO_URL = "https://ip-api.com/json";
|
||||
|
||||
function geoLiteDir(): string {
|
||||
return process.env.GEOLITE2_DIR ?? "/opt/geolite2";
|
||||
@@ -341,7 +353,19 @@ async function lookupIpLocationOnline(ip: string): Promise<string | null> {
|
||||
// resolves null and drops the location (the "—" symptom on apps01).
|
||||
{ timeoutMs: 2_500, requirePublicHost: true },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {
|
||||
// v1.18.11 (W3) — a non-ok response (403 free-plan/CORS rejection,
|
||||
// 429 rate-limit, 5xx) is not a clean "this IP has no location": it is
|
||||
// a provider-level failure that, left silent, renders as "—" with no
|
||||
// signal anywhere. Surface it on the wide event so the next provider
|
||||
// rejection is visible instead of swallowed (the ipwho.is 403 that
|
||||
// caused the prod "—" never produced a single log line). Still return
|
||||
// null so the offline fallback / negative-cache path runs unchanged.
|
||||
getEvent()?.addWarning(
|
||||
`geo: online lookup returned HTTP ${res.status} — location not resolved`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await readUtf8Json(res)) as GeoResponse;
|
||||
const ok =
|
||||
|
||||
Reference in new issue
Block a user