Skip to content

Commit 50fbac4

Browse files
🛡️ Sentinel: [CRITICAL] Fix XSS in blog markdown parser (#1082)
* 🛡️ Sentinel: [CRITICAL] Fix XSS in blog markdown parser * 🛡️ Sentinel: [CRITICAL] Fix XSS in blog markdown parser --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: GDS K S <gagandua078@gmail.com>
1 parent f1a48e6 commit 50fbac4

2 files changed

Lines changed: 26 additions & 7 deletions

File tree

‎.jules/sentinel.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
**Learning:** Browsers do not escape single (`'`) or double (`"`) quotes when reading `.innerHTML` after assigning `.textContent`. If this "escaped" string is placed inside an HTML attribute (like `<button title="${escapeHtml(input)}">`), it can break out and cause XSS.
88
**Prevention:** Always use regex replacements or a robust library to escape HTML entities (`&`, `<`, `>`, `"`, `'`) for user input, especially when the output will be embedded within HTML attributes.
99

10+
## 2025-02-14 - XSS in Markdown rendering logic
11+
**Vulnerability:** The Markdown rendering logic in `src/app/blog/[slug]/page.tsx` directly used user input for HTML string formatting without HTML-escaping characters, allowing both raw HTML execution and `javascript:` protocol link injections.
12+
**Learning:** Custom markdown parsers that rely on string `.replace` and pass the output directly into `dangerouslySetInnerHTML` are highly susceptible to XSS if not carefully escaped at the correct step, particularly against attributes like `href`.
13+
**Prevention:** Always use a robust HTML-escaping utility before converting markdown tokens to HTML elements, and strictly block or filter unsafe protocols like `javascript:` when formatting user-provided URLs in `href` attributes.
1014
## 2026-09-16 - Sentinel Character Injection in Regex-based Syntax Highlighter
11-
**Vulnerability:** The `esc()` function in `src/components/icons/shared/syntax-highlight.tsx` failed to strip internal sentinel characters (\`\` and \`\`) before they were used as placeholders in regex replacements for syntax highlighting.
15+
**Vulnerability:** The `esc()` function in `src/components/icons/shared/syntax-highlight.tsx` failed to strip internal sentinel characters (`` and ``) before they were used as placeholders in regex replacements for syntax highlighting.
1216
**Learning:** When using internal sentinel characters for multi-pass string replacements, they must be stripped from the original user input first. Otherwise, an attacker can submit an SVG containing these characters, bypassing the intended markup structure and injecting arbitrary HTML/JavaScript (XSS) when the highlighted code is rendered.
1317
**Prevention:** Always strip custom sentinel markers from the input before any regex substitution logic begins. Better yet, avoid replacing string tags into `dangerouslySetInnerHTML` if possible, favoring a safer AST-based rendering approach.

‎src/app/blog/[slug]/page.tsx‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,21 +68,36 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
6868
};
6969
}
7070

71+
function escapeHtml(str: string): string {
72+
return str
73+
.replace(/&/g, "&amp;")
74+
.replace(/</g, "&lt;")
75+
.replace(/>/g, "&gt;")
76+
.replace(/"/g, "&quot;")
77+
.replace(/'/g, "&#39;");
78+
}
79+
7180
function processInline(text: string): string {
72-
return text
81+
return escapeHtml(text)
7382
.replace(/\*\*(.+?)\*\*/g, '<strong class="font-semibold text-foreground">$1</strong>')
7483
.replace(/`(.+?)`/g, (_match, code: string) => {
75-
const escaped = code.replace(/</g, "&lt;").replace(/>/g, "&gt;");
76-
return `<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] dark:bg-white/[0.06]">${escaped}</code>`;
84+
return `<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] dark:bg-white/[0.06]">${code}</code>`;
7785
})
7886
.replace(
7987
/\[(.+?)\]\((.+?)\)/g,
8088
(_match, label: string, href: string) => {
89+
const unescapedHref = href.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
90+
91+
// Prevent javascript: URIs (XSS vector)
92+
if (unescapedHref.trim().toLowerCase().startsWith("javascript:")) {
93+
return label;
94+
}
95+
8196
// External links get UTM-tagged (via withUtm, which no-ops for
8297
// mailto:/internal hrefs) and open in a new tab; internal links
8398
// keep the plain same-tab behavior.
84-
const isExternal = href.startsWith("http");
85-
const taggedHref = withUtm(href, "blog_post").replace(/&/g, "&amp;");
99+
const isExternal = unescapedHref.startsWith("http");
100+
const taggedHref = escapeHtml(withUtm(unescapedHref, "blog_post"));
86101
const extraAttrs = isExternal
87102
? ' target="_blank" rel="noopener noreferrer"'
88103
: "";
@@ -102,7 +117,7 @@ function renderMarkdown(body: string): string {
102117
// spliced back in after the paragraph pass.
103118
const codeBlocks: string[] = [];
104119
const withPlaceholders = body.replace(/```[a-z]*\n([\s\S]*?)\n```/g, (_match, code: string) => {
105-
const html = `<pre class="my-4 overflow-x-auto rounded-xl border border-border/40 bg-muted/30 p-4 font-mono text-xs text-foreground dark:border-white/[0.06] dark:bg-white/[0.03]"><code>${code.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</code></pre>`;
120+
const html = `<pre class="my-4 overflow-x-auto rounded-xl border border-border/40 bg-muted/30 p-4 font-mono text-xs text-foreground dark:border-white/[0.06] dark:bg-white/[0.03]"><code>${escapeHtml(code)}</code></pre>`;
106121
codeBlocks.push(html);
107122
return ` CODEBLOCK${codeBlocks.length - 1} `;
108123
});

0 commit comments

Comments
 (0)