Files
cinny/src/app/utils/linkSafety.test.ts
T
jaredandClaude Opus 5 74d8e3119b feat(security): confirm before opening a link whose text names a different site (#122)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:57:07 -04:00

41 lines
1.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});