CVE-2022-39324: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: 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.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 3.5
Published: 2023-01-27T23:15Z
Repository: https://github.com/grafana/grafana
Language: Other
Files changed: packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx, pkg/api/dashboard_snapshot.go, public/app/features/dashboard/components/DashNav/DashNav.tsx, public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx
Commit date: 2022-12-13 05:48:54 -0800
Commit message: Snapshots: Build snapshot originalUrl on the backend  (#60232)

* Snapshots: Fix originalUrl spoof security issue

* Store relative URL only & validate UID

* use existing modal management tools

* Dummy commit to nudge CI

* Remove unused hooks file

* Fix import after backport

* Backport fixes

Co-authored-by: kay delaney <kay@grafana.com>
Diff: diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
index 86058fa945f..e7ce2635b1e 100644
--- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
+++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
@@ -19,12 +19,16 @@ export interface ConfirmModalProps {
   body: React.ReactNode;
   /** Modal description */
   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 */
   modalClass?: string;
   /** Text user needs to fill in before confirming */
@@ -45,12 +49,14 @@ export const ConfirmModal = ({
   isOpen,
   title,
   body,
   description,
   confirmText,
+  confirmVariant = 'destructive',
   confirmationText,
   dismissText = 'Cancel',
+  dismissVariant = 'secondary',
   alternativeText,
   modalClass,
   icon = 'exclamation-triangle',
   onConfirm,
   onDismiss,
@@ -83,11 +89,11 @@ export const ConfirmModal = ({
             </HorizontalGroup>
           </div>
         ) : null}
       </div>
       <Modal.ButtonRow>
-        <Button variant="secondary" onClick={onDismiss} fill="outline">
+        <Button variant={dismissVariant} onClick={onDismiss} fill="outline">
           {dismissText}
         </Button>
         <Button
           variant={confirmButtonVariant}
           onClick={onConfirm}
diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go
index afc8772f871..47ae50544ab 100644
--- a/pkg/api/dashboard_snapshot.go
+++ b/pkg/api/dashboard_snapshot.go
@@ -82,10 +82,19 @@ 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.
 //
 // Snapshot public mode should be enabled or authentication is required.
@@ -102,14 +111,18 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
 	}
 	if cmd.Name == "" {
 		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 {
 			c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
 			return nil
@@ -119,19 +132,21 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
 		if err != nil {
 			c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
 			return nil
 		}
 
-		url = response.Url
+		snapshotUrl = response.Url
 		cmd.Key = response.Key
 		cmd.DeleteKey = response.DeleteKey
 		cmd.ExternalUrl = response.Url
 		cmd.ExternalDeleteUrl = response.DeleteUrl
 		cmd.Dashboard = simplejson.New()
 
 		metrics.MApiDashboardSnapshotExternal.Inc()
 	} else {
+		cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL)
+
 		if cmd.Key == "" {
 			var err error
 			cmd.Key, err = util.GetRandomString(32)
 			if err != nil {
 				c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
@@ -146,11 +161,11 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
 				c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
 				return nil
 			}
 		}
 
-		url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
+		snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
 
 		metrics.MApiDashboardSnapshotCreate.Inc()
 	}
 
 	if err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil {
@@ -159,11 +174,11 @@ 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,
 	})
 	return nil
 }
diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx
index 5ffbef293d9..87b2dfbdf28 100644
--- a/public/app/features/dashboard/components/DashNav/DashNav.tsx
+++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx
@@ -1,5 +1,6 @@
+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';
 
 import { locationUtil, textUtil } from '@grafana/data';
@@ -12,24 +13,27 @@ import {
   PageToolbar,
   useForceUpdate,
   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';
 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';
 import { DashboardModel } from '../../state';
 
@@ -81,10 +85,49 @@ export const DashNav = React.memo<Props>((props) => {
   const { showModal, hideModal } = useContext(ModalsContext);
 
   // 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;
 
     dashboardSrv.starDashboard(dashboard.id, Boolean(dashboard.meta.isStarred)).then((newState) => {
@@ -314,11 +357,11 @@ export const DashNav = React.memo<Props>((props) => {
 
     if (snapshotUrl) {
       buttons.push(
         <ToolbarButton
           tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')}
-          onClick={() => gotoSnapshotOrigin(snapshotUrl)}
+          onClick={onOpenSnapshotOriginal}
           icon="link"
           key="button-snapshot"
         />
       );
     }
@@ -350,14 +393,10 @@ 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();
   const titleHref = locationUtil.getUrlForPartial(location, { search: 'open' });
   const parentHref = locationUtil.getUrlForPartial(location, { search: 'open', query: 'folder:current' });
@@ -393,5 +432,10 @@ export const DashNav = React.memo<Props>((props) => {
 });
 
 DashNav.displayName = 'DashNav';
 
 export default connector(DashNav);
+
+const modalStyles = css({
+  width: 'max-content',
+  maxWidth: '80vw',
+});
diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx
index a2282f748ce..a7bb549f9a4 100644
--- a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx
+++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx
@@ -84,14 +84,10 @@ export class ShareSnapshot extends PureComponent<Props, State> {
     const { timeoutSeconds } = this.state;
     this.dashboard.snapshot = {
       timestamp: new Date(),
     };
 
-    if (!external) {
-      this.dashboard.snapshot.originalUrl = window.location.href;
-    }
-
     this.setState({ isLoading: true });
     this.dashboard.startRefresh();
 
     setTimeout(() => {
       this.saveSnapshot(this.dashboard, external);

Vulnerable code:         <Button variant="secondary" onClick={onDismiss} fill="outline">
	var url string
		url = response.Url
		url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
		"url":       url,
import { DashboardMetaChangedEvent } from 'app/types/events';
          onClick={() => gotoSnapshotOrigin(snapshotUrl)}
  const gotoSnapshotOrigin = (snapshotUrl: string) => {
    window.location.href = textUtil.sanitizeUrl(snapshotUrl);
  };

    if (!external) {
      this.dashboard.snapshot.originalUrl = window.location.href;
    }

Fixed code:   /** Variant for confirm button */
  confirmVariant?: ButtonVariant;
  /** Variant for dismiss button */
  dismissVariant?: ButtonVariant;
  confirmVariant = 'destructive',
  dismissVariant = 'secondary',
        <Button variant={dismissVariant} onClick={onDismiss} fill="outline">
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
}

	var snapshotUrl string
	originalDashboardURL, err := createOriginalDashboardURL(hs.Cfg.AppURL, &cmd)
	if err != nil {
		return response.Error(http.StatusInternalServerError, "Invalid app URL", err)
	}
		snapshotUrl = response.Url
		cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL)

		snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
		"url":       snapshotUrl,
import { css } from '@emotion/css';
  ConfirmModal,
import { useAppNotification } from 'app/core/copy/appNotification';
import { appEvents } from 'app/core/core';
import { DashboardMetaChangedEvent, ShowModalReactEvent } from 'app/types/events';
  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);
    }
  };

          onClick={onOpenSnapshotOriginal}

const modalStyles = css({
  width: 'max-content',
  maxWidth: '80vw',
});