/** * [Gitea #122] Phishing-shape detection for rendered links: the visible text * looks like a URL/hostname but the real destination is a different site. * * https://matrix.lotusguild.org/login * * Compares registrable domains (so `youtube.com/x` text over a `www.youtube.com` * href is honest), ignores non-http(s) targets, and surfaces punycode hosts * so an IDN homograph can't hide behind a familiar-looking label. */ // Two-label public suffixes we care about; everything else is eTLD+1 = last 2 labels. const TWO_LABEL_SUFFIXES = new Set([ 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk', 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'co.nz', 'org.nz', 'net.nz', 'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'com.br', 'net.br', 'org.br', 'co.in', 'net.in', 'org.in', 'co.za', 'org.za', 'com.mx', 'com.ar', 'com.tr', 'com.cn', 'com.hk', 'com.sg', 'com.tw', ]); // [Gitea #335] Shared-hosting suffixes (the Public Suffix List's "private" // section, the ones phishing actually uses): every customer gets their own // subdomain, so `alice.github.io` and `evil.github.io` are different sites. // A curated list rather than the full PSL keeps this out of the bundle budget. const SHARED_HOSTING_SUFFIXES = new Set([ 'github.io', 'gitlab.io', 'codeberg.page', 'pages.dev', 'workers.dev', 'trycloudflare.com', 'netlify.app', 'vercel.app', 'web.app', 'firebaseapp.com', 'appspot.com', 'herokuapp.com', 'onrender.com', 'fly.dev', 'glitch.me', 'replit.app', 'repl.co', 'surge.sh', 'blogspot.com', 'wordpress.com', 'wixsite.com', 'weebly.com', 'azurewebsites.net', 'cloudfront.net', 'ngrok.io', 'ngrok-free.app', 'duckdns.org', 's3.amazonaws.com', ]); const isPublicSuffix = (suffix: string): boolean => TWO_LABEL_SUFFIXES.has(suffix) || SHARED_HOSTING_SUFFIXES.has(suffix); export const registrableDomain = (host: string): string => { const labels = host.toLowerCase().replace(/\.$/, '').split('.'); // Longest matching public suffix wins (3 labels, e.g. s3.amazonaws.com, then 2). for (const n of [3, 2]) { if (labels.length > n && isPublicSuffix(labels.slice(-n).join('.'))) { return labels.slice(-(n + 1)).join('.'); } } return labels.slice(-2).join('.'); }; const HOST_LIKE = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^\s/?#@]+@)?([a-z0-9¡-￿-]+(?:\.[a-z0-9¡-￿-]+)+)(?::\d+)?(?:[/?#]|$)/i; /** The hostname a piece of visible text CLAIMS to point at, if it looks like one. */ export const hostFromText = (text: string): string | null => { const t = text.trim(); if (!t || /\s/.test(t)) return null; const m = HOST_LIKE.exec(t); if (!m) return null; try { // Normalise through URL so unicode hosts become punycode like real hrefs do. return new URL(`http://${m[1]}`).hostname; } catch { return null; } }; // Words that look like hosts but are usually files or versions in chat // ("see main.py", "report.pdf", "v1.2.3"): skipped inside sentences unless // written as a URL (scheme, www., or a path) so ordinary links aren't flagged. const FILE_EXTENSIONS = new Set([ 'py', 'sh', 'md', 'rs', 'js', 'ts', 'tsx', 'jsx', 'json', 'yaml', 'yml', 'toml', 'txt', 'log', 'csv', 'pdf', 'zip', 'gz', 'tar', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp3', 'mp4', 'mov', 'html', 'htm', 'css', 'exe', 'dll', 'lock', 'cfg', 'conf', 'ini', 'xml', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'apk', 'iso', 'jar', 'java', 'go', 'rb', 'php', 'c', 'h', 'cpp', ]); const TRIM_PUNCT = /^[\s"'“”‘’«»([{<]+|[\s"'“”‘’«»)\]}>.,;:!?]+$/g; /** Every host a sentence names (Gitea #335), e.g. "Log in at chat.lotusguild.org". */ export const hostsFromText = (text: string): string[] => { const hosts: string[] = []; text.split(/\s+/).forEach((raw) => { const word = raw.replace(TRIM_PUNCT, ''); const host = word && hostFromText(word); if (!host) return; const tld = host.split('.').pop() ?? ''; if (!/[a-z]/i.test(tld)) return; // version numbers, IPs-as-words const urlShaped = /^[a-z][a-z0-9+.-]*:\/\//i.test(word) || /^www\./i.test(word) || word.includes('/'); if (!urlShaped && host.split('.').length === 2 && FILE_EXTENSIONS.has(tld.toLowerCase())) return; if (!hosts.includes(host)) hosts.push(host); }); return hosts; }; export type LinkSafety = { /** The visible text names a different site than the destination. */ mismatch: boolean; shownHost?: string; realHost: string; /** Destination host contains an IDN (punycode) label. */ punycode: boolean; }; export const analyzeLink = (text: string, href: string): LinkSafety | null => { let url: URL; try { url = new URL(href); } catch { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; if (url.hostname === 'matrix.to') return null; const realHost = url.hostname; const punycode = realHost.split('.').some((l) => l.startsWith('xn--')); // A single URL/host-shaped text, or (Gitea #335) host-shaped words inside a // sentence: "Log in at chat.lotusguild.org" must not hide a different site. const single = hostFromText(text); const shown = single ? [single] : hostsFromText(text); const realSite = registrableDomain(realHost); const other = shown.find((h) => registrableDomain(h) !== realSite); return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode }; };