diff --git a/web/src/components/console/utils/link.spec.tsx b/web/src/components/console/utils/link.spec.tsx
new file mode 100644
index 000000000..ec4d141d4
--- /dev/null
+++ b/web/src/components/console/utils/link.spec.tsx
@@ -0,0 +1,45 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+
+jest.mock('@patternfly/react-core', () => ({
+ Button: ({ children, href, rel, target }) => (
+
+ {children}
+
+ ),
+ Icon: ({ children }) => <>{children}>,
+}));
+
+jest.mock('@patternfly/react-icons', () => ({
+ ExternalLinkAltIcon: () => null,
+}));
+
+jest.mock('react-linkify', () => ({ children }) => <>{children}>);
+
+import { ExternalLink } from './link';
+
+describe('ExternalLink', () => {
+ it.each([
+ 'https://runbooks.example.com/alert',
+ 'http://runbooks.example.com/alert?severity=high',
+ ])('renders an absolute HTTP(S) URL as a link: %s', (href) => {
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain(`href="${href}"`);
+ });
+
+ it.each([
+ 'javascript:alert(1)',
+ 'JaVaScRiPt:alert(1)',
+ 'data:text/html,',
+ 'vbscript:msgbox(1)',
+ 'mailto:security@example.com',
+ '/runbooks/alert',
+ '//runbooks.example.com/alert',
+ '\tjavascript:alert(1)',
+ 'not a URL',
+ ])('renders an unsafe or invalid URL as text: %s', (href) => {
+ const html = renderToStaticMarkup();
+
+ expect(html).not.toMatch(/]/);
+ });
+});
diff --git a/web/src/components/console/utils/link.tsx b/web/src/components/console/utils/link.tsx
index bf5043824..84402dd7c 100644
--- a/web/src/components/console/utils/link.tsx
+++ b/web/src/components/console/utils/link.tsx
@@ -1,36 +1,32 @@
-import type { FC, PropsWithChildren, ReactNode } from 'react';
+import type { FC, ReactNode } from 'react';
import Linkify from 'react-linkify';
import { Button, Icon } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
-export const ExternalLink: FC> = ({
- children,
- href,
- text,
- additionalClassName = '',
- dataTestID,
- stopPropagation,
-}) => (
-
-);
+export const ExternalLink: FC = ({ href, text }) => {
+ if (!isSafeExternalURL(href)) {
+ return <>{text}>;
+ }
+
+ return (
+
+ );
+};
// Open links in a new window and set noopener/noreferrer.
export const LinkifyExternal: FC<{ children: ReactNode }> = ({ children }) => (
@@ -41,7 +37,13 @@ LinkifyExternal.displayName = 'LinkifyExternal';
type ExternalLinkProps = {
href: string;
text?: ReactNode;
- additionalClassName?: string;
- 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:';
};