Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/fix-card-horizontal-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@clickhouse/click-ui": patch
---

Improved how the `CardHorizontal` component handles external links to keep end-users safe.

### What's changed
- **Link validation**: Only secure web links (starting with `http://` or `https://`) can be opened. This blocks potentially harmful links like `javascript:` or `data:` URIs that could be used in attacks.

- **Safer tab opening**: When a link opens in a new tab, it now uses `noopener,noreferrer` to prevent the new page from accessing information about your application. See [MDN: Window.open() - Security considerations](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#security_considerations) for more details.
30 changes: 30 additions & 0 deletions src/components/CardHorizontal/CardHorizontal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { screen } from '@testing-library/react';
import { CardHorizontal, CardHorizontalProps } from '@/components/CardHorizontal';
import { isValidHttpUrl } from '@/utils/url';
import { renderCUI } from '@/utils/test-utils';

describe('CardHorizontal Component', () => {
Expand Down Expand Up @@ -188,3 +189,32 @@ describe('CardHorizontal Component', () => {
windowOpenSpy.mockRestore();
});
});

describe('isValidHttpUrl', () => {
it('should return true for valid HTTP URLs', () => {
expect(isValidHttpUrl('http://example.com')).toBe(true);
expect(isValidHttpUrl('http://localhost:3000')).toBe(true);
expect(isValidHttpUrl('http://test.com/path')).toBe(true);
});

it('should return true for valid HTTPS URLs', () => {
expect(isValidHttpUrl('https://example.com')).toBe(true);
expect(isValidHttpUrl('https://clickhouse.com')).toBe(true);
expect(isValidHttpUrl('https://api.example.com/v1/test')).toBe(true);
});

it('should return false for invalid URLs', () => {
expect(isValidHttpUrl('javascript:alert(1)')).toBe(false);
expect(isValidHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isValidHttpUrl('file:///etc/passwd')).toBe(false);
expect(isValidHttpUrl('ftp://example.com')).toBe(false);
expect(isValidHttpUrl('about:blank')).toBe(false);
expect(isValidHttpUrl('vbscript:msgbox(1)')).toBe(false);
});

it('should return false for non-HTTP protocols', () => {
expect(isValidHttpUrl('')).toBe(false);
expect(isValidHttpUrl('not-a-url')).toBe(false);
expect(isValidHttpUrl('://invalid')).toBe(false);
});
});
5 changes: 3 additions & 2 deletions src/components/CardHorizontal/CardHorizontal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from '@/components/Button';
import { Container } from '@/components/Container';
import { Icon } from '@/components/Icon';
import { CardHorizontalProps, CardSize, CardColor } from './CardHorizontal.types';
import { isValidHttpUrl } from '@/utils/url';

const Header = styled.div`
max-width: 100%;
Expand Down Expand Up @@ -204,8 +205,8 @@ export const CardHorizontal = ({
if (typeof onButtonClick === 'function') {
onButtonClick(e);
}
if (infoUrl && infoUrl.length > 0) {
window.open(infoUrl, '_blank');
if (infoUrl && infoUrl.length > 0 && isValidHttpUrl(infoUrl)) {
window.open(infoUrl, '_blank', 'noopener,noreferrer');
}
};
return (
Expand Down
8 changes: 8 additions & 0 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const isValidHttpUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
} catch {
return false;
}
};
Loading