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; + } + }); +};