Skip to content

Pre-launch hardening: security, correctness, API polish, CI gating#42

Open
brunolemos wants to merge 5 commits into
mainfrom
prelaunch-fixes
Open

Pre-launch hardening: security, correctness, API polish, CI gating#42
brunolemos wants to merge 5 commits into
mainfrom
prelaunch-fixes

Conversation

@brunolemos

@brunolemos brunolemos commented Jul 8, 2026

Copy link
Copy Markdown
Member

Final hardening pass before launch: a full audit of the public API, rendering pipeline, and CI, with every finding fixed here.

Security

  • All plain-text surfaces are now HTML-escaped — children, text props, previewText, Menu/Table shorthands. Previously, interpolating user data into these could inject markup into the rendered email/page. URLs are attribute-escaped and javascript: schemes stripped. Raw HTML still works where it's explicit: <Html>, the html prop, and the values escape hatch.
  • Prototype-pollution guard in the deep-merge utility.

Bug fixes

  • UnlayerProvider now actually works — config resolves as prop > provider > defaults (it was a documented no-op before).
  • Fragments are transparent<>...</> around rows/columns no longer breaks mode/width propagation, renderToJson, or head extraction.
  • Video blocks in emails link to the video (the anchor URL was never passed through); also supports Shorts/live/nocookie URLs and a new thumbnail prop.
  • <Html> renders its string children instead of a "Hello, world!" placeholder.
  • CJS TypeScript resolution fixed (require() consumers got TS1479) — verified clean with arethetypeswrong.

API & DX

  • Render functions (renderToHtml etc.) now throw on render errors by default instead of silently shipping broken HTML; onError: "render-fallback" restores the old behavior.
  • Clear errors for invalid <Row cells> and registerTool names; warning when a Column skips an unsupported child; React.memo/forwardRef-wrapped components now render.
  • Typo'd props are now compile errors instead of silent no-ops; more types exported (RenderMode, BodyProps, ...); internal plumbing props hidden from autocomplete.
  • Removed the duplicate registerElementsTool alias; Page/Document no longer accept the email-only previewText.

CI & docs

  • Publishing is now gated on the full quality suite (typecheck, tests, CSP, browser E2E, Storybook, Next.js integration) — previously only build + unit tests blocked a release.
  • Workflow hardening: publish the tested commit (not main tip), least-privilege permissions, no npm token on PR runs, SHA-pinned actions, fork-gated Storybook previews.
  • README fixes: image-width rule was documented backwards, bundle size claim corrected (~20KB gzipped), fontFamily contradiction resolved; new sections on escaping and error handling.

Breaking changes (pre-launch only)

  1. Raw markup in plain-text surfaces now renders literally — use <Html>, html, or values instead.
  2. Render functions throw on errors by default.
  3. Button/Heading gain an inner <span> (editor-parity markup) and serialize as textJson; children now take precedence over the text prop.
  4. registerElementsTool removed; invalid registerTool names and malformed cells throw.

Verification: 572 unit tests green (29 new injection/hardening tests), all browser/visual/CSP/Next.js gates pass, type resolution clean in every mode.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pb2aCSMrkB7EK4udu5t5tA


📖 Storybook Preview: https://unlayer.github.io/elements/pr/42/


📖 Storybook Preview: https://unlayer.github.io/elements/pr/42/

brunolemos and others added 3 commits July 7, 2026 03:12
…, validation

Security / correctness:
- HTML-escape all plain-text surfaces (previewText, Menu/Table shorthands,
  Row/Column string children); sanitize + attribute-escape link URLs and
  strip javascript:/vbscript:/data: schemes
- Button/Heading text now travels as inline-tool textJson (the editor's
  modern storage) — their exporters entity-decode the legacy text field,
  so pre-escaping alone was not safe; also unifies children > text prop
  precedence with Paragraph
- Wire UnlayerProvider into Body (prop > provider > defaults) with an
  RSC-safe guarded context read; renderToHtml/Parts see through a provider
  root; stop leaking the internal provider flag from useUnlayerConfig
- Flatten Fragments in every tree walker (Body/Row/Column, renderToJson,
  head extraction) — a <>…</> around a Row no longer silently renders in
  web mode with default width
- Video: parsed videoUrl now carries the original url so email thumbnails
  link to the video; support shorts/live/nocookie URLs; warn on
  unparseable input; add thumbnail prop (needed for Vimeo)
- Html: string children map to values.html; remove the "Hello, world!"
  placeholder default; warn on element children
- renderToHtml/renderToHtmlParts/renderToPlainText throw on render errors
  by default (onError: "render-fallback" opts out); Column catches
  per-child so a failing block no longer wipes its siblings
- Validate <Row cells> length and positivity (was emitting width="NaN");
  warn on skipped non-Unlayer Column children; unwrap memo/forwardRef;
  validate registerTool names (/^[a-z0-9_-]+$/)

API surface:
- Export ColumnProps, BodyProps, RenderMode, HeadConfig from the barrel
- Remove the registerElementsTool alias (registerTool is the documented name)
- Page/Document no longer accept the email-only previewText
- Tag internal plumbing props @internal; drop index-signature passthrough
  in FlattenObjectProps (prop typos now fail to compile); flat nested
  props typed instead of any; textDirection narrowed to "ltr" | "rtl"
- Reference dist/index.d.cts in the exports map (fixes TS for CJS
  node16 consumers; verified with @arethetypeswrong/cli); add engines;
  ship LICENSE inside the package

Misc: fromCodePoint for astral entities, __proto__ guard in mergeValues,
empty Table headers no longer emit a header row, freeze DEFAULT_CONFIG,
drop dead DEFAULT_MENU and the stale examples/* workspace glob.

New test suites: escaping.test.tsx (injection contracts for every
surface), render-hardening.test.tsx (fragments, provider, onError,
cells validation, column child handling). Snapshots and the Storybook
visual baseline regenerated for the intentional Button/Heading inline
<span> (editor-parity) and Html content changes; browser E2E baseline
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- test.yml is now reusable (workflow_call) and publish.yml's publish job
  depends on it — typecheck, bundle budget, unit tests, CSP, browser E2E,
  Storybook smoke/visual, and the Next.js integration all block a release
  (previously only build + unit tests gated, with the rest non-blocking)
- publish checks out the tested SHA instead of main (which can advance
  between the gate run and the environment approval); npm pinned to @11;
  npm publish --provenance (repo is public — the old comment was stale)
- drop the unnecessary TEST_NPM_TOKEN npm-auth steps (all deps are public;
  a live token was written to ~/.npmrc on every PR); add least-privilege
  permissions blocks; remove the nonexistent develop branch trigger
- Storybook PR previews restricted to same-repo PRs, peaceiris action
  pinned to a commit SHA, and previews cleaned from gh-pages on PR close
- update-deps: a failed verify now fails the workflow run (the PR body
  already showed the failure, but the run looked green); document the
  GITHUB_TOKEN-does-not-trigger-CI limitation + branch-protection
  mitigation
- bundle budget 75KB -> 100KB (hardening added ~8KB of real code; the old
  budget had 1.5KB of headroom)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Image width rule was documented backwards: px/number PINS the display
  width; percent stays responsive
- bundle claim corrected to ~20KB gzipped (was "~12KB gzipped / under
  60KB ESM"); fontFamily contradiction fixed (strings ARE accepted and
  normalized); heading levels: h1–h6 render, editor round-trips h1–h4
- new sections: "Plain Text vs Raw HTML" (escaping contract + toSafeHtml)
  and "Error Handling" (onError); UnlayerProvider section now documents
  precedence and the Server Components caveat
- CLAUDE.md gotchas/budget updated to match

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 8, 2026 16:01

Copilot AI 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.

Pull request overview

Pre-launch hardening for the @unlayer/react-elements + shared utilities package: closes multiple security and correctness gaps in HTML output generation, tightens/clarifies the public API and docs, and makes the publish pipeline depend on the full quality-gate suite.

Changes:

  • Enforces a consistent “plain text renders as text” contract by adding shared escaping/sanitization utilities and applying them at HTML/attribute sinks (plus Lexical textJson routing for Button/Heading).
  • Fixes several rendering correctness issues (Fragments handling, provider precedence wiring, video/email linking, <Html> children mapping, row/column validation, and default error behavior).
  • Polishes public API/typing surface and strengthens CI/release workflows (typed export conditions, publish gating, permissions tightening, docs updates).

Reviewed changes

Copilot reviewed 46 out of 47 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
README.md Updates bundle-size claim in root README.
pnpm-workspace.yaml Removes examples/* workspace glob.
packages/shared/src/utils/semantic-props.ts Escaping + textJson routing + link normalization hardening + typing tweaks.
packages/shared/src/utils/semantic-props.test.ts Updates tests for Button children → inline textJson.
packages/shared/src/utils/merge-values.ts Adds prototype-pollution guard when merging values.
packages/shared/src/utils/lexical-helpers.ts Adds textToInlineTextJson for inline-tool serialization.
packages/shared/src/utils/html-to-text.ts Fixes numeric entity decoding for astral codepoints via fromCodePoint.
packages/shared/src/utils/escape-html.ts Introduces shared HTML/url escaping + scheme sanitization helper.
packages/shared/src/index.ts Re-exports new lexical and escaping utilities.
packages/shared/src/config.ts Adds onError config, narrows textDirection, freezes DEFAULT_CONFIG.
packages/react/src/utils/render-to-json.ts Flattens Fragment children during JSON walking.
packages/react/src/utils/render-to-json.test.tsx Updates expectations for Button values to use textJson.
packages/react/src/utils/render-to-html.tsx Provider-root unwrapping, config injection, default onError: "throw", head extraction fixes.
packages/react/src/utils/register-tool.tsx Validates tool names and removes registerElementsTool alias.
packages/react/src/utils/register-tool.test.tsx Updates tests to use registerTool.
packages/react/src/utils/register-tool-edge-cases.test.tsx Updates edge-case tests + adds default-throw coverage and fallback-mode test.
packages/react/src/utils/extract-head.ts Flattens Fragment children during head extraction walking.
packages/react/src/utils/create-component.tsx Honors onError: "throw" by rethrowing render errors.
packages/react/src/utils/children.ts Adds flattenChildren + memo/forwardRef unwrapping helper.
packages/react/src/render-hardening.test.tsx Adds contracts/regression tests for fragments/provider/errors/validation/Column behavior.
packages/react/src/index.ts Removes registerElementsTool export; exports additional public types/props.
packages/react/src/escaping.test.tsx Adds injection/escaping contract tests for text and URL surfaces.
packages/react/src/context/UnlayerProvider.tsx Stops leaking provider-active flag; adds readProviderConfig helper.
packages/react/src/context/UnlayerProvider.test.tsx Updates test to assert the internal provider flag is not exposed.
packages/react/src/components/Video.tsx Fixes parsed video data to retain original URL; adds thumbnail override + warnings.
packages/react/src/components/Video.test.tsx Extends parsing tests and adds email-link + warning coverage.
packages/react/src/components/Table.tsx Escapes shorthand headers/cells and fixes empty-headers behavior.
packages/react/src/components/snapshots.test.tsx Updates Html snapshot tests to assert children-driven output.
packages/react/src/components/Row.tsx Fragment flattening + text escaping + cells validation + strict error behavior.
packages/react/src/components/Page.tsx Removes email-only previewText from Page prop surface.
packages/react/src/components/Menu.tsx Escapes shorthand menu item text and removes dead DEFAULT_MENU constant.
packages/react/src/components/Html.tsx Maps string children to values.html, removes placeholder default, warns on element children.
packages/react/src/components/Document.tsx Removes email-only previewText from Document prop surface.
packages/react/src/components/CustomTools.stories.tsx Updates Storybook docs from registerElementsToolregisterTool.
packages/react/src/components/Column.tsx Fragment flattening + text escaping + per-child error handling + memo/forwardRef rendering + warnings.
packages/react/src/components/Body.tsx Escapes previewText, provider-aware config resolution, fragment flattening, strict error behavior.
packages/react/src/components/snapshots/snapshots.test.tsx.snap Snapshot updates for Html and inline <span> changes.
packages/react/src/snapshots/golden-template.test.tsx.snap Golden snapshot updates for inline <span> changes.
packages/react/README.md Documents escaping contract, error handling, provider precedence/RSC caveat, and other doc fixes.
packages/react/package.json Fixes conditional type exports for CJS + adds Node>=18 engines.
packages/react/LICENSE Adds LICENSE file to the published package directory.
CLAUDE.md Updates bundle budget and hardening gotchas/documentation.
.github/workflows/update-deps.yml Makes failed verify runs visibly red; documents GITHUB_TOKEN CI limitation.
.github/workflows/test.yml Adds workflow_call, tightens triggers/permissions, updates bundle budget and removes npm auth.
.github/workflows/publish.yml Gates publish on full reusable test workflow; pins npm major; publishes from tested SHA with provenance.
.github/workflows/deploy-storybook.yml Tightens permissions, restricts PR previews to same-repo PRs, pins action, adds cleanup job.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

(element.props as { children?: React.ReactNode }).children
);
const elementChildren = children.filter(React.isValidElement) as React.ReactElement[];
if (children.length === 1 && elementChildren.length === 1) {
Comment on lines +30 to +40
export function sanitizeUrl(url: string): string {
const scheme = /^\s*([a-z][a-z0-9+.-]*):/i.exec(url)?.[1]?.toLowerCase();
if (scheme === "javascript" || scheme === "vbscript" || scheme === "data") {
console.warn(
`[Unlayer] Blocked link with unsafe "${scheme}:" URL scheme. ` +
"Only http(s), mailto, tel and relative URLs are rendered."
);
return "";
}
return url;
}
brunolemos and others added 2 commits July 8, 2026 13:27
The Html Default story rendered nothing (empty `html` + Html renders empty
verbatim), so the Storybook smoke test failed on "rendered nothing visible".
Add sample markup matching the other stories, and refresh the visual baseline
for the now-painting default render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wAbQny1Lz3vDKYbn3chbW
…in actions

Address the pre-launch review findings:

- sanitizeUrl: browsers strip TAB/LF/CR from a URL before parsing its scheme,
  so `java\tscript:alert(1)` executed on click despite the blocklist. Strip
  those three characters before the scheme regex runs. Also correct the warning
  message, which promised an allowlist ("only http(s)/mailto/tel...") while the
  code is a blocklist (flagged by Copilot).
- Add escape-html unit tests (escapeHtml, sanitizeUrl bypass shapes,
  escapeUrlAttribute) and Button-level integration tests for the tab/newline/CR/
  case bypasses.
- Add prototype-pollution guard tests for mergeValues (top-level + nested
  __proto__ from JSON.parse, constructor/prototype keys, sibling-key survival).
- Pin all third-party GitHub Actions to commit SHAs (were on mutable major
  tags), making the "SHA-pinned actions" hardening claim accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wAbQny1Lz3vDKYbn3chbW
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