diff --git a/src/constants/app.constants.ts b/src/constants/app.constants.ts index 2cd48129..2e75f05e 100644 --- a/src/constants/app.constants.ts +++ b/src/constants/app.constants.ts @@ -42,3 +42,17 @@ export const STORAGE_KEYS = { PERF_TRENDS: 'teachlink:perf:trends', AUTH_TOKEN: 'token', }; + +/** + * Domains permitted in sanitized HTML links and sanitizeUrl(). + * Subdomains are automatically permitted (e.g. www.youtube.com matches youtube.com). + * Add entries here to extend the allowlist — one bare hostname per entry, no leading dot. + */ +export const ALLOWED_LINK_DOMAINS = [ + 'teachlink.com', + 'youtube.com', + 'youtube-nocookie.com', + 'vimeo.com', + 'github.com', + 'loom.com', +]; diff --git a/src/utils/__tests__/sanitize.test.ts b/src/utils/__tests__/sanitize.test.ts new file mode 100644 index 00000000..7da4f99e --- /dev/null +++ b/src/utils/__tests__/sanitize.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeUrl, sanitizeHtml } from '../sanitize'; + +// --------------------------------------------------------------------------- +// sanitizeUrl – allowed domains +// --------------------------------------------------------------------------- +describe('sanitizeUrl – allowed domains', () => { + it('allows https on teachlink.com', () => { + expect(sanitizeUrl('https://teachlink.com/course/1')).toBe('https://teachlink.com/course/1'); + }); + + it('allows subdomains of teachlink.com', () => { + expect(sanitizeUrl('https://app.teachlink.com/dashboard')).toBe('https://app.teachlink.com/dashboard'); + }); + + it('allows http on teachlink.com', () => { + expect(sanitizeUrl('http://teachlink.com/')).toBe('http://teachlink.com/'); + }); + + it('allows youtube.com', () => { + expect(sanitizeUrl('https://youtube.com/watch?v=dQw4w9WgXcQ')).not.toBeNull(); + }); + + it('allows www.youtube.com (subdomain)', () => { + expect(sanitizeUrl('https://www.youtube.com/watch?v=abc')).not.toBeNull(); + }); + + it('allows youtube-nocookie.com', () => { + expect(sanitizeUrl('https://www.youtube-nocookie.com/embed/abc')).not.toBeNull(); + }); + + it('allows vimeo.com', () => { + expect(sanitizeUrl('https://vimeo.com/123456789')).not.toBeNull(); + }); + + it('allows github.com', () => { + expect(sanitizeUrl('https://github.com/org/repo')).not.toBeNull(); + }); + + it('allows URLs with query parameters and fragments', () => { + expect(sanitizeUrl('https://teachlink.com/search?q=test#results')).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeUrl – disallowed domains +// --------------------------------------------------------------------------- +describe('sanitizeUrl – disallowed domains', () => { + it('blocks arbitrary https destinations', () => { + expect(sanitizeUrl('https://evil.com/phishing')).toBeNull(); + }); + + it('blocks domains that start with an allowed name but are not subdomains', () => { + expect(sanitizeUrl('https://teachlink.com.evil.com')).toBeNull(); + expect(sanitizeUrl('https://noteachlink.com')).toBeNull(); + }); + + it('blocks domains that end with an allowed name but have a different TLD', () => { + expect(sanitizeUrl('https://fakeyoutube.com')).toBeNull(); + }); + + it('blocks URLs with ports on disallowed domains', () => { + expect(sanitizeUrl('https://evil.com:8080/attack')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeUrl – protocol blocking +// --------------------------------------------------------------------------- +describe('sanitizeUrl – protocol blocking', () => { + it('blocks javascript: URIs', () => { + expect(sanitizeUrl('javascript:alert(1)')).toBeNull(); + }); + + it('blocks data: URIs', () => { + expect(sanitizeUrl('data:text/html,

XSS

')).toBeNull(); + }); + + it('blocks vbscript: URIs', () => { + expect(sanitizeUrl('vbscript:msgbox(1)')).toBeNull(); + }); + + it('blocks ftp: URIs', () => { + expect(sanitizeUrl('ftp://teachlink.com/file')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeUrl – edge cases +// --------------------------------------------------------------------------- +describe('sanitizeUrl – edge cases', () => { + it('returns null for empty string', () => { + expect(sanitizeUrl('')).toBeNull(); + }); + + it('returns null for whitespace-only string', () => { + expect(sanitizeUrl(' ')).toBeNull(); + }); + + it('returns null for relative URLs', () => { + expect(sanitizeUrl('/about')).toBeNull(); + }); + + it('returns null for malformed URLs', () => { + expect(sanitizeUrl('not a url')).toBeNull(); + }); + + it('trims surrounding whitespace before parsing', () => { + expect(sanitizeUrl(' https://teachlink.com/ ')).not.toBeNull(); + }); + + it('returns null for URLs with authentication credentials on disallowed domains', () => { + expect(sanitizeUrl('https://user:pass@evil.com/')).toBeNull(); + }); + + it('allows URLs with URL-encoded characters on allowed domains', () => { + expect(sanitizeUrl('https://teachlink.com/path%20with%20spaces')).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeHtml – DOMPurify hook enforces domain allowlist on hrefs +// --------------------------------------------------------------------------- +describe('sanitizeHtml – href domain enforcement', () => { + it('keeps href pointing to an allowed domain', () => { + const result = sanitizeHtml('Course'); + expect(result).toContain('href="https://teachlink.com/course"'); + expect(result).toContain('Course'); + }); + + it('keeps href for youtube.com', () => { + const result = sanitizeHtml('Video'); + expect(result).toContain('href='); + expect(result).toContain('Video'); + }); + + it('strips href pointing to a disallowed domain', () => { + const result = sanitizeHtml('Click me'); + expect(result).not.toContain('href='); + expect(result).toContain('Click me'); + }); + + it('strips href pointing to an arbitrary https destination', () => { + const result = sanitizeHtml('Free gift'); + expect(result).not.toContain('href='); + }); + + it('strips javascript: hrefs (belt-and-suspenders with DOMPurify defaults)', () => { + const result = sanitizeHtml('XSS'); + expect(result).not.toContain('javascript:'); + }); + + it('strips data: hrefs', () => { + const result = sanitizeHtml('data'); + expect(result).not.toContain('data:text'); + }); + + it('allows relative hrefs (same-origin links)', () => { + const result = sanitizeHtml('About'); + expect(result).toContain('href="/about"'); + }); + + it('allows hash fragment hrefs', () => { + const result = sanitizeHtml('Jump'); + expect(result).toContain('href="#section"'); + }); + + it('preserves link text even when href is stripped', () => { + const result = sanitizeHtml('Important text'); + expect(result).toContain('Important text'); + expect(result).not.toContain('href='); + }); + + it('handles multiple links in one HTML string', () => { + const html = + 'Good Bad'; + const result = sanitizeHtml(html); + expect(result).toContain('href="https://teachlink.com/"'); + expect(result).toContain('Good'); + expect(result).toContain('Bad'); + // The bad link should have its href removed + const badLinkMatch = result.match(/Bad/); + expect(badLinkMatch).not.toBeNull(); + // Ensure evil.com doesn't appear anywhere + expect(result).not.toContain('evil.com'); + }); +}); diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts index 175f2c73..d2c95da2 100644 --- a/src/utils/sanitize.ts +++ b/src/utils/sanitize.ts @@ -1,7 +1,35 @@ import DOMPurify from 'dompurify'; +import { ALLOWED_LINK_DOMAINS } from '@/constants/app.constants'; const SAFE_URL_SCHEMES = ['http:', 'https:']; +/** + * Returns true when the hostname belongs to (or is a subdomain of) an allowlisted domain. + * e.g. "www.youtube.com" matches "youtube.com". + */ +const isAllowedDomain = (hostname: string): boolean => + ALLOWED_LINK_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); + +// Register the DOMPurify hook once at module load time. +// It strips `href` attributes whose absolute URLs don't pass domain validation. +// Relative URLs (e.g. "/about", "#section") are left untouched — they resolve to the same origin. +let _hookRegistered = false; +if (typeof window !== 'undefined' && !_hookRegistered) { + _hookRegistered = true; + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + const href = node.getAttribute('href'); + if (href === null) return; + try { + const parsed = new URL(href); + if (!SAFE_URL_SCHEMES.includes(parsed.protocol) || !isAllowedDomain(parsed.hostname)) { + node.removeAttribute('href'); + } + } catch { + // new URL() throws for relative URLs — allow them (they stay on the same origin) + } + }); +} + export const sanitizeHtml = (html: string): string => { if (typeof window === 'undefined') return html; return DOMPurify.sanitize(html, { @@ -15,7 +43,9 @@ export const sanitizeUrl = (url: string): string | null => { if (!trimmed) return null; try { const parsed = new URL(trimmed); - return SAFE_URL_SCHEMES.includes(parsed.protocol) ? parsed.toString() : null; + if (!SAFE_URL_SCHEMES.includes(parsed.protocol)) return null; + if (!isAllowedDomain(parsed.hostname)) return null; + return parsed.toString(); } catch { return null; }