diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index 191d1895a..c69f9c2fd 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -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 ;
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 ;
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 ;
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 (
<>
diff --git a/src/app/utils/linkSafety.test.ts b/src/app/utils/linkSafety.test.ts
index 7bd2f8a72..5d288e840 100644
--- a/src/app/utils/linkSafety.test.ts
+++ b/src/app/utils/linkSafety.test.ts
@@ -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, 'https://paypal.com'),
+ [],
+ );
+ // An honest link keeps its preview.
+ assert.deepEqual(
+ previewUrlsFor(urls, 'https://paypal.com'),
+ urls,
+ );
+ assert.deepEqual(previewUrlsFor(urls, undefined), urls);
+ // Other links in the message keep theirs.
+ assert.deepEqual(
+ previewUrlsFor(
+ ['https://paypal.com', 'https://lotusguild.org'],
+ 'see paypal.com and https://lotusguild.org',
+ ),
+ ['https://lotusguild.org'],
+ );
+});
diff --git a/src/app/utils/linkSafety.ts b/src/app/utils/linkSafety.ts
index a6026a935..73a793a4e 100644
--- a/src/app/utils/linkSafety.ts
+++ b/src/app/utils/linkSafety.ts
@@ -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 = /]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
+const decodeEntities = (s: string): string =>
+ s
+ .replace(/<[^>]+>/g, '')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/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();
+ 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;
+ }
+ });
+};