From caa9add59c1ccbe932fda0d9f7138a06869e47c9 Mon Sep 17 00:00:00 2001 From: Daniel Chromik Date: Tue, 15 Sep 2026 11:01:49 +0200 Subject: [PATCH 1/2] OU-1488: sanitize alert runbook URLs --- .../components/alerting/AlertRulesDetailsPage.tsx | 4 ++-- web/src/components/alerting/AlertsDetailPage.tsx | 4 ++-- web/src/components/utils.ts | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/web/src/components/alerting/AlertRulesDetailsPage.tsx b/web/src/components/alerting/AlertRulesDetailsPage.tsx index 6d06f459e..97925f55e 100644 --- a/web/src/components/alerting/AlertRulesDetailsPage.tsx +++ b/web/src/components/alerting/AlertRulesDetailsPage.tsx @@ -46,7 +46,7 @@ import KebabDropdown from '../kebab-dropdown'; import { Labels } from '../labels'; import { ToggleGraph } from '../MetricsPage'; import { Alerts } from '../types'; -import { alertDescription, RuleResource } from '../utils'; +import { alertDescription, getSafeExternalURL, RuleResource } from '../utils'; import './alert-rules-details-page.scss'; @@ -187,7 +187,7 @@ const AlertRulesDetailsPage_: React.FC = ({ match }) }; // eslint-disable-next-line camelcase - const runbookURL = rule?.annotations?.runbook_url; + const runbookURL = getSafeExternalURL(rule?.annotations?.runbook_url); return ( <> diff --git a/web/src/components/alerting/AlertsDetailPage.tsx b/web/src/components/alerting/AlertsDetailPage.tsx index c7dc738c0..a257b7da4 100644 --- a/web/src/components/alerting/AlertsDetailPage.tsx +++ b/web/src/components/alerting/AlertsDetailPage.tsx @@ -11,7 +11,7 @@ import { Alerts } from '../types'; import { useSelector } from 'react-redux'; import * as _ from 'lodash-es'; import { getAllQueryArguments } from '../console/utils/router'; -import { AlertResource, alertState, RuleResource } from '../utils'; +import { AlertResource, alertState, getSafeExternalURL, RuleResource } from '../utils'; import { Alert, AlertStates, @@ -125,7 +125,7 @@ const AlertsDetailsPage_: React.FC = ({ history, match } const labels: PrometheusLabels = React.useMemo(() => alert?.labels, [labelsMemoKey]); // eslint-disable-next-line camelcase - const runbookURL = alert?.annotations?.runbook_url; + const runbookURL = getSafeExternalURL(alert?.annotations?.runbook_url); const sourceId = rule?.sourceId; diff --git a/web/src/components/utils.ts b/web/src/components/utils.ts index 2478bc53a..81a9ce526 100644 --- a/web/src/components/utils.ts +++ b/web/src/components/utils.ts @@ -189,6 +189,21 @@ export const targetSource = (target: Target): AlertSource => export const isTimeoutError = (err: Error): boolean => err.name === 'TimeoutError' || err.message.includes('timed out'); +/** + * Returns an externally supplied URL only when it is an absolute HTTP(S) URL. + * + * Do not normalize the value before returning it: callers display this value to + * users, and the browser will perform the same URL parsing when navigating. + */ +export const getSafeExternalURL = (value?: string): string | undefined => { + if (!value || !URL.canParse(value)) { + return undefined; + } + + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined; +}; + /** * This function is used to get the parameters needed to break a long time period down into smaller * chunks which won't timeout From ccb9a78a43b15f7e15bb750cda9d34e40dad6bff Mon Sep 17 00:00:00 2001 From: Daniel Chromik Date: Tue, 15 Sep 2026 13:00:32 +0200 Subject: [PATCH 2/2] OU-1488: move URL validation into ExternalLink --- .../alerting/AlertRulesDetailsPage.tsx | 4 +- .../components/alerting/AlertsDetailPage.tsx | 4 +- web/src/components/console/utils/link.tsx | 57 ++++++++++++------- web/src/components/utils.ts | 15 ----- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/web/src/components/alerting/AlertRulesDetailsPage.tsx b/web/src/components/alerting/AlertRulesDetailsPage.tsx index 97925f55e..6d06f459e 100644 --- a/web/src/components/alerting/AlertRulesDetailsPage.tsx +++ b/web/src/components/alerting/AlertRulesDetailsPage.tsx @@ -46,7 +46,7 @@ import KebabDropdown from '../kebab-dropdown'; import { Labels } from '../labels'; import { ToggleGraph } from '../MetricsPage'; import { Alerts } from '../types'; -import { alertDescription, getSafeExternalURL, RuleResource } from '../utils'; +import { alertDescription, RuleResource } from '../utils'; import './alert-rules-details-page.scss'; @@ -187,7 +187,7 @@ const AlertRulesDetailsPage_: React.FC = ({ match }) }; // eslint-disable-next-line camelcase - const runbookURL = getSafeExternalURL(rule?.annotations?.runbook_url); + const runbookURL = rule?.annotations?.runbook_url; return ( <> diff --git a/web/src/components/alerting/AlertsDetailPage.tsx b/web/src/components/alerting/AlertsDetailPage.tsx index a257b7da4..c7dc738c0 100644 --- a/web/src/components/alerting/AlertsDetailPage.tsx +++ b/web/src/components/alerting/AlertsDetailPage.tsx @@ -11,7 +11,7 @@ import { Alerts } from '../types'; import { useSelector } from 'react-redux'; import * as _ from 'lodash-es'; import { getAllQueryArguments } from '../console/utils/router'; -import { AlertResource, alertState, getSafeExternalURL, RuleResource } from '../utils'; +import { AlertResource, alertState, RuleResource } from '../utils'; import { Alert, AlertStates, @@ -125,7 +125,7 @@ const AlertsDetailsPage_: React.FC = ({ history, match } const labels: PrometheusLabels = React.useMemo(() => alert?.labels, [labelsMemoKey]); // eslint-disable-next-line camelcase - const runbookURL = getSafeExternalURL(alert?.annotations?.runbook_url); + const runbookURL = alert?.annotations?.runbook_url; const sourceId = rule?.sourceId; diff --git a/web/src/components/console/utils/link.tsx b/web/src/components/console/utils/link.tsx index b9e47eeaa..055d7f402 100644 --- a/web/src/components/console/utils/link.tsx +++ b/web/src/components/console/utils/link.tsx @@ -10,27 +10,33 @@ export const ExternalLink: React.FC = ({ additionalClassName = '', dataTestID, stopPropagation, -}) => ( - -); +}) => { + if (!isSafeExternalURL(href)) { + return <>{children || text}; + } + + return ( + + ); +}; // Open links in a new window and set noopener/noreferrer. export const LinkifyExternal: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -45,3 +51,12 @@ type ExternalLinkProps = { dataTestID?: string; stopPropagation?: boolean; }; + +const isSafeExternalURL = (value: string): value is string => { + if (!URL.canParse(value)) { + return false; + } + + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; +}; diff --git a/web/src/components/utils.ts b/web/src/components/utils.ts index 81a9ce526..2478bc53a 100644 --- a/web/src/components/utils.ts +++ b/web/src/components/utils.ts @@ -189,21 +189,6 @@ export const targetSource = (target: Target): AlertSource => export const isTimeoutError = (err: Error): boolean => err.name === 'TimeoutError' || err.message.includes('timed out'); -/** - * Returns an externally supplied URL only when it is an absolute HTTP(S) URL. - * - * Do not normalize the value before returning it: callers display this value to - * users, and the browser will perform the same URL parsing when navigating. - */ -export const getSafeExternalURL = (value?: string): string | undefined => { - if (!value || !URL.canParse(value)) { - return undefined; - } - - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined; -}; - /** * This function is used to get the parameters needed to break a long time period down into smaller * chunks which won't timeout