fix(geo): preserve umlauts in city names (login-overview no longer renders "Nrnberg")

In /admin/login-overview I caught a row that should have rendered
"Nürnberg, DE" showing up as "Nrnberg, DE" instead — the umlaut was
gone. The geo source (ipwho.is) returns the city as proper UTF-8 over
the wire, and the audit-log render path is a straight pass-through, so
the corruption had to be in the geo helper itself.

Two defensive fixes, in lib/geo.ts:

1. Decode the response body as UTF-8 explicitly via `arrayBuffer()` +
   `TextDecoder('utf-8')` instead of `Response.json()`. The latter
   defers its charset decision to whatever the upstream
   `Content-Type: application/json; charset=…` claims, which an
   intermediate proxy can strip or rewrite — exactly the kind of
   transit-layer surprise that produces "Nrnberg" out of "Nürnberg"
   when the byte stream stays UTF-8 but the parser thinks it's
   latin-1. Reading via TextDecoder bypasses that entirely; a
   malformed byte surfaces as a parse failure, not silent character
   loss.

2. Send `Accept-Language: de, en;q=0.5` on the lookup request.
   ipwho.is and ip-api both honour it, and without the hint ip-api
   falls back to the English ASCII fold ("Nuremberg" instead of
   "Nürnberg"). German is preferred (DACH-skewed user base) with
   English as a safety net for cities that don't have a German name.

Also audited the rest of src/ for ASCII-fold patterns that could chew
umlauts elsewhere — `String.prototype.normalize("NFD")`, slugify
calls, `[^a-zA-Z0-9]/g` strips: zero hits. So this is the only fix
needed for now.

Regression tests: roundtrip 7 known-umlaut cities (Nürnberg, München,
Düsseldorf, Köln, Würzburg, Bückeburg, Weißenfels) plus eszett, plus
a Content-Type-without-charset case to lock in the explicit UTF-8
decode, plus an Accept-Language assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 23:37:27 +02:00
1 parent 84731e0968
commit 2da0703a46
2 files changed
+166 -13

No files matched your search

+129 -12
View File
@@ -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/);
});
});
+37 -1
View File
@@ -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<unknown> {
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<string | null> {
@@ -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" ||