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
48 changes: 42 additions & 6 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { buildChromePalette, type ThemeSample, type ChromePalette } from "@/lib/docs/theme";
import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown";

// CommentsShell — the THIRD React surface (birthday.md "Production
// architecture", "CHOSEN: variant B"). The google-docs-style comment rail. The
Expand Down Expand Up @@ -479,6 +480,7 @@ export default function CommentsShell(props: Props) {
return (
<div className="jh-shell" data-theme={isDark ? "dark" : "light"} style={themeVars}>
<style>{RAIL_CSS}</style>
<style>{JH_MD_CSS}</style>
<div style={barStyle}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 700 }}>
{title}
Expand Down Expand Up @@ -896,12 +898,12 @@ const Card = forwardRef<
) : null}
<div style={{ minWidth: 0 }}>
<span style={{ fontWeight: 700, color: "var(--jh-card-fg, #222)" }}>{t.author ?? "someone"}</span>{" "}
<span style={{ color: "var(--jh-card-muted, #999)", fontSize: 10.5 }}>{fmtTime(t.created_at)}</span>{" "}
<span style={{ color: "var(--jh-card-muted, #999)", fontSize: 10.5 }}>{fmtTime(t.created_at)}</span>
{t.edited_at ? <span style={{ color: "var(--jh-card-faint, #aaa)", fontSize: 10.5 }}> (edited)</span> : null}{" "}
{t.resolved ? <Badge kind="res">resolved</Badge> : null}
{t.orphaned ? <Badge kind="orp">orphaned</Badge> : null}
<div style={{ color: "var(--jh-card-fg, #222)", marginTop: 2, fontFamily: "Georgia, serif", fontSize: 13, lineHeight: 1.45 }}>
Comment thread
cursor[bot] marked this conversation as resolved.
{t.body}
{t.edited_at ? <span style={{ color: "var(--jh-card-faint, #aaa)", fontSize: 10.5 }}> (edited)</span> : null}
<div style={{ marginTop: 2 }}>
<CommentMarkdown body={t.body} />
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
<Reactions reactions={t.reactions} canComment={canComment} onReact={onReact} />
</div>
Expand All @@ -924,10 +926,10 @@ const Card = forwardRef<
// eslint-disable-next-line @next/next/no-img-element
<img src={r.author_avatar} alt="" width={20} height={20} style={{ borderRadius: "50%", boxShadow: "0 0 0 1px var(--jh-avatar-ring, transparent)" }} />
) : null}
<div>
<div style={{ minWidth: 0 }}>
<span style={{ fontWeight: 700, color: "var(--jh-card-fg, #222)" }}>{r.author ?? "someone"}</span>{" "}
<span style={{ color: "var(--jh-card-muted, #999)", fontSize: 10.5 }}>{fmtTime(r.created_at)}</span>
<div style={{ color: "var(--jh-card-fg, #222)", fontFamily: "Georgia, serif", fontSize: 13, lineHeight: 1.45 }}>{r.body}</div>
<CommentMarkdown body={r.body} />
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
</div>
))}
Expand Down Expand Up @@ -1092,6 +1094,40 @@ const RAIL_CSS = `
}
`;

// Markdown styling for rendered comment bodies (CommentMarkdown -> .jh-md). The
// "familiar prose" treatment: keeps the rail's serif body, lifts structure with real
// bold, mono inline-code chips, a neutral scrolling code slab, indented lists, and a
// left-rule blockquote. Every color reads the --jh-card-* vars (set by paletteVars in
// dark, falling back to the light literals) so it themes both ways, and pre/table
// scroll so wide code never widens the ~320px rail.
const JH_MD_CSS = `
.jh-md { font-family: Georgia, "Times New Roman", serif; font-size: 13px; line-height: 1.5; color: var(--jh-card-fg, #222); overflow-wrap: anywhere; }
.jh-md > :first-child { margin-top: 0; }
.jh-md > :last-child { margin-bottom: 0; }
.jh-md p { margin: 0 0 8px; }
.jh-md strong { font-weight: 700; }
.jh-md em { font-style: italic; }
.jh-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.jh-md h1, .jh-md h2, .jh-md h3, .jh-md h4, .jh-md h5, .jh-md h6 { font-weight: 700; line-height: 1.3; margin: 12px 0 6px; }
.jh-md h1 { font-size: 1.25em; }
.jh-md h2 { font-size: 1.15em; }
.jh-md h3 { font-size: 1.05em; }
.jh-md h4, .jh-md h5, .jh-md h6 { font-size: 1em; }
.jh-md ul, .jh-md ol { margin: 0 0 8px; padding-left: 20px; }
.jh-md li { margin: 0 0 4px; }
.jh-md li:last-child { margin-bottom: 0; }
.jh-md li::marker { color: var(--jh-card-muted, #999); }
.jh-md li > ul, .jh-md li > ol { margin: 4px 0 0; }
.jh-md code { font-family: ${MONO}; font-size: 0.86em; background: var(--jh-chip-bg, #ececec); border-radius: 4px; padding: 1px 4px; overflow-wrap: anywhere; word-break: break-word; }
.jh-md pre { margin: 0 0 8px; padding: 8px 10px; background: var(--jh-chip-bg, #ececec); border: 1px solid var(--jh-card-border, #e2e2e2); border-radius: 6px; overflow-x: auto; max-width: 100%; }
.jh-md pre code { display: block; padding: 0; background: none; border-radius: 0; font-size: 12px; line-height: 1.45; white-space: pre; word-break: normal; overflow-wrap: normal; }
.jh-md blockquote { margin: 0 0 8px; padding: 2px 0 2px 10px; border-left: 2px solid var(--jh-card-border, #e2e2e2); color: var(--jh-card-muted, #999); }
.jh-md blockquote :last-child { margin-bottom: 0; }
.jh-md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; font-size: 12px; margin: 0 0 8px; }
.jh-md th, .jh-md td { border: 1px solid var(--jh-card-border, #e2e2e2); padding: 3px 6px; text-align: left; }
.jh-md hr { border: none; border-top: 1px solid var(--jh-card-border, #e2e2e2); margin: 10px 0; }
`;

// --------------------------- styles ---------------------------

// Transition applied to every themed chrome color so a late jh:theme refinement
Expand Down
81 changes: 81 additions & 0 deletions lib/docs/comments/CommentMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import CommentMarkdown from "./CommentMarkdown";

// renderToStaticMarkup keeps this a pure string assertion (no DOM/jsdom needed),
// matching the repo's node-env vitest setup.
const render = (body: string) => renderToStaticMarkup(<CommentMarkdown body={body} />);

describe("CommentMarkdown", () => {
it("renders GFM structure", () => {
const html = render(
"**bold** and `code`\n\n1. one\n2. two\n\n```sql\nSELECT 1\n```\n\n> quote"
);
expect(html).toContain("<strong>");
expect(html).toContain("<code>");
expect(html).toContain("<ol>");
expect(html).toContain("<pre>");
expect(html).toContain("<blockquote>");
});

it("renders a GFM table", () => {
const html = render("| a | b |\n| - | - |\n| 1 | 2 |");
expect(html).toContain("<table>");
});

it("neutralizes raw HTML — no live script element", () => {
const html = render("<script>alert(document.cookie)</script>");
expect(html).not.toContain("<script");
});

it("strips javascript: link hrefs", () => {
const html = render("[x](javascript:alert(1))");
expect(html).not.toContain("javascript:");
});

it("hardens links with target, rel, and data-no-pin", () => {
const html = render("[k](https://example.com)");
expect(html).toContain('href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain("noopener");
expect(html).toContain("noreferrer");
// a link click must not bubble to the card's click-to-pin handler
expect(html).toContain("data-no-pin");
});

it("strips images", () => {
const html = render("![x](https://evil.example/p.png)");
expect(html).not.toContain("<img");
});

it("keeps in-page (#fragment) links inline, not target=_blank", () => {
const frag = render("[jump](#section)");
expect(frag).toContain('href="#section"');
expect(frag).not.toContain('target="_blank"');
// external links still open a new tab
expect(render("[x](https://example.com)")).toContain('target="_blank"');
});

it("namespaces footnote ids per card with working forward + back links", () => {
const md = "needs a note[^1]\n\n[^1]: the note.";
// two cards in one tree must get distinct footnote ids (no cross-card collision)
const both = renderToStaticMarkup(
<>
<CommentMarkdown body={md} />
<CommentMarkdown body={md} />
</>
);
const defIds = [...both.matchAll(/id="([^"]*-fn-1)"/g)].map((m) => m[1]);
expect(defIds).toHaveLength(2);
expect(defIds[0]).not.toBe(defIds[1]);

// within one card, every #fragment link (forward jump + ↩ back-ref) resolves to
// an id in that card, and ids are ascii-safe (React 19 useId delimiters stripped)
const one = render(md);
const ids = new Set([...one.matchAll(/id="([^"]+)"/g)].map((m) => m[1]));
const targets = [...one.matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
expect(targets.length).toBeGreaterThanOrEqual(2);
expect(targets.every((t) => ids.has(t))).toBe(true);
expect([...ids].every((id) => /^[A-Za-z0-9_-]+$/.test(id))).toBe(true);
});
});
67 changes: 67 additions & 0 deletions lib/docs/comments/CommentMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { memo, useId, type ComponentPropsWithoutRef } from "react";
import ReactMarkdown, { type ExtraProps } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";

// Comment bodies are untrusted — any human grantee or agent can post them — and the
// rail renders in the justhtml.sh shell origin (cookies/session), not the sandboxed
// doc iframe, so this is a stored-XSS surface. react-markdown v10 is safe by default
// (raw HTML is inert, dangerous URL protocols are dropped); rehype-sanitize is
// defense-in-depth, so turning raw HTML on later can never escalate a comment into
// account takeover. Images are stripped: a remote <img> in a session origin is a
// tracking / CSRF-pixel vector with no value in a review thread.
//
// clobber is emptied because the only id/name attributes in the output are the
// footnote ids remark-rehype generates (raw HTML is off, so authors can't inject
// ids); those are namespaced per render below via clobberPrefix instead, which keeps
// each card's footnote refs pointing inside that card.
const schema = {
...defaultSchema,
tagNames: defaultSchema.tagNames?.filter((tag) => tag !== "img"),
clobber: [],
attributes: {
...defaultSchema.attributes,
a: [...(defaultSchema.attributes?.a ?? []), "target", "rel"],
},
};

const components = {
// data-no-pin: comment bodies render inside a click-to-pin card (see CommentsShell
// Card onClick); without it, clicking a link would also toggle the thread's pin.
// Only off-page links open a new tab — in-page (#fragment) links, e.g. footnote
// back-references, must navigate within the card rather than spawn a tab. The rest
// of the sanitized attributes (id, aria-*, data-footnote-*) are forwarded so
// footnote ref/backref anchors keep the ids their return links target.
a: ({ node, href, children, ...rest }: ComponentPropsWithoutRef<"a"> & ExtraProps) => {
const inPage = href?.startsWith("#") ?? false;
return (
<a {...rest} href={href} data-no-pin {...(inPage ? {} : { target: "_blank", rel: "noopener noreferrer" })}>
{children}
</a>
);
Comment thread
cursor[bot] marked this conversation as resolved.
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Task checkbox clicks toggle pin

Medium Severity

GFM task lists can render checkbox controls inside comment bodies, but only markdown links get data-no-pin. Clicks on those controls (or through disabled checkboxes onto the list row) bubble to the card’s click-to-pin handler and toggle pin/unpin instead of behaving like a checklist interaction.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 386ab64. Configure here.

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.

Verified this isn't reachable in practice, so leaving it as-is.

GFM task checkboxes render disabled (confirmed in the sanitized output: <input type="checkbox" disabled>), and disabled form controls don't dispatch click events — so a click on the checkbox never bubbles to the card's pin handler. Confirmed in a real browser by replicating the exact closest('[data-no-pin]') handler:

  • click the disabled checkbox → 0 pin toggles
  • click the row text → 1 toggle — but that's the existing body-click-to-pin behavior that applies to all comment text and predates this PR (plain-text bodies behaved identically).

The only genuinely new interactive element markdown introduces is links, which already carry data-no-pin. Happy to add data-no-pin to the checkbox as defense-in-depth if task lists ever become interactive, but as rendered (read-only/disabled) it can't toggle pin.


function CommentMarkdown({ body }: { body: string }) {
// Namespace footnote ids per comment so multiple cards in one shell DOM don't
// collide — a footnote ref must scroll to its own card's note, not another's. The
// id is stripped to ASCII (React 19's useId returns non-alphanumeric delimiters).
const prefix = useId().replace(/[^a-zA-Z0-9]/g, "") + "-";
return (
<div className="jh-md">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkRehypeOptions={{ clobberPrefix: prefix }}
rehypePlugins={[[rehypeSanitize, schema]]}
components={components}
>
{body}
</ReactMarkdown>
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
);
}

export default memo(CommentMarkdown);
Loading
Loading