Skip to content

feat!: escape interpolated values in html tagged template#1459

Merged
pi0 merged 3 commits into
mainfrom
feat/html-escape-interpolations
Jul 14, 2026
Merged

feat!: escape interpolated values in html tagged template#1459
pi0 merged 3 commits into
mainfrom
feat/html-escape-interpolations

Conversation

@pi0x

@pi0x pi0x commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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

  • Tagged-template interpolations are now HTML-escaped (& < > " ') by default.
  • New raw() helper marks trusted markup (e.g. adding <script> tags) to pass through unescaped:
    app.get("/", () => html`<head>${raw(analyticsScript)}</head><h1>Hello ${userName}</h1>`);
  • Plain-string usage is now escaped too: html(string) escapes the whole string and renders it as text, and logs a one-time warning suggesting the tagged template (or raw()) when escaping changed the input. This closes the near-miss footgun where html(...${user}...) — parentheses instead of a tagged template — was silently unescaped.
  • Trusted markup strings are sent as-is with the new html(raw(markup)) form.
  • The escape helper is extracted and shared with redirect()'s meta-refresh body, which now also escapes single quotes.
  • raw() values are branded with Symbol.for("h3.rawHTML") rather than an instanceof check, so they stay recognized across duplicated copies of h3 (dual package hazard) while remaining unforgeable through JSON deserialization.
  • JSDoc and guide docs state the escaping contexts explicitly: safe for element content and quoted attribute values only — unquoted attributes, URL attributes (javascript: in href), 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 as text/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

  • New Features
    • Added raw and RawHTML to support inserting trusted, pre-escaped HTML without further escaping.
    • Enhanced html to auto-escape tagged template interpolations, while passing trusted raw markup through unchanged.
    • raw (and RawHTML) are now exported publicly.
  • Bug Fixes
    • Redirect responses now generate the meta-refresh HTML body with safe escaping.
  • Documentation
    • Updated response docs with safer html and raw usage examples and warnings.
  • Tests
    • Expanded html/raw test coverage and updated export snapshots.

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]>
@pi0x
pi0x requested a review from pi0 as a code owner July 14, 2026 09:04
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The response utilities now escape HTML interpolations, provide a raw() opt-out for trusted HTML, reuse escaping for redirects, and publicly export and document the new helper and type. Tests cover escaping, raw pass-through, warnings, and package exports.

Changes

Response HTML safety

Layer / File(s) Summary
HTML escaping and raw values
src/utils/response.ts
Tagged-template and plain-string inputs are escaped unless wrapped with raw(), while redirect bodies use the shared HTML escaping utility.
Public API and validation
src/index.ts, docs/1.guide/..., docs/2.utils/2.response.md, test/utils.test.ts, test/unit/package.test.ts
The raw helper and RawHTML type are exported and documented, with tests covering interpolation, warning, cross-instance branding, and package exports.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • h3js/h3#1317: Updates redirect meta-refresh HTML escaping in the same response utility.

Suggested reviewers: pi0

Poem

I’m a bunny guarding HTML bright,
Escaping carrots left and right.
Trusted markup hops in raw,
Safe redirects follow the law.
Tests thump paws: the exports grow!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: escaping interpolated values in the html tagged template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/html-escape-interpolations

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pi0 pi0 changed the title feat(response): escape interpolated values in html`` tagged template feat!: escape interpolated values in html tagged template Jul 14, 2026
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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
test/utils.test.ts (2)

51-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider verifying exact escaped output for duck-typed objects.

Because String({ value: "..." }) evaluates to "[object Object]", the current test correctly asserts that not.toContain("<script>") passes, but it doesn't explicitly prove that the malicious string itself is escaped. Adding a toString() method to the spoofed object ensures the content is serialized into the template, allowing you to explicitly verify that &lt;script&gt; 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>&lt;script&gt;alert(1)&lt;/script&gt;</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 (&lt;script&gt;)
rather than only asserting the raw tag is absent.

58-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prevent test pollution from mutated module state.

The tests reset (html as any)._isWarned = false but do not restore it after execution. Since the second test doesn't trigger the warning, it leaves _isWarned as false. If any subsequent test elsewhere in the file passes an unescaped string to html(...), it will unexpectedly log the warning to the console because console.warn is no longer mocked.

Consider capturing the original value of _isWarned and restoring it in the finally block.

♻️ 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("&lt;p&gt;raw &amp; &lt;string&gt;&lt;/p&gt;");
        expect(warnSpy).toHaveBeenCalledTimes(1);
        expect(warnSpy.mock.calls[0][0]).toContain("html``");

        const res2 = await t.fetch("/test");
        expect(await res2.text()).toBe("&lt;p&gt;raw &amp; &lt;string&gt;&lt;/p&gt;");
        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 (&lt;script&gt;) 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

📥 Commits

Reviewing files that changed from the base of the PR and between c2185b7 and 7f33c1c.

📒 Files selected for processing (4)
  • docs/1.guide/1.basics/5.response.md
  • docs/2.utils/2.response.md
  • src/utils/response.ts
  • test/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]>
@pi0
pi0 merged commit 8ed97e4 into main Jul 14, 2026
9 checks passed
@pi0
pi0 deleted the feat/html-escape-interpolations branch July 14, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants