Grafana vulnerable to spoofing originalUrl of snapshots
Description
Grafana is an open-source platform for monitoring and observability. Prior to versions 8.5.16 and 9.2.8, malicious user can create a snapshot and arbitrarily choose the originalUrl parameter by editing the query, thanks to a web proxy. When another user opens the URL of the snapshot, they will be presented with the regular web interface delivered by the trusted Grafana server. The Open original dashboard button no longer points to the to the real original dashboard but to the attacker’s injected URL. This issue is fixed in versions 8.5.16 and 9.2.8.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/grafana/grafanaGo | >= 9.0.0, < 9.2.8 | 9.2.8 |
github.com/grafana/grafanaGo | < 8.5.16 | 8.5.16 |
Affected products
1Patches
2d7dcea71ea76[v9.2.x] Snapshots: Build snapshot originalUrl on the backend (#60232) (#60256)
4 files changed · +78 −17
packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx+9 −3 modified@@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { HorizontalGroup, Input } from '..'; import { useStyles2 } from '../../themes'; import { IconName } from '../../types/icon'; -import { Button } from '../Button'; +import { Button, ButtonVariant } from '../Button'; import { Modal } from '../Modal/Modal'; export interface ConfirmModalProps { @@ -21,8 +21,12 @@ export interface ConfirmModalProps { description?: React.ReactNode; /** Text for confirm button */ confirmText: string; + /** Variant for confirm button */ + confirmVariant?: ButtonVariant; /** Text for dismiss button */ dismissText?: string; + /** Variant for dismiss button */ + dismissVariant?: ButtonVariant; /** Icon for the modal header */ icon?: IconName; /** Text user needs to fill in before confirming */ @@ -43,8 +47,10 @@ export const ConfirmModal = ({ body, description, confirmText, + confirmVariant = 'destructive', confirmationText, dismissText = 'Cancel', + dismissVariant = 'secondary', alternativeText, icon = 'exclamation-triangle', onConfirm, @@ -79,11 +85,11 @@ export const ConfirmModal = ({ ) : null} </div> <Modal.ButtonRow> - <Button variant="secondary" onClick={onDismiss} fill="outline"> + <Button variant={dismissVariant} onClick={onDismiss} fill="outline"> {dismissText} </Button> <Button - variant="destructive" + variant={confirmVariant} onClick={onConfirm} disabled={disabled} ref={buttonRef}
pkg/api/dashboard_snapshot.go+19 −4 modified@@ -84,6 +84,15 @@ func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnaps return &createSnapshotResponse, nil } +func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { + dashUID := cmd.Dashboard.Get("uid").MustString("") + if ok := util.IsValidShortUID(dashUID); !ok { + return "", fmt.Errorf("invalid dashboard UID") + } + + return fmt.Sprintf("/d/%v", dashUID), nil +} + // swagger:route POST /snapshots snapshots createDashboardSnapshot // // When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI. @@ -104,10 +113,14 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res cmd.Name = "Unnamed snapshot" } - var url string + var snapshotUrl string cmd.ExternalUrl = "" cmd.OrgId = c.OrgID cmd.UserId = c.UserID + originalDashboardURL, err := createOriginalDashboardURL(hs.Cfg.AppURL, &cmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Invalid app URL", err) + } if cmd.External { if !setting.ExternalEnabled { @@ -121,7 +134,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res return nil } - url = response.Url + snapshotUrl = response.Url cmd.Key = response.Key cmd.DeleteKey = response.DeleteKey cmd.ExternalUrl = response.Url @@ -130,6 +143,8 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res metrics.MApiDashboardSnapshotExternal.Inc() } else { + cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL) + if cmd.Key == "" { var err error cmd.Key, err = util.GetRandomString(32) @@ -148,7 +163,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res } } - url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) + snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) metrics.MApiDashboardSnapshotCreate.Inc() } @@ -161,7 +176,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res c.JSON(http.StatusOK, util.DynMap{ "key": cmd.Key, "deleteKey": cmd.DeleteKey, - "url": url, + "url": snapshotUrl, "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey), "id": cmd.Result.Id, })
public/app/features/dashboard/components/DashNav/DashNav.tsx+50 −6 modified@@ -1,3 +1,4 @@ +import { css } from '@emotion/css'; import { t, Trans } from '@lingui/macro'; import React, { FC, ReactNode } from 'react'; import { connect, ConnectedProps } from 'react-redux'; @@ -14,19 +15,22 @@ import { useForceUpdate, Tag, ToolbarButtonRow, + ConfirmModal, } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator'; import config from 'app/core/config'; import { useGrafana } from 'app/core/context/GrafanaContext'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { appEvents } from 'app/core/core'; import { useBusEvent } from 'app/core/hooks/useBusEvent'; import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal'; import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { ShareModal } from 'app/features/dashboard/components/ShareModal'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { KioskMode } from 'app/types'; -import { DashboardMetaChangedEvent } from 'app/types/events'; +import { DashboardMetaChangedEvent, ShowModalReactEvent } from 'app/types/events'; import { setStarred } from '../../../../core/reducers/navBarTree'; import { getDashboardSrv } from '../../services/DashboardSrv'; @@ -80,6 +84,45 @@ export const DashNav = React.memo<Props>((props) => { // We don't really care about the event payload here only that it triggeres a re-render of this component useBusEvent(props.dashboard.events, DashboardMetaChangedEvent); + const originalUrl = props.dashboard.snapshot?.originalUrl ?? ''; + const gotoSnapshotOrigin = () => { + window.location.href = textUtil.sanitizeUrl(props.dashboard.snapshot.originalUrl); + }; + + const notifyApp = useAppNotification(); + const onOpenSnapshotOriginal = () => { + try { + const sanitizedUrl = new URL(textUtil.sanitizeUrl(originalUrl), config.appUrl); + const appUrl = new URL(config.appUrl); + if (sanitizedUrl.host !== appUrl.host) { + appEvents.publish( + new ShowModalReactEvent({ + component: ConfirmModal, + props: { + title: 'Proceed to external site?', + modalClass: modalStyles, + body: ( + <> + <p> + {`This link connects to an external website at`} <code>{originalUrl}</code> + </p> + <p>{"Are you sure you'd like to proceed?"}</p> + </> + ), + confirmVariant: 'primary', + confirmText: 'Proceed', + onConfirm: gotoSnapshotOrigin, + }, + }) + ); + } else { + gotoSnapshotOrigin(); + } + } catch (err) { + notifyApp.error('Invalid URL', err instanceof Error ? err.message : undefined); + } + }; + const onStarDashboard = () => { const dashboardSrv = getDashboardSrv(); const { dashboard, setStarred } = props; @@ -294,7 +337,7 @@ export const DashNav = React.memo<Props>((props) => { buttons.push( <ToolbarButton tooltip={t({ id: 'dashboard.toolbar.open-original', message: 'Open original dashboard' })} - onClick={() => gotoSnapshotOrigin(snapshotUrl)} + onClick={onOpenSnapshotOriginal} icon="link" key="button-snapshot" /> @@ -319,10 +362,6 @@ export const DashNav = React.memo<Props>((props) => { return buttons; }; - const gotoSnapshotOrigin = (snapshotUrl: string) => { - window.location.href = textUtil.sanitizeUrl(snapshotUrl); - }; - const { isFullscreen, title, folderTitle } = props; // this ensures the component rerenders when the location changes const location = useLocation(); @@ -362,3 +401,8 @@ export const DashNav = React.memo<Props>((props) => { DashNav.displayName = 'DashNav'; export default connector(DashNav); + +const modalStyles = css({ + width: 'max-content', + maxWidth: '80vw', +});
public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx+0 −4 modified@@ -98,10 +98,6 @@ export class ShareSnapshot extends PureComponent<Props, State> { timestamp: new Date(), }; - if (!external) { - this.dashboard.snapshot.originalUrl = window.location.href; - } - this.setState({ isLoading: true }); this.dashboard.startRefresh();
239888f22983Snapshots: Build snapshot originalUrl on the backend (#60232)
4 files changed · +76 −15
packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx+7 −1 modified@@ -21,8 +21,12 @@ export interface ConfirmModalProps { description?: React.ReactNode; /** Text for confirm button */ confirmText: string; + /** Variant for confirm button */ + confirmVariant?: ButtonVariant; /** Text for dismiss button */ dismissText?: string; + /** Variant for dismiss button */ + dismissVariant?: ButtonVariant; /** Icon for the modal header */ icon?: IconName; /** Additional styling for modal container */ @@ -47,8 +51,10 @@ export const ConfirmModal = ({ body, description, confirmText, + confirmVariant = 'destructive', confirmationText, dismissText = 'Cancel', + dismissVariant = 'secondary', alternativeText, modalClass, icon = 'exclamation-triangle', @@ -85,7 +91,7 @@ export const ConfirmModal = ({ ) : null} </div> <Modal.ButtonRow> - <Button variant="secondary" onClick={onDismiss} fill="outline"> + <Button variant={dismissVariant} onClick={onDismiss} fill="outline"> {dismissText} </Button> <Button
pkg/api/dashboard_snapshot.go+19 −4 modified@@ -84,6 +84,15 @@ func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnaps return &createSnapshotResponse, nil } +func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { + dashUID := cmd.Dashboard.Get("uid").MustString("") + if ok := util.IsValidShortUID(dashUID); !ok { + return "", fmt.Errorf("invalid dashboard UID") + } + + return fmt.Sprintf("/d/%v", dashUID), nil +} + // swagger:route POST /snapshots snapshots createDashboardSnapshot // // When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI. @@ -104,10 +113,14 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res cmd.Name = "Unnamed snapshot" } - var url string + var snapshotUrl string cmd.ExternalUrl = "" cmd.OrgId = c.OrgID cmd.UserId = c.UserID + originalDashboardURL, err := createOriginalDashboardURL(hs.Cfg.AppURL, &cmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Invalid app URL", err) + } if cmd.External { if !setting.ExternalEnabled { @@ -121,7 +134,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res return nil } - url = response.Url + snapshotUrl = response.Url cmd.Key = response.Key cmd.DeleteKey = response.DeleteKey cmd.ExternalUrl = response.Url @@ -130,6 +143,8 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res metrics.MApiDashboardSnapshotExternal.Inc() } else { + cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL) + if cmd.Key == "" { var err error cmd.Key, err = util.GetRandomString(32) @@ -148,7 +163,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res } } - url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) + snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) metrics.MApiDashboardSnapshotCreate.Inc() } @@ -161,7 +176,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res c.JSON(http.StatusOK, util.DynMap{ "key": cmd.Key, "deleteKey": cmd.DeleteKey, - "url": url, + "url": snapshotUrl, "deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey), "id": cmd.Result.Id, })
public/app/features/dashboard/components/DashNav/DashNav.tsx+50 −6 modified@@ -1,3 +1,4 @@ +import { css } from '@emotion/css'; import React, { FC, ReactNode, useContext, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useLocation } from 'react-router-dom'; @@ -14,11 +15,14 @@ import { Tag, ToolbarButtonRow, ModalsContext, + ConfirmModal, } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator'; import config from 'app/core/config'; import { useGrafana } from 'app/core/context/GrafanaContext'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { appEvents } from 'app/core/core'; import { useBusEvent } from 'app/core/hooks/useBusEvent'; import { t, Trans } from 'app/core/internationalization'; import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal'; @@ -27,7 +31,7 @@ import { ShareModal } from 'app/features/dashboard/components/ShareModal'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { KioskMode } from 'app/types'; -import { DashboardMetaChangedEvent } from 'app/types/events'; +import { DashboardMetaChangedEvent, ShowModalReactEvent } from 'app/types/events'; import { setStarred } from '../../../../core/reducers/navBarTree'; import { getDashboardSrv } from '../../services/DashboardSrv'; @@ -83,6 +87,45 @@ export const DashNav = React.memo<Props>((props) => { // We don't really care about the event payload here only that it triggeres a re-render of this component useBusEvent(props.dashboard.events, DashboardMetaChangedEvent); + const originalUrl = props.dashboard.snapshot?.originalUrl ?? ''; + const gotoSnapshotOrigin = () => { + window.location.href = textUtil.sanitizeUrl(props.dashboard.snapshot.originalUrl); + }; + + const notifyApp = useAppNotification(); + const onOpenSnapshotOriginal = () => { + try { + const sanitizedUrl = new URL(textUtil.sanitizeUrl(originalUrl), config.appUrl); + const appUrl = new URL(config.appUrl); + if (sanitizedUrl.host !== appUrl.host) { + appEvents.publish( + new ShowModalReactEvent({ + component: ConfirmModal, + props: { + title: 'Proceed to external site?', + modalClass: modalStyles, + body: ( + <> + <p> + {`This link connects to an external website at`} <code>{originalUrl}</code> + </p> + <p>{"Are you sure you'd like to proceed?"}</p> + </> + ), + confirmVariant: 'primary', + confirmText: 'Proceed', + onConfirm: gotoSnapshotOrigin, + }, + }) + ); + } else { + gotoSnapshotOrigin(); + } + } catch (err) { + notifyApp.error('Invalid URL', err instanceof Error ? err.message : undefined); + } + }; + const onStarDashboard = () => { const dashboardSrv = getDashboardSrv(); const { dashboard, setStarred } = props; @@ -316,7 +359,7 @@ export const DashNav = React.memo<Props>((props) => { buttons.push( <ToolbarButton tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')} - onClick={() => gotoSnapshotOrigin(snapshotUrl)} + onClick={onOpenSnapshotOriginal} icon="link" key="button-snapshot" /> @@ -352,10 +395,6 @@ export const DashNav = React.memo<Props>((props) => { return buttons; }; - const gotoSnapshotOrigin = (snapshotUrl: string) => { - window.location.href = textUtil.sanitizeUrl(snapshotUrl); - }; - const { isFullscreen, title, folderTitle } = props; // this ensures the component rerenders when the location changes const location = useLocation(); @@ -395,3 +434,8 @@ export const DashNav = React.memo<Props>((props) => { DashNav.displayName = 'DashNav'; export default connector(DashNav); + +const modalStyles = css({ + width: 'max-content', + maxWidth: '80vw', +});
public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx+0 −4 modified@@ -86,10 +86,6 @@ export class ShareSnapshot extends PureComponent<Props, State> { timestamp: new Date(), }; - if (!external) { - this.dashboard.snapshot.originalUrl = window.location.href; - } - this.setState({ isLoading: true }); this.dashboard.startRefresh();
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-4724-7jwc-3fpwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-39324ghsaADVISORY
- github.com/grafana/grafana/commit/239888f22983010576bb3a9135a7294e88c0c74aghsax_refsource_MISCWEB
- github.com/grafana/grafana/commit/d7dcea71ea763780dc286792a0afd560bff2985cghsax_refsource_MISCWEB
- github.com/grafana/grafana/pull/60232ghsax_refsource_MISCWEB
- github.com/grafana/grafana/pull/60256ghsax_refsource_MISCWEB
- github.com/grafana/grafana/security/advisories/GHSA-4724-7jwc-3fpwghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.