Date: 2026-05-16
Reviewer: pre-publish hardening pass before the next release.
Methodology: OWASP-aligned code review of src/, direct inspection of
the runtime behavior of every third-party renderer in node_modules
(slack-blocks-to-jsx@1.0.4, @tiptap/extension-link@3.23.4,
react-markdown@9.1.0, @tightknitai/slack-block-kit-validator@0.1.0-alpha.0),
and unit-test proof-of-concepts for each finding. pnpm audit clean.
| Severity | Count | Status |
|---|---|---|
| Critical | 0 | — |
| High | 1 | Fixed in this PR. |
| Medium | 4 | Fixed in this PR. |
| Low | 3 | Fixed in this PR. |
| Info | 4 | Documented; no code change required. |
The single highest-impact issue is stored DOM-XSS via crafted Block
Kit JSON: user-supplied URLs (rich-text link url, section / card /
button url, image_url) were stored raw and passed to
slack-blocks-to-jsx's <a href> and <img src> renderers without
URI-scheme filtering. A payload containing javascript:alert(...) —
deliverable via the JSON drawer, a shareable encoded URL state, or a
consumer's own backend — became a clickable script gadget in the
preview, executing in the consumer's origin. The validator
(format: "uri") accepts any RFC-3986 URI and is not a defense.
This PR adds a single shared allowlist (src/lib/url-safety.ts) and
wires it into:
- the preview boundary (
SlackBlockPreviewsanitizes every block before handing it to<Message>), - the public
toSlackBlocks(sanitizes payloads on the way out to the Slack API or any consumer), - the TipTap link extension config (
isAllowedUriandprotocols), - the structured rich-text editor's URL field (visible warning),
SendDialog's "Sign in with Slack" link (refuses to render an unsafeoauthUrl),proseMirrorToRichText(drops unsafe URLs at serialization).
It also adds size guards (1 MiB) on the URL-state codec and JSON drawer to prevent UI freezes on pathological inputs, tightens the Dependabot auto-merge workflow to exclude security-sensitive packages, and trims the publish workflow so the npm-token-bearing job no longer reinstalls Chromium.
206 tests pass, lint clean, typecheck clean.
- OWASP: A03 Injection (DOM-XSS).
- Surfaces: src/components/preview/slack-block-preview.tsx, every editor that accepts a URL (image-editor.tsx:30, section-editor.tsx:192,259, context-editor.tsx:244, card-editor.tsx:152,249, rich-text-structured-editor.tsx:462), the public
decodeBlocksFromStringAPI. - Root cause:
slack-blocks-to-jsx@1.0.4renders non-mrkdwn URLs into<a href={url}>directly — there are 10 distinct anchor construction sites and 6 image construction sites in its bundle, none of which filter URI schemes. The Slack validator (format: "uri", AJV) accepts any RFC-3986 URI, includingjavascript:. - Repro:
Before this PR: the preview rendered
<a href="javascript:alert(document.cookie)">click me</a>, which fired on click in the consumer's origin. - Note on mrkdwn:
react-markdown@9.1.0does blockjavascript:viadefaultUrlTransform, butslack-blocks-to-jsx@1.0.4does NOT routesection.text.mrkdwnthrough react-markdown — it ships its own inline mrkdwn parser that extracts[label](url)and<url|label>links and handsurlstraight to<a href={url}>without filtering. The same applies tomrkdwntext in headers, contexts, cards, etc. In practice, React 19's runtimejavascript:-URL mitigation kept these attacks from firing (it replaces the href withjavascript:throw new Error('React has blocked a javascript: URL as a security precaution.')), but the URL still reached the DOM, the consumer is not guaranteed to be on React ≥ 16.9 (peerDep allows ^18), and an attacker could still exploit non-javascript:unsafe schemes (data:text/html,vbscript:,file:) which React does NOT block. The post-render DOM scrub closes this hole. - Note on TipTap:
@tiptap/extension-link@3.23.4already gatessetLink/toggleLinkonisAllowedUri(default allowlist: http/https/ftp/ftps/mailto/tel/callto/sms/cid/xmpp), so the WYSIWYG entry path was safe. But the structured editor's URL field bypassed TipTap entirely, and a crafted payload imported from JSON or URL state never went throughsetLink. - Fix:
- New module src/lib/url-safety.ts exports
isSafeHref/isSafeImageSrc/sanitizeHref/sanitizeImageSrc. Mirrors react-markdown'sdefaultUrlTransformshape; allowlist matches Slack's documented link types (http/https/mailto/tel/sms/xmpp/ircs?). - New module src/lib/sanitize-blocks.ts walks a block tree and rewrites every
url/image_urlfield through the allowlist. - src/components/preview/slack-block-preview.tsx memo-sanitizes the block before handing it to
<Message>AND runs a post-render DOM walk that scrubs<a href>and<img src>produced by the renderer's own mrkdwn parser (which extracts link URLs from[label](url)and<url|label>syntax insidetextstrings — those URLs do not live in a structuredurlkey and so are missed by the payload-level pass). This is the load-bearing belt-and-suspenders fix; the rest of this finding's mitigations layer on top. - src/lib/to-slack-blocks.ts sanitizes on the way OUT, so the public API consumer also cannot inadvertently ship
javascript:to Slack. - src/components/editors/rich-text-editor.tsx: explicit
protocolsallowlist and anisAllowedUrihook bound toisSafeHref; popover Apply validates and shows an inline error. - src/lib/rich-text-tiptap.ts:
proseMirrorToRichTextsanitizes linkurlbefore emitting the Slack payload, so a payload imported viasetContentcannot round-trip out unchanged. - src/components/editors/rich-text-structured-editor.tsx: URL field flags unsafe input with
aria-invalidand an inline warning ("will be stripped before send and preview").
- New module src/lib/url-safety.ts exports
- Tests: test/url-safety.test.ts, test/sanitize-blocks.test.ts, and the new
toSlackBlocks URL sanitizationblock in test/public-api.test.ts cover the safe / unsafe matrix and the reference-stability invariant. - End-to-end verification: pasted a payload mixing
[click](javascript:...),<javascript:...|label>, a rich_textlink.urlofjavascript:..., adata:image/svg+xml,<svg onload=alert(1)>image, and safe controls into the demo's JSON drawer. Result: all four anchors carrying ajavascript:URL ended up withhref="#"and adata-bk-blocked-href="1"marker (mrkdwn path) or emptyhref(rich-text payload path), the SVG image'ssrcwas removed, and thehttps://controls passed through unchanged. Screenshot in the PR.
- OWASP: A03 Injection (DOM-XSS at a consumer-trust boundary).
- Location: src/components/send-dialog.tsx:196 (before fix).
- Root cause:
userStatus.oauthUrlis the unfiltered return value of the consumer'sloadSendAsUserStatuscallback and was rendered into<a href={userStatus.oauthUrl} target="_blank" rel="noreferrer">. If a downstream backend was ever compromised, MITM'd, or simply buggy, returningjavascript:...would execute on click. Also missing explicitnoopener. - Fix: Gate the entire
<p>onisSafeHref(userStatus.oauthUrl); upgraderel="noreferrer"torel="noopener noreferrer". - Residual risk: the consumer can still display a misleading OAuth URL pointing to a non-Slack origin (open-redirect-flavoured phishing). That's a consumer-side decision; we now refuse only the URI-scheme attack.
- OWASP: A05 Security Misconfiguration / A04 Insecure Design.
- Location: src/components/editors/rich-text-editor.tsx:99-110 (before fix).
- Root cause: relied on
@tiptap/extension-link's defaults. The default allowlist is wider than we need (http/https/ftp/ftps/mailto/tel/callto/sms/cid/xmpp) and could silently widen further on a minor upgrade. - Fix: explicit
protocols: ['http','https','mailto','tel','sms','xmpp']plusisAllowedUri: (url) => isSafeHref(url). The popover Apply handler also pre-validates and shows an inline error, so the user sees why the link did not stick.
- OWASP: A05 Security Misconfiguration (client-side DoS).
- Location: src/lib/url-state.ts:31 (before fix).
- Root cause: a hostile-or-buggy URL hash could feed an arbitrarily large base64url string into
atob+JSON.parse, hanging the tab before the validator gets a look. - Fix: a 1 MiB cap (
MAX_ENCODED_BYTES) returnsnullimmediately on oversized input. Realistic Slack Block Kit payloads sit well under 100 KiB; the cap is generous enough that legitimate consumers will never hit it. - Test:
test/public-api.test.ts—rejects encoded input larger than 1 MiB without invoking atob.
- OWASP: A05 Security Misconfiguration (client-side DoS).
- Location: src/components/json-drawer.tsx:60 (before fix).
- Root cause:
JSON.parseon a multi-megabyte paste froze the UI thread. - Fix: a 1 MiB cap surfaces a clear inline error ("JSON exceeds the 1024 KiB editor limit.") before
JSON.parseruns.
- OWASP: A08 Software & Data Integrity (supply chain).
- Location: .github/workflows/dependabot-automerge.yml (before fix).
- Root cause: any non-major Dependabot PR auto-approved and auto-merged after CI passed, including direct deps that render user content (
slack-blocks-to-jsx,react-markdown,remark-gfm, every@tiptap/*,ajv, the validator). A single compromised minor release ships to consumers within minutes (cf.event-stream,colors.js,node-ipc,peacenotwar). - Fix: an exclusion list checked from Dependabot metadata. Updates to any of the renderer / validator / Tiptap / markdown deps now post a "held for manual review" comment and require a human to merge.
- OWASP: A08 Software & Data Integrity (supply chain).
- Location: .github/workflows/publish.yml (before fix).
- Root cause: the
publishjob — which holdsNPM_TOKEN— re-installed Chromium and re-ran the entire Playwright-backed Vitest suite beforepnpm publish. Every additional install on the token-bearing job is supply-chain surface area for nothing: the same tests already ran in CI against the same commit before release-please merged it to main. - Fix: publish runs lint + typecheck + build only. The verbose comment explains why.
- OWASP: A05 Security Misconfiguration.
- Location: src/components/send-dialog.tsx:196 (before fix).
- Root cause:
rel="noreferrer"impliesnoopenerin modern browsers but not in all older ones. Defense-in-depth says set both. - Fix: upgraded to
rel="noopener noreferrer"(combined with F-002).
These were inspected, deemed safe as-shipped, and noted here so future reviewers can see what was covered:
dangerouslySetInnerHTML: exactly one occurrence (brand-theme-scope.tsx:73), defended byisSafeCssValue— 200-char cap, rejects;{}<>&and CSS comment markers and newlines. The scope id isuseId()-derived. Verified: a malicioustokens.primary = "red; <script>"is rejected by the regex before injection, and any value that does pass cannot break out of the declaration into a new rule or close the<style>element. Note for future contributors: do not widen the regex without re-deriving the proof.eval/new Function/vm.runIn*: zero occurrences anywhere insrc/.innerHTML/outerHTML/document.write/insertAdjacentHTML: zero occurrences.postMessagehandlers: none.localStorage/sessionStorage/document.cookie: not read or written by the library itself (state persistence is delegated to the consumer per design).- Clipboard reads: none.
- Raw
fetch/ XHR: not performed by the library; all I/O is brokered by consumer callbacks (loadChannels,loadSendAsUserStatus,onSend). - File uploads /
FileReader/URL.createObjectURL: none. <iframe>elements: none.JSON.parseof untrusted input: two sites (url-state.ts:42, json-drawer.tsx:64) — both wrapped in try/catch, top-level array check, and now size-capped.__proto__keys in JSON do not polluteObject.prototypein modern engines and the sanitizer was verified to be free ofObject.assign-flavored merges that walk the prototype chain (test/sanitize-blocks.test.tsprototype pollution shape).- Random IDs:
nanoid@5.x(CSPRNG-backed). Not used for security tokens; appropriate. - Toolbar docs link: toolbar.tsx:158-166 is a hardcoded
docs.slack.devURL withrel="noreferrer noopener". Safe. .gitignore:.env*excluded. No.env*tracked.- Action pinning:
actions/checkout@v6,dependabot/fetch-metadata@v3,googleapis/release-please-action@v5. Major-tag pins, GitHub's recommended practice for trusted publishers. SHA-pinning is the highest-rigor option (see Info-002). - CI trigger: uses
pull_request(NOT the footgunpull_request_target). npm publishprovenance: enabled via--provenancein the publish workflow.prepublishOnly: runsbuild:clean && test— localpnpm publishis gated.pnpm audit: 0 advisories at the time of review (679 dependencies, 0 critical / 0 high / 0 moderate / 0 low / 0 info).
- Info-001 — Validator scope.
@tightknitai/slack-block-kit-validator@0.1.0-alpha.0is a structural validator. Itsformat: "uri"rule accepts any RFC-3986 URI and is not a URI-scheme allowlist. Do not rely on it for sanitizing user URLs at any layer. - Info-002 — Action SHA pinning. Consider SHA-pinning all third-party actions in
.github/workflows/(dependabot/fetch-metadata,googleapis/release-please-action) for the highest supply-chain rigor. Major-tag pinning is the current GitHub recommendation and is acceptable. - Info-003 — CSP guidance for consumers. This is a UI library; we cannot set HTTP response headers ourselves. Consumers should set a strict CSP (
script-src 'self',style-src 'self' 'unsafe-inline'to permit our scoped brand<style>,img-src https: data:,connect-src 'self' slack.com). Worth adding toREADME.mdas a "Hardening guide". - Info-004 — Consumer-trust contract for
loadSendAsUserStatus. The library now refuses to render an unsafeoauthUrl. Consumers should know that we will silently drop ajavascript:-flavoured value rather than render it. Document in the prop's JSDoc on the next minor.
- The library does not (and cannot) prevent a consumer from rendering the same block payload in their own UI without the preview boundary. Consumers should call the public
toSlackBlocks(...)before passing a block list to any non-builder renderer; that is now the documented hardening boundary. - The shareable URL state codec is intentionally a transparent base64url-of-JSON. Consumers must treat decoded blocks as untrusted; they will be sanitized when rendered by
SlackBlockPreviewor returned bytoSlackBlocks, but anything else the consumer does with them is the consumer's responsibility. Document on the next minor.
src/lib/url-safety.ts (new) F-001
src/lib/sanitize-blocks.ts (new) F-001
src/components/preview/slack-block-preview.tsx (edit) F-001
src/lib/to-slack-blocks.ts (edit) F-001
src/components/editors/rich-text-editor.tsx (edit) F-001, F-003
src/lib/rich-text-tiptap.ts (edit) F-001
src/components/editors/rich-text-structured-editor.tsx (edit) F-001
src/components/send-dialog.tsx (edit) F-002, F-008
src/lib/url-state.ts (edit) F-004
src/components/json-drawer.tsx (edit) F-005
.github/workflows/dependabot-automerge.yml (edit) F-006
.github/workflows/publish.yml (edit) F-007
test/url-safety.test.ts (new) F-001
test/sanitize-blocks.test.ts (new) F-001
test/public-api.test.ts (edit) F-001, F-004