[v10.4.x] Alerting: Add useReturnTo hook to safely handle returnTo parameter (#96477)

Add useReturnTo hook to safely handle returnTo parameter

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Kevin Minehart
2024-11-14 17:32:41 +01:00
committed by GitHub
co-authored by Konrad Lalik
parent a875535761
commit 583aabf9aa
5 changed files with 107 additions and 5 deletions
@@ -31,6 +31,10 @@ export function logError(error: Error, context?: Record<string, string>) {
alertingLogger.logError(error, context);
}
export function logWarning(message: string, context?: Record<string, string>) {
alertingLogger.logWarning(message, context);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
func: TFunc,
@@ -15,6 +15,7 @@ import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { LogMessages, logInfo, trackNewAlerRuleFormError } from '../../../Analytics';
import { useReturnTo } from '../../../hooks/useReturnTo';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { deleteRuleAction, saveRuleFormAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
@@ -57,7 +58,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
const ruleType = translateRouteParamToRuleType(routeParams.type);
const uidFromParams = routeParams.id;
const returnTo = !queryParams['returnTo'] ? '/alerting/list' : String(queryParams['returnTo']);
const { returnTo } = useReturnTo('/alerting/list');
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const defaultValues: RuleFormValues = useMemo(() => {
@@ -195,7 +196,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
{submitState.loading && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule and exit
</Button>
<Link to={returnTo}>
<Link to={returnTo ?? '/alerting/list'}>
<Button variant="secondary" disabled={submitState.loading} type="button" onClick={cancelRuleCreation} size="sm">
Cancel
</Button>
@@ -4,13 +4,13 @@ import { useAsync } from 'react-use';
import { Button, CustomScrollbar, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate';
import { RulerRuleDTO, RulerRuleGroupDTO } from '../../../../../../types/unified-alerting-dto';
import { alertRuleApi, ModifyExportPayload } from '../../../api/alertRuleApi';
import { fetchRulerRulesGroup } from '../../../api/ruler';
import { useDataSourceFeatures } from '../../../hooks/useCombinedRule';
import { useReturnTo } from '../../../hooks/useReturnTo';
import { RuleFormValues } from '../../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { formValuesToRulerGrafanaRuleDTO, MINUTE } from '../../../utils/rule-form';
@@ -35,11 +35,10 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
defaultValues: ruleForm,
shouldFocusError: true,
});
const [queryParams] = useQueryParams();
const existing = Boolean(ruleForm); // always should be true
const notifyApp = useAppNotification();
const returnTo = !queryParams['returnTo'] ? '/alerting/list' : String(queryParams['returnTo']);
const { returnTo } = useReturnTo('/alerting/list');
const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined);
@@ -0,0 +1,49 @@
import { renderHook } from '@testing-library/react';
import * as React from 'react';
import { MemoryRouter } from 'react-router-dom';
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');
});
});
@@ -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;
}
}