From df63f2caacdbdf750bce27f26850028fc5971129 Mon Sep 17 00:00:00 2001 From: rohitneharabrowserstack Date: Tue, 25 Aug 2026 18:17:53 +0530 Subject: [PATCH] fix(security): RQ-3841 read the auth token from the URL fragment Prepares the client for requestly-cloud moving the Firebase custom token out of the query string. Ship and deploy this BEFORE the server change: the server half switches the token to the fragment, and a client that only reads window.location.search would break every OAuth login. - accessToken is now read from the fragment first, falling back to the query string. The fallback keeps this deploy-order-independent and covers cached or in-flight callback URLs; it can be dropped once the server change is live everywhere. - clearAccessTokenFromUrl() rewrites the address bar via replaceState once the token is captured, so the credential is not left visible to be copied, bookmarked, or read by a browser extension. It strips the whole fragment plus a legacy query accessToken, preserving every other param the flow reads (isNewUser, redirectURL). - The token is captured in a useMemo on mount, so clearing the URL afterwards cannot null it out mid-flow. Why the fragment: browsers never send it to a server, so the token stays out of Referer headers (this page loads Amplitude / Sentry / GrowthBook), out of GCP and CDN access logs, and out of anything else that records request URLs. Verified: file parses clean (tsc transpile, 0 syntax diagnostics); fragment round-trip checked with a token containing '+', '/' and '=' -- URLSearchParams percent-encodes on write and decodes on read, which naive concatenation would corrupt. getDesktopAppAuthParams reads localStorage, not the URL, so the desktop auth flow is unaffected by clearing the address bar. Server half: requestly-cloud RQ-3841. Refs RQ-3841 Co-Authored-By: Claude Opus 5 (1M context) --- .../authentication/LoginHandler/index.tsx | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/app/src/components/authentication/LoginHandler/index.tsx b/app/src/components/authentication/LoginHandler/index.tsx index 0689a3f591..913b8b7c24 100644 --- a/app/src/components/authentication/LoginHandler/index.tsx +++ b/app/src/components/authentication/LoginHandler/index.tsx @@ -24,6 +24,25 @@ const ARGUMENTS = { ACCESS_TOKEN: "accessToken", }; +/* + RQ-3841: remove the token from the visible URL without adding a history entry. Strips + the whole fragment (it only ever carries the token) and any legacy accessToken still + arriving in the query string, while preserving every other param the flow reads. +*/ +const clearAccessTokenFromUrl = () => { + try { + const url = new URL(window.location.href); + if (!url.hash && !url.searchParams.has(ARGUMENTS.ACCESS_TOKEN)) { + return; + } + url.hash = ""; + url.searchParams.delete(ARGUMENTS.ACCESS_TOKEN); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + } catch (error) { + Logger.log("[LoginHandler-clearAccessTokenFromUrl] catch", { error }); + } +}; + const LoginHandler: React.FC = () => { const dispatch = useDispatch(); const navigate = useNavigate(); @@ -37,6 +56,21 @@ const LoginHandler: React.FC = () => { const params = useMemo(() => new URLSearchParams(window.location.search), []); const isNewUser = params.get("isNewUser") === "true"; + /* + RQ-3841: the custom token arrives in the URL fragment, which browsers never send to a + server -- so it stays out of Referer headers (this page loads Amplitude / Sentry / + GrowthBook), out of GCP + CDN access logs, and out of anything else recording URLs. + The query-string read is a compatibility fallback: requestly-cloud only moves the + token to the fragment in a later deploy, and older/cached callback URLs may still + carry it in the query. Safe to drop once that has shipped everywhere. + Read once on mount -- the token is consumed immediately below, and clearAccessTokenFromUrl + rewrites the address bar afterwards. + */ + const accessToken = useMemo(() => { + const hash = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash; + return new URLSearchParams(hash).get(ARGUMENTS.ACCESS_TOKEN) ?? params.get(ARGUMENTS.ACCESS_TOKEN); + }, [params]); + const postLoginDesktopAppRedirect = useCallback(() => { let desktopAuthParams = getDesktopAppAuthParams(); @@ -125,7 +159,6 @@ const LoginHandler: React.FC = () => { } isAuthenticationAttempted.current = true; - const accessToken = params.get(ARGUMENTS.ACCESS_TOKEN); if (!accessToken) { // this route is only meant to be accessed programmatically redirectToHome(appMode, navigate); @@ -144,6 +177,9 @@ const LoginHandler: React.FC = () => { } const redirectMetadata = getRedirectMetadata(); + // RQ-3841: token is in hand -- drop it from the address bar so it is not left in + // the visible URL to be copied, bookmarked, or read by an extension. + clearAccessTokenFromUrl(); const auth = getAuth(firebaseApp); signInWithCustomToken(auth, accessToken) .then((result) => { @@ -183,7 +219,7 @@ const LoginHandler: React.FC = () => { // todo: setup error monitoring setLoginComplete(true); }); - }, [params, user.loggedIn, loginComplete, navigate, appMode, dispatch, isNewUser]); + }, [accessToken, params, user.loggedIn, loginComplete, navigate, appMode, dispatch, isNewUser]); return ; };