Skip to content

fix: drop the renderer's iframeProps bag before a video block renders - #209

Merged
StephenTangCook merged 1 commit into
mainfrom
claude/sweet-meitner-ejwgx5
Sep 3, 2026
Merged

fix: drop the renderer's iframeProps bag before a video block renders#209
StephenTangCook merged 1 commit into
mainfrom
claude/sweet-meitner-ejwgx5

Conversation

@StephenTangCook

@StephenTangCook StephenTangCook commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

slack-blocks-to-jsx destructures iframeProps off the video block payload and spreads it onto the <iframe> it renders, after its own src. Nothing in block-kitchen looked at that field, so a JSON payload could put srcdoc, a src override, or a loosened sandbox / allow on the frame. The payload sanitizer now drops renderer prop bags at both the preview and the toSlackBlocks boundary, and the preview's DOM scrub strips srcdoc from every frame as a backstop.

Why

The recent video-block hardening (#204) routed video_url through an http(s)-only allowlist and added an iframe[src] arm to the DOM scrub, but the renderer has a second way into the same frame. iframeProps is a documented slack-blocks-to-jsx extension, not a Slack field, so sanitizeBlock and toSlackBlocks both passed it through: the key-shape URL classifier sees a src inside the bag, but srcdoc, sandbox, and allow are 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 for javascript: on React 18 and that the data: 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 set sandbox="allow-scripts allow-same-origin" / allow="camera; microphone" on the frame.

What changed:

  • src/lib/sanitize-blocks.ts: any key whose last _-delimited segment is props (iframeProps, iframe_props, a future imgProps) 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 shared normalizeKey helper.
  • src/components/preview/slack-block-preview.tsx: a backstop arm strips srcdoc from every frame, whatever its value, and marks it data-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 as unknown property 'iframeProps'.
  • SECURITY-REVIEW.md: follow-up entry under F-009 and the file map.

Test plan

  • pnpm typecheck
  • pnpm lint
  • pnpm test — the unit project (42 files, 483 tests) passes locally with BK_CHROMIUM_EXECUTABLE pointed 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.
  • Spot-checked in pnpm storybook — n/a, no UI change.
  • The 15 new assertions that exercise the bag fail against the pre-fix src/ and pass with it (git stash push -- src / run / git stash pop).
  • New coverage exercises both layers independently: test/preview-url-scrub.test.tsx renders a video block carrying each hostile bag (srcdoc, srcDoc, data: / javascript: / relative src overrides, sandbox + allow) and asserts it renders exactly as if the bag were absent, with the legitimate video_url intact; test/preview-srcdoc-scrub.test.tsx mocks the payload sanitizer to a pass-through so the DOM scrub has to do the work. Field-level cases in test/sanitize-blocks.test.ts and test/public-api.test.ts (including the validator now accepting toSlackBlocks output).

Notes for reviewer

  • Dropping the whole bag rather than sanitizing its members: block-kitchen's own VideoBlock type never exposed iframeProps, 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.
  • The srcdoc scrub 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.
  • Pushing a scheme allowlist (and a prop-bag allowlist) upstream into slack-blocks-to-jsx would 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

`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
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

✅ Deployed f68c814 · Branch preview · Commit preview · Build logs

Copy link
Copy Markdown
Contributor Author

CI status: the only red check is Audit (pnpm), and it is not this PR's. Everything else on f68c814 is green, including Test (Node 20), Test (Node 22), CodeQL, Fencer, Lint, Typecheck, Build, Storybook build, and Demo build.

What is failing. The Audit root step reports four high advisories against fast-uri@3.1.5 (GHSA-5jgf-p345-68v8, GHSA-f65p-4m7j-42xc, GHSA-fph4-wmhf-6fwf, GHSA-jqff-g426-hqxp), all on the single path @tightknitai/slack-block-kit-validator > ajv > fast-uri. Patched versions are >=3.1.6. The demo audit step is clean.

Why it is not this PR's. This branch does not touch package.json or pnpm-lock.yaml; its lockfile is byte-identical to main. The same Audit root step fails identically on main's own CI run for acc97af (run 33810317333), which merged minutes before this PR opened. The advisories landed after main's last green run on Sept 1, so any PR opened now hits it. It is deterministic, so a re-run would not change the result.

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 found

The resulting diff is four lines in pnpm-lock.yaml (the fast-uri resolution and ajv's pinned dependency). I have not pushed it here because it is a production-dependency change unrelated to this fix, and the repo just narrowed Dependabot auto-merge to devDependencies. Happy to add it to this branch if you would rather have it ride along than land separately.


Generated by Claude Code

@StephenTangCook
StephenTangCook merged commit 9fd618b into main Sep 3, 2026
13 of 14 checks passed
@StephenTangCook
StephenTangCook deleted the claude/sweet-meitner-ejwgx5 branch September 3, 2026 22:03
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