Skip to content

Commit 9396751

Browse files
committed
fix(shared): render safe raw HTML and GFM task lists in markdown descriptions
Real PR descriptions routinely embed raw HTML — bot-generated badges (<a><picture><source><img>), <sup> commit notes, <details> spoilers — alongside plain markdown, and HTML comments to hide metadata markers. The previous renderer escaped all of it into visible "&lt;sup&gt;" / "&lt;!-- ... --&gt;" garbage instead of rendering it. Now: - HTML comments are stripped, not shown. - A fixed allowlist of tags (a, img, picture/source, details/summary, table, sup/sub, etc.) renders as sanitized real markup — attributes outside a per-tag allowlist are dropped, href/src schemes are restricted to http(s)/mailto, and any <a> always gets a forced target="_blank" rel="noopener noreferrer" regardless of what the source specified. Everything else still falls through to plain-text escaping, unchanged. - Multi-line raw HTML (badge blocks, collapsible sections) is detected and consumed as one block, terminated at the next blank line. - `- [x]`/`- [ ]` task-list items render as real disabled checkboxes instead of literal bracket text. Verified against the real HTML in superset-sh/superset PR #6901's description (cubic.dev/CodeRabbit bot summaries) in a live browser.
1 parent 447afd0 commit 9396751

2 files changed

Lines changed: 254 additions & 10 deletions

File tree

packages/shared/src/review-report.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,4 +491,87 @@ describe("renderReviewReportHtml", () => {
491491
expect(html).toContain('id="diff-file-0"');
492492
expect(html).toContain('id="diff-file-1"');
493493
});
494+
495+
it("strips HTML comments from a description instead of showing them as text", () => {
496+
const html = renderReviewReportHtml({
497+
title: "Fix bug",
498+
generatedAt: "2026-01-01T00:00:00.000Z",
499+
description:
500+
"Real text.\n\n<!-- This is an auto-generated comment: bot marker -->\n\nMore text.",
501+
});
502+
expect(html).not.toContain("auto-generated comment");
503+
expect(html).not.toContain("&lt;!--");
504+
expect(html).toContain("Real text.");
505+
expect(html).toContain("More text.");
506+
});
507+
508+
it("renders an allowlisted inline HTML tag instead of escaping it", () => {
509+
const html = renderReviewReportHtml({
510+
title: "Fix bug",
511+
generatedAt: "2026-01-01T00:00:00.000Z",
512+
description:
513+
"Written for commit abc123. <sup>Updates on new commits.</sup>",
514+
});
515+
expect(html).toContain("<sup>Updates on new commits.</sup>");
516+
expect(html).not.toContain("&lt;sup&gt;");
517+
});
518+
519+
it("renders a multi-line raw HTML block (bot badge pattern) with sanitized attributes", () => {
520+
const html = renderReviewReportHtml({
521+
title: "Fix bug",
522+
generatedAt: "2026-01-01T00:00:00.000Z",
523+
description: [
524+
"Some text.",
525+
"",
526+
'<a href="https://example.com/pr/1" target="_self" rel="bogus">',
527+
"<picture>",
528+
'<source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.svg">',
529+
'<img alt="Review" src="https://example.com/badge.svg">',
530+
"</picture>",
531+
"</a>",
532+
"",
533+
"More text.",
534+
].join("\n"),
535+
});
536+
expect(html).toContain('<a href="https://example.com/pr/1"');
537+
// The source's own target/rel are never trusted — we always force ours.
538+
expect(html).toContain('target="_blank" rel="noopener noreferrer"');
539+
expect(html).not.toContain('target="_self"');
540+
expect(html).not.toContain("bogus");
541+
expect(html).toContain("<picture>");
542+
expect(html).toContain(
543+
'<source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.svg">',
544+
);
545+
expect(html).toContain(
546+
'<img alt="Review" src="https://example.com/badge.svg">',
547+
);
548+
expect(html).toContain("Some text.");
549+
expect(html).toContain("More text.");
550+
});
551+
552+
it("drops a disallowed tag and any event-handler/javascript: attribute, even inside allowed tags", () => {
553+
const html = renderReviewReportHtml({
554+
title: "Fix bug",
555+
generatedAt: "2026-01-01T00:00:00.000Z",
556+
description:
557+
'<script>alert(1)</script>\n\n<img src="x" onerror="alert(1)">\n\n<a href="javascript:alert(1)">click</a>',
558+
});
559+
expect(html).not.toContain("<script>");
560+
expect(html).not.toContain("onerror");
561+
expect(html).not.toContain("javascript:");
562+
});
563+
564+
it("renders GitHub task-list checkboxes instead of literal [x]/[ ] text", () => {
565+
const html = renderReviewReportHtml({
566+
title: "Fix bug",
567+
generatedAt: "2026-01-01T00:00:00.000Z",
568+
description: "- [x] Done thing\n- [ ] Todo thing",
569+
});
570+
expect(html).toContain(
571+
'<li class="task-list-item"><input type="checkbox" disabled checked> Done thing</li>',
572+
);
573+
expect(html).toContain(
574+
'<li class="task-list-item"><input type="checkbox" disabled> Todo thing</li>',
575+
);
576+
});
494577
});

packages/shared/src/review-report.ts

Lines changed: 171 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,127 @@ const META_SEPARATOR = "\n\t\t<span aria-hidden>·</span>\n\t\t";
563563
// unescaped in real prose.
564564
const CODE_PLACEHOLDER = "CODE";
565565

566-
/** Inline markdown within a single line/paragraph: code, bold, italic, links. */
566+
/**
567+
* Raw HTML a PR description is allowed to embed and have actually rendered,
568+
* instead of showing up as visible `&lt;sup&gt;` garbage. GitHub descriptions
569+
* routinely contain hand-written or bot-generated HTML (badges, `<details>`
570+
* spoilers, `<sup>`/`<br>`) alongside plain markdown — real CommonMark (and
571+
* GitHub's renderer) render both. Anything not on this list is left for
572+
* `escapeHtml` to neutralize as plain text, same as today.
573+
*/
574+
const ALLOWED_HTML_TAGS = [
575+
"a",
576+
"b",
577+
"i",
578+
"em",
579+
"strong",
580+
"code",
581+
"pre",
582+
"br",
583+
"hr",
584+
"sup",
585+
"sub",
586+
"small",
587+
"mark",
588+
"kbd",
589+
"p",
590+
"div",
591+
"span",
592+
"details",
593+
"summary",
594+
"table",
595+
"thead",
596+
"tbody",
597+
"tr",
598+
"td",
599+
"th",
600+
"ul",
601+
"ol",
602+
"li",
603+
"blockquote",
604+
"img",
605+
"picture",
606+
"source",
607+
"figure",
608+
"figcaption",
609+
] as const;
610+
const VOID_HTML_TAGS = new Set(["br", "hr", "img", "source"]);
611+
/** Attributes kept per tag; everything else (style, on*, id, class, …) is dropped. */
612+
const ALLOWED_HTML_ATTRS: Partial<
613+
Record<(typeof ALLOWED_HTML_TAGS)[number], string[]>
614+
> = {
615+
img: ["src", "alt", "title", "width", "height"],
616+
source: ["srcset", "src", "media", "type"],
617+
details: ["open"],
618+
td: ["align", "colspan", "rowspan"],
619+
th: ["align", "colspan", "rowspan"],
620+
};
621+
const SAFE_URL_SCHEME = /^(https?:|mailto:)/i;
622+
623+
// Matches only a complete `<tag ...>`, `<tag ... />`, or `</tag>` for a
624+
// tag name in ALLOWED_HTML_TAGS — anything else (a stray `<`, a "5 < 10"
625+
// comparison, a disallowed tag like `<script>`) simply doesn't match and
626+
// falls through to normal text escaping.
627+
const INLINE_HTML_TAG_PATTERN = new RegExp(
628+
`<\\/?(?:${ALLOWED_HTML_TAGS.join("|")})(?:\\s[^<>]*)?\\/?>`,
629+
"gi",
630+
);
631+
632+
/** Rebuilds one matched tag with only its allowlisted, scheme-checked attributes. */
633+
function sanitizeTag(tag: string): string {
634+
const isClosing = tag.startsWith("</");
635+
const name = /^<\/?([a-zA-Z][a-zA-Z0-9]*)/.exec(tag)?.[1]?.toLowerCase();
636+
if (!name) return "";
637+
if (isClosing) return VOID_HTML_TAGS.has(name) ? "" : `</${name}>`;
638+
639+
const allowedAttrs =
640+
ALLOWED_HTML_ATTRS[name as (typeof ALLOWED_HTML_TAGS)[number]] ?? [];
641+
const attrs: string[] = [];
642+
for (const match of tag.matchAll(/([a-zA-Z-:]+)\s*=\s*"([^"]*)"/g)) {
643+
const attrName = match[1]?.toLowerCase();
644+
const attrValue = match[2] ?? "";
645+
if (!attrName || !allowedAttrs.includes(attrName)) continue;
646+
if (
647+
(attrName === "src" || attrName === "href") &&
648+
!SAFE_URL_SCHEME.test(attrValue)
649+
) {
650+
continue;
651+
}
652+
attrs.push(`${attrName}="${escapeHtml(attrValue)}"`);
653+
}
654+
// A raw <a href> gets the same forced target/rel as markdown links below —
655+
// the source's own target/rel (if any) is never trusted or kept.
656+
if (name === "a" && /\shref\s*=/.test(tag)) {
657+
const hrefMatch = /href\s*=\s*"([^"]*)"/.exec(tag);
658+
const href = hrefMatch?.[1];
659+
if (href && SAFE_URL_SCHEME.test(href)) {
660+
attrs.unshift(`href="${escapeHtml(href)}"`);
661+
attrs.push('target="_blank"', 'rel="noopener noreferrer"');
662+
} else {
663+
return ""; // an <a> with no safe href isn't worth keeping as a tag
664+
}
665+
}
666+
const attrsHtml = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
667+
return `<${name}${attrsHtml}>`;
668+
}
669+
670+
/**
671+
* Escapes plain text but passes allowlisted HTML tags through (sanitized) as
672+
* real markup — used for both inline text and raw HTML blocks below.
673+
*/
674+
function sanitizeInlineHtml(text: string): string {
675+
let result = "";
676+
let lastIndex = 0;
677+
for (const match of text.matchAll(INLINE_HTML_TAG_PATTERN)) {
678+
result += escapeHtml(text.slice(lastIndex, match.index));
679+
result += sanitizeTag(match[0]);
680+
lastIndex = match.index + match[0].length;
681+
}
682+
result += escapeHtml(text.slice(lastIndex));
683+
return result;
684+
}
685+
686+
/** Inline markdown within a single line/paragraph: code, bold, italic, links, safe raw HTML. */
567687
function renderInline(text: string): string {
568688
const codeSpans: string[] = [];
569689
// Pull inline code out first so its literal contents never get treated as
@@ -574,7 +694,7 @@ function renderInline(text: string): string {
574694
return CODE_PLACEHOLDER;
575695
});
576696

577-
let html = escapeHtml(withPlaceholders);
697+
let html = sanitizeInlineHtml(withPlaceholders);
578698
// Links first — its own escaped brackets/parens would otherwise collide
579699
// with the bold/italic patterns below. A PR description is authored by
580700
// whoever opened the PR, not us — reject any scheme but http(s)/mailto so
@@ -598,13 +718,27 @@ function renderInline(text: string): string {
598718
return html.replaceAll(CODE_PLACEHOLDER, () => codeSpans[spanIndex++] ?? "");
599719
}
600720

721+
/** `- [x] done` / `- [ ] todo` — GitHub's task-list checkbox syntax. */
722+
function renderListItem(item: string): string {
723+
const task = /^\[([ xX])\]\s+(.*)$/.exec(item);
724+
if (!task) return `<li>${renderInline(item)}</li>`;
725+
const checked = task[1] !== " ";
726+
return `<li class="task-list-item"><input type="checkbox" disabled${checked ? " checked" : ""}> ${renderInline(task[2] ?? "")}</li>`;
727+
}
728+
601729
/**
602730
* Minimal markdown-to-HTML for a PR's own description — headers, fenced code
603-
* blocks, blockquotes, lists, and paragraphs with inline formatting. Not a
604-
* full CommonMark implementation (no nested lists, no tables); this package
605-
* stays dependency-free by design, same reasoning as parseUnifiedDiff above.
731+
* blocks, blockquotes, lists, task checkboxes, and paragraphs with inline
732+
* formatting (including a safe subset of raw HTML — see ALLOWED_HTML_TAGS).
733+
* Not a full CommonMark implementation (no nested lists, no tables); this
734+
* package stays dependency-free by design, same reasoning as
735+
* parseUnifiedDiff above.
606736
*/
607-
function renderMarkdown(markdown: string): string {
737+
function renderMarkdown(rawMarkdown: string): string {
738+
// HTML comments (bot-generated descriptions lean on these to hide
739+
// metadata markers) render as nothing, same as every real markdown
740+
// renderer — not as visible "&lt;!-- ... --&gt;" text.
741+
const markdown = rawMarkdown.replace(/<!--[\s\S]*?-->/g, "");
608742
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
609743
const blocks: string[] = [];
610744
let paragraph: string[] = [];
@@ -618,10 +752,9 @@ function renderMarkdown(markdown: string): string {
618752
};
619753
const flushList = () => {
620754
if (!list) return;
621-
const items = list.items
622-
.map((item) => `<li>${renderInline(item)}</li>`)
623-
.join("");
624-
blocks.push(`<${list.tag}>${items}</${list.tag}>`);
755+
blocks.push(
756+
`<${list.tag}>${list.items.map(renderListItem).join("")}</${list.tag}>`,
757+
);
625758
list = null;
626759
};
627760
const flushQuote = () => {
@@ -655,6 +788,25 @@ function renderMarkdown(markdown: string): string {
655788
continue;
656789
}
657790

791+
// A line opening or closing one of our allowed HTML tags starts a raw
792+
// HTML block — real bot-generated PR descriptions embed multi-line
793+
// blocks like `<a href=…><picture><source …><img …></picture></a>` or
794+
// `<details><summary>…</summary>…</details>` for badges/collapsibles.
795+
// Consumed verbatim through the next blank line (CommonMark's own
796+
// blank-line-terminated HTML block rule), then sanitized as one unit
797+
// rather than run through paragraph/list parsing.
798+
if (/^<\/?[a-zA-Z]/.test(line.trim())) {
799+
flushAll();
800+
const htmlLines: string[] = [line];
801+
i += 1;
802+
while (i < lines.length && !/^\s*$/.test(lines[i] ?? "")) {
803+
htmlLines.push(lines[i] ?? "");
804+
i += 1;
805+
}
806+
blocks.push(sanitizeInlineHtml(htmlLines.join("\n")));
807+
continue;
808+
}
809+
658810
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
659811
if (heading?.[1] && heading[2] !== undefined) {
660812
flushAll();
@@ -1143,6 +1295,15 @@ details.failure p { margin: 0.375rem 0 0; font-size: 0.875rem; line-height: 1.25
11431295
.markdown pre code { background: none; padding: 0; }
11441296
.markdown a { color: var(--foreground); text-decoration: underline; text-underline-offset: 2px; }
11451297
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
1298+
.markdown img, .markdown picture { max-width: 100%; height: auto; }
1299+
.markdown .task-list-item { list-style: none; margin-left: -1.25rem; }
1300+
.markdown .task-list-item input { margin-right: 0.375rem; vertical-align: middle; }
1301+
.markdown details { margin: 0.75rem 0; border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); border-radius: 8px; padding: 0.5rem 0.75rem; }
1302+
.markdown summary { cursor: pointer; font-weight: 500; }
1303+
.markdown details[open] summary { margin-bottom: 0.5rem; }
1304+
.markdown table { border-collapse: collapse; margin: 0.75rem 0; font-size: 0.8125rem; }
1305+
.markdown th, .markdown td { border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); padding: 0.375rem 0.625rem; text-align: left; }
1306+
.markdown th { background: color-mix(in oklab, var(--muted) 35%, transparent); font-weight: 600; }
11461307
</style>
11471308
</head>
11481309
<body>

0 commit comments

Comments
 (0)