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