Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@
"@openshift-console/dynamic-plugin-sdk-webpack": "4.22.0",
"@swc/core": "^1.15.3",
"@swc/helpers": "0.5.23",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@types/ajv": "^0.0.5",
"@types/classnames": "^2.2.7",
"@types/jest": "^30.0.0",
Expand Down
45 changes: 45 additions & 0 deletions web/src/shared/console/utils/Link.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** @jest-environment jsdom */

import { render, screen } from '@testing-library/react';

import { ExternalLink, LinkifyExternal } from '@/shared/console/utils/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) => {
render(<ExternalLink href={href} text={href} />);

expect(screen.getByRole('link', { name: href }).getAttribute('href')).toBe(href);
});

it.each([
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'data:text/html,<script>alert(1)</script>',
'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 { container } = render(<ExternalLink href={href} text={href} />);

expect(container.textContent).toBe(href);
expect(screen.queryByRole('link')).toBeNull();
});
});

describe('LinkifyExternal', () => {
it('turns URLs in its children into protected external links', () => {
const href = 'https://runbooks.example.com/alert';
render(<LinkifyExternal>{href}</LinkifyExternal>);

const link = screen.getByRole('link', { name: href });
expect(link.getAttribute('href')).toBe(href);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
});
});
66 changes: 34 additions & 32 deletions web/src/shared/console/utils/Link.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,32 @@
import { Button, Icon } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import Linkify from 'linkify-react';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import type { FC, ReactNode } from 'react';

export const ExternalLink: FC<PropsWithChildren<ExternalLinkProps>> = ({
children,
href,
text,
additionalClassName = '',
dataTestID,
stopPropagation,
}) => (
<Button
variant="link"
component="a"
icon={
<Icon size="sm">
<ExternalLinkAltIcon />
</Icon>
}
className={additionalClassName}
href={href}
target="_blank"
iconPosition="end"
rel="noopener noreferrer"
data-test-id={dataTestID}
{...(stopPropagation ? { onClick: (e) => e.stopPropagation() } : {})}
isInline
>
{children || text}
</Button>
);
export const ExternalLink: FC<ExternalLinkProps> = ({ href, text }) => {
if (!isSafeExternalURL(href)) {
return <>{text}</>;
}

return (
<Button
variant="link"
component="a"
icon={
<Icon size="sm">
<ExternalLinkAltIcon />
</Icon>
}
href={href}
target="_blank"
iconPosition="end"
rel="noopener noreferrer"
isInline
>
{text}
</Button>
);
};

// Open links in a new window and set noopener/noreferrer.
export const LinkifyExternal: FC<{ children: ReactNode }> = ({ children }) => (
Expand All @@ -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:';
};