Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 7b3d1bad8f fix(previews): no genuine preview card for a site a deceptive link only pretends to be (#286)
CI / Build & Quality Checks (pull_request) Successful in 3m6s
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 21m16s
Previews are built from the plain body, clicks follow formatted_body's
hrefs, and a sender controls both: text "https://paypal.com" linked to
evil.example showed a real PayPal card. Drop body URLs whose site is
only claimed by the visible text of a mismatched link (linkSafety).

Fixes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:05:06 -04:00
4 changed files with 80 additions and 16 deletions
+1 -12
View File
@@ -44,9 +44,6 @@ export function ConsentRequiredPrompt() {
const [opened, setOpened] = useState(false);
const [blockedCount, setBlockedCount] = useState(0);
const snoozedUntil = useRef(0);
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
const closingForRetry = useRef(false);
const blocked = useRef(new Map<MatrixEvent, Room>());
const modalStyle = useModalStyle(440);
const server = mx.getDomain() ?? 'Your homeserver';
@@ -77,8 +74,6 @@ export function ConsentRequiredPrompt() {
}, [consentUri]);
const retry = useCallback(() => {
closingForRetry.current = true;
snoozedUntil.current = 0;
setOpen(false);
setOpened(false);
const pending = Array.from(blocked.current.entries());
@@ -102,13 +97,7 @@ export function ConsentRequiredPrompt() {
focusTrapOptions={{
initialFocus: '#consent-review',
fallbackFocus: '#consent-later',
onDeactivate: () => {
if (closingForRetry.current) {
closingForRetry.current = false;
return;
}
later();
},
onDeactivate: later,
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
import { previewUrlsFor } from '../../utils/linkSafety';
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
const COLLAPSE_MAX_HEIGHT_REM = 20;
@@ -343,7 +344,13 @@ export function MText({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -401,7 +408,13 @@ export function MEmote({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -458,7 +471,13 @@ export function MNotice({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
+23 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { analyzeLink, hostFromText, registrableDomain } from './linkSafety';
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
test('visible text that names another site is a mismatch', () => {
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
@@ -73,3 +73,25 @@ test('shared hosting suffixes are separate sites (#335)', () => {
);
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
});
test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => {
const urls = ['https://paypal.com'];
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://evil.example/login">https://paypal.com</a>'),
[],
);
// An honest link keeps its preview.
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://www.paypal.com/x">https://paypal.com</a>'),
urls,
);
assert.deepEqual(previewUrlsFor(urls, undefined), urls);
// Other links in the message keep theirs.
assert.deepEqual(
previewUrlsFor(
['https://paypal.com', 'https://lotusguild.org'],
'see <a href="https://evil.example">paypal.com</a> and https://lotusguild.org',
),
['https://lotusguild.org'],
);
});
+34
View File
@@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
const other = shown.find((h) => registrableDomain(h) !== realSite);
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
};
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
const decodeEntities = (s: string): string =>
s
.replace(/<[^>]+>/g, '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
/**
* [Gitea #286] The URLs to build link previews for. Previews come from the
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
* controls both: text reading "https://paypal.com" linked to evil.example got
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
* deceptive link's text.
*/
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
if (!formattedBody || urls.length === 0) return urls;
const claimed = new Set<string>();
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
});
if (claimed.size === 0) return urls;
return urls.filter((u) => {
try {
return !claimed.has(registrableDomain(new URL(u).hostname));
} catch {
return true;
}
});
};