VYPR
Moderate severityNVD Advisory· Published Jan 27, 2023· Updated Jan 28, 2026

Grafana vulnerable to spoofing originalUrl of snapshots

CVE-2022-39324

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.

PackageAffected versionsPatched versions
github.com/grafana/grafanaGo
>= 9.0.0, < 9.2.89.2.8
github.com/grafana/grafanaGo
< 8.5.168.5.16

Affected products

1

Patches

2
d7dcea71ea76

[v9.2.x] Snapshots: Build snapshot originalUrl on the backend (#60232) (#60256)

https://github.com/grafana/grafanaAlexa VDec 13, 2022via ghsa
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();
     
    
239888f22983

Snapshots: Build snapshot originalUrl on the backend (#60232)

https://github.com/grafana/grafanaDominik ProkopDec 13, 2022via ghsa
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

News mentions

0

No linked articles in our index yet.