From 6e61f68187452c91f4e4f1671aa2b59ec23e2a7f Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Wed, 27 May 2026 13:56:18 -0700 Subject: [PATCH 1/2] fix(account-settings): decode URL-encoded city in active sessions The Active Sessions table showed locations like "San%20Francisco" instead of "San Francisco". Vercel percent-encodes its geolocation headers, and the city name was stored verbatim without decoding. Decode the city name where the Vercel geo header is read, so recorded sessions store the human-readable name. Falls back to the raw value if it isn't valid percent-encoding, so a stray "%" can't break things. --- apps/backend/src/lib/end-users.tsx | 58 ++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index 67aa08696e..d1409cad69 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -92,6 +92,16 @@ function parseCoordinate(raw: string | null | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +function decodeVercelGeoHeader(raw: string | null | undefined): string | undefined { + // An absent or empty geo header carries no city, so normalize both to undefined. + if (raw == null || raw === "") return undefined; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): | { maybeSpoofed: true, spoofedInfo: EndUserInfoInner } | { maybeSpoofed: false, exactInfo: EndUserInfoInner } @@ -133,7 +143,7 @@ function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): const geoLocation: EndUserLocation = { countryCode: rawCountryCode ? normalizeCountryCode(rawCountryCode) : undefined, regionCode: (isVercelTrusted ? allHeaders.get("x-vercel-ip-country-region") : undefined) || undefined, - cityName: (isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined) || undefined, + cityName: decodeVercelGeoHeader(isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined), latitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-latitude") : null), longitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-longitude") : null), tzIdentifier: (isVercelTrusted ? allHeaders.get("x-vercel-ip-timezone") : undefined) || undefined, @@ -144,7 +154,7 @@ function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy): const spoofedGeoLocation: EndUserLocation = trustedProxy === "" ? { countryCode: rawSpoofedCountryCode ? normalizeCountryCode(rawSpoofedCountryCode) : undefined, regionCode: allHeaders.get("x-vercel-ip-country-region") || undefined, - cityName: allHeaders.get("x-vercel-ip-city") || undefined, + cityName: decodeVercelGeoHeader(allHeaders.get("x-vercel-ip-city")), latitude: parseCoordinate(allHeaders.get("x-vercel-ip-latitude")), longitude: parseCoordinate(allHeaders.get("x-vercel-ip-longitude")), tzIdentifier: allHeaders.get("x-vercel-ip-timezone") || undefined, @@ -315,4 +325,48 @@ import.meta.vitest?.describe("getBrowserEndUserInfo(...)", () => { }, }); }); + + test("decodes URL-encoded city names from Vercel geo headers", () => { + // Vercel percent-encodes city names, so a multi-word city arrives as "San%20Francisco". + const result = getBrowserEndUserInfo(new Headers({ + "user-agent": "Mozilla/5.0", + "x-vercel-forwarded-for": "203.0.113.10", + "x-vercel-ip-country": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-city": "San%20Francisco", + "x-vercel-ip-latitude": "37.77", + "x-vercel-ip-longitude": "-122.41", + "x-vercel-ip-timezone": "America/Los_Angeles", + }), "vercel"); + + expect(result).toEqual({ + maybeSpoofed: false, + exactInfo: { + ip: "203.0.113.10", + countryCode: "US", + regionCode: "CA", + cityName: "San Francisco", + latitude: 37.77, + longitude: -122.41, + tzIdentifier: "America/Los_Angeles", + }, + }); + }); + + test("falls back to the raw city name when it is not valid percent-encoding", () => { + // A lone "%" is invalid percent-encoding; decoding must not throw, just pass it through. + const result = getBrowserEndUserInfo(new Headers({ + "user-agent": "Mozilla/5.0", + "x-vercel-forwarded-for": "203.0.113.10", + "x-vercel-ip-city": "100% Real City", + }), "vercel"); + + expect(result).toEqual({ + maybeSpoofed: false, + exactInfo: { + ip: "203.0.113.10", + cityName: "100% Real City", + }, + }); + }); }); From bec62797460a3896e5ad9ed237de5f7379db22b5 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Wed, 27 May 2026 14:38:28 -0700 Subject: [PATCH 2/2] minor change --- apps/backend/src/lib/end-users.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index d1409cad69..34dec474d5 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -93,7 +93,6 @@ function parseCoordinate(raw: string | null | undefined): number | undefined { } function decodeVercelGeoHeader(raw: string | null | undefined): string | undefined { - // An absent or empty geo header carries no city, so normalize both to undefined. if (raw == null || raw === "") return undefined; try { return decodeURIComponent(raw);