mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(geo): allow an opt-in plain-HTTP location provider
A self-hoster can set IP_GEO_ALLOW_INSECURE=true alongside an http:// URL to use a free HTTP-only provider — the free ip-api.com endpoint is HTTP-only (its HTTPS form needs a paid key) but its geolocation is often more accurate and it carries the ISP. The default stays HTTPS-only with ipwho.is; plain HTTP is refused unless the operator explicitly opts in, since the looked-up IP then travels unencrypted over the server's own egress. Whitelisted in compose and documented in the env example.
This commit is contained in:
1 parent
fbb88b81bc
commit
e51c6dc2ad
8 files changed
+88
-24
No files matched your search
@@ -34,6 +34,7 @@ beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.IP_GEO_LOOKUP_URL;
|
||||
delete process.env.IP_GEO_LOOKUP_DISABLED;
|
||||
delete process.env.IP_GEO_ALLOW_INSECURE;
|
||||
addWarningSpy.mockClear();
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -122,6 +123,39 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
|
||||
expect(url.startsWith("https://")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls a plain-HTTP provider only when IP_GEO_ALLOW_INSECURE=true", async () => {
|
||||
// v1.25.6 — a self-hoster who explicitly opts in can use the free
|
||||
// HTTP-only ip-api.com endpoint; the helper then issues the HTTP request
|
||||
// verbatim and parses its `status`/`countryCode` shape.
|
||||
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
|
||||
process.env.IP_GEO_ALLOW_INSECURE = "true";
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonOk({ status: "success", city: "Bochum", countryCode: "DE" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBe("Bochum, DE");
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url).toBe("http://ip-api.com/json/8.8.8.8");
|
||||
});
|
||||
|
||||
it("still refuses plain HTTP when the opt-in is any value other than 'true'", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
|
||||
process.env.IP_GEO_ALLOW_INSECURE = "1"; // not the literal "true"
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonOk({ status: "success", countryCode: "DE" }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url.startsWith("https://")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the ip-api pro response shape (status:'success', countryCode)", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "https://pro.ip-api.com/json";
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
|
||||
+25
-13
@@ -30,14 +30,15 @@
|
||||
* 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 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
|
||||
* Default provider for the online lookup is ipwho.is (free, no key, HTTPS).
|
||||
* 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.
|
||||
* The URL must be HTTPS by default; a self-hoster can opt into a plain-HTTP
|
||||
* provider (e.g. the free, HTTP-only, often-more-accurate ip-api.com endpoint)
|
||||
* with `IP_GEO_ALLOW_INSECURE=true` — see `buildLookupUrl`. 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
|
||||
@@ -300,14 +301,25 @@ function buildLookupUrl(ip: string): string {
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
if (!base.startsWith("https://")) {
|
||||
// V3 audit: never leak audit-event IPs over plaintext HTTP. Reject any
|
||||
// configuration that would do so by upgrading to https; if the operator
|
||||
// has explicitly opted into HTTP via env, we still refuse and return a
|
||||
// dummy URL the parser will fail on.
|
||||
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
|
||||
if (base.startsWith("https://")) {
|
||||
return `${base}/${encodeURIComponent(ip)}`;
|
||||
}
|
||||
return `${base}/${encodeURIComponent(ip)}`;
|
||||
// v1.25.6 — plain HTTP is refused BY DEFAULT (V3 audit: never leak the
|
||||
// looked-up IP over an unencrypted hop). A self-hoster who deliberately
|
||||
// wants a free HTTP-only provider — e.g. the free ip-api.com endpoint,
|
||||
// whose HTTPS form needs a paid key but whose geolocation is often more
|
||||
// accurate — opts in explicitly with `IP_GEO_ALLOW_INSECURE=true`. The
|
||||
// trade-off (the IP travels in clear over the server's own egress) is the
|
||||
// operator's to make; the default stays HTTPS-only for everyone else.
|
||||
if (
|
||||
base.startsWith("http://") &&
|
||||
process.env.IP_GEO_ALLOW_INSECURE === "true"
|
||||
) {
|
||||
return `${base}/${encodeURIComponent(ip)}`;
|
||||
}
|
||||
// Any other scheme — or HTTP without the explicit opt-in — is refused with a
|
||||
// dummy URL the parser fails on (a clean miss, never a plaintext request).
|
||||
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user