Skip to content

fix: escape HTML entities in component payload attribute#40

Merged
wendyyuchensun merged 1 commit into
coseeing:mainfrom
wendyyuchensun:json
Jun 3, 2026
Merged

fix: escape HTML entities in component payload attribute#40
wendyyuchensun merged 1 commit into
coseeing:mainfrom
wendyyuchensun:json

Conversation

@wendyyuchensun

@wendyyuchensun wendyyuchensun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

問題

當 Markdown 的 meta 內容包含單引號時,整個轉換會失效:

![Coseeing'官網](https://coseeing.org)

alt 文字 Coseeing'官網 裡的 ' 會提前結束單引號包住的 data-seemark-payload 屬性,導致後面的標記損壞,因此 HTML 標記無法轉換成 React 元件。

修正

新增 escapeHTMLAttribute,在 buildHTMLMarkup 把 JSON payload 放進單引號的 data-seemark-payload 之前,先用 escapeHTMLAttribute 處理,將 & ' " < > 轉成 HTML entity;其中 & 會最先被取代,確保 payload 中既有的 entity(例如 MathJax 輸出的 &#x2212;)不會被破壞。讀取端透過 html-react-parser 解碼後再 JSON.parse,因此資料能完整還原。

<span
data-seemark-element-type="math"
data-seemark-payload="{"math":"c-b=a","typed":"latex","mathMl":"<math xmlns=\\"http://www.w3.org/1998/Math/MathML\\">\\n <mi>c</mi>\\n <mo>−</mo>\\n <mi>b</mi>\\n <mo>=</mo>\\n <mi>a</mi>\\n</math>","svg":"<mjx-container class=\\"MathJax\\" jax=\\"SVG\\"><svg style=\\"vertical-align: -0.186ex;\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"8.93ex\\" height=\\"1.756ex\\" role=\\"img\\" focusable=\\"false\\" viewBox=\\"0 -694 3947 776\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\"><defs><path id=\\"MJX-8-TEX-I-1D450\\" d=\\"M34 159Q34 268 120 355T306 442Q362 442 394 418T427 355Q427 326 408 306T360 285Q341 285 330 295T319 325T330 359T352 380T366 386H367Q367 388 361 392T340 400T306 404Q276 404 249 390Q228 381 206 359Q162 315 142 235T121 119Q121 73 147 50Q169 26 205 26H209Q321 26 394 111Q403 121 406 121Q410 121 419 112T429 98T420 83T391 55T346 25T282 0T202 -11Q127 -11 81 37T34 159Z\\"></path><path id=\\"MJX-8-TEX-N-2212\\" d=\\"M84 237T84 250T98 270H679Q694 262 694 250T679 230H98Q84 237 84 250Z\\"></path><path id=\\"MJX-8-TEX-I-1D44F\\" d=\\"M73 647Q73 657 77 670T89 683Q90 683 161 688T234 694Q246 694 246 685T212 542Q204 508 195 472T180 418L176 399Q176 396 182 402Q231 442 283 442Q345 442 383 396T422 280Q422 169 343 79T173 -11Q123 -11 82 27T40 150V159Q40 180 48 217T97 414Q147 611 147 623T109 637Q104 637 101 637H96Q86 637 83 637T76 640T73 647ZM336 325V331Q336 405 275 405Q258 405 240 397T207 376T181 352T163 330L157 322L136 236Q114 150 114 114Q114 66 138 42Q154 26 178 26Q211 26 245 58Q270 81 285 114T318 219Q336 291 336 325Z\\"></path><path id=\\"MJX-8-TEX-N-3D\\" d=\\"M56 347Q56 360 70 367H707Q722 359 722 347Q722 336 708 328L390 327H72Q56 332 56 347ZM56 153Q56 168 72 173H708Q722 163 722 153Q722 140 707 133H70Q56 140 56 153Z\\"></path><path id=\\"MJX-8-TEX-I-1D44E\\" d=\\"M33 157Q33 258 109 349T280 441Q331 441 370 392Q386 422 416 422Q429 422 439 414T449 394Q449 381 412 234T374 68Q374 43 381 35T402 26Q411 27 422 35Q443 55 463 131Q469 151 473 152Q475 153 483 153H487Q506 153 506 144Q506 138 501 117T481 63T449 13Q436 0 417 -8Q409 -10 393 -10Q359 -10 336 5T306 36L300 51Q299 52 296 50Q294 48 292 46Q233 -10 172 -10Q117 -10 75 30T33 157ZM351 328Q351 334 346 350T323 385T277 405Q242 405 210 374T160 293Q131 214 119 129Q119 126 119 118T118 106Q118 61 136 44T179 26Q217 26 254 59T298 110Q300 114 325 217T351 328Z\\"></path></defs><g stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" transform=\\"scale(1,-1)\\"><g data-mml-node=\\"math\\"><g data-mml-node=\\"mi\\"><use data-c=\\"1D450\\" xlink:href=\\"#MJX-8-TEX-I-1D450\\"></use></g><g data-mml-node=\\"mo\\" transform=\\"translate(655.2,0)\\"><use data-c=\\"2212\\" xlink:href=\\"#MJX-8-TEX-N-2212\\"></use></g><g data-mml-node=\\"mi\\" transform=\\"translate(1655.4,0)\\"><use data-c=\\"1D44F\\" xlink:href=\\"#MJX-8-TEX-I-1D44F\\"></use></g><g data-mml-node=\\"mo\\" transform=\\"translate(2362.2,0)\\"><use data-c=\\"3D\\" xlink:href=\\"#MJX-8-TEX-N-3D\\"></use></g><g data-mml-node=\\"mi\\" transform=\\"translate(3418,0)\\"><use data-c=\\"1D44E\\" xlink:href=\\"#MJX-8-TEX-I-1D44E\\"></use></g></g></g></svg></mjx-container>","position":{"start":15,"end":24}}"
data-seemark-payload="{"math":"c-b=a","typed":"latex","mathMl":"<math xmlns=\\"http://www.w3.org/1998/Math/MathML\\">\\n <mi>c</mi>\\n <mo>&#x2212;</mo>\\n <mi>b</mi>\\n <mo>=</mo>\\n <mi>a</mi>\\n</math>","svg":"<mjx-container class=\\"MathJax\\" jax=\\"SVG\\"><svg style=\\"vertical-align: -0.186ex;\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"8.93ex\\" height=\\"1.756ex\\" role=\\"img\\" focusable=\\"false\\" viewBox=\\"0 -694 3947 776\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\"><defs><path id=\\"MJX-8-TEX-I-1D450\\" d=\\"M34 159Q34 268 120 355T306 442Q362 442 394 418T427 355Q427 326 408 306T360 285Q341 285 330 295T319 325T330 359T352 380T366 386H367Q367 388 361 392T340 400T306 404Q276 404 249 390Q228 381 206 359Q162 315 142 235T121 119Q121 73 147 50Q169 26 205 26H209Q321 26 394 111Q403 121 406 121Q410 121 419 112T429 98T420 83T391 55T346 25T282 0T202 -11Q127 -11 81 37T34 159Z\\"></path><path id=\\"MJX-8-TEX-N-2212\\" d=\\"M84 237T84 250T98 270H679Q694 262 694 250T679 230H98Q84 237 84 250Z\\"></path><path id=\\"MJX-8-TEX-I-1D44F\\" d=\\"M73 647Q73 657 77 670T89 683Q90 683 161 688T234 694Q246 694 246 685T212 542Q204 508 195 472T180 418L176 399Q176 396 182 402Q231 442 283 442Q345 442 383 396T422 280Q422 169 343 79T173 -11Q123 -11 82 27T40 150V159Q40 180 48 217T97 414Q147 611 147 623T109 637Q104 637 101 637H96Q86 637 83 637T76 640T73 647ZM336 325V331Q336 405 275 405Q258 405 240 397T207 376T181 352T163 330L157 322L136 236Q114 150 114 114Q114 66 138 42Q154 26 178 26Q211 26 245 58Q270 81 285 114T318 219Q336 291 336 325Z\\"></path><path id=\\"MJX-8-TEX-N-3D\\" d=\\"M56 347Q56 360 70 367H707Q722 359 722 347Q722 336 708 328L390 327H72Q56 332 56 347ZM56 153Q56 168 72 173H708Q722 163 722 153Q722 140 707 133H70Q56 140 56 153Z\\"></path><path id=\\"MJX-8-TEX-I-1D44E\\" d=\\"M33 157Q33 258 109 349T280 441Q331 441 370 392Q386 422 416 422Q429 422 439 414T449 394Q449 381 412 234T374 68Q374 43 381 35T402 26Q411 27 422 35Q443 55 463 131Q469 151 473 152Q475 153 483 153H487Q506 153 506 144Q506 138 501 117T481 63T449 13Q436 0 417 -8Q409 -10 393 -10Q359 -10 336 5T306 36L300 51Q299 52 296 50Q294 48 292 46Q233 -10 172 -10Q117 -10 75 30T33 157ZM351 328Q351 334 346 350T323 385T277 405Q242 405 210 374T160 293Q131 214 119 129Q119 126 119 118T118 106Q118 61 136 44T179 26Q217 26 254 59T298 110Q300 114 325 217T351 328Z\\"></path></defs><g stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" transform=\\"scale(1,-1)\\"><g data-mml-node=\\"math\\"><g data-mml-node=\\"mi\\"><use data-c=\\"1D450\\" xlink:href=\\"#MJX-8-TEX-I-1D450\\"></use></g><g data-mml-node=\\"mo\\" transform=\\"translate(655.2,0)\\"><use data-c=\\"2212\\" xlink:href=\\"#MJX-8-TEX-N-2212\\"></use></g><g data-mml-node=\\"mi\\" transform=\\"translate(1655.4,0)\\"><use data-c=\\"1D44F\\" xlink:href=\\"#MJX-8-TEX-I-1D44F\\"></use></g><g data-mml-node=\\"mo\\" transform=\\"translate(2362.2,0)\\"><use data-c=\\"3D\\" xlink:href=\\"#MJX-8-TEX-N-3D\\"></use></g><g data-mml-node=\\"mi\\" transform=\\"translate(3418,0)\\"><use data-c=\\"1D44E\\" xlink:href=\\"#MJX-8-TEX-I-1D44E\\"></use></g></g></g></svg></mjx-container>","position":{"start":15,"end":24}}"

@wendyyuchensun wendyyuchensun Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

數學內容的 snapshot 變更,只有減號由 變成 &#x2212;

MathJax 本來就會把減號(U+2212,非 ASCII 字元)序列化成 &#x2212;。先前 payload 沒有跳脫 &,所以這個實體在解析屬性時被還原成 ;加上 escapeHTMLAttribute 後,& 會被保留,payload 維持 MathJax 的原始輸出。兩者顯示結果相同,snapshot 只是反映正確保留後的字串。

下面的兩個 snapshot change 亦同。

@jamliaoo jamliaoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, 感謝!

@wendyyuchensun

Copy link
Copy Markdown
Collaborator Author

感謝!

@wendyyuchensun
wendyyuchensun merged commit 8e58769 into coseeing:main Jun 3, 2026
1 check passed
@wendyyuchensun
wendyyuchensun deleted the json branch June 3, 2026 07:29
jamliaoo added a commit that referenced this pull request Jun 6, 2026
Phase 1A: deliver a framework-free renderer that consumes the same Stage 1
markup pipeline as the React adapter, lets consumers replace 16 component
slots with `(props, childrenHtml) => htmlString` functions, and ships escape
utilities + a safeUrl scheme allow-list for XSS hygiene. The React adapter
and Stage 1 are untouched (the payload attribute-escape fix this branch
originally carried landed on main independently as #40).

Phase 1A includes the 5 default components Access8MathTemplate needs (alert,
heading, internal-link, image, math); the other 11 are Phase 1B. The
package now exports three entries: `@coseeing/see-mark` (React, unchanged),
`@coseeing/see-mark/html` (renderToHtml), `@coseeing/see-mark/html/utils`
(escape helpers). `peerDependenciesMeta` makes React optional for
consumers using only `/html`.
jamliaoo added a commit that referenced this pull request Jun 29, 2026
* feat: add HTML adapter via @coseeing/see-mark/html

Phase 1A: deliver a framework-free renderer that consumes the same Stage 1
markup pipeline as the React adapter, lets consumers replace 16 component
slots with `(props, childrenHtml) => htmlString` functions, and ships escape
utilities + a safeUrl scheme allow-list for XSS hygiene. The React adapter
and Stage 1 are untouched (the payload attribute-escape fix this branch
originally carried landed on main independently as #40).

Phase 1A includes the 5 default components Access8MathTemplate needs (alert,
heading, internal-link, image, math); the other 11 are Phase 1B. The
package now exports three entries: `@coseeing/see-mark` (React, unchanged),
`@coseeing/see-mark/html` (renderToHtml), `@coseeing/see-mark/html/utils`
(escape helpers). `peerDependenciesMeta` makes React optional for
consumers using only `/html`.

* fix(html): neutralize dangerous raw HTML passthrough (XSS)

Browser verification (agent-browser) surfaced that the design assumption
"marked escapes inline raw HTML" was false — marked passes raw HTML through
Stage 1 verbatim. The HTML adapter then re-emitted it into a string the
consumer assigns to innerHTML, making `<img src=x onerror=alert(2)>` actually
fire — strictly more dangerous than the React adapter (which drops on*
handlers and never executes vDOM scripts).

The converter now, for raw passthrough elements only (component placeholders
are unaffected): strips on* event-handler attributes, runs href/src/etc.
through safeUrl, and drops <script>/<style> entirely. Safe inline HTML
(<strong>, <br>, ...) is preserved. Added 4 tests covering each case.

* feat(html): complete remaining default components + cross-adapter parity

Phase 1B completes the HTML adapter's default component set (15 total,
matching the React adapter's SUPPORTED_COMPONENT_TYPES): adds
internal-link-title, image-link, image-display, image-display-link,
external-link-tab/-title/-tab-title, youtube, codepen, iframe. URL-bearing
props (href/src/target/source) run through safeUrl; text props through
escapeHtml/escapeAttr.

Adds a cross-adapter parity test that renders the same markdown through both
the React and HTML adapters, parses both outputs with the same DOM parser,
and asserts identical normalized semantic signatures (tag, semantic attrs,
text) — normalizing away formatting differences (self-closing tags, attr
order, className/class, style spacing, empty-vs-omitted attrs, and MathJax's
globally-incrementing element IDs). 18 cases cover all component types.

138 tests pass. Verified in a real browser (agent-browser): all new
components render correctly and the javascript:-URL guard holds on external
links.

* fix(html): harden raw-HTML passthrough against XSS (3 review findings)

Address three findings from the automated security review of the pushed
branch:

1. Forged component payload (math XSS) — a user could smuggle
   `data-seemark-element-type`/`data-seemark-payload` via raw HTML in their
   markdown to forge a `math` component (whose payload is emitted as live
   HTML) and inject script. Fixed at the source in Stage 1: the marked `html`
   renderer now strips those attributes from user raw HTML, so only genuine
   extension-renderer placeholders are ever dispatched. Protects both adapters.

2. Dangerous passthrough tags/attributes — the converter now drops iframe,
   object, embed, meta, base, form, link (in addition to script/style) and
   strips srcdoc, formaction, http-equiv, ping, background (in addition to
   on*). `<iframe srcdoc>` re-parses as HTML, so escaping the attribute value
   was insufficient. Component output (e.g. the youtube/iframe components'
   <iframe>) is not re-walked, so embeds still render.

3. URL scheme allowlist bypass — safeUrl now strips C0 control chars / DEL
   before scheme detection, so `java\tscript:alert(1)` (which browsers parse
   as `javascript:`) is correctly rejected instead of being treated as a
   relative URL.

Verified in a real browser (agent-browser): the forged payload, raw <iframe
srcdoc>, and control-char javascript: URL all fail to execute (no alert
fires), while legitimate math and youtube embeds still render. 146 tests pass
(+8). Cross-adapter parity still holds — the Stage 1 change only affects
user raw HTML containing seemark attributes, not legitimate content.

* refactor(html): dedup image components + single-pass attr serialization

/simplify pass over the HTML adapter:
- image-link / image-display / image-display-link now reuse the `image`
  leaf component instead of repeating the identical `<img>` markup +
  escape triple, so the image element is defined in one place.
- converter.js serializeAttrs lower-cases each attribute name once and
  drops the separate filter pass (single map), instead of lower-casing
  up to twice per attribute.

Output is byte-identical; cross-adapter parity + 150 tests still pass.

* test(html): add full-syntax fixture covering every component

Automates the manual end-to-end smoke test as jest tests, so the full
matrix of supported syntax is guarded on every `npm test` (no browser
needed — renderToHtml output is deterministic, MathJax runs in Node).

- full-syntax-fixture.js: one markdown doc exercising headings, inline
  formatting, nested lists, table, all 4 link variants, all 4 image
  variants, iframe/youtube(embed+watch→embed)/codepen, and 5 LaTeX
  expressions. String.raw keeps the LaTeX backslashes literal.
- full-syntax.test.js: structural assertions on the HTML adapter output
  (counts, target/rel/title, external https src, figcaptions, math
  MathML+SVG a11y dual track, no leftover data-seemark-* placeholders).
- cross-adapter-parity: add the full document as a parity case so React
  and HTML stay semantically identical for a real-world composition.

* refactor(html): address review — policy module, strict contract, factory API

Four review findings on the HTML adapter:

1. Extract the raw-HTML security policy (DROPPED_TAGS / DROPPED_ATTRS /
   URL_ATTRS / serializeAttrs) out of converter.js into raw-html-policy.js,
   so the converter only does intermediate-markup → HTML and the policy is
   a named, independently auditable artifact.

2. Stop swallowing broken component payloads. Stage 1 guarantees parseable
   payloads (attribute-escaped JSON + forged data-seemark-* stripped), so an
   unparseable payload is a SeeMark bug: throw a descriptive contract error
   instead of falling back to a plain element that leaked data-seemark-*
   attributes. The try no longer wraps the component call either — custom
   component exceptions now propagate instead of degrading silently.

3. Promote cross-adapter-parity.test.js to a formal contract test: each
   case declares which component types it covers, and a coverage test
   enforces that every SUPPORTED_COMPONENT_TYPES entry has at least one
   parity case — adding component #16 without one fails CI.

4. Symmetric API: add createMarkdownToHtmlParser({options, components,
   sanitize}) factory (default export, mirroring createMarkdownToReactParser);
   renderToHtml stays as the one-shot convenience wrapper, so the
   exported-website template is unaffected.

* refactor(html): single /html entry under src/entries, fold /html/utils in

Two pre-publish API/layout decisions for the many-adapters future:

- One subpath per adapter: the escape utilities (escapeHtml/escapeAttr/
  safeUrl, for authoring custom components) are now named exports of
  `@coseeing/see-mark/html` instead of a separate `/html/utils` entry.
  Halves the entry count each future adapter (vue, wc, ...) would add.
- Entry files move off the src/ root into src/entries/ (html.js), so
  package facades stay grouped as adapters multiply. Input filenames are
  decoupled from output names — lib/see-mark-html.cjs is unchanged.

* docs(comments): drop development-process narration from tests

* docs(comments): replace React-mirror narration with provenance/rationale

The cross-adapter parity contract test is the single documented source of
the React<->HTML mirror relationship; per-file comments keep only what has
standalone value (embed-attribute provenance, math trust rationale).

* refactor(html): drop safeUrl/DROPPED_TAGS; correct-escaping-only, not a sanitizer

Remove safeUrl (URL scheme allowlist), raw-html-policy.js (tag/attr denylist),
and the DROPPED_TAGS check in the walker. All 8 URL-bearing default components
switch from safeUrl(x) to escapeAttr(x).

The HTML adapter is now positioned like marked: attribute-escaping prevents
quote-context breakout, but scheme filtering and tag/attr removal are left to
the sanitize hook (DOMPurify). Tests updated to assert passthrough behaviour
rather than XSS blocking; Stage 1 forgery protection (stripSeemarkAttrs) is
unaffected and remains the single data-integrity boundary.
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