107 lines
3.7 KiB
TypeScript
107 lines
3.7 KiB
TypeScript
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> = {}): 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,<script>alert(1)</script>',
|
|
'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');
|
|
});
|
|
});
|