Pre-launch hardening: security, correctness, API polish, CI gating#42
Open
brunolemos wants to merge 5 commits into
Open
Pre-launch hardening: security, correctness, API polish, CI gating#42brunolemos wants to merge 5 commits into
brunolemos wants to merge 5 commits into
Conversation
…, 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>
There was a problem hiding this comment.
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
textJsonrouting 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 registerElementsTool → registerTool. |
| 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; | ||
| } |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Final hardening pass before launch: a full audit of the public API, rendering pipeline, and CI, with every finding fixed here.
Security
textprops,previewText, Menu/Table shorthands. Previously, interpolating user data into these could inject markup into the rendered email/page. URLs are attribute-escaped andjavascript:schemes stripped. Raw HTML still works where it's explicit:<Html>, thehtmlprop, and thevaluesescape hatch.Bug fixes
UnlayerProvidernow actually works — config resolves as prop > provider > defaults (it was a documented no-op before).<>...</>around rows/columns no longer breaks mode/width propagation,renderToJson, or head extraction.thumbnailprop.<Html>renders its string children instead of a "Hello, world!" placeholder.require()consumers got TS1479) — verified clean witharethetypeswrong.API & DX
renderToHtmletc.) now throw on render errors by default instead of silently shipping broken HTML;onError: "render-fallback"restores the old behavior.<Row cells>andregisterToolnames; warning when a Column skips an unsupported child;React.memo/forwardRef-wrapped components now render.RenderMode,BodyProps, ...); internal plumbing props hidden from autocomplete.registerElementsToolalias;Page/Documentno longer accept the email-onlypreviewText.CI & docs
maintip), least-privilege permissions, no npm token on PR runs, SHA-pinned actions, fork-gated Storybook previews.fontFamilycontradiction resolved; new sections on escaping and error handling.Breaking changes (pre-launch only)
<Html>,html, orvaluesinstead.<span>(editor-parity markup) and serialize astextJson; children now take precedence over thetextprop.registerElementsToolremoved; invalidregisterToolnames and malformedcellsthrow.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/