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;
}