From 5b9ae9c44320c496979e907fc0b885c17b614791 Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Thu, 28 May 2026 11:50:00 -0700 Subject: [PATCH] fix(account-settings): decode URL-encoded city in active sessions (#1503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The **Active Sessions** table in account settings showed locations like `San%20Francisco` instead of `San Francisco`. Vercel percent-encodes its geolocation headers (e.g. `x-vercel-ip-city`), so a multi-word city arrives URL-encoded. The city name was being stored verbatim, so the raw `%20` leaked into the UI. The fix decodes the city name where the Vercel geo header is read, so recorded sessions store the human-readable name. This also benefits any other consumer of the location data. It falls back to the raw value if it isn't valid percent-encoding, so a stray `%` can't break things. ## Test plan - [ ] Unit tests (in-source, `apps/backend/src/lib/end-users.tsx`): simulating Vercel headers with `x-vercel-ip-city: San%20Francisco` now yields `cityName: "San Francisco"`; an invalid-encoding value (`100% Real City`) passes through unchanged instead of throwing. All 8 tests in the file pass. - [ ] In a Vercel-deployed environment, sign in and open Account Settings → Active Sessions; confirm the Location column shows a plain city name (e.g. `San Francisco`) with no `%20`. > Note: this can't be reproduced on localhost because there's no Vercel proxy supplying geo headers (the location shows `Unknown`). The behavior is covered by the unit tests, which feed the exact headers Vercel sends. ## Summary by CodeRabbit * **Bug Fixes** * Corrected handling of city name data from hosting-provided location headers so multi-word city names display correctly and invalid percent-encoding no longer causes errors. * **Tests** * Added tests to verify URL-decoded city names from location headers and to ensure malformed encodings are safely preserved. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1503?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- apps/backend/src/lib/end-users.tsx | 57 ++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index 67aa08696..34dec474d 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -92,6 +92,15 @@ function parseCoordinate(raw: string | null | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +function decodeVercelGeoHeader(raw: string | null | undefined): string | 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 +142,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 +153,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 +324,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", + }, + }); + }); });