@dfosco/storyboard 0.11.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.11.8",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -114,9 +114,12 @@
114
114
  "iconoir": "^7.11.0",
115
115
  "jsonc-parser": "^3.3.1",
116
116
  "lucide-react": "^1.8.0",
117
- "remark": "^15.0.1",
117
+ "rehype-raw": "^7.0.0",
118
+ "rehype-sanitize": "^6.0.0",
119
+ "rehype-stringify": "^10.0.1",
118
120
  "remark-gfm": "^4.0.1",
119
- "remark-html": "^16.0.1",
121
+ "remark-parse": "^11.0.0",
122
+ "remark-rehype": "^11.1.2",
120
123
  "tailwind-merge": "^3.5.0",
121
124
  "ws": "^8.21.0",
122
125
  "zod": "^3.23.8"
@@ -200,4 +203,4 @@
200
203
  "optionalDependencies": {
201
204
  "node-pty": "^1.0.0"
202
205
  }
203
- }
206
+ }
@@ -41,6 +41,11 @@ export function deepClone(val) {
41
41
  */
42
42
  export function setByPath(obj, path, value) {
43
43
  const segments = path.split('.')
44
+ // Guard against prototype pollution: refuse paths that traverse into
45
+ // special object internals. Override params can come from the URL.
46
+ if (segments.some((seg) => seg === '__proto__' || seg === 'constructor' || seg === 'prototype')) {
47
+ return
48
+ }
44
49
  let current = obj
45
50
  for (let i = 0; i < segments.length - 1; i++) {
46
51
  const seg = segments[i]
@@ -11,6 +11,16 @@
11
11
  */
12
12
  import { loadFlow } from '../data/loader.js'
13
13
 
14
+ /** Escape a string for safe interpolation into HTML text/attribute contexts. */
15
+ function escapeHtml(value) {
16
+ return String(value)
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/'/g, '&#39;')
22
+ }
23
+
14
24
  const STYLES = `
15
25
  .sb-scene-debug {
16
26
  padding: 16px;
@@ -89,7 +99,7 @@ export function mountFlowDebug(container, flowName) {
89
99
  el.innerHTML = `
90
100
  <div class="sb-scene-debug-error">
91
101
  <div class="sb-scene-debug-error-title">Error loading flow</div>
92
- <p class="sb-scene-debug-error-message">${error}</p>
102
+ <p class="sb-scene-debug-error-message">${escapeHtml(error)}</p>
93
103
  </div>`
94
104
  } else {
95
105
  const title = document.createElement('h2')
@@ -827,9 +827,10 @@ function showToast(message, route, basePath, filePath) {
827
827
  })
828
828
 
829
829
  const href = route?.startsWith('/') ? (basePath.replace(/\/$/, '') + route) : route
830
+ const escAttr = (v) => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
830
831
  let html = `<span style="font-weight:500">✓ ${message.replace(/</g, '&lt;')}</span>`
831
832
  if (href) {
832
- html += `<a href="${href}" style="color:var(--color-primary, #0969da);text-decoration:underline;font-size:0.8125rem">Open canvas</a>`
833
+ html += `<a href="${escAttr(href)}" style="color:var(--color-primary, #0969da);text-decoration:underline;font-size:0.8125rem">Open canvas</a>`
833
834
  }
834
835
  if (filePath) {
835
836
  html += `<span style="font-size:0.75rem;color:var(--color-muted, #64748b)">To edit your component, go to <code style="background:var(--color-muted, #f1f5f9);padding:1px 4px;border-radius:3px;font-size:0.75rem">${filePath.replace(/</g, '&lt;')}</code></span>`
@@ -1,7 +1,4 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
2
- import { remark } from 'remark'
3
- import remarkGfm from 'remark-gfm'
4
- import remarkHtml from 'remark-html'
5
2
  import { GitBranchIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, MarkGithubIcon } from '@primer/octicons-react'
6
3
  import WidgetWrapper from './WidgetWrapper.jsx'
7
4
  import ResizeHandle from './ResizeHandle.jsx'
@@ -9,6 +6,7 @@ import { readProp, linkPreviewSchema } from './widgetProps.js'
9
6
  import ExpandedPane from './ExpandedPane.jsx'
10
7
  import { findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
11
8
  import { useExpandOverride } from './useExpandOverride.js'
9
+ import { markdownToSafeHtml } from './markdown/markdownSanitize.js'
12
10
  import styles from './LinkPreview.module.css'
13
11
 
14
12
  const VIDEO_URL_LINE_RE = /^<p>\s*(https?:\/\/[^\s<]+\.(mp4|mov|webm|ogg)(?:\?[^\s<]*)?)\s*<\/p>$/gim
@@ -69,11 +67,7 @@ function postProcessHtml(html) {
69
67
 
70
68
  function renderMarkdown(text) {
71
69
  if (!text) return ''
72
- const result = remark()
73
- .use(remarkGfm)
74
- .use(remarkHtml, { sanitize: false })
75
- .processSync(text)
76
- return postProcessHtml(String(result))
70
+ return postProcessHtml(markdownToSafeHtml(text))
77
71
  }
78
72
 
79
73
  function timeAgo(dateStr) {
@@ -5,17 +5,11 @@
5
5
  * Split from `MarkdownEditor.jsx` so the latter only exports React
6
6
  * components — keeping Fast Refresh happy and the surface area minimal.
7
7
  */
8
- import { remark } from 'remark'
9
- import remarkGfm from 'remark-gfm'
10
- import remarkHtml from 'remark-html'
8
+ import { markdownToSafeHtml } from './markdownSanitize.js'
11
9
 
12
10
  export function renderMarkdown(text) {
13
11
  if (!text) return ''
14
- const result = remark()
15
- .use(remarkGfm)
16
- .use(remarkHtml, { sanitize: false })
17
- .processSync(text)
18
- return String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
12
+ return markdownToSafeHtml(text).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
19
13
  }
20
14
 
21
15
  let hljsPromise = null
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared markdown → safe-HTML pipeline.
3
+ *
4
+ * Renders markdown (with GFM) to HTML while preserving inline HTML that
5
+ * users legitimately embed in markdown widgets and GitHub issue/PR bodies
6
+ * (`<video>`, `<audio>`, `<details>`, …), but runs every node through a
7
+ * trusted allow-list sanitizer (`rehype-sanitize` / `hast-util-sanitize`).
8
+ *
9
+ * Because the sanitizer is allow-list based, anything not explicitly
10
+ * permitted is dropped: `<script>`/`<iframe>`, every `on*` event handler,
11
+ * and dangerous URL protocols (`javascript:`, `data:`) are statically
12
+ * removed. The extra tags re-admitted below are all *inert* elements, and
13
+ * only safe attributes (no event handlers) with `http(s)`-only `src`/`poster`
14
+ * are allowed.
15
+ */
16
+ import { unified } from 'unified'
17
+ import remarkParse from 'remark-parse'
18
+ import remarkGfm from 'remark-gfm'
19
+ import remarkRehype from 'remark-rehype'
20
+ import rehypeRaw from 'rehype-raw'
21
+ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
22
+ import rehypeStringify from 'rehype-stringify'
23
+
24
+ const schema = {
25
+ ...defaultSchema,
26
+ // `details`/`summary` are already in the default allow-list; add the inert
27
+ // media elements consumers embed in markdown.
28
+ tagNames: [...new Set([...(defaultSchema.tagNames || []), 'video', 'audio', 'track'])],
29
+ attributes: {
30
+ ...defaultSchema.attributes,
31
+ video: ['src', 'poster', 'controls', 'loop', 'muted', 'autoPlay', 'playsInline', 'preload', 'width', 'height'],
32
+ audio: ['src', 'controls', 'loop', 'muted', 'autoPlay', 'preload'],
33
+ source: [...(defaultSchema.attributes?.source || []), 'src', 'type'],
34
+ track: ['src', 'kind', 'srcLang', 'label', 'default'],
35
+ },
36
+ protocols: {
37
+ ...defaultSchema.protocols,
38
+ // Restrict media sources to http(s) — blocks `javascript:`/`data:` URLs.
39
+ src: ['http', 'https'],
40
+ poster: ['http', 'https'],
41
+ },
42
+ }
43
+
44
+ const processor = unified()
45
+ .use(remarkParse)
46
+ .use(remarkGfm)
47
+ .use(remarkRehype, { allowDangerousHtml: true })
48
+ .use(rehypeRaw)
49
+ .use(rehypeSanitize, schema)
50
+ .use(rehypeStringify)
51
+
52
+ /**
53
+ * Render markdown to sanitized HTML.
54
+ * @param {string} text Raw markdown.
55
+ * @returns {string} Safe HTML string.
56
+ */
57
+ export function markdownToSafeHtml(text) {
58
+ if (!text) return ''
59
+ return String(processor.processSync(text))
60
+ }
@@ -1341,12 +1341,13 @@ export default function storyboardDataPlugin() {
1341
1341
  return {
1342
1342
  optimizeDeps: {
1343
1343
  // @dfosco/storyboard is excluded (virtual module), so Vite
1344
- // can't trace into its deps. Include the remark entry points so
1345
- // Vite pre-bundles the full chain — covers all transitive CJS
1346
- // packages (debug, extend, etc.) without whack-a-mole.
1344
+ // can't trace into its deps. Include the markdown pipeline entry
1345
+ // points so Vite pre-bundles the full chain — covers all transitive
1346
+ // CJS packages (debug, extend, etc.) without whack-a-mole.
1347
1347
  include: [
1348
1348
  'cmdk',
1349
- 'remark', 'remark-gfm', 'remark-html',
1349
+ 'remark-parse', 'remark-gfm', 'remark-rehype',
1350
+ 'rehype-raw', 'rehype-sanitize', 'rehype-stringify',
1350
1351
  'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector',
1351
1352
  'feather-icons', '@primer/octicons', 'ansi-to-html',
1352
1353
  // @primer/react ≥38 ships pre-compiled with React Compiler and
@@ -53,12 +53,12 @@ describe('storyboardDataPlugin', () => {
53
53
  expect(config.optimizeDeps.exclude).toContain('@dfosco/storyboard')
54
54
  })
55
55
 
56
- it('config() includes remark stack in optimizeDeps so Vite pre-bundles transitive CJS deps', () => {
56
+ it('config() includes markdown pipeline stack in optimizeDeps so Vite pre-bundles transitive CJS deps', () => {
57
57
  const plugin = storyboardDataPlugin()
58
58
  const config = plugin.config()
59
- expect(config.optimizeDeps.include).toContain('remark')
59
+ expect(config.optimizeDeps.include).toContain('remark-parse')
60
60
  expect(config.optimizeDeps.include).toContain('remark-gfm')
61
- expect(config.optimizeDeps.include).toContain('remark-html')
61
+ expect(config.optimizeDeps.include).toContain('rehype-sanitize')
62
62
  })
63
63
 
64
64
  it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {