Open Redirect / Cross-origin Redirect

HIGH
grafana/grafana
Commit: 3727e122ed2d
Affected: Grafana 12.x prior to this fix (pre-commit in the 12.x line; the tracked version is 12.4.0)
2026-04-28 18:43 UTC

Description

Open Redirect / Cross-origin Redirect vulnerability in login redirect handling. Prior to this fix, a redirectTo parameter could cause a logged-in user to be redirected to an attacker-controlled origin after authentication, enabling open redirect and potential token leakage or phishing. The commit adds strict origin validation for redirects, only allowing same-origin redirects or internal /goto/ navigation, and it uses URL parsing with a try-catch safeguard. It also ensures cross-origin redirects are not performed via the frontend navigation path and handles normalization/stripping of the redirect target for internal routing. This mitigates cross-origin redirection attacks following login.

Proof of Concept

PoC (demonstrates exploitation on a vulnerable Grafana 12.x instance pre-fix). Attack scenario: - The attacker crafts a link to the Grafana login page with a redirectTo parameter that points to a cross-origin domain under attacker control. - The user has a valid session or signs in through that link. - After successful authentication, Grafana would redirect the user to the target specified by redirectTo, which in an unpatched system could be a cross-origin site controlled by the attacker. Example vulnerable URL (pre-fix behavior): https://grafana.example/login?redirectTo=https%3A%2F%2Fevil.example%2Fsteal%3ForgId%3D2 Expected post-login behavior on a vulnerable system: - The app would navigate the user to https://evil.example/steal?orgId=2 (cross-origin), potentially exposing tokens or credentials. Patch impact (after fix): - The redirect is validated for same-origin or internal routing (e.g., /goto/ paths). Cross-origin redirect targets are not performed via the frontend; the app will either redirect internally or block the cross-origin redirect, preventing token leakage/phishing. Notes: - This PoC assumes an active Grafana session and a redirectTo parameter present in the login URL. The exact tokens/parameters depend on the environment and may be included in the redirect URL. - Always test in a controlled environment and with proper authorization.

Commit Details

Author: Ryan Melendez

Date: 2026-04-28 18:01 UTC

Message:

Frontend: Fix bug for crossorg redirect after login (#123078) * Fix bug for crossorg redirect after login * avoid redirects to other origins * extract handleRedirectTo to create a cleaner test seam and add tests * address feedback on if-condition * add some basic tests to restore test coverage * use try-catch instead of URL.canParse

Triage Assessment

Vulnerability Type: Open Redirect / Cross-origin Redirect

Confidence: HIGH

Reasoning:

The commit introduces a redirect handling mechanism that blocks redirects to other origins, validates and normalizes redirect targets, and prevents redirection after login to potentially malicious sites. It explicitly handles cross-origin cases and uses strict checks (same-origin validation, try-catch URL parsing) to mitigate open redirect/Cross-origin redirect vulnerabilities.

Verification Assessment

Vulnerability Type: Open Redirect / Cross-origin Redirect

Confidence: HIGH

Affected Versions: Grafana 12.x prior to this fix (pre-commit in the 12.x line; the tracked version is 12.4.0)

Code Diff

diff --git a/public/app/app.ts b/public/app/app.ts index 5172a1cfbe338..45f2271396390 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -76,11 +76,12 @@ import { NAMESPACES, GRAFANA_NAMESPACE } from './core/internationalization/const import { loadTranslations } from './core/internationalization/loadTranslations'; import { postInitTasks, preInitTasks } from './core/lifecycle-hooks'; import { setMonacoEnv } from './core/monacoEnv'; +import { handleRedirectTo } from './core/navigation/handleRedirectTo'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { CorrelationsService } from './core/services/CorrelationsService'; import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; -import { contextSrv, RedirectToUrlKey } from './core/services/context_srv'; +import { contextSrv } from './core/services/context_srv'; import { initEchoSrv } from './core/services/echo/init'; import { KeybindingSrv } from './core/services/keybindingSrv'; import { startMeasure, stopMeasure } from './core/utils/metrics'; @@ -335,44 +336,4 @@ function initExtensions() { } } -function handleRedirectTo(): void { - const queryParams = locationService.getSearch(); - const redirectToParamKey = 'redirectTo'; - - if (queryParams.has('auth_token')) { - // URL Login should not be redirected - window.sessionStorage.removeItem(RedirectToUrlKey); - return; - } - - if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') { - const rawRedirectTo = queryParams.get(redirectToParamKey)!; - window.sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent(rawRedirectTo)); - queryParams.delete(redirectToParamKey); - window.history.replaceState({}, '', `${window.location.pathname}${queryParams.size > 0 ? `?${queryParams}` : ''}`); - return; - } - - if (!contextSrv.user.isSignedIn) { - return; - } - - const redirectTo = window.sessionStorage.getItem(RedirectToUrlKey); - if (!redirectTo) { - return; - } - - window.sessionStorage.removeItem(RedirectToUrlKey); - let decodedRedirectTo = decodeURIComponent(redirectTo); - if (decodedRedirectTo.startsWith('/goto/')) { - // In this case there should be a request to the backend - const urlToRedirectTo = locationUtil.assureBaseUrl(decodedRedirectTo); - window.location.replace(urlToRedirectTo); - return; - } - // Ensure that the appsuburl is stripped from the redirect to in case of a frontend redirect - const stripped = locationUtil.stripBaseFromUrl(decodedRedirectTo); - locationService.replace(stripped); -} - export default new GrafanaApp(); diff --git a/public/app/core/navigation/handleRedirectTo.test.ts b/public/app/core/navigation/handleRedirectTo.test.ts new file mode 100644 index 0000000000000..27949f8471dc8 --- /dev/null +++ b/public/app/core/navigation/handleRedirectTo.test.ts @@ -0,0 +1,141 @@ +import { type GrafanaConfig, locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +import { contextSrv, RedirectToUrlKey } from '../services/context_srv'; + +import { handleRedirectTo } from './handleRedirectTo'; + +describe('handleRedirectTo', () => { + // These tests replace shared globals and singleton methods, so keep the originals to restore test isolation. + const originalLocation = window.location; + const originalReplace = locationService.replace; + const originalGetSearch = locationService.getSearch; + const originalIsSignedIn = contextSrv.user.isSignedIn; + const originalOrgId = contextSrv.user.orgId; + + beforeEach(() => { + sessionStorage.clear(); + + locationUtil.initialize({ + config: { appSubUrl: '/grafana' } as GrafanaConfig, + getVariablesUrlParams: jest.fn(), + getTimeRangeForUrl: jest.fn(), + }); + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + origin: 'http://grafana.local', + pathname: '/', + replace: jest.fn(), + }, + }); + + const mockLocationService = locationService as jest.Mocked<typeof locationService>; + mockLocationService.replace = jest.fn(); + mockLocationService.getSearch = jest.fn().mockReturnValue(new URLSearchParams()); + + contextSrv.user.isSignedIn = false; + contextSrv.user.orgId = 1; + }); + + afterEach(() => { + locationService.replace = originalReplace; + locationService.getSearch = originalGetSearch; + contextSrv.user.isSignedIn = originalIsSignedIn; + contextSrv.user.orgId = originalOrgId; + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + jest.restoreAllMocks(); + }); + + it('clears the stored redirect when handling URL login', () => { + const mockLocationService = locationService as jest.Mocked<typeof locationService>; + mockLocationService.getSearch.mockReturnValue(new URLSearchParams('auth_token=test-token')); + sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent('/grafana/d/test?orgId=2')); + + handleRedirectTo(); + + expect(sessionStorage.getItem(RedirectToUrlKey)).toBeNull(); + expect(window.location.replace).not.toHaveBeenCalled(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('stores redirectTo from non-root paths and removes it from the URL', () => { + const mockLocationService = locationService as jest.Mocked<typeof locationService>; + const replaceStateSpy = jest.spyOn(window.history, 'replaceState'); + const queryParams = new URLSearchParams(); + queryParams.set('redirectTo', '/grafana/d/test?orgId=2'); + queryParams.set('foo', 'bar'); + mockLocationService.getSearch.mockReturnValue(queryParams); + + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...window.location, + pathname: '/login', + }, + }); + + handleRedirectTo(); + + expect(sessionStorage.getItem(RedirectToUrlKey)).toBe(encodeURIComponent('/grafana/d/test?orgId=2')); + expect(queryParams.has('redirectTo')).toBe(false); + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/login'); + expect(window.location.replace).not.toHaveBeenCalled(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('does not consume the stored redirect before the user is signed in', () => { + sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent('/grafana/d/test?orgId=2')); + + handleRedirectTo(); + + expect(sessionStorage.getItem(RedirectToUrlKey)).toBe(encodeURIComponent('/grafana/d/test?orgId=2')); + expect(window.location.replace).not.toHaveBeenCalled(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('hard redirects goto URLs through the backend', () => { + contextSrv.user.isSignedIn = true; + sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent('/goto/abc123')); + + handleRedirectTo(); + + expect(window.location.replace).toHaveBeenCalledWith('/grafana/goto/abc123'); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('does not hard redirect cross-origin URLs even when orgId changes', () => { + contextSrv.user.isSignedIn = true; + contextSrv.user.orgId = 1; + sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent('https://evil.com/d/test?orgId=2')); + + handleRedirectTo(); + + expect(window.location.replace).not.toHaveBeenCalled(); + expect(locationService.replace).toHaveBeenCalledWith('https://evil.com/d/test?orgId=2'); + }); + + it('hard redirects same-origin URLs that switch orgs', () => { + contextSrv.user.isSignedIn = true; + contextSrv.user.orgId = 1; + sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent('/grafana/d/test?orgId=2')); + + handleRedirectTo(); + + expect(window.location.replace).toHaveBeenCalledWith('/grafana/d/test?orgId=2'); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('falls back to frontend navigation for same-origin redirects in the current org', () => { + contextSrv.user.isSignedIn = true; + contextSrv.user.orgId = 1; + sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent('/grafana/d/test?orgId=1')); + + handleRedirectTo(); + + expect(window.location.replace).not.toHaveBeenCalled(); + expect(locationService.replace).toHaveBeenCalledWith('/d/test?orgId=1'); + }); +}); diff --git a/public/app/core/navigation/handleRedirectTo.ts b/public/app/core/navigation/handleRedirectTo.ts new file mode 100644 index 0000000000000..f89a6a06da5a6 --- /dev/null +++ b/public/app/core/navigation/handleRedirectTo.ts @@ -0,0 +1,70 @@ +import { locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +import { contextSrv, RedirectToUrlKey } from '../services/context_srv'; + +const redirectToParamKey = 'redirectTo'; + +export function handleRedirectTo(): void { + const queryParams = locationService.getSearch(); + + if (queryParams.has('auth_token')) { + // URL Login should not be redirected + window.sessionStorage.removeItem(RedirectToUrlKey); + return; + } + + if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') { + const rawRedirectTo = queryParams.get(redirectToParamKey)!; + window.sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent(rawRedirectTo)); + queryParams.delete(redirectToParamKey); + window.history.replaceState({}, '', `${window.location.pathname}${queryParams.size > 0 ? `?${queryParams}` : ''}`); + return; + } + + if (!contextSrv.user.isSignedIn) { + return; + } + + const redirectTo = window.sessionStorage.getItem(RedirectToUrlKey); + if (!redirectTo) { + return; + } + + window.sessionStorage.removeItem(RedirectToUrlKey); + const decodedRedirectTo = decodeURIComponent(redirectTo); + + if (decodedRedirectTo.startsWith('/goto/')) { + // In this case there should be a request to the backend + const urlToRedirectTo = locationUtil.assureBaseUrl(decodedRedirectTo); + window.location.replace(urlToRedirectTo); + return; + } + + let redirectUrl: URL | undefined; + + try { + redirectUrl = new URL(decodedRedirectTo, window.location.origin); + } catch {} + + // Only allow same-origin redirects to avoid an open redirect via the redirectTo query param. + // Note that `window.location.origin` is only used in `new URL()` if the first param isn't + // an absolute URL. + if (redirectUrl?.origin === window.location.origin) { + const redirectOrgId = redirectUrl.searchParams.get('orgId'); + + if (redirectOrgId) { + const targetOrgId = Number(redirectOrgId); + + if (Number.isFinite(targetOrgId) && targetOrgId !== contextSrv.user.orgId) { + const urlToRedirectTo = locationUtil.assureBaseUrl(decodedRedirectTo); + window.location.replace(urlToRedirectTo); + return; + } + } + } + + // Ensure that the appSubUrl is stripped from the redirect to in case of a frontend redirect + const stripped = locationUtil.stripBaseFromUrl(decodedRedirectTo); + locationService.replace(stripped); +}
← Back to Alerts View on GitHub →