import { describe, expect, it } from 'vitest'; import { safeHref } from '#web-runtime/components/links'; import type { NavigationLink } from '#web-runtime/types'; /** * `safeHref` is the gate between CMS-authored link data and the `href` * attribute the browser receives, so these tests pin the allowlist hard: * anything not explicitly safe must come back as `null`. */ function link(overrides: Partial = {}): NavigationLink { return { linkType: 'external', pageId: '', externalUrl: '', openInNewTab: false, label: 'A link', variant: 'primary', ...overrides }; } describe('allowed protocols', () => { it.each([ 'http://example.com/page', 'https://example.com/page?q=1#frag', 'mailto:hello@example.com', 'tel:+380441234567' ])('passes %s through untouched', (href) => { expect(safeHref(link({ href }))).toBe(href); }); }); describe('dangerous protocols', () => { it.each([ 'javascript:alert(1)', 'data:text/html,', 'vbscript:msgbox(1)', 'file:///etc/passwd', 'blob:https://example.com/uuid', 'ftp://example.com/file' ])('rejects %s', (href) => { expect(safeHref(link({ href }))).toBeNull(); }); it('rejects javascript: regardless of letter case', () => { expect(safeHref(link({ href: 'JavaScript:alert(1)' }))).toBeNull(); expect(safeHref(link({ href: 'JAVASCRIPT:alert(1)' }))).toBeNull(); }); it('rejects javascript: hidden behind leading whitespace, which URL parsing strips', () => { expect(safeHref(link({ href: ' javascript:alert(1)' }))).toBeNull(); expect(safeHref(link({ href: '\njavascript:alert(1)' }))).toBeNull(); }); it('rejects a dangerous externalUrl the same way as a dangerous href', () => { expect(safeHref(link({ externalUrl: 'javascript:alert(1)' }))).toBeNull(); }); }); describe('malformed and missing values', () => { it('rejects plain text that is not a URL at all', () => { expect(safeHref(link({ href: 'not a url' }))).toBeNull(); expect(safeHref(link({ href: 'example.com/no-protocol' }))).toBeNull(); }); it('returns null when neither href nor externalUrl is set', () => { expect(safeHref(link())).toBeNull(); expect(safeHref(link({ href: '' }))).toBeNull(); }); }); describe('href source precedence', () => { it('prefers href over externalUrl when both exist', () => { expect( safeHref( link({ href: 'https://primary.example.com', externalUrl: 'https://other.example.com' }) ) ).toBe('https://primary.example.com'); }); it('falls back to externalUrl when href is empty', () => { expect(safeHref(link({ href: '', externalUrl: 'https://fallback.example.com' }))).toBe( 'https://fallback.example.com' ); }); }); describe('anchors and relative paths', () => { it('passes in-page anchors through untouched', () => { expect(safeHref(link({ href: '#pricing' }))).toBe('#pricing'); }); it('routes site-relative paths through toPublicHref, which keeps the locale prefix in this multi-locale config', () => { // site.config.ts declares en/uk/de, so localePrefix is 'always' and // toPublicHref is the identity function. expect(safeHref(link({ href: '/en/about' }))).toBe('/en/about'); expect(safeHref(link({ href: '/pricing' }))).toBe('/pricing'); }); it('currently lets protocol-relative URLs through via the "/" branch (pinned behavior)', () => { // '//evil.example.com' starts with '/', so it never reaches the protocol // allowlist. Browsers resolve it against the current scheme (http/https), // so it cannot smuggle javascript:, but it is an external URL in disguise. expect(safeHref(link({ href: '//evil.example.com' }))).toBe('//evil.example.com'); }); });