Compare commits

...
Author SHA1 Message Date
jared d55a8ba8a5 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
2026-10-16 19:59:31 -04:00
jared 4b8c3219e9 Merge pull request 'fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)' (#360) from fix/link-safety-bypasses into lotus
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 12m20s
2026-10-16 19:59:19 -04:00
jared 47328d6fee Merge branch 'lotus' into fix/link-safety-bypasses
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m25s
2026-10-16 19:33:54 -04:00
jared b0eb3da830 Merge pull request 'test(e2e): allow the register probe's 403 now that sign-up is closed' (#362) from fix/e2e-register-403 into lotus
CI / Build & Quality Checks (push) Successful in 1m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m28s
2026-10-16 19:33:43 -04:00
Lotus CIandClaude Opus 5.5 f22f9f07a3 test(e2e): allow the register probe's 403 now that sign-up is closed
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 9m50s
matrix.lotusguild.org turned off open registration (2026-10-16; accounts
come from SSO/LLDAP), so the login page's registration probe
(POST /_matrix/client/v3/register) now answers 403 "Registration has been
disabled" instead of 401 + UIA flows. The boot smoke test treated that
console line as unexpected and failed on every branch (seen on #360).

collectConsole now skips a 403 only when its source URL is the register
endpoint; any other 403 still fails the test.

Verified: boot.spec passes locally in chromium, webkit and iphone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:12:15 -04:00
Lotus CIandClaude Opus 5.5 2700aad209 perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)
CI / Build & Quality Checks (pull_request) Successful in 1m57s
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) Failing after 9m56s
background-position can't run on the compositor, so every frame of the
animated chat backgrounds repainted the whole chat area: 51 to 72% main-thread
busy while idle in a room at 60 fps. The drifts are slow (3 to 22 px/s,
under a pixel per frame), so each animation now uses steps(): about 12
updates/s for fireflies, stars and aurora and about 24/s for rain and
pulse.

Measured headless (20 s idle in a room, CDP TaskDuration):
  none 0.7% → 0.9%; rain 50.7 → 5.9; stars 71.2 → 4.5;
  pulse 64.9 → 5.8; aurora 58.9 → 4.2; fireflies 71.7 → 3.8.

Aurora and pulse used ease-in-out; with steps() their motion is now
even-paced. Pause animations / reduced motion still strip the animation.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:05:52 -04:00
Lotus CIandClaude Opus 5.5 a8a7e639a9 fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)
CI / Build & Quality Checks (pull_request) Successful in 1m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Failing after 11m30s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 18:56:01 -04:00
9 changed files with 193 additions and 18 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);
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
animation: `${auroraFlow} 60s steps(180) infinite`,
};
const light: CSSProperties = {
@@ -75,7 +75,7 @@ const light: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
animation: `${auroraFlow} 60s steps(180) infinite`,
};
export const animAurora: ChatBgVariants = { dark, light };
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
animation: `${firefliesDrift} 44s steps(528) infinite`,
},
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
animation: `${firefliesDrift} 44s steps(528) infinite`,
},
};
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
animation: `${gridPulse} 22s steps(528) infinite`,
},
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
animation: `${gridPulse} 22s steps(528) infinite`,
},
};
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
animation: `${rainFall} 12s steps(288) infinite`,
},
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
animation: `${rainFall} 12s steps(288) infinite`,
},
};
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
'0 0', // nebula A
'0 0', // nebula B
].join(','),
animation: `${starDrift} 90s linear infinite`,
animation: `${starDrift} 90s steps(1080) infinite`,
},
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
'0 0', // wash A
'0 0', // wash B
].join(','),
animation: `${starDrift} 100s linear infinite`,
animation: `${starDrift} 100s steps(1200) infinite`,
},
};
+5
View File
@@ -149,6 +149,11 @@ export const getChatBg = (
}
// For animated backgrounds, promote the element to its own compositor layer so
// background-position keyframes don't trigger repaints on descendant elements.
// [Gitea #337] background-position can't run on the compositor, so every
// animation frame repaints the whole chat area (45 to 70% main-thread busy
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
// same, a fraction of the repaints.
if (style.animation) {
return { ...style, willChange: 'background-position', contain: 'paint' };
}
+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 };
};