From a8a7e639a9d3e028b37e105cc41671eb14721752 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 18:56:01 -0400 Subject: [PATCH] fix(security): link warning no longer bypassed by a sentence or shared hosting (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two easy bypasses of the "this link goes somewhere else" check: - Any whitespace in the link text disabled it, so "Log in at chat.lotusguild.org" → evil.example wasn't flagged. hostsFromText now scans each word for host-shaped tokens; a link is flagged if any named host is a different site. Bare file names (main.py, report.pdf) and version numbers are skipped inside sentences so ordinary links aren't flagged. - Shared-hosting suffixes counted as one site (alice.github.io vs evil.github.io, *.pages.dev, netlify.app, vercel.app, …). registrableDomain now treats a curated list of them as public suffixes (longest match, incl. s3.amazonaws.com). Curated rather than the full PSL to stay inside the bundle budget. Verified with unit tests and headless on real messages: the four bypass cases are flagged; the honest sentence, file/version text and "click here" are not. Fixes #335 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/utils/linkSafety.test.ts | 35 ++++++++ src/app/utils/linkSafety.ts | 138 +++++++++++++++++++++++++++++-- 2 files changed, 166 insertions(+), 7 deletions(-) diff --git a/src/app/utils/linkSafety.test.ts b/src/app/utils/linkSafety.test.ts index aaaad5ea9..7bd2f8a72 100644 --- a/src/app/utils/linkSafety.test.ts +++ b/src/app/utils/linkSafety.test.ts @@ -38,3 +38,38 @@ test('hostFromText only accepts URL/host-shaped text; IDN becomes punycode', () assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true); assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true); }); + +test('a trusted name inside a sentence still exposes a different destination (#335)', () => { + const a = analyzeLink('Log in at chat.lotusguild.org', 'https://evil.example/'); + assert.equal(a?.mismatch, true); + assert.equal(a?.shownHost, 'chat.lotusguild.org'); + assert.equal( + analyzeLink('chat.lotusguild.org (official)', 'https://evil.example/')?.mismatch, + true, + ); + assert.equal( + analyzeLink('Open https://paypal.com/login now', 'https://evil.example/')?.mismatch, + true, + ); + // honest sentences are not flagged + assert.equal( + analyzeLink('Log in at chat.lotusguild.org', 'https://chat.lotusguild.org/login')?.mismatch, + false, + ); + assert.equal(analyzeLink('read the docs', 'https://evil.example/')?.mismatch, false); + assert.equal(analyzeLink('see main.py and report.pdf', 'https://github.com/x')?.mismatch, false); + assert.equal(analyzeLink('release v1.2.3 notes', 'https://github.com/x')?.mismatch, false); +}); + +test('shared hosting suffixes are separate sites (#335)', () => { + assert.equal(registrableDomain('alice.github.io'), 'alice.github.io'); + assert.equal(registrableDomain('www.alice.github.io'), 'alice.github.io'); + assert.equal(registrableDomain('bucket.s3.amazonaws.com'), 'bucket.s3.amazonaws.com'); + assert.equal(analyzeLink('alice.github.io', 'https://evil.github.io/')?.mismatch, true); + assert.equal(analyzeLink('lotusguild.pages.dev', 'https://evil.pages.dev/')?.mismatch, true); + assert.equal( + analyzeLink('alice.github.io/blog', 'https://alice.github.io/blog/1')?.mismatch, + false, + ); + assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk'); +}); diff --git a/src/app/utils/linkSafety.ts b/src/app/utils/linkSafety.ts index 6a2682167..a6026a935 100644 --- a/src/app/utils/linkSafety.ts +++ b/src/app/utils/linkSafety.ts @@ -48,11 +48,53 @@ const TWO_LABEL_SUFFIXES = new Set([ '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('.'); - if (labels.length <= 2) return labels.join('.'); - const lastTwo = labels.slice(-2).join('.'); - return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo; + // 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 = @@ -72,6 +114,85 @@ export const hostFromText = (text: string): string | 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; @@ -92,8 +213,11 @@ export const analyzeLink = (text: string, href: string): LinkSafety | 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 }; + // 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 }; };