mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add useReturnTo hook to safely handle returnTo parameter (#96474)
Add useReturnTo hook to safely handle returnTo parameter Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
parent
8375fcd350
commit
54cc666aa0
@ -29,9 +29,11 @@ export const LogMessages = {
|
|||||||
loadedCentralAlertStateHistory: 'loaded central alert state history',
|
loadedCentralAlertStateHistory: 'loaded central alert state history',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { logInfo, logError, logMeasurement } = createMonitoringLogger('features.alerting', { module: 'Alerting' });
|
const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger('features.alerting', {
|
||||||
|
module: 'Alerting',
|
||||||
|
});
|
||||||
|
|
||||||
export { logError, logInfo, logMeasurement };
|
export { logError, logInfo, logMeasurement, logWarning };
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
|
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
|
||||||
|
@ -36,6 +36,7 @@ import {
|
|||||||
import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
|
import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
|
||||||
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
|
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
|
||||||
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
||||||
|
import { useReturnTo } from '../../../hooks/useReturnTo';
|
||||||
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
|
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
|
||||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||||
import { DataSourceType } from '../../../utils/datasource';
|
import { DataSourceType } from '../../../utils/datasource';
|
||||||
@ -84,6 +85,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
|||||||
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
|
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
|
||||||
const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
|
const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
|
||||||
|
|
||||||
|
const { returnTo } = useReturnTo();
|
||||||
const routeParams = useParams<{ type: string; id: string }>();
|
const routeParams = useParams<{ type: string; id: string }>();
|
||||||
const ruleType = translateRouteParamToRuleType(routeParams.type);
|
const ruleType = translateRouteParamToRuleType(routeParams.type);
|
||||||
|
|
||||||
@ -178,9 +180,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
|||||||
|
|
||||||
const { dataSourceName, namespaceName, groupName } = ruleGroupIdentifier;
|
const { dataSourceName, namespaceName, groupName } = ruleGroupIdentifier;
|
||||||
if (exitOnSave) {
|
if (exitOnSave) {
|
||||||
const returnTo = queryParams.get('returnTo') || getReturnToUrl(ruleGroupIdentifier, ruleDefinition);
|
const returnToUrl = returnTo || getReturnToUrl(ruleGroupIdentifier, ruleDefinition);
|
||||||
|
|
||||||
locationService.push(returnTo);
|
locationService.push(returnToUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import { useAsync } from 'react-use';
|
|||||||
|
|
||||||
import { Button, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui';
|
import { Button, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
|
||||||
|
|
||||||
import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate';
|
import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate';
|
||||||
import {
|
import {
|
||||||
@ -15,6 +14,7 @@ import {
|
|||||||
import { alertRuleApi } from '../../../api/alertRuleApi';
|
import { alertRuleApi } from '../../../api/alertRuleApi';
|
||||||
import { fetchRulerRulesGroup } from '../../../api/ruler';
|
import { fetchRulerRulesGroup } from '../../../api/ruler';
|
||||||
import { useDataSourceFeatures } from '../../../hooks/useCombinedRule';
|
import { useDataSourceFeatures } from '../../../hooks/useCombinedRule';
|
||||||
|
import { useReturnTo } from '../../../hooks/useReturnTo';
|
||||||
import { RuleFormValues } from '../../../types/rule-form';
|
import { RuleFormValues } from '../../../types/rule-form';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL, formValuesToRulerGrafanaRuleDTO } from '../../../utils/rule-form';
|
import { DEFAULT_GROUP_EVALUATION_INTERVAL, formValuesToRulerGrafanaRuleDTO } from '../../../utils/rule-form';
|
||||||
@ -39,11 +39,10 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
|
|||||||
defaultValues: ruleForm,
|
defaultValues: ruleForm,
|
||||||
shouldFocusError: true,
|
shouldFocusError: true,
|
||||||
});
|
});
|
||||||
const [queryParams] = useQueryParams();
|
|
||||||
|
|
||||||
const existing = Boolean(ruleForm); // always should be true
|
const existing = Boolean(ruleForm); // always should be true
|
||||||
const notifyApp = useAppNotification();
|
const notifyApp = useAppNotification();
|
||||||
const returnTo = !queryParams.returnTo ? '/alerting/list' : String(queryParams.returnTo);
|
const { returnTo } = useReturnTo('/alerting/list');
|
||||||
|
|
||||||
const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined);
|
const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined);
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-
|
|||||||
import { defaultPageNav } from '../../RuleViewer';
|
import { defaultPageNav } from '../../RuleViewer';
|
||||||
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
|
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
|
||||||
import { usePrometheusCreationConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
|
import { usePrometheusCreationConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck';
|
||||||
|
import { useReturnTo } from '../../hooks/useReturnTo';
|
||||||
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
|
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
|
||||||
import { Annotation } from '../../utils/constants';
|
import { Annotation } from '../../utils/constants';
|
||||||
import { makeDashboardLink, makePanelLink, stringifyErrorLike } from '../../utils/misc';
|
import { makeDashboardLink, makePanelLink, stringifyErrorLike } from '../../utils/misc';
|
||||||
@ -242,9 +243,9 @@ interface TitleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
|
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
|
||||||
const [queryParams] = useQueryParams();
|
|
||||||
const isRecordingRule = ruleType === PromRuleType.Recording;
|
const isRecordingRule = ruleType === PromRuleType.Recording;
|
||||||
const returnTo = queryParams.returnTo ? String(queryParams.returnTo) : '/alerting/list';
|
|
||||||
|
const { returnTo } = useReturnTo('/alerting/list');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
|
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||||
|
import { renderHook } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { useReturnTo } from './useReturnTo';
|
||||||
|
|
||||||
|
describe('useReturnTo', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
// @ts-expect-error
|
||||||
|
delete window.location;
|
||||||
|
window.location = { origin: 'https://play.grafana.net' } as Location;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when `returnTo` is not present in the query string', () => {
|
||||||
|
const { result } = renderHook(() => useReturnTo('/fallback'), { wrapper: MemoryRouter });
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBe('/fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the sanitized `returnTo` value when it is present in the query string and is a valid URL within the Grafana app', () => {
|
||||||
|
const { result } = renderHook(() => useReturnTo('/fallback'), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<MemoryRouter initialEntries={[{ search: '?returnTo=/dashboard/db/my-dashboard' }]}>{children}</MemoryRouter>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBe('/dashboard/db/my-dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when `returnTo` is present in the query string but is not a valid URL within the Grafana app', () => {
|
||||||
|
const { result } = renderHook(() => useReturnTo('/fallback'), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<MemoryRouter initialEntries={[{ search: '?returnTo=https://example.com' }]}>{children}</MemoryRouter>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBe('/fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when `returnTo` is present in the query string but is a malicious JavaScript URL', () => {
|
||||||
|
const { result } = renderHook(() => useReturnTo('/fallback'), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<MemoryRouter initialEntries={[{ search: '?returnTo=javascript:alert(1)' }]}>{children}</MemoryRouter>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBe('/fallback');
|
||||||
|
});
|
||||||
|
});
|
49
public/app/features/alerting/unified/hooks/useReturnTo.ts
Normal file
49
public/app/features/alerting/unified/hooks/useReturnTo.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { textUtil } from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { logWarning } from '../Analytics';
|
||||||
|
|
||||||
|
import { useURLSearchParams } from './useURLSearchParams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook provides a safe way to obtain the `returnTo` URL from the query string parameter
|
||||||
|
* It validates the origin and protocol to ensure the URL is withing the Grafana app
|
||||||
|
*/
|
||||||
|
export function useReturnTo(fallback?: string): { returnTo: string | undefined } {
|
||||||
|
const emptyResult = { returnTo: fallback };
|
||||||
|
|
||||||
|
const [searchParams] = useURLSearchParams();
|
||||||
|
const returnTo = searchParams.get('returnTo');
|
||||||
|
|
||||||
|
if (!returnTo) {
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedReturnTo = textUtil.sanitizeUrl(returnTo);
|
||||||
|
const baseUrl = `${window.location.origin}/${config.appSubUrl}`;
|
||||||
|
|
||||||
|
const sanitizedUrl = tryParseURL(sanitizedReturnTo, baseUrl);
|
||||||
|
|
||||||
|
if (!sanitizedUrl) {
|
||||||
|
logWarning('Malformed returnTo parameter', { returnTo });
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { protocol, origin, pathname, search } = sanitizedUrl;
|
||||||
|
if (['http:', 'https:'].includes(protocol) === false || origin !== window.location.origin) {
|
||||||
|
logWarning('Malformed returnTo parameter', { returnTo });
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { returnTo: `${pathname}${search}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tries to mimic URL.parse method https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static
|
||||||
|
function tryParseURL(sanitizedReturnTo: string, baseUrl: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(sanitizedReturnTo, baseUrl);
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user