[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:
co-authored by
Konrad Lalik
parent
a875535761
commit
583aabf9aa
@@ -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,
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+2
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user