fix: drop the renderer's iframeProps bag before a video block renders - #209
Conversation
`slack-blocks-to-jsx` destructures `iframeProps` off the video block
payload and spreads it onto the `<iframe>` *after* its own `src`. It is a
documented renderer extension, not a Slack field, so both `sanitizeBlock`
and `toSlackBlocks` passed it through untouched: the key-shape URL
classifier sees a `src` inside the bag, but `srcdoc`, `sandbox`, and
`allow` are not URLs and were never looked at.
A payload of
{"type":"video","video_url":"https://…",
"iframeProps":{"srcdoc":"<script>…</script>"}}
rendered `<iframe srcdoc="<script>…">`. An inline frame document runs
with the embedding app's own origin on every React version, so this is
the same-origin outcome that the recent `video_url` hardening closed for
`javascript:` on React 18 and that the `data:` variant never reached.
The bag could also override the frame source with a relative URL (the
DOM scrub caught it, but only after navigation had started) and loosen
`sandbox` / `allow` on the frame.
- `sanitize-blocks.ts`: any key whose last `_`-delimited segment is
`props` (`iframeProps`, `iframe_props`, a future `imgProps`) is dropped
wherever it appears, at both the preview and the `toSlackBlocks`
boundary. Same name-shape reasoning as the URL keys: Slack has no such
field and rejects one on send, so over-matching costs nothing. As a
side effect, a previewed payload that carried the bag now passes the
Block Kit validator, which had been rejecting it as an unknown
property.
- `slack-block-preview.tsx`: a backstop arm in the DOM scrub strips
`srcdoc` from every frame, whatever its value, and marks it
`data-bk-blocked-srcdoc`. The renderer never emits one on its own.
- Tests cover both layers independently: the preview walk asserts that a
video block carrying each hostile bag renders exactly as if the bag
were absent (legitimate `video_url` intact, no `srcdoc` / `sandbox` /
`allow` on the frame), and a second file mocks the payload sanitizer
to a pass-through so the DOM scrub has to do the work. 15 of the new
assertions fail against the pre-fix tree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqD2Gt19aTjBLFwTYU6vTv
Cloudflare preview✅ Deployed |
|
CI status: the only red check is Audit (pnpm), and it is not this PR's. Everything else on What is failing. The Why it is not this PR's. This branch does not touch Fix. No PR carries one yet. A lockfile-only bump resolves it, verified locally: pnpm update fast-uri # 3.1.5 -> 3.1.7, within ajv's ^3.0.1 range
pnpm audit --audit-level=high # No known vulnerabilities foundThe resulting diff is four lines in Generated by Claude Code |
Summary
slack-blocks-to-jsxdestructuresiframePropsoff the video block payload and spreads it onto the<iframe>it renders, after its ownsrc. Nothing in block-kitchen looked at that field, so a JSON payload could putsrcdoc, asrcoverride, or a loosenedsandbox/allowon the frame. The payload sanitizer now drops renderer prop bags at both the preview and thetoSlackBlocksboundary, and the preview's DOM scrub stripssrcdocfrom every frame as a backstop.Why
The recent video-block hardening (#204) routed
video_urlthrough an http(s)-only allowlist and added aniframe[src]arm to the DOM scrub, but the renderer has a second way into the same frame.iframePropsis a documentedslack-blocks-to-jsxextension, not a Slack field, sosanitizeBlockandtoSlackBlocksboth passed it through: the key-shape URL classifier sees asrcinside the bag, butsrcdoc,sandbox, andalloware not URLs and were never inspected.Reproduced in jsdom against
main:[ { "type": "video", "alt_text": "poc", "title": { "type": "plain_text", "text": "PoC" }, "thumbnail_url": "https://example.com/t.png", "video_url": "https://www.youtube.com/embed/abc", "iframeProps": { "srcdoc": "<script>top.__pwned=1</script>" } } ]renders
<iframe src="https://www.youtube.com/embed/abc" srcdoc="<script>top.__pwned=1</script>">. An inline frame document runs with the embedding app's own origin on every React version, which is the same-origin outcome the earlier fix closed forjavascript:on React 18 and that thedata:variant never reached. The same entry points apply: the JSON drawer, the?blocks=URL state,initialBlocks,onLoadMessage, any consumer backend. The bag could also override the frame source with a relative URL (the DOM scrub removed it, but only after navigation had started) and setsandbox="allow-scripts allow-same-origin"/allow="camera; microphone"on the frame.What changed:
src/lib/sanitize-blocks.ts: any key whose last_-delimited segment isprops(iframeProps,iframe_props, a futureimgProps) is dropped wherever it appears in the tree. Same name-shape reasoning as the URL keys: Slack has no such field and rejects one on send, so over-matching costs nothing, and a renderer upgrade that adds another bag is covered on arrival. The camelCase fold is now a sharednormalizeKeyhelper.src/components/preview/slack-block-preview.tsx: a backstop arm stripssrcdocfrom every frame, whatever its value, and marks itdata-bk-blocked-srcdoc. The renderer never emits one on its own.src/lib/to-slack-blocks.ts, README: doc updates. As a side effect, a previewed payload that carried the bag now passes the Block Kit validator, which had been rejecting it asunknown property 'iframeProps'.SECURITY-REVIEW.md: follow-up entry under F-009 and the file map.Test plan
pnpm typecheckpnpm lintpnpm test— theunitproject (42 files, 483 tests) passes locally withBK_CHROMIUM_EXECUTABLEpointed at the container's Chromium. The three built-stylesheet suites and the storybook browser project cannot launch in this container (the pinned Playwright headless-shell build is not installed there), so CI is the authority for those; the local pre-push hook was skipped for that reason.pnpm storybook— n/a, no UI change.src/and pass with it (git stash push -- src/ run /git stash pop).test/preview-url-scrub.test.tsxrenders a video block carrying each hostile bag (srcdoc,srcDoc,data:/javascript:/ relativesrcoverrides,sandbox+allow) and asserts it renders exactly as if the bag were absent, with the legitimatevideo_urlintact;test/preview-srcdoc-scrub.test.tsxmocks the payload sanitizer to a pass-through so the DOM scrub has to do the work. Field-level cases intest/sanitize-blocks.test.tsandtest/public-api.test.ts(including the validator now acceptingtoSlackBlocksoutput).Notes for reviewer
VideoBlocktype never exposediframeProps, Slack rejects it on send, and every member that matters here (srcdoc,sandbox,allow) is an attribute with no scheme to allowlist. If a consumer ever needs to tune the embed, that should be an explicit prop on the preview, not a payload field.srcdocscrub arm is a backstop only: an inline document parses on insertion, before any effect runs, so the payload layer is the load-bearing one. That is why the two layers are tested separately.slack-blocks-to-jsxwould still close this class at the source for every consumer of that package.🤖 Generated with Claude Code
https://claude.ai/code/session_01CqD2Gt19aTjBLFwTYU6vTv
Generated by Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.