Pi Agent: Potential XSS in HTML session exports via Markdown URL sanitization bypass
Description
CVE-2026-54326 is a stored XSS via sanitization bypass using C0 control characters in Markdown URLs in Pi HTML session exports, fixed in version 0.78.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2026-54326 is a stored XSS via sanitization bypass using C0 control characters in Markdown URLs in Pi HTML session exports, fixed in version 0.78.1.
Vulnerability
Pi HTML exports render session Markdown into a static HTML file. Affected versions did not consistently reject unsafe Markdown link and image URL schemes. In versions with scheme filtering, C0 control characters in the URL scheme could bypass the check because browsers normalize those characters before navigation [1][2]. The vulnerability affects @mariozechner/pi-coding-agent from version 0.27.5 up to 0.73.1 and @earendil-works/pi-coding-agent from 0.74.0 up to (but not including) 0.78.1. The old @mariozechner/pi-coding-agent package has no patched release [2].
Exploitation
An attacker must first get suitable malicious Markdown into a session, for example via prompt injection that causes the model to include an unsafe link, or through other untrusted session content [2][3]. The user must then export the session as HTML, open or share that file, and click the crafted link [2][3].
Impact
If triggered, script runs in the exported document, not in pi or the user's shell. The main risk is limited disclosure of data embedded in that exported session file [2][3].
Mitigation
The fix was committed on 2026-06-02 and released in version 0.78.1 of @earendil-works/pi-coding-agent on 2026-06-04 [1][3]. Version 0.78.1 sanitizes Markdown link and image URLs with an allow-list after stripping C0 control characters [1][2]. Users of the old @mariozechner/pi-coding-agent scope should migrate to the new @earendil-works/pi-coding-agent package and upgrade to 0.78.1 or later. Regenerate shared HTML exports after upgrading if the underlying sessions contained untrusted content [2][3].
AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: >= 0.74.0, < 0.78.1
Patches
16cb23f9b5d5bFix HTML export URL sanitization
3 files changed · +30 −11
packages/coding-agent/CHANGELOG.md+1 −0 modified@@ -10,6 +10,7 @@ ### Fixed +- Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). - Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)).
packages/coding-agent/src/core/export-html/template.js+19 −6 modified@@ -613,6 +613,18 @@ .replace(/'/g, '''); } + function sanitizeMarkdownUrl(value) { + const href = String(value || '').trim().replace(/[\x00-\x1f\x7f]/g, ''); + if (!href) return href; + + const scheme = href.match(/^([A-Za-z][A-Za-z0-9+.-]*):/); + if (scheme && !/^(https?|mailto|tel|ftp)$/i.test(scheme[1])) { + return null; + } + + return href; + } + /** * Truncate string to maxLen chars, append "..." if truncated. */ @@ -1569,10 +1581,11 @@ } }, renderer: { - // Sanitize link URLs to prevent javascript:/vbscript:/data: XSS + // Sanitize link URLs with a scheme allow-list. Browsers strip C0 + // controls from schemes, so strip them before checking and emitting. link(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { + const href = sanitizeMarkdownUrl(token.href); + if (href === null) { return this.parser.parseInline(token.tokens); } let out = '<a href="' + escapeHtml(href) + '"'; @@ -1582,10 +1595,10 @@ out += '>' + this.parser.parseInline(token.tokens) + '</a>'; return out; }, - // Sanitize image src URLs + // Sanitize image src URLs with the same scheme allow-list. image(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { + const href = sanitizeMarkdownUrl(token.href); + if (href === null) { return escapeHtml(token.text || ''); } let out = '<img src="' + escapeHtml(href) + '" alt="' + escapeHtml(token.text || '') + '"';
packages/coding-agent/test/export-html-xss.test.ts+10 −5 modified@@ -4,15 +4,20 @@ import { describe, expect, it } from "vitest"; describe("export HTML markdown link sanitization", () => { const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); - it("overrides the marked link renderer to block javascript: protocol", () => { - // The custom link renderer must check for dangerous protocols + it("overrides the marked link renderer to use scheme allow-list sanitization", () => { expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/); - expect(templateJs).toMatch(/javascript/i); - expect(templateJs).toMatch(/vbscript/i); + expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/); + expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/); }); - it("overrides the marked image renderer to block javascript: protocol", () => { + it("overrides the marked image renderer to use scheme allow-list sanitization", () => { expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/); + expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/); + }); + + it("strips C0 controls before checking and emitting markdown URLs", () => { + expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')"); + expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i); }); it("escapes href attributes in the custom link renderer", () => {
Vulnerability mechanics
Root cause
"The Markdown-to-HTML renderer did not strip C0 control characters before checking URL schemes, allowing an attacker to bypass the scheme blocklist by embedding control characters that browsers normalize."
Attack vector
An attacker injects Markdown containing a link or image with a dangerous URL scheme (e.g., `javascript:`) into a Pi session, for instance through prompt injection that causes the model to include an unsafe link. When the user exports the session as HTML and clicks the crafted link, the browser executes the injected script in the context of the exported document. The previous scheme check could be bypassed by embedding C0 control characters in the scheme, because browsers normalize those characters before navigation [ref_id=1].
What the fix does
The patch introduces a `sanitizeMarkdownUrl()` function that first strips C0 control characters (`\x00-\x1f\x7f`) from the URL, then extracts the scheme and checks it against an allow-list of `https?`, `mailto`, `tel`, and `ftp`. If the scheme is not allowed, the function returns `null`, causing the renderer to drop the link/image and render only the plain text. This closes the bypass because control characters are removed before the scheme check, so a scheme like `\x00javascript:` is normalized to `javascript:` by the browser but is caught as disallowed by the sanitizer [patch_id=6216867].
Preconditions
- inputThe attacker must inject Markdown with a dangerous URL scheme into a Pi session (e.g., via prompt injection or untrusted session content).
- authThe user must export the session as HTML and then click or interact with the crafted link/image in the exported file.
Generated on Jun 16, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.