From e51c6dc2ad0122234ab6ad215cb9ef99bbcbc1ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 20:38:04 +0200 Subject: [PATCH] feat(geo): allow an opt-in plain-HTTP location provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.production.example | 19 ++++++++++++------ CHANGELOG.md | 8 ++++++++ docker-compose.yml | 7 +++++-- docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- src/lib/__tests__/geo.test.ts | 34 +++++++++++++++++++++++++++++++ src/lib/geo.ts | 38 +++++++++++++++++++++++------------ 8 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.env.production.example b/.env.production.example index 55bcd326f..673c3be1a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -223,13 +223,20 @@ API_TOKEN_HMAC_KEY="" # 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 +# "City, CC" location + carrier. The default online provider is ipwho.is +# (free, no key, HTTPS). 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`). The # `/` segment is appended automatically. -# IP_GEO_LOOKUP_URL="https://ip-api.com/json" +# IP_GEO_LOOKUP_URL="https://ipwho.is" +# +# By default a non-HTTPS URL is refused (audit-event IPs must never egress over +# plaintext HTTP). The free ip-api.com endpoint is HTTP-only (its HTTPS form +# needs a paid key) but is often more accurate. To use it, set BOTH the HTTP +# URL and the explicit opt-in below -- the looked-up IP then travels in clear +# over your server's own egress; this is your call to make. +# IP_GEO_LOOKUP_URL="http://ip-api.com/json" +# IP_GEO_ALLOW_INSECURE="true" # # 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e83cda4..c47984624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.25.6] — 2026-06-29 — Optional HTTP geolocation provider + +A focused configuration release. No schema change. + +### Added + +- A self-hoster can now opt into a plain-HTTP IP-location provider by setting `IP_GEO_ALLOW_INSECURE=true` alongside an `http://` `IP_GEO_LOOKUP_URL`. This makes the free ip-api.com endpoint usable (its HTTPS form needs a paid key, but its geolocation is often more accurate, and it includes the carrier). 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. + ## [1.25.5] — 2026-06-29 — Mental-wellbeing polish, location lookup A focused polish release. No schema change. diff --git a/docker-compose.yml b/docker-compose.yml index 8b12fe468..da3301513 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,13 +84,16 @@ services: # 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. + # IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by + # default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress. + # IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the + # free ip-api.com endpoint) — off by default, an explicit operator choice. # 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:-}" + IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}" # 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}" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 63996baec..b172ec38c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.25.5 + version: 1.25.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index e3829577d..c596973bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.25.5", + "version": "1.25.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 289554e4f..ad2cb3618 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.25.5"; + /* @sw-version-fallback */ "v1.25.6"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts index 172b2a799..aa1807767 100644 --- a/src/lib/__tests__/geo.test.ts +++ b/src/lib/__tests__/geo.test.ts @@ -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( diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 1f15b8320..c76857a08 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -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)}`; } /**