fix(account-settings): decode URL-encoded city in active sessions (#1503)

## 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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_stack_entry_start -->

[![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)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
aadesh18 2026-05-28 11:50:00 -07:00 committed by GitHub
parent 80d1530b48
commit 5b9ae9c443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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",
},
});
});
});