diff --git a/.changeset/fix-card-horizontal-security.md b/.changeset/fix-card-horizontal-security.md new file mode 100644 index 000000000..62f1e63b9 --- /dev/null +++ b/.changeset/fix-card-horizontal-security.md @@ -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. diff --git a/src/components/CardHorizontal/CardHorizontal.test.tsx b/src/components/CardHorizontal/CardHorizontal.test.tsx index 38f14a619..2c356161f 100644 --- a/src/components/CardHorizontal/CardHorizontal.test.tsx +++ b/src/components/CardHorizontal/CardHorizontal.test.tsx @@ -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', () => { @@ -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,')).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); + }); +}); diff --git a/src/components/CardHorizontal/CardHorizontal.tsx b/src/components/CardHorizontal/CardHorizontal.tsx index d00628c61..52a8faab7 100644 --- a/src/components/CardHorizontal/CardHorizontal.tsx +++ b/src/components/CardHorizontal/CardHorizontal.tsx @@ -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%; @@ -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 ( diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 000000000..876000e2b --- /dev/null +++ b/src/utils/url.ts @@ -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; + } +};