diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts index 89035595c..8eda58777 100644 --- a/src/lib/__tests__/geo.test.ts +++ b/src/lib/__tests__/geo.test.ts @@ -41,11 +41,26 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + // v1.4.16 A8a: helper turns a JS object into a real `Response` whose + // body is the UTF-8 byte stream of `JSON.stringify(value)`. The geo + // helper now reads via `arrayBuffer() + TextDecoder('utf-8')` so the + // test fakes have to expose those, not just `json()`. + function jsonOk(value: unknown): Response { + return new Response( + new TextEncoder().encode(JSON.stringify(value)).buffer as ArrayBuffer, + { + status: 200, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + } + it("uses HTTPS by default (ipwho.is)", async () => { - const fetchSpy = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ success: true, city: "Berlin", country_code: "DE" }), - }); + const fetchSpy = vi + .fn() + .mockResolvedValue( + jsonOk({ success: true, city: "Berlin", country_code: "DE" }), + ); vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); @@ -56,10 +71,12 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { it("refuses to call non-HTTPS configured providers", async () => { process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json"; - const fetchSpy = vi.fn().mockResolvedValue({ - ok: false, - json: async () => ({}), - }); + const fetchSpy = vi.fn().mockResolvedValue( + new Response("", { + status: 503, + headers: { "content-type": "application/json" }, + }), + ); vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); @@ -73,17 +90,117 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { 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({ - ok: true, - json: async () => ({ + const fetchSpy = vi.fn().mockResolvedValue( + jsonOk({ status: "success", city: "Hamburg", countryCode: "DE", }), - }); + ); vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE"); }); }); + +// v1.4.16 A8a: Marc 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 +// charset decoding to whatever the server's `Content-Type` header +// claims, so an upstream proxy that strips the charset parameter (or +// re-serves the body as latin-1) can poison the umlaut path. The fix +// reads the body as a UTF-8 ArrayBuffer first, then JSON.parses. +// The umlaut-roundtrip test is the regression guard. +describe("lookupIpLocation umlaut roundtrip (v1.4.16 A8a)", () => { + // Build a Response-like that serves a UTF-8 byte stream — what ipwho.is + // actually puts on the wire — instead of letting the Vitest test runner + // hand the parser a pre-decoded JS object, which masks any + // `Content-Type`-charset-driven decoding bug. + function utf8JsonResponse(body: object): Response { + return new Response( + new TextEncoder().encode(JSON.stringify(body)).buffer as ArrayBuffer, + { + status: 200, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + } + + for (const city of [ + "Nürnberg", + "München", + "Düsseldorf", + "Köln", + "Würzburg", + "Bückeburg", + ]) { + it(`preserves "${city}" through fetch → DB-ready string`, async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + utf8JsonResponse({ success: true, city, country_code: "DE" }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBe(`${city}, DE`); + }); + } + + it('preserves the eszett ("ß") in country/region names', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + utf8JsonResponse({ + success: true, + city: "Weißenfels", + country_code: "DE", + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBe("Weißenfels, DE"); + }); + + it("survives a Content-Type header that omits the charset", async () => { + // Defensive: some upstream proxies strip `; charset=utf-8`. The body + // stays UTF-8 bytes; the helper must not let Response.json()'s + // charset-defaulting behaviour hide an umlaut. + const headers = new Headers({ "content-type": "application/json" }); + const body = new TextEncoder().encode( + JSON.stringify({ success: true, city: "Nürnberg", country_code: "DE" }), + ); + const fetchSpy = vi.fn().mockResolvedValue( + new Response(body.buffer as ArrayBuffer, { + status: 200, + headers, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBe("Nürnberg, DE"); + }); + + it("hints providers it wants localized names via Accept-Language", async () => { + // ipwho.is and ip-api both honour Accept-Language for the city + // field; without the hint, ip-api falls back to English ASCII fold + // ("Nuremberg"). Setting de;q=1, en;q=0.5 keeps the user-facing + // text close to the locale the audit-log overview renders in. + const fetchSpy = vi.fn().mockResolvedValue( + utf8JsonResponse({ + success: true, + city: "Nürnberg", + country_code: "DE", + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + await lookupIpLocation("8.8.8.8"); + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined; + const headers = new Headers(init?.headers ?? {}); + const al = headers.get("accept-language") ?? ""; + expect(al.toLowerCase()).toMatch(/de/); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index ebbea3194..b986c6d92 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -9,6 +9,14 @@ * Setting IP_GEO_LOOKUP_DISABLED=1 disables lookup entirely — used by * deployments that do not want any IP egress to a third-party service * (V3 audit: GDPR Art. 32 + Art. 44, plaintext HTTP IP egress). + * + * v1.4.16 A8a: the body is decoded as UTF-8 explicitly via + * `TextDecoder('utf-8')` instead of `Response.json()`, and an + * `Accept-Language: de, en;q=0.5` hint is sent so providers return + * native city names ("Nürnberg") rather than ASCII folds + * ("Nuremberg"). The previous path lost umlauts in production for at + * least one Marc-Brief login row that rendered as "Nrnberg" in + * /admin/login-overview — see `docs/audit/v1416-summary.md` once cut. */ interface IpwhoIsResponse { @@ -45,6 +53,23 @@ function buildLookupUrl(ip: string): string { return `${base}/${encodeURIComponent(ip)}`; } +/** + * Decode the raw response bytes as UTF-8, then JSON-parse. Bypasses + * `Response.json()` so the result is independent of the upstream + * `Content-Type: application/json; charset=…` value — relevant in + * production where ipwho.is sits behind Cloudflare and an intermediate + * proxy can re-serve the body without preserving the charset hint. + * Falls back to whatever decoding errors `TextDecoder` produces (which + * substitute U+FFFD rather than dropping bytes), so a malformed + * response surfaces as a parse failure instead of silent character + * loss. + */ +async function readUtf8Json(res: Response): Promise { + const buf = await res.arrayBuffer(); + const text = new TextDecoder("utf-8").decode(buf); + return JSON.parse(text); +} + export async function lookupIpLocation( ip: string | null, ): Promise { @@ -54,10 +79,21 @@ export async function lookupIpLocation( try { const res = await fetch(buildLookupUrl(ip), { signal: AbortSignal.timeout(3000), + headers: { + // ipwho.is and ip-api both honour Accept-Language for the + // city field. Without the hint, ip-api falls back to the + // English ASCII fold ("Nuremberg" instead of "Nürnberg"). + // German first because the user-base is DACH-skewed; English + // as a graceful fallback for cities that don't have a German + // name. Quality 0.5 on the en fallback keeps providers from + // tie-breaking the wrong way. + "Accept-Language": "de, en;q=0.5", + Accept: "application/json", + }, }); if (!res.ok) return null; - const data = (await res.json()) as GeoResponse; + const data = (await readUtf8Json(res)) as GeoResponse; const ok = data.success === true || data.status === "success" ||