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
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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 (
|
||||
<>
|
||||
<a
|
||||
{...anchorProps}
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
data-lotus-suspicious-link
|
||||
title={`Goes to ${safety.realHost}`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{open && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" style={{ maxWidth: 440 }}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4">
|
||||
This link doesn't go where it says
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="300"
|
||||
onClick={() => setOpen(false)}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text>
|
||||
{safety.shownHost ? (
|
||||
<>
|
||||
It shows <b>{safety.shownHost}</b> but goes to{' '}
|
||||
<b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
It goes to <b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
|
||||
</>
|
||||
)}
|
||||
{safety.punycode &&
|
||||
' The destination uses look-alike (internationalised) characters in its name.'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-all' }}>
|
||||
{href}
|
||||
</Text>
|
||||
<Box gap="200" justifyContent="End">
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => setOpen(false)}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button variant="Critical" onClick={proceed}>
|
||||
<Text size="B400">Open anyway</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SuspiciousLink';
|
||||
Reference in New Issue
Block a user