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
6 changed files with 81 additions and 32 deletions
@@ -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 (
<>
+2 -14
View File
@@ -12,8 +12,7 @@ import {
markAutoTranslateTried,
} from '../state/translation';
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
import { sameLanguage, translateSkipMessage } from '../utils/translation/langUtils';
import { toastQueueAtom } from '../state/toast';
import { sameLanguage } from '../utils/translation/langUtils';
export type MsgTranslationStatus =
| 'idle' // not translating (original shown)
@@ -53,7 +52,6 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
// Write-only: reading the whole cache array here (via useAtom) would
// resubscribe this message to every other message's writes. [Gitea #39]
const setCache = useSetAtom(translationCacheAtom);
const setToast = useSetAtom(toastQueueAtom);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale
@@ -126,16 +124,6 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
if (!from || sameLanguage(from, targetLang)) {
setStatus('skipped');
setActive(false);
// [Gitea #287] Say why nothing happened instead of silently undoing
// the click (no chip shows once translation is switched back off).
setToast({
id: `translate-skipped-${eventId}-${Date.now()}`,
displayName: 'Translate',
body: translateSkipMessage(from, targetLang),
roomName: '',
roomId: '',
onClick: () => undefined,
});
return;
}
setFromLang(from);
@@ -166,7 +154,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
// Invalidate this run's writes if inputs change / unmount.
runIdRef.current += 1;
};
}, [active, cached, supported, text, targetLang, key, setActive, setCache, setToast, eventId]);
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
// When switched off, drop back to idle (keep the cached translation around so
// toggling back on is instant).
+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;
}
});
};
-6
View File
@@ -85,9 +85,3 @@ export function languageName(code: string, uiLocale = 'en'): string {
export function isSupportedTargetLang(code: string): boolean {
return CURATED_NAME.has(normalizeLang(code));
}
/** Pure: why a manual Translate did nothing (#287). */
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
from
? `This message is already in ${languageName(targetLang) || targetLang}.`
: "Couldn't tell what language this message is in, so it wasn't translated.";
@@ -1,8 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { translateSkipMessage } from './langUtils';
test('translateSkipMessage explains a Translate that did nothing (#287)', () => {
assert.equal(translateSkipMessage('en', 'en'), 'This message is already in English.');
assert.match(translateSkipMessage(undefined, 'en'), /^Couldn't tell what language/);
});