Files
cinny/src/app/utils/linkSafety.ts
T

258 lines
6.8 KiB
TypeScript
Raw Normal View History

/**
* [Gitea #122] Phishing-shape detection for rendered links: the visible text
* looks like a URL/hostname but the real destination is a different site.
*
* <a href="https://evil.example/login">https://matrix.lotusguild.org/login</a>
*
* 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 };
};
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
const decodeEntities = (s: string): string =>
s
.replace(/<[^>]+>/g, '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
/**
* [Gitea #286] The URLs to build link previews for. Previews come from the
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
* controls both: text reading "https://paypal.com" linked to evil.example got
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
* deceptive link's text.
*/
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
if (!formattedBody || urls.length === 0) return urls;
const claimed = new Set<string>();
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
});
if (claimed.size === 0) return urls;
return urls.filter((u) => {
try {
return !claimed.has(registrableDomain(new URL(u).hostname));
} catch {
return true;
}
});
};