feat!: escape interpolated values in html tagged template#1459
Conversation
The html`` tagged template raw-concatenated interpolations, while its
docstring (`html`<h1>Hello, ${name}!</h1>``) and its name (matching
auto-escaping libraries like lit-html) suggest values are sanitized —
a reflected/stored XSS footgun.
Interpolated values are now HTML-escaped (& < > " ') by default. A new
raw() helper marks trusted markup (e.g. script tags) to pass through
unescaped. Plain-string html("...") usage is unchanged. The escape
helper is shared with redirect()'s meta-refresh body, which now also
escapes single quotes.
Co-Authored-By: Claude Fable 5 <[email protected]>
📝 WalkthroughWalkthroughThe response utilities now escape HTML interpolations, provide a ChangesResponse HTML safety
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
html tagged template
Calling html() with a plain string now escapes the whole string and
logs a one-time warning when escaping changed the input, closing the
footgun where html(`...${user}...`) with parentheses was silently
unescaped. Trusted markup can be sent as-is with html(raw(markup)).
Also documents the escaping contexts (element content and quoted
attributes only) and adds a spoof-resistance test for duck-typed
raw-like objects.
Co-Authored-By: Claude Fable 5 <[email protected]>
There was a problem hiding this comment.
🧹 Nitpick comments (2)
test/utils.test.ts (2)
51-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider verifying exact escaped output for duck-typed objects.
Because
String({ value: "..." })evaluates to"[object Object]", the current test correctly asserts thatnot.toContain("<script>")passes, but it doesn't explicitly prove that the malicious string itself is escaped. Adding atoString()method to the spoofed object ensures the content is serialized into the template, allowing you to explicitly verify that<script>was safely output.♻️ Proposed refactor for a more rigorous assertion
it("does not treat duck-typed objects as raw values", async () => { - const spoofed = { value: "<script>alert(1)</script>" }; + const spoofed = { + value: "<script>alert(1)</script>", + toString() { return this.value; } + }; t.app.get("/test", () => html`<div>${spoofed}</div>`); const res = await t.fetch("/test"); - expect(await res.text()).not.toContain("<script>"); + expect(await res.text()).toBe("<div><script>alert(1)</script></div>"); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/utils.test.ts` around lines 51 - 56, Strengthen the duck-typed object test around the spoofed value in “does not treat duck-typed objects as raw values” by adding a toString() implementation that returns the malicious markup, then assert the response contains its escaped representation (<script>) rather than only asserting the raw tag is absent.
58-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPrevent test pollution from mutated module state.
The tests reset
(html as any)._isWarned = falsebut do not restore it after execution. Since the second test doesn't trigger the warning, it leaves_isWarnedasfalse. If any subsequent test elsewhere in the file passes an unescaped string tohtml(...), it will unexpectedly log the warning to the console becauseconsole.warnis no longer mocked.Consider capturing the original value of
_isWarnedand restoring it in thefinallyblock.♻️ Proposed fixes to restore state
it("escapes plain string usage and warns once", async () => { + const originalWarned = (html as { _isWarned?: boolean })._isWarned; (html as { _isWarned?: boolean })._isWarned = false; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { t.app.get("/test", () => html("<p>raw & <string></p>")); const res = await t.fetch("/test"); expect(await res.text()).toBe("<p>raw & <string></p>"); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0][0]).toContain("html``"); const res2 = await t.fetch("/test"); expect(await res2.text()).toBe("<p>raw & <string></p>"); expect(warnSpy).toHaveBeenCalledTimes(1); } finally { warnSpy.mockRestore(); + (html as { _isWarned?: boolean })._isWarned = originalWarned; } }); it("does not warn for plain strings without special characters", async () => { + const originalWarned = (html as { _isWarned?: boolean })._isWarned; (html as { _isWarned?: boolean })._isWarned = false; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { t.app.get("/test", () => html("Hello, World!")); const res = await t.fetch("/test"); expect(await res.text()).toBe("Hello, World!"); expect(warnSpy).not.toHaveBeenCalled(); } finally { warnSpy.mockRestore(); + (html as { _isWarned?: boolean })._isWarned = originalWarned; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/utils.test.ts` around lines 58 - 86, Update both tests around the html warning behavior to capture the original html._isWarned value before resetting it, then restore that value in each existing finally block after restoring console.warn. Keep the current assertions and warning-spy behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@test/utils.test.ts`:
- Around line 51-56: Strengthen the duck-typed object test around the spoofed
value in “does not treat duck-typed objects as raw values” by adding a
toString() implementation that returns the malicious markup, then assert the
response contains its escaped representation (<script>) rather than only
asserting the raw tag is absent.
- Around line 58-86: Update both tests around the html warning behavior to
capture the original html._isWarned value before resetting it, then restore that
value in each existing finally block after restoring console.warn. Keep the
current assertions and warning-spy behavior unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ea2f5622-aaa1-4f85-a656-5fd448cd0ee8
📒 Files selected for processing (4)
docs/1.guide/1.basics/5.response.mddocs/2.utils/2.response.mdsrc/utils/response.tstest/utils.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- docs/2.utils/2.response.md
- src/utils/response.ts
instanceof checks break when multiple copies of h3 are loaded (dual
package hazard) — a raw() value from one copy would be escaped by
another. Brand raw values with Symbol.for("h3.rawHTML") instead, which
is shared across module instances while still impossible to forge via
JSON deserialization.
Co-Authored-By: Claude Fable 5 <[email protected]>
Why
The
html``` tagged template raw-concatenates interpolated values, while its docstring example (html`Hello, ${name}!
``) and its name (matching auto-escaping libraries like lit-html) suggest values are sanitized. Interpolating user input this way is a reflected/stored XSS footgun.What
& < > " ') by default.raw()helper marks trusted markup (e.g. adding<script>tags) to pass through unescaped:html(string)escapes the whole string and renders it as text, and logs a one-time warning suggesting the tagged template (orraw()) when escaping changed the input. This closes the near-miss footgun wherehtml(...${user}...)— parentheses instead of a tagged template — was silently unescaped.html(raw(markup))form.redirect()'s meta-refresh body, which now also escapes single quotes.raw()values are branded withSymbol.for("h3.rawHTML")rather than aninstanceofcheck, so they stay recognized across duplicated copies of h3 (dual package hazard) while remaining unforgeable through JSON deserialization.javascript:inhref), and<script>/<style>contents still need separate validation.Tests
Regression tests in
test/utils.test.ts(web + node matrix): interpolated<script>payload is escaped and served astext/html;raw()passes markup through while adjacent values are still escaped; plain-string form is fully escaped and warns exactly once (no warning when escaping is a no-op); duck-typed{ value }objects do not bypass escaping while symbol-branded values from another h3 copy pass through. All failed before the fix, pass after. Full suite green.🤖 Generated with Claude Code
Summary by CodeRabbit
rawandRawHTMLto support inserting trusted, pre-escaped HTML without further escaping.htmlto auto-escape tagged template interpolations, while passing trustedrawmarkup through unchanged.raw(andRawHTML) are now exported publicly.htmlandrawusage examples and warnings.html/rawtest coverage and updated export snapshots.