Skip to content

Commit 1a1bf86

Browse files
committed
fix(plan-diff): sanitize link hrefs against javascript: / data: schemes
PlanCleanDiffView has its own local copy of InlineMarkdown (separate from the one in Viewer.tsx). The link-rendering branch was passing the captured URL directly to href with no validation, so a plan containing [click me](javascript:alert(document.cookie)) would render as a live clickable anchor in the diff view. Plan content is attacker-influenced — Claude pulls from source comments, READMEs, fetched URLs — so this is a real exploit path in the diff flow. Port the same guard Viewer.tsx already has: sanitizeLinkUrl() rejects javascript:, data:, vbscript:, and file: schemes (case-insensitive, with optional leading whitespace). Rejected links render their anchor text as plain text instead of a clickable <a>, so the content is still visible to the reader but no longer dangerous. For provenance purposes, this commit was AI assisted.
1 parent c3e940d commit 1a1bf86

1 file changed

Lines changed: 38 additions & 12 deletions

File tree

packages/ui/components/plan-diff/PlanCleanDiffView.tsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,21 @@ const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => {
723723
);
724724
};
725725

726+
/**
727+
* Block dangerous link protocols (javascript:, data:, vbscript:, file:) from
728+
* rendering as clickable anchors in the diff view. Plan content is attacker-
729+
* influenced (Claude pulls from source comments, READMEs, fetched URLs), so
730+
* a malicious `[click me](javascript:...)` link embedded in a plan must not
731+
* render as a live <a>. Mirrors the same guard in Viewer.tsx; returns null
732+
* for blocked schemes so the caller can render the anchor text as plain
733+
* text instead of a clickable link.
734+
*/
735+
const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i;
736+
function sanitizeLinkUrl(url: string): string | null {
737+
if (DANGEROUS_PROTOCOL.test(url)) return null;
738+
return url;
739+
}
740+
726741
const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
727742
const parts: React.ReactNode[] = [];
728743
let remaining = text;
@@ -809,18 +824,29 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
809824
if (match) {
810825
// Recursively parse the anchor text so <ins>/<del> diff tags (and
811826
// other inline markdown) inside the link render correctly instead of
812-
// showing up as literal HTML tag text.
813-
parts.push(
814-
<a
815-
key={key++}
816-
href={match[2]}
817-
target="_blank"
818-
rel="noopener noreferrer"
819-
className="text-primary underline underline-offset-2 hover:text-primary/80"
820-
>
821-
<InlineMarkdown text={match[1]} />
822-
</a>
823-
);
827+
// showing up as literal HTML tag text. Sanitize the href: dangerous
828+
// schemes (javascript:, data:, vbscript:, file:) are rendered as
829+
// plain text instead of a live anchor to block XSS via plan content.
830+
const safeHref = sanitizeLinkUrl(match[2]);
831+
if (safeHref === null) {
832+
parts.push(
833+
<span key={key++}>
834+
<InlineMarkdown text={match[1]} />
835+
</span>
836+
);
837+
} else {
838+
parts.push(
839+
<a
840+
key={key++}
841+
href={safeHref}
842+
target="_blank"
843+
rel="noopener noreferrer"
844+
className="text-primary underline underline-offset-2 hover:text-primary/80"
845+
>
846+
<InlineMarkdown text={match[1]} />
847+
</a>
848+
);
849+
}
824850
remaining = remaining.slice(match[0].length);
825851
previousChar = match[0][match[0].length - 1] || previousChar;
826852
continue;

0 commit comments

Comments
 (0)