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>
This commit is contained in:
Dominik Prokop
2022-12-13 05:48:54 -08:00
committed by GitHub
parent f864be5024
commit 239888f229
4 changed files with 76 additions and 15 deletions

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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',
});

View File

@@ -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();