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:'; };