diff --git a/e2e/helpers.ts b/e2e/helpers.ts index e3d251c69..006d20acb 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -42,7 +42,18 @@ export function collectConsole(page: Page): ConsoleCollector { const errors: string[] = []; const pageErrors: string[] = []; page.on('console', (msg) => { - if (msg.type() === 'error') errors.push(msg.text()); + if (msg.type() !== 'error') return; + // The login page's registration probe (POST /_matrix/client/v3/register) + // answers 403 "Registration has been disabled" on a homeserver with sign-up + // closed (matrix.lotusguild.org since 2026-10-16: accounts come from SSO). + // Allowed only for that URL so any other 403 still fails the test. + if ( + /status of 403/.test(msg.text()) && + /\/_matrix\/client\/v3\/register/.test(msg.location().url) + ) { + return; + } + errors.push(msg.text()); }); page.on('pageerror', (err) => { pageErrors.push(err.message); 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 }; };