11import 'slack-blocks-to-jsx/dist/style.css' ;
22
3- import { useEffect , useRef } from 'react' ;
3+ import { useEffect , useMemo , useRef } from 'react' ;
44import type { Block } from 'slack-blocks-to-jsx' ;
55import { Message } from 'slack-blocks-to-jsx' ;
6+ import { sanitizeBlock } from '../../lib/sanitize-blocks' ;
7+ import { isSafeHref , isSafeImageSrc } from '../../lib/url-safety' ;
68import type { PreviewHooks , PreviewTheme , SupportedBlock } from '../../types' ;
79
810/**
@@ -33,10 +35,28 @@ export function SlackBlockPreview({
3335} ) {
3436 const rootRef = useRef < HTMLDivElement > ( null ) ;
3537
38+ // Strip dangerous URI schemes (`javascript:`, `data:text/html`, etc.)
39+ // from every `url`/`image_url` field in the block before handing it
40+ // to slack-blocks-to-jsx, which renders rich-text links, button URLs,
41+ // and image sources directly into `<a href>` / `<img src>` without
42+ // its own scheme filter. Memoized so an unchanged block keeps the
43+ // same reference and doesn't churn the renderer.
44+ const safeBlock = useMemo ( ( ) => sanitizeBlock ( block ) , [ block ] ) ;
45+
3646 // slack-blocks-to-jsx renders an SVG-only collapse toggle in image and
3747 // video blocks without an aria-label, which violates axe's `button-name`
3848 // rule and is unreachable to screen readers. Post-mount we add a label
39- // to any such buttons we find under our wrapper.
49+ // to any such buttons we find under our wrapper. We also do a final
50+ // pass to neutralize any `<a href>` or `<img src>` that carries a
51+ // disallowed URI scheme — the block-payload sanitizer catches URLs
52+ // that live in structured fields (`url`, `image_url`), but mrkdwn /
53+ // rich-text content can encode link URLs inside text strings
54+ // (`[label](javascript:...)` or `<javascript:...|label>`) that
55+ // `slack-blocks-to-jsx`'s own parser hands straight to `<a href>`
56+ // without filtering. React 19 also blocks `javascript:` URLs at
57+ // setAttribute time, but we don't rely on that — this loop applies
58+ // our allowlist (which is tighter and covers `data:`/`vbscript:`/`file:`
59+ // as well) and replaces unsafe values with `#`.
4060 useEffect ( ( ) => {
4161 const root = rootRef . current ;
4262 if ( ! root ) return ;
@@ -48,6 +68,20 @@ export function SlackBlockPreview({
4868 btn . setAttribute ( 'aria-label' , title ? `Toggle ${ title } ` : 'Toggle media' ) ;
4969 }
5070 }
71+ for ( const a of root . querySelectorAll < HTMLAnchorElement > ( 'a[href]' ) ) {
72+ const href = a . getAttribute ( 'href' ) ;
73+ if ( ! isSafeHref ( href ) ) {
74+ a . setAttribute ( 'href' , '#' ) ;
75+ a . setAttribute ( 'data-bk-blocked-href' , '1' ) ;
76+ }
77+ }
78+ for ( const img of root . querySelectorAll < HTMLImageElement > ( 'img[src]' ) ) {
79+ const src = img . getAttribute ( 'src' ) ;
80+ if ( ! isSafeImageSrc ( src ) ) {
81+ img . removeAttribute ( 'src' ) ;
82+ img . setAttribute ( 'data-bk-blocked-src' , '1' ) ;
83+ }
84+ }
5185 } ) ;
5286
5387 return (
@@ -64,7 +98,7 @@ export function SlackBlockPreview({
6498 logo = ""
6599 withoutWrapper
66100 theme = { theme }
67- blocks = { [ block as unknown as Block ] }
101+ blocks = { [ safeBlock as unknown as Block ] }
68102 hooks = { hooks as Record < string , unknown > | undefined }
69103 />
70104 </ div >
0 commit comments