Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions desktop/src/shared/lib/maskedLink.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isMaskedLink } from "./maskedLink.ts";

test("masked when label text differs from destination", () => {
assert.equal(isMaskedLink("click here", "https://example.com"), true);
assert.equal(
isMaskedLink("the docs", "https://docs.example.com/guide"),
true,
);
});

test("masked when label is a different URL (spoof)", () => {
assert.equal(isMaskedLink("https://good.com", "https://evil.com"), true);
assert.equal(
isMaskedLink("example.com/safe", "https://example.com/pwn"),
true,
);
});

test("not masked when label matches href exactly", () => {
assert.equal(
isMaskedLink("https://example.com/a/b", "https://example.com/a/b"),
false,
);
});

test("not masked across cosmetic differences", () => {
// scheme omitted in label
assert.equal(isMaskedLink("example.com", "https://example.com"), false);
// trailing slash
assert.equal(isMaskedLink("example.com", "https://example.com/"), false);
// GFM autolink of www URLs prefixes http://
assert.equal(
isMaskedLink("www.example.com", "http://www.example.com"),
false,
);
// case-insensitive
assert.equal(isMaskedLink("Example.COM", "https://example.com"), false);
// surrounding whitespace in label
assert.equal(isMaskedLink(" example.com ", "https://example.com"), false);
});

test("not masked when there is nothing useful to compare", () => {
assert.equal(isMaskedLink("", "https://example.com"), false);
assert.equal(isMaskedLink(" ", "https://example.com"), false);
assert.equal(isMaskedLink("label", ""), false);
});

test("non-http schemes are never masked", () => {
assert.equal(isMaskedLink("email me", "mailto:a@b.com"), false);
assert.equal(
isMaskedLink("open thread", "buzz://message?channel=x&id=y"),
false,
);
});

test("masked on scheme downgrade even when host matches", () => {
assert.equal(
isMaskedLink("https://accounts.example.com", "http://accounts.example.com"),
true,
);
});

test("not masked when label repeats href scheme exactly", () => {
assert.equal(isMaskedLink("http://example.com", "http://example.com"), false);
});

test("masked when path or query differ only by case", () => {
assert.equal(
isMaskedLink(
"https://example.com/Reset?token=ABC123",
"https://example.com/reset?token=abc123",
),
true,
);
});

test("host case differences alone are not masked", () => {
assert.equal(
isMaskedLink("Example.COM/path", "https://example.com/path"),
false,
);
});

test("protocol-relative hrefs are covered", () => {
assert.equal(isMaskedLink("company wiki", "//evil.example/login"), true);
assert.equal(
isMaskedLink("//evil.example/login", "//evil.example/login"),
false,
);
});

test("labels with userinfo are always masked", () => {
// Visually reads as safe.com but actually resolves to evil.com
assert.equal(isMaskedLink("safe.com@evil.com", "https://evil.com"), true);
assert.equal(
isMaskedLink("https://safe.com@evil.com", "https://evil.com"),
true,
);
});
62 changes: 62 additions & 0 deletions desktop/src/shared/lib/maskedLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* A link is "masked" when its visible text is not the destination URL —
* `[click here](https://example.com)` — so the reader can't tell where it
* goes. Masked links get a hover tooltip showing the full destination
* (anti-phishing, mirrors Slack). Bare pasted URLs and GFM autolinks are
* not masked: their label already is the URL, modulo cosmetic differences
* (omitted scheme, trailing slash, host case).
*
* Deliberately strict where it matters: scheme downgrades (label says
* https, href is http), path/query case changes, and protocol-relative
* hrefs all count as masked.
*/
export function isMaskedLink(label: string, href: string): boolean {
const trimmedLabel = label.trim();
if (!trimmedLabel) return false;

const hrefUrl = parseExternal(href);
if (!hrefUrl) return false;

const labelUrl = parseExternal(trimmedLabel, { assumeScheme: true });
if (!labelUrl) return true; // label isn't URL-shaped at all

// A label with userinfo (`safe.com@evil.com`) visually leads with the
// wrong host — always treat it as masked.
if (labelUrl.username || labelUrl.password) return true;

// If the label spells out a scheme, it must match the destination's —
// otherwise [https://x](http://x) silently downgrades.
const labelHasScheme = /^https?:\/\//i.test(trimmedLabel);
if (labelHasScheme && labelUrl.protocol !== hrefUrl.protocol) return true;

// Host comparison is case-insensitive (the URL parser lowercases it);
// path/query/hash stay case-sensitive — many backends distinguish them.
return (
labelUrl.host !== hrefUrl.host ||
normalizePath(labelUrl.pathname) !== normalizePath(hrefUrl.pathname) ||
labelUrl.search !== hrefUrl.search ||
labelUrl.hash !== hrefUrl.hash
);
}

function parseExternal(
value: string,
{ assumeScheme = false }: { assumeScheme?: boolean } = {},
): URL | null {
let candidate = value;
if (/^\/\//.test(candidate)) {
candidate = `https:${candidate}`;
} else if (assumeScheme && !/^https?:\/\//i.test(candidate)) {
candidate = `https://${candidate}`;
}
if (!/^https?:\/\//i.test(candidate)) return null;
try {
return new URL(candidate);
} catch {
return null;
}
}

function normalizePath(pathname: string): string {
return pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname;
}
9 changes: 8 additions & 1 deletion desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { MarkdownInput } from "./markdown/MarkdownInput";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { resolveFileCard } from "./markdownFileCard";
import type {
Expand Down Expand Up @@ -1658,7 +1659,7 @@ function createMarkdownComponents(
: null;
const isLinearLink = supportedLinkPreview?.kind === "linear-issue";

return (
const anchor = (
<a
{...props}
className={cn(
Expand All @@ -1672,6 +1673,12 @@ function createMarkdownComponents(
{children}
</a>
);

return (
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
Comment on lines +1677 to +1680

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress masked-link tooltips inside hidden spoilers

When the masked link is inside an unrevealed spoiler, this wrapper still installs a Radix tooltip trigger on the hidden anchor. The spoiler CSS only makes .buzz-spoiler__content transparent; it does not disable pointer/focus events, and the tooltip content is portaled outside the spoiler, so hovering or tab-focusing ||[secret](https://private.example/path)|| reveals the URL before the spoiler is opened. Please disable the tooltip while the trigger is within .buzz-spoiler[data-revealed="false"] or otherwise prevent hidden spoiler descendants from opening it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea27128.

SpoilerInline now provides a SpoilerHiddenContext (true while unrevealed, and always true for inert preview spoilers); MaskedLinkTooltip consumes it and renders the plain anchor with no Radix trigger while hidden, so neither hover nor focus can open the tooltip. It re-enables reactively when the spoiler is revealed.

Added an e2e regression in spoiler.spec.ts asserting the URL tooltip stays closed on both hover and focus of a masked link inside a hidden spoiler, then appears normally after reveal. The focus vector was the reproducible leak (the particles overlay intercepts the pointer, but focus bypasses it).

);
},
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground [&>*:first-child]:mt-0 [&>*+*]:mt-2">
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/shared/ui/markdown/MaskedLinkTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from "react";

import { isMaskedLink } from "@/shared/lib/maskedLink";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { SpoilerHiddenContext } from "./SpoilerInline";

/**
* Masked link (`[text](url)` where the text hides the destination): reveal
* the full URL on hover so readers can see where a "click here" actually
* goes before clicking. Bare/autolinked URLs render children unchanged —
* their label already is the destination.
*/
export function MaskedLinkTooltip({
href,
label,
disabled = false,
children,
}: {
href: string | undefined;
label: string;
disabled?: boolean;
children: React.ReactElement;
}) {
// A masked link inside an unrevealed spoiler must not expose its URL via a
// hover/focus tooltip — that would leak the spoiler's content early.
const hiddenInSpoiler = React.useContext(SpoilerHiddenContext);
if (disabled || hiddenInSpoiler || !href || !isMaskedLink(label, href)) {
return children;
}
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{/* pointer-events-none: the tooltip is purely informational and must
never intercept clicks meant for the link (or a spoiler wrapping
it) when it opens mid-interaction.
overflow-wrap:anywhere (not break-all): most URLs fit on one line
at max-w-md; only genuinely long ones wrap, breaking at natural
boundaries rather than mid-word. Never truncate — the full
destination must stay auditable for the anti-phishing use case. */}
<TooltipContent className="pointer-events-none max-w-md [overflow-wrap:anywhere]">
{href}
</TooltipContent>
</Tooltip>
);
}
24 changes: 20 additions & 4 deletions desktop/src/shared/ui/markdown/SpoilerInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import * as React from "react";
import { hasBlockMedia } from "../markdownUtils";
import { SpoilerParticles } from "../SpoilerParticles";

/**
* True for descendants of a spoiler that is currently hidden. Consumers (e.g.
* `MaskedLinkTooltip`) use it to suppress hover/focus affordances that would
* otherwise leak masked content before the spoiler is revealed. Default
* `false` — content outside any spoiler is never hidden.
*/
export const SpoilerHiddenContext = React.createContext(false);

export function SpoilerInline({
block = false,
children,
Expand Down Expand Up @@ -80,7 +88,9 @@ export function SpoilerInline({
>
<SpoilerParticles active contentRef={contentRef} />
<div className="buzz-spoiler__content" ref={setContentElement}>
{children}
<SpoilerHiddenContext.Provider value={true}>
{children}
</SpoilerHiddenContext.Provider>
</div>
</div>
);
Expand All @@ -94,7 +104,9 @@ export function SpoilerInline({
>
<SpoilerParticles active contentRef={contentRef} />
<span className="buzz-spoiler__content" ref={setContentElement}>
{children}
<SpoilerHiddenContext.Provider value={true}>
{children}
</SpoilerHiddenContext.Provider>
</span>
</span>
);
Expand All @@ -110,7 +122,9 @@ export function SpoilerInline({
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<div className="buzz-spoiler__content" ref={setContentElement}>
{children}
<SpoilerHiddenContext.Provider value={!revealed}>
{children}
</SpoilerHiddenContext.Provider>
</div>
</div>
);
Expand All @@ -125,7 +139,9 @@ export function SpoilerInline({
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<span className="buzz-spoiler__content" ref={setContentElement}>
{children}
<SpoilerHiddenContext.Provider value={!revealed}>
{children}
</SpoilerHiddenContext.Provider>
</span>
</span>
);
Expand Down
Loading
Loading