From 74d8e3119b0692c3ea2ad7d3cf0eaeeb8e47a1e8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 22:57:07 -0400 Subject: [PATCH] feat(security): confirm before opening a link whose text names a different site (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils/linkSafety.ts compares the registrable domain the visible text claims (when it looks like a URL/host) with the href's; a mismatch, or a punycode (IDN) destination, renders the anchor as SuspiciousLink, whose click opens a confirm — "It shows matrix.lotusguild.org but goes to evil.example." with the full URL, Cancel / Open anyway (opens in a new tab with noopener). Honest links are untouched: same registrable domain (youtube.com text over www.youtube.com, bbc.co.uk over news.bbc.co.uk), plain-word text, mailto:, matrix.to and Lotus permalinks, anchors with non-text children. Comparator unit-tested (incl. a Cyrillic-а paypal homograph); verified headless that the phish and IDN messages are flagged, the honest ones are not, the click shows the confirm and does not navigate, Cancel keeps you put, Open anyway opens the real target. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../suspicious-link/SuspiciousLink.tsx | 125 ++++++++++++++++++ src/app/components/suspicious-link/index.ts | 1 + src/app/plugins/react-custom-html-parser.tsx | 23 ++++ src/app/utils/linkSafety.test.ts | 40 ++++++ src/app/utils/linkSafety.ts | 99 ++++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 src/app/components/suspicious-link/SuspiciousLink.tsx create mode 100644 src/app/components/suspicious-link/index.ts create mode 100644 src/app/utils/linkSafety.test.ts create mode 100644 src/app/utils/linkSafety.ts diff --git a/src/app/components/suspicious-link/SuspiciousLink.tsx b/src/app/components/suspicious-link/SuspiciousLink.tsx new file mode 100644 index 000000000..ee9bffe1d --- /dev/null +++ b/src/app/components/suspicious-link/SuspiciousLink.tsx @@ -0,0 +1,125 @@ +import React, { MouseEvent, ReactNode, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, + color, + config, +} from 'folds'; +import { stopPropagation } from '../../utils/keyboard'; +import { LinkSafety } from '../../utils/linkSafety'; + +type SuspiciousLinkProps = { + href: string; + safety: LinkSafety; + anchorProps: Record; + children: ReactNode; +}; + +/** + * [Gitea #122] A link whose visible text names a different site than its + * destination (or whose destination is a punycode host) opens a small + * confirm instead of navigating straight away. Honest links never see this. + */ +export function SuspiciousLink({ href, safety, anchorProps, children }: SuspiciousLinkProps) { + const [open, setOpen] = useState(false); + + const handleClick = (evt: MouseEvent) => { + evt.preventDefault(); + evt.stopPropagation(); + setOpen(true); + }; + const proceed = () => { + setOpen(false); + window.open(href, '_blank', 'noopener,noreferrer'); + }; + + return ( + <> + + {children} + + {open && ( + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + + This link doesn't go where it says + + + setOpen(false)} + radii="300" + aria-label="Close" + > + + +
+ + + {safety.shownHost ? ( + <> + It shows {safety.shownHost} but goes to{' '} + {safety.realHost}. + + ) : ( + <> + It goes to {safety.realHost}. + + )} + {safety.punycode && + ' The destination uses look-alike (internationalised) characters in its name.'} + + + {href} + + + + + + +
+
+
+
+ )} + + ); +} diff --git a/src/app/components/suspicious-link/index.ts b/src/app/components/suspicious-link/index.ts new file mode 100644 index 000000000..677e9136b --- /dev/null +++ b/src/app/components/suspicious-link/index.ts @@ -0,0 +1 @@ +export * from './SuspiciousLink'; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 5368a9073..76bcc6622 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -23,6 +23,8 @@ import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import { stripTrackingParams } from '../utils/urlTracking'; +import { analyzeLink } from '../utils/linkSafety'; +import { SuspiciousLink } from '../components/suspicious-link'; import { lotusPermalinkToMatrixTo } from './lotus-permalink'; import * as css from '../styles/CustomHtml.css'; import { @@ -595,6 +597,27 @@ export const getReactCustomHtmlParser = ( if (mention) return mention; } + // [Gitea #122] Link text that names another site than the href (or a + // punycode destination) gets a confirm on click instead of a straight + // navigation. Only plain-text anchors are checked — an image or + // formatted child isn't a URL claim. + if (name === 'a' && typeof props.href === 'string' && !matrixHref) { + // Duck-typed: with duplicate domhandler copies (dev server) the + // parser's Text class is not the one we import, so instanceof lies. + const textOnly = children.every((c) => c.type === 'text'); + const visible = textOnly + ? children.map((c) => (c.type === 'text' ? (c as DOMText).data : '')).join('') + : ''; + const safety = textOnly ? analyzeLink(visible, String(props.href)) : null; + if (safety && (safety.mismatch || safety.punycode)) { + return ( + + {domToReact(children as unknown as DOMNode[], opts)} + + ); + } + } + if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) { // Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX // (block for
, inline for ). On failure fall back to the diff --git a/src/app/utils/linkSafety.test.ts b/src/app/utils/linkSafety.test.ts new file mode 100644 index 000000000..aaaad5ea9 --- /dev/null +++ b/src/app/utils/linkSafety.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { analyzeLink, hostFromText, 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'); + assert.equal(r?.mismatch, true); + assert.equal(r?.shownHost, 'matrix.lotusguild.org'); + assert.equal(r?.realHost, 'evil.example'); +}); + +test('honest links are not flagged: same registrable domain, plain words, non-http', () => { + assert.equal( + analyzeLink('youtube.com/watch?v=1', 'https://www.youtube.com/watch?v=1')?.mismatch, + false, + ); + assert.equal(analyzeLink('bbc.co.uk', 'https://news.bbc.co.uk/x')?.mismatch, false); + assert.equal(analyzeLink('click here', 'https://evil.example')?.mismatch, false); + assert.equal(analyzeLink('evil.example', 'mailto:someone@evil.example'), null); + assert.equal(analyzeLink('elsewhere.org', 'https://matrix.to/#/#room:x'), null); + assert.equal( + analyzeLink('lotusguild.org', 'https://chat.lotusguild.org/home/!r:x')?.mismatch, + false, + ); +}); + +test('registrable domain handles two-label suffixes', () => { + assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk'); + assert.equal(registrableDomain('www.example.com'), 'example.com'); + assert.equal(registrableDomain('example.com'), 'example.com'); +}); + +test('hostFromText only accepts URL/host-shaped text; IDN becomes punycode', () => { + assert.equal(hostFromText('Visit https://a.example/path'), null); + assert.equal(hostFromText('a.example/path'), 'a.example'); + assert.equal(hostFromText('user@a.example:8448/x'), 'a.example'); + assert.equal(hostFromText('pаypal.com'), 'xn--pypal-4ve.com'); // Cyrillic а + assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true); + assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true); +}); diff --git a/src/app/utils/linkSafety.ts b/src/app/utils/linkSafety.ts new file mode 100644 index 000000000..6a2682167 --- /dev/null +++ b/src/app/utils/linkSafety.ts @@ -0,0 +1,99 @@ +/** + * [Gitea #122] Phishing-shape detection for rendered links: the visible text + * looks like a URL/hostname but the real destination is a different site. + * + * https://matrix.lotusguild.org/login + * + * Compares registrable domains (so `youtube.com/x` text over a `www.youtube.com` + * href is honest), ignores non-http(s) targets, and surfaces punycode hosts + * so an IDN homograph can't hide behind a familiar-looking label. + */ + +// Two-label public suffixes we care about; everything else is eTLD+1 = last 2 labels. +const TWO_LABEL_SUFFIXES = new Set([ + 'co.uk', + 'org.uk', + 'ac.uk', + 'gov.uk', + 'me.uk', + 'ltd.uk', + 'plc.uk', + 'net.uk', + 'com.au', + 'net.au', + 'org.au', + 'edu.au', + 'gov.au', + 'co.nz', + 'org.nz', + 'net.nz', + 'co.jp', + 'ne.jp', + 'or.jp', + 'ac.jp', + 'com.br', + 'net.br', + 'org.br', + 'co.in', + 'net.in', + 'org.in', + 'co.za', + 'org.za', + 'com.mx', + 'com.ar', + 'com.tr', + 'com.cn', + 'com.hk', + 'com.sg', + 'com.tw', +]); + +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; +}; + +const HOST_LIKE = + /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^\s/?#@]+@)?([a-z0-9¡-￿-]+(?:\.[a-z0-9¡-￿-]+)+)(?::\d+)?(?:[/?#]|$)/i; + +/** The hostname a piece of visible text CLAIMS to point at, if it looks like one. */ +export const hostFromText = (text: string): string | null => { + const t = text.trim(); + if (!t || /\s/.test(t)) return null; + const m = HOST_LIKE.exec(t); + if (!m) return null; + try { + // Normalise through URL so unicode hosts become punycode like real hrefs do. + return new URL(`http://${m[1]}`).hostname; + } catch { + return null; + } +}; + +export type LinkSafety = { + /** The visible text names a different site than the destination. */ + mismatch: boolean; + shownHost?: string; + realHost: string; + /** Destination host contains an IDN (punycode) label. */ + punycode: boolean; +}; + +export const analyzeLink = (text: string, href: string): LinkSafety | null => { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 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 }; +};