Open Redirect / Cross-origin Redirect
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);
+}