mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
84731e0968
commit
2da0703a46
2 files changed
+166
-13
No files matched your search
+129
-12
@@ -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
@@ -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" ||
|
||||
|
||||
Reference in new issue
Block a user