fix(coding-agent/export): prevented HTML template $ substitution expansions

- Switched template CSS/JS inlining replacements to callback form in generation scripts to avoid `$` replacement expansion semantics.
- Updated HTML export generation to use callback-based replacements for theme variables and session data injection to prevent accidental `$` substitution parsing.
- Added regression tests for the inlined script ensuring literal `$'` regex tokens remain intact, no closing HTML tags are injected, and the script parses via `new Function`.
This commit is contained in:
can1357
2026-05-12 11:15:36 +02:00
parent c42c2313a7
commit 075e2f47ad
5 changed files with 55 additions and 9 deletions
@@ -18,10 +18,11 @@ const minifiedCss = css
.replace(/\s*([{}:;,])\s*/g, "$1")
.trim();
// Inline everything
// Inline everything; use function replacements so `$'`, `$&`, `$$`, etc. inside
// the embedded CSS/JS are not interpreted as substitution patterns.
const template = html
.replace("<template-css/>", `<style>${minifiedCss}</style>`)
.replace("<template-js/>", `<script>${js}</script>`);
.replace("<template-css/>", () => `<style>${minifiedCss}</style>`)
.replace("<template-js/>", () => `<script>${js}</script>`);
// Write generated file
const output = `// Auto-generated by scripts/generate-template.ts - DO NOT EDIT
@@ -103,9 +103,12 @@ async function generateHtml(sessionData: SessionData, themeName?: string): Promi
const themeVars = await generateThemeVars(themeName);
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toBase64();
return TEMPLATE.replace("<theme-vars/>", `<style>:root { ${themeVars} }</style>`).replace(
// Use function replacements so `$'`, `$&`, `$$`, `$n`, etc. in the
// substituted CSS/base64 are not interpreted as substitution patterns
// (see https://mdn.io/String.replace).
return TEMPLATE.replace("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`).replace(
"{{SESSION_DATA}}",
sessionDataBase64,
() => sessionDataBase64,
);
}
File diff suppressed because one or more lines are too long
@@ -17,8 +17,9 @@ export async function getTemplate(): Promise<string> {
.replace(/\s*([{}:;,])\s*/g, "$1")
.trim();
// Inline everything
// Inline everything; use function replacements so `$'`, `$&`, `$$`, etc.
// inside the embedded CSS/JS are not interpreted as substitution patterns.
return html
.replace("<template-css/>", `<style>${minifiedCss}</style>`)
.replace("<template-js/>", `<script>${js}</script>`);
.replace("<template-css/>", () => `<style>${minifiedCss}</style>`)
.replace("<template-js/>", () => `<script>${js}</script>`);
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "bun:test";
import { TEMPLATE } from "../../src/export/html/template.generated";
// Regression: `String.prototype.replace(string, string)` treats `$'`, `$&`,
// `$$`, `$n`, etc. as substitution patterns. The inlined `<script>` body now
// contains JS regex literals like `'\\s*Cell\\b\\s*(.*)$'` whose trailing `$'`
// would be expanded to "the text after `<template-js/>`" (i.e. `</body></html>`)
// if the replacement is a plain string instead of a function. That spliced the
// closing HTML tags into the middle of a regex string and produced
// `Uncaught SyntaxError: Invalid or unexpected token` at runtime.
// The fix is to pass the replacement as a function in
// scripts/generate-template.ts (and the mirror in template.macro.ts).
describe("HTML export template script inlining", () => {
function extractScript(): string {
const match = TEMPLATE.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/);
if (!match) throw new Error("inlined <script> block not found in TEMPLATE");
return match[1];
}
it("preserves the literal `$'` regex anchor inside the inlined script", () => {
const script = extractScript();
// The eval-cell parser must still contain the raw `(.*)$'` and `End\\b.*$'`
// regex sources — these are exactly the substrings that trigger the bug
// when the replacement is treated as a substitution template.
expect(script).toContain("\\\\s*Cell\\\\b\\\\s*(.*)$', 'i'");
expect(script).toContain("\\\\s*End\\\\b.*$', 'i'");
});
it("does not splice closing HTML tags into the inlined script", () => {
const script = extractScript();
expect(script).not.toMatch(/<\/body>/i);
expect(script).not.toMatch(/<\/html>/i);
});
it("produces a syntactically valid inlined script", () => {
const script = extractScript();
// `new Function(body)` parses without executing. Throws SyntaxError on
// the spliced-tag corruption the substitution-pattern bug produces.
expect(() => new Function(script)).not.toThrow();
});
});