/** * [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', ]); export const registrableDomain = (host: string): string => { const labels = host.toLowerCase().replace(/\.$/, '').split('.'); if (labels.length <= 2) return labels.join('.'); const lastTwo = labels.slice(-2).join('.'); return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo; }; 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; } }; 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--')); const shownHost = hostFromText(text) ?? undefined; const mismatch = shownHost !== undefined && registrableDomain(shownHost) !== registrableDomain(realHost); return { mismatch, shownHost, realHost, punycode }; };