Merge branch 'lotus' into fix/animated-bg-compositor
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m11s

This commit is contained in:
2026-10-16 19:59:31 -04:00
3 changed files with 178 additions and 8 deletions
+12 -1
View File
@@ -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);
+35
View File
@@ -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');
});
+131 -7
View File
@@ -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 };
};