100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
/**
|
|||
|
|
* [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',
|
||
|
|
]);
|
||
|
|
|
||
|
|
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 };
|
||
|
|
};
|