Skip to content

Harden the template renderer and fix the CI audit gate for 3.0.2 - #31

Merged
sabind merged 9 commits into
masterfrom
solidify-email-drafter-20fa9012
Oct 17, 2026
Merged

sabind merged 9 commits into
masterfrom
solidify-email-drafter-20fa9012

Conversation

@sabind

@sabind sabind commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Why

Four confirmed bugs and one privately reported security issue in 3.0.1, found while reviewing the repo to solidify it:

What

The renderer now runs in a single pass, so rendered values are never rescanned; subject and text render as plain text; render-context detection only triggers on object-valued data and style; nested blocks that share a name close innermost first; CI and release install from the lockfile and audit production dependencies. Version bumped to 3.0.2.

Changes

  • Single-pass template renderer (src/template.ts, tests, README). Plain text between blocks and each block body are rendered as separate pieces and concatenated, so no output is reparsed. Slot HTML that itself contains {{...}} is now output literally as a side effect. Fixes the advisory.
  • Subject and text render without HTML escaping (src/compile.ts, src/types.ts, tests, README). renderTemplate takes an escape option, default true; the compiled template turns it off for subject and text. The HTML body is unchanged. Fixes Subject and plain-text bodies are HTML-escaped at render time #26.
  • Render-context detection (src/compile.ts, tests, README). Input is a { data, style } context only when every key is one of those two and both are plain objects when present. Fixes render() silently drops all data when template data has a key named "style" #27.
  • CI audit gate (both workflows, README, CONTRIBUTING, lockfile). Lockfile refreshed with npm audit fix; both workflows use npm ci and npm audit --audit-level=high --omit=dev. The advisories were in juice's production tree, not dev-only as the issue and the commit message first said; the lockfile bump is what clears them, and --omit=dev stays as policy because the dev tree is not shipped. Fixes CI and release fail on npm audit for dev-only advisories #28.
  • Post-review tidy and regression tests (src/template.ts, src/compile.ts, README, CONTRIBUTING, tests). Combined text pattern hoisted to a module constant, an unreachable fallback removed, context detection reuses the renderer's plain-object check, contributor setup says npm ci, and eleven tests pin the adversarial and ordinary cases the independent review ran by hand.
  • Stack-based block matching (src/template.ts, tests, README). Top-level blocks are found by scanning open and close tags with a stack, so a close pops the innermost open block of that name. Stray closes and unclosed opens stay literal. Done now because it rewrites the same matcher as the advisory fix and belongs in the same release. Fixes Nested blocks with the same name pair the outer open with the inner close #29.
  • Version 3.0.2 (package.json, lockfile). Bumped so the fix for CI and the new version ship together.

Testing

  • Full suite: 6 files, 38 tests, passing. Build clean, examples render, npm pack --dry-run unchanged at 29 files.
  • Each of the four original fixes was independently reviewed: the reviewer reproduced the bug on the base commit, confirmed the new tests fail there and pass here, and accepted. The advisory reviewer ran twenty adversarial inputs against the renderer, including a value containing a complete block, a slot reference two blocks deep, a value trying to close its enclosing block, and a split {{ token; all held, and every ordinary case was byte-identical to the base.
  • Both audit forms exit 1 on the base commit and 0 here. package.json dependencies unchanged.
  • Visual regression tests were not run locally; CI runs them.

Risk and rollout

  • Visible output change: an ampersand or apostrophe in a subject or text body is no longer entity-encoded. Worth a release note.
  • Slot HTML containing {{...}} is no longer expanded.
  • render({ style: null }) is now read as template data rather than a context; output is identical either way.
  • After merge: tag v3.0.2 and push the tag to trigger the release workflow, then publish advisory GHSA-25x2-qphj-x3cv against 3.0.2. The first CI run on GitHub's runners is the real confirmation of the audit fix, since the advisory database moves.

Notes for reviewers

🤖 Generated with Claude Code

dansabin and others added 9 commits October 17, 2026 16:38
isRenderContext() returned true for any input with a style key, so bare
template data such as { name: 'Dan', style: 'casual' } was read as a
context with no data and every placeholder rendered empty. Require that
style and data, when present, are non-null, non-array objects before
treating the input as a render context.

Fixes #27

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The audit step in CI and release failed on high advisories in nanoid
and postcss, both reached only through vitest. Bump the dev tree with
npm audit fix, audit with --omit=dev so dev-only advisories do not
block publishing, and use npm ci so the lockfile is enforced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Values substituted inside a {{#block}} were spliced back into the outer
string and then re-scanned for slot and variable syntax, so client data
such as "{{secret}}" or "{{slot:admin}}" in an array item resolved
against the full render data. renderTemplate now walks the template
once, rendering the text between blocks and each block body separately,
so rendered output is never re-scanned. Fixes GHSA-25x2-qphj-x3cv.

Subject and plain-text templates were HTML-escaped, turning O'Brien into
O&#39;Brien in the inbox. renderTemplate takes an optional
{ escape: false } and compile.ts uses it for subject and text; the HTML
body is still escaped. Fixes #26.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hoist the combined slot and variable pattern to a module constant, drop
the unreachable key fallback, reuse the renderer's plain-object check in
render-context detection, and align the contributor setup command with
the workflows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lock in the adversarial cases the review ran by hand against the
single-pass renderer, and the ordinary block, slot and path semantics it
must keep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…first (#29)

The block regex paired an opening tag with the nearest close of the same
name, so a block nested under its own key ended at the inner close and
left the tail as text. Top-level blocks are now found by scanning block
tags with a stack of open names. Stray close tags and unclosed opens
stay literal as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/compile.ts
Comment on lines +47 to +49
subject: renderTemplate(subject, data, { escape: false }),
html: applyEmailStyle(renderedHtml, style),
text: renderTemplate(text, data)
text: renderTemplate(text, data, { escape: false })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Should we default to escape true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It already does. renderTemplate(template, data) escapes, and the only place the default lives is options.escape ?? true in src/template.ts. The HTML body two lines up uses that default.

Subject and text are the two callers that opt out, because they are not HTML. Escaping them is what produced O&#39;Brien in the subject line (#26), so passing escape: true here would bring that bug back.

If the inline { escape: false } reads too much like a footgun, the alternative is a named helper such as renderPlainText(template, data) that wraps it, so the opt-out is visible by name at the call site. Happy to add that if you prefer it.

@sabind
sabind merged commit 5b96083 into master Oct 17, 2026
4 checks passed
@sabind
sabind deleted the solidify-email-drafter-20fa9012 branch October 17, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants