@tightknitai/block-kitchen 0.10.12 → 0.10.13

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/dist/helpers.cjs CHANGED
@@ -4,6 +4,7 @@
4
4
  var SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;
5
5
  var SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;
6
6
  var SAFE_IMAGE_DATA_PREFIX = /^data:image\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\.microsoft\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;
7
+ var SAFE_EMBED_PROTOCOLS = /^(https?)$/i;
7
8
  function getScheme(value) {
8
9
  const trimmed = value.replace(/^\s+/, "");
9
10
  const colon = trimmed.indexOf(":");
@@ -53,16 +54,47 @@ function isSafeImageSrc(value) {
53
54
  }
54
55
  return SAFE_IMAGE_PROTOCOLS.test(scheme);
55
56
  }
57
+ function isSafeEmbedSrc(value) {
58
+ if (typeof value !== "string") {
59
+ return false;
60
+ }
61
+ if (value.length === 0) {
62
+ return true;
63
+ }
64
+ const scheme = getScheme(value);
65
+ if (scheme === null) {
66
+ return false;
67
+ }
68
+ return SAFE_EMBED_PROTOCOLS.test(scheme);
69
+ }
56
70
  function sanitizeHref(value) {
57
71
  return isSafeHref(value) ? value ?? "" : "";
58
72
  }
59
73
  function sanitizeImageSrc(value) {
60
74
  return isSafeImageSrc(value) ? value ?? "" : "";
61
75
  }
76
+ function sanitizeEmbedSrc(value) {
77
+ return isSafeEmbedSrc(value) ? value ?? "" : "";
78
+ }
62
79
 
63
80
  // src/lib/sanitize-blocks.ts
64
- var HREF_KEYS = /* @__PURE__ */ new Set(["url"]);
65
- var IMAGE_KEYS = /* @__PURE__ */ new Set(["image_url"]);
81
+ var EMBED_KEYS = /* @__PURE__ */ new Set(["video_url"]);
82
+ var URL_KEY = /(^|_)(url|uri|link|href|src)$/;
83
+ var MAYBE_URL_KEY = /url|uri|link|href|src/i;
84
+ var IMAGE_KEY_HINT = /image|thumb|icon|avatar|logo|photo|picture/;
85
+ function classifyUrlKey(key) {
86
+ if (!MAYBE_URL_KEY.test(key)) {
87
+ return null;
88
+ }
89
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
90
+ if (EMBED_KEYS.has(normalized)) {
91
+ return "embed";
92
+ }
93
+ if (!URL_KEY.test(normalized)) {
94
+ return null;
95
+ }
96
+ return IMAGE_KEY_HINT.test(normalized) ? "image" : "href";
97
+ }
66
98
  var RETRIEVAL_ONLY_KEYS = /* @__PURE__ */ new Map([
67
99
  // Covers both the image block and the image element — both `type: 'image'`.
68
100
  ["image", /* @__PURE__ */ new Set(["image_width", "image_height", "image_bytes", "fallback", "is_animated"])],
@@ -98,10 +130,13 @@ function sanitizeValue(value) {
98
130
  const original = src[key];
99
131
  let next = original;
100
132
  if (typeof original === "string") {
101
- if (HREF_KEYS.has(key)) {
133
+ const kind = classifyUrlKey(key);
134
+ if (kind === "href") {
102
135
  next = sanitizeHref(original);
103
- } else if (IMAGE_KEYS.has(key)) {
136
+ } else if (kind === "image") {
104
137
  next = sanitizeImageSrc(original);
138
+ } else if (kind === "embed") {
139
+ next = sanitizeEmbedSrc(original);
105
140
  }
106
141
  } else if (typeof original === "object" && original !== null) {
107
142
  next = sanitizeValue(original);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/lib/url-safety.ts","../src/lib/sanitize-blocks.ts","../src/lib/to-slack-blocks.ts","../src/lib/url-state.ts"],"names":[],"mappings":"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","file":"helpers.cjs","sourcesContent":["/**\n * URL-scheme allowlists used as a defense-in-depth layer against\n * DOM-XSS via user-supplied URLs that flow into `<a href>` and `<img src>`\n * in the preview (rendered by `slack-blocks-to-jsx`, which does not\n * filter URI schemes itself) or in the builder's own chrome (e.g. the\n * \"Sign in with Slack\" link in `SendDialog`).\n *\n * `format: \"uri\"` on the validator schema (AJV / ajv-formats) accepts\n * any RFC-3986 URI including `javascript:` and `data:`, so the validator\n * cannot be relied on for this purpose.\n *\n * Mirrors react-markdown 9's `defaultUrlTransform` approach: parse the\n * scheme, allow the safe set, and treat anything without a recognized\n * scheme as a relative URL (preserved as-is).\n */\n\n/**\n * Schemes safe to use as an `<a href>` target.\n * Mirrors react-markdown's `safeProtocol` set (`https?|ircs?|mailto|xmpp`)\n * plus `tel` and `sms` (Slack's documented link types).\n */\nconst SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;\n\n/**\n * Schemes safe to use as an `<img src>` source. We allow `data:image/*`\n * because Slack itself emits inline images that way for some emoji and\n * file thumbnails; everything else (e.g. `data:text/html`, `data:image/svg+xml`\n * — which can carry script in some contexts) is rejected.\n */\nconst SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;\n// Allow `data:image/<safe-mime>` followed by any sequence of media-type\n// parameters. Each parameter is `;<name>` or `;<name>=<value>` (RFC 2397\n// allows the bare `;base64` token, which has no `=`). The comma\n// terminates the parameter list and starts the payload.\nconst SAFE_IMAGE_DATA_PREFIX =\n /^data:image\\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\\.microsoft\\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;\n\n/**\n * Splits a URL into its scheme prefix and tail without invoking the\n * `URL` constructor (which throws on relative URLs and varies in how\n * it normalizes whitespace and unicode). Matches react-markdown's\n * approach: look for the first `:`, `?`, `#`, or `/`; if `:` comes\n * first, that prefix is the scheme.\n * @param value - the candidate URL\n * @returns the lowercase scheme if the URL is absolute, or `null` if relative\n */\nfunction getScheme(value: string): string | null {\n // Strip leading whitespace so `\\tjavascript:` and ` javascript:`\n // still resolve to the `javascript` scheme; browsers ignore leading\n // whitespace when following an href, so the sanitizer must too.\n const trimmed = value.replace(/^\\s+/, '');\n const colon = trimmed.indexOf(':');\n if (colon === -1) {\n return null;\n }\n const slash = trimmed.indexOf('/');\n const question = trimmed.indexOf('?');\n const hash = trimmed.indexOf('#');\n // If any of `/`, `?`, `#` appears before the `:`, then the `:` is\n // inside the path/query/fragment, not a scheme delimiter — treat the\n // URL as relative.\n if ((slash !== -1 && slash < colon) || (question !== -1 && question < colon) || (hash !== -1 && hash < colon)) {\n return null;\n }\n return trimmed.slice(0, colon).toLowerCase();\n}\n\n/**\n * Returns true when `value` is safe to use as an `<a href>` target.\n * Accepts:\n * - absolute URLs whose scheme is in the safe-link allowlist\n * (`http`, `https`, `ircs?`, `mailto`, `tel`, `sms`, `xmpp`).\n * - relative URLs (no scheme).\n * - empty string (caller decides whether to render).\n *\n * Rejects `javascript:`, `data:`, `vbscript:`, `file:`, `about:`,\n * and any other unrecognized scheme.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as a link\n */\nexport function isSafeHref(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n return SAFE_LINK_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns true when `value` carries an explicit, safe URL scheme\n * (e.g. `https://example.com`, `mailto:a@b.com`).\n *\n * Used to gate the TipTap link extension's autolinker. TipTap's default\n * `shouldAutoLink` links any token containing a dot whose host isn't a\n * bare IPv4 — so `2.xyz`, `report.zip`, or `logo.png` all become links\n * just because the suffix happens to be a real gTLD. Requiring an\n * explicit scheme means only URLs the user actually typed as links\n * (`https://…`) auto-link; bare host-like text stays plain text, and\n * deliberate links can still be added via the link button or by typing\n * the scheme. Schemes outside the safe-link allowlist are rejected here\n * too, so an unsafe scheme can never be auto-linked.\n * @param value - the candidate URL (the linkifier's matched token text)\n * @returns true when the value has an explicit, allowlisted scheme\n */\nexport function hasExplicitSafeScheme(value: string | null | undefined): boolean {\n if (typeof value !== 'string' || value.length === 0) {\n return false;\n }\n return getScheme(value) !== null && isSafeHref(value);\n}\n\n/**\n * Returns true when `value` is safe to use as an `<img src>`.\n * Accepts http(s), relative URLs, empty string, and `data:image/*`\n * URLs whose MIME subtype is in a tight allowlist. Rejects\n * `data:image/svg+xml` (can carry script) and everything else.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as an image\n */\nexport function isSafeImageSrc(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n if (scheme === 'data') {\n return SAFE_IMAGE_DATA_PREFIX.test(value);\n }\n return SAFE_IMAGE_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns `value` if it passes {@link isSafeHref}, otherwise an empty\n * string. Use at render time / payload boundaries so an unsafe URL\n * never reaches the DOM as an `href` attribute.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeHref(value: string | null | undefined): string {\n return isSafeHref(value) ? (value ?? '') : '';\n}\n\n/**\n * Returns `value` if it passes {@link isSafeImageSrc}, otherwise an\n * empty string. Use at render time / payload boundaries.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeImageSrc(value: string | null | undefined): string {\n return isSafeImageSrc(value) ? (value ?? '') : '';\n}\n","import type { SupportedBlock } from '../types';\nimport { sanitizeHref, sanitizeImageSrc } from './url-safety';\n\n/**\n * Recursively walks an unknown value and rewrites string values at the\n * specified keys via the matching sanitizer. Used to strip dangerous\n * URI schemes (e.g. `javascript:`) from `url`, `image_url`, and\n * rich-text link `url` fields before a Block Kit payload reaches a\n * renderer or is handed back to the consumer.\n *\n * Allocates a new object whenever a child is rewritten; otherwise\n * returns the input unchanged so unaffected payloads are reference-stable.\n *\n * Defensive against prototype-polluted shapes: copies are made via\n * `Object.assign({}, ...)` over own enumerable keys returned by\n * `Object.keys`, which skips inherited properties.\n */\nconst HREF_KEYS = new Set(['url']);\nconst IMAGE_KEYS = new Set(['image_url']);\n\n/**\n * Read-only metadata Slack attaches when a message is *retrieved* via the\n * API, but rejects on *send*, keyed by the `type` of the object it appears\n * on. Blocks loaded from an existing message carry these; drop them only\n * from the matching object type — some are common field names (`fallback`)\n * that are valid elsewhere — so a round-tripped payload stays send-valid\n * without over-scrubbing. Add an entry to extend this to other block types.\n */\nconst RETRIEVAL_ONLY_KEYS = new Map<string, Set<string>>([\n // Covers both the image block and the image element — both `type: 'image'`.\n ['image', new Set(['image_width', 'image_height', 'image_bytes', 'fallback', 'is_animated'])],\n // Slack renders the chart server-side and attaches the rendered previews on\n // retrieval; sending them back is rejected as `unknown property 'preview_images'`.\n ['data_visualization', new Set(['preview_images'])]\n]);\n\n/**\n * Recursively sanitize all known URL-bearing string fields inside a\n * Slack Block Kit payload fragment. Returns a value with the same\n * structural shape, where any field whose name matches an href or\n * image-src key has been replaced by the safe variant (`''` if the\n * original scheme was unsafe).\n * @param value - any payload fragment (object, array, primitive)\n * @returns the sanitized payload fragment\n */\nfunction sanitizeValue(value: unknown): unknown {\n if (value === null || typeof value !== 'object') {\n return value;\n }\n if (Array.isArray(value)) {\n let changed = false;\n const out = new Array(value.length);\n for (let i = 0; i < value.length; i++) {\n const next = sanitizeValue(value[i]);\n if (next !== value[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : value;\n }\n const src = value as Record<string, unknown>;\n const dropKeys = typeof src.type === 'string' ? RETRIEVAL_ONLY_KEYS.get(src.type) : undefined;\n let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\n if (dropKeys?.has(key)) {\n copy ??= { ...src };\n delete copy[key];\n continue;\n }\n const original = src[key];\n let next: unknown = original;\n if (typeof original === 'string') {\n if (HREF_KEYS.has(key)) {\n next = sanitizeHref(original);\n } else if (IMAGE_KEYS.has(key)) {\n next = sanitizeImageSrc(original);\n }\n } else if (typeof original === 'object' && original !== null) {\n next = sanitizeValue(original);\n }\n if (next !== original) {\n if (!copy) {\n copy = { ...src };\n }\n copy[key] = next;\n }\n }\n return copy ?? src;\n}\n\n/**\n * Sanitize a single Block Kit block, scrubbing dangerous URI schemes\n * from `url` and `image_url` fields anywhere in the payload tree.\n * @param block - the block payload to sanitize\n * @returns the sanitized block (same reference if nothing changed)\n */\nexport function sanitizeBlock<T extends SupportedBlock>(block: T): T {\n return sanitizeValue(block) as T;\n}\n\n/**\n * Sanitize an array of Block Kit blocks. See {@link sanitizeBlock}.\n * @param blocks - the block payloads to sanitize\n * @returns the sanitized blocks\n */\nexport function sanitizeBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n let changed = false;\n const out = new Array<SupportedBlock>(blocks.length);\n for (let i = 0; i < blocks.length; i++) {\n const next = sanitizeBlock(blocks[i]);\n if (next !== blocks[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : blocks;\n}\n","import type { SupportedBlock } from '../types';\nimport { sanitizeBlock } from './sanitize-blocks';\n\n/**\n * Prepare blocks for the Slack API: scrub dangerous URI schemes from any\n * `url`/`image_url` fields and drop the read-only metadata Slack attaches\n * on retrieval but rejects on send. Every URL/image-url is routed through\n * the allowlist in `lib/url-safety.ts` so a payload that round-trips\n * through the builder cannot carry `javascript:`/`data:text/html` URIs to\n * a downstream consumer or to the Slack API.\n *\n * Header `level` is passed through: it is a real Slack field (an integer\n * 1-4, see https://docs.slack.dev/reference/block-kit/blocks/header-block),\n * not a builder-only extension. Earlier versions stripped it here, which\n * silently dropped the heading level from every sent message. Out-of-range\n * values are left intact so they surface as validation errors rather than\n * being quietly discarded.\n *\n * @param blocks - the working draft blocks\n * @returns blocks with retrieval-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => sanitizeBlock(block));\n}\n","import type { SupportedBlock } from '../types';\n\n/**\n * Upper bound on the encoded string length we accept on decode. Block\n * Kit JSON in practice is well under 100 KB; we cap at 1 MiB of encoded\n * input so a pathologically large URL hash cannot freeze the tab in\n * `atob` + `JSON.parse` before the validator gets a look. Encoded\n * (base64url) is ~33% larger than the binary, so this bounds the decoded\n * JSON at roughly 768 KiB.\n */\nconst MAX_ENCODED_BYTES = 1024 * 1024;\n\n/**\n * Encodes a block list to a base64url string suitable for use as a URL\n * search param. The consumer chooses where to put it (search param, hash,\n * localStorage, etc).\n * Returns an empty string for an empty list so the consumer can omit the\n * param entirely.\n * @param blocks - the blocks to encode\n * @returns base64url-encoded JSON, or '' if blocks is empty\n */\nexport function encodeBlocksToString(blocks: SupportedBlock[]): string {\n if (blocks.length === 0) {\n return '';\n }\n const json = JSON.stringify(blocks);\n const utf8Bytes = new TextEncoder().encode(json);\n const chars = new Array<string>(utf8Bytes.length);\n for (let i = 0; i < utf8Bytes.length; i++) {\n chars[i] = String.fromCharCode(utf8Bytes[i]);\n }\n return toBase64Url(btoa(chars.join('')));\n}\n\n/**\n * Decodes a string produced by {@link encodeBlocksToString} back to a\n * block list. Returns null on any decode/parse error.\n * @param encoded - the encoded string\n * @returns decoded blocks, or null if input is empty/invalid\n */\nexport function decodeBlocksFromString(encoded: string | undefined | null): SupportedBlock[] | null {\n if (!encoded) {\n return null;\n }\n if (encoded.length > MAX_ENCODED_BYTES) {\n return null;\n }\n try {\n const binary = atob(fromBase64Url(encoded));\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const json = new TextDecoder().decode(bytes);\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) {\n return null;\n }\n return parsed as SupportedBlock[];\n } catch {\n return null;\n }\n}\n\n/**\n * Converts a standard base64 string to base64url (URL-safe alphabet, no padding).\n * @param b64 - standard base64-encoded string\n * @returns the base64url-encoded equivalent\n */\nfunction toBase64Url(b64: string): string {\n return b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n/**\n * Converts a base64url string back to standard base64 with padding restored.\n * @param b64url - base64url-encoded string\n * @returns the standard base64 equivalent\n */\nfunction fromBase64Url(b64url: string): string {\n const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');\n const padLen = (4 - (b64.length % 4)) % 4;\n return b64 + '='.repeat(padLen);\n}\n"]}
1
+ {"version":3,"sources":["../src/lib/url-safety.ts","../src/lib/sanitize-blocks.ts","../src/lib/to-slack-blocks.ts","../src/lib/url-state.ts"],"names":[],"mappings":"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","file":"helpers.cjs","sourcesContent":["/**\n * URL-scheme allowlists used as a defense-in-depth layer against\n * DOM-XSS via user-supplied URLs that flow into `<a href>` and `<img src>`\n * in the preview (rendered by `slack-blocks-to-jsx`, which does not\n * filter URI schemes itself) or in the builder's own chrome (e.g. the\n * \"Sign in with Slack\" link in `SendDialog`).\n *\n * `format: \"uri\"` on the validator schema (AJV / ajv-formats) accepts\n * any RFC-3986 URI including `javascript:` and `data:`, so the validator\n * cannot be relied on for this purpose.\n *\n * Mirrors react-markdown 9's `defaultUrlTransform` approach: parse the\n * scheme, allow the safe set, and treat anything without a recognized\n * scheme as a relative URL (preserved as-is).\n */\n\n/**\n * Schemes safe to use as an `<a href>` target.\n * Mirrors react-markdown's `safeProtocol` set (`https?|ircs?|mailto|xmpp`)\n * plus `tel` and `sms` (Slack's documented link types).\n */\nconst SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;\n\n/**\n * Schemes safe to use as an `<img src>` source. We allow `data:image/*`\n * because Slack itself emits inline images that way for some emoji and\n * file thumbnails; everything else (e.g. `data:text/html`, `data:image/svg+xml`\n * — which can carry script in some contexts) is rejected.\n */\nconst SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;\n// Allow `data:image/<safe-mime>` followed by any sequence of media-type\n// parameters. Each parameter is `;<name>` or `;<name>=<value>` (RFC 2397\n// allows the bare `;base64` token, which has no `=`). The comma\n// terminates the parameter list and starts the payload.\nconst SAFE_IMAGE_DATA_PREFIX =\n /^data:image\\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\\.microsoft\\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;\n\n/**\n * Schemes safe to use as the source of a nested browsing context or an\n * auto-loaded subresource — `<iframe src>` (the video block's\n * `video_url`), `<embed src>`, `<object data>`, `<source src>`.\n *\n * Same schemes as {@link SAFE_IMAGE_PROTOCOLS}, but kept separate\n * because the surrounding predicate is the narrowest of the three in\n * this file and the two policies must be free to diverge: a frame\n * source is fetched and rendered with no click, so it admits neither\n * the `data:image/*` payloads an `<img>` may carry, the relative URLs\n * both other predicates pass through, nor the `mailto`/`tel`/`sms`/\n * `xmpp`/`irc` schemes that are harmless in a link the user chooses to\n * follow.\n */\nconst SAFE_EMBED_PROTOCOLS = /^(https?)$/i;\n\n/**\n * Splits a URL into its scheme prefix and tail without invoking the\n * `URL` constructor (which throws on relative URLs and varies in how\n * it normalizes whitespace and unicode). Matches react-markdown's\n * approach: look for the first `:`, `?`, `#`, or `/`; if `:` comes\n * first, that prefix is the scheme.\n * @param value - the candidate URL\n * @returns the lowercase scheme if the URL is absolute, or `null` if relative\n */\nfunction getScheme(value: string): string | null {\n // Strip leading whitespace so `\\tjavascript:` and ` javascript:`\n // still resolve to the `javascript` scheme; browsers ignore leading\n // whitespace when following an href, so the sanitizer must too.\n const trimmed = value.replace(/^\\s+/, '');\n const colon = trimmed.indexOf(':');\n if (colon === -1) {\n return null;\n }\n const slash = trimmed.indexOf('/');\n const question = trimmed.indexOf('?');\n const hash = trimmed.indexOf('#');\n // If any of `/`, `?`, `#` appears before the `:`, then the `:` is\n // inside the path/query/fragment, not a scheme delimiter — treat the\n // URL as relative.\n if ((slash !== -1 && slash < colon) || (question !== -1 && question < colon) || (hash !== -1 && hash < colon)) {\n return null;\n }\n return trimmed.slice(0, colon).toLowerCase();\n}\n\n/**\n * Returns true when `value` is safe to use as an `<a href>` target.\n * Accepts:\n * - absolute URLs whose scheme is in the safe-link allowlist\n * (`http`, `https`, `ircs?`, `mailto`, `tel`, `sms`, `xmpp`).\n * - relative URLs (no scheme).\n * - empty string (caller decides whether to render).\n *\n * Rejects `javascript:`, `data:`, `vbscript:`, `file:`, `about:`,\n * and any other unrecognized scheme.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as a link\n */\nexport function isSafeHref(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n return SAFE_LINK_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns true when `value` carries an explicit, safe URL scheme\n * (e.g. `https://example.com`, `mailto:a@b.com`).\n *\n * Used to gate the TipTap link extension's autolinker. TipTap's default\n * `shouldAutoLink` links any token containing a dot whose host isn't a\n * bare IPv4 — so `2.xyz`, `report.zip`, or `logo.png` all become links\n * just because the suffix happens to be a real gTLD. Requiring an\n * explicit scheme means only URLs the user actually typed as links\n * (`https://…`) auto-link; bare host-like text stays plain text, and\n * deliberate links can still be added via the link button or by typing\n * the scheme. Schemes outside the safe-link allowlist are rejected here\n * too, so an unsafe scheme can never be auto-linked.\n * @param value - the candidate URL (the linkifier's matched token text)\n * @returns true when the value has an explicit, allowlisted scheme\n */\nexport function hasExplicitSafeScheme(value: string | null | undefined): boolean {\n if (typeof value !== 'string' || value.length === 0) {\n return false;\n }\n return getScheme(value) !== null && isSafeHref(value);\n}\n\n/**\n * Returns true when `value` is safe to use as an `<img src>`.\n * Accepts http(s), relative URLs, empty string, and `data:image/*`\n * URLs whose MIME subtype is in a tight allowlist. Rejects\n * `data:image/svg+xml` (can carry script) and everything else.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as an image\n */\nexport function isSafeImageSrc(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n if (scheme === 'data') {\n return SAFE_IMAGE_DATA_PREFIX.test(value);\n }\n return SAFE_IMAGE_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns true when `value` is safe to use as an `<iframe src>` or any\n * other attribute that makes the browser load a document/subresource on\n * its own (`<embed src>`, `<object data>`, `<source src>`).\n *\n * Accepts http(s) and the empty string (an empty `src` loads\n * `about:blank` per the HTML spec, so the caller may leave it in place).\n * Everything else is rejected, including two cases the link and image\n * predicates accept:\n * - `data:` of any media type. `data:text/html,<script>…</script>` in a\n * frame renders and executes in an opaque origin on every React\n * version — the variant of this bug that no React runtime guard stops.\n * - scheme-less (relative) URLs. A relative frame source points at a\n * page of the embedding app itself, which is a UI-redress vector\n * rather than a legitimate embed; Slack requires `video_url` to be an\n * https URL on one of the app's configured unfurl domains.\n * @param value - the candidate URL\n * @returns true when the URL is safe to load as a frame or subresource\n */\nexport function isSafeEmbedSrc(value: string | null | undefined): boolean {\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return false;\n }\n return SAFE_EMBED_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns `value` if it passes {@link isSafeHref}, otherwise an empty\n * string. Use at render time / payload boundaries so an unsafe URL\n * never reaches the DOM as an `href` attribute.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeHref(value: string | null | undefined): string {\n return isSafeHref(value) ? (value ?? '') : '';\n}\n\n/**\n * Returns `value` if it passes {@link isSafeImageSrc}, otherwise an\n * empty string. Use at render time / payload boundaries.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeImageSrc(value: string | null | undefined): string {\n return isSafeImageSrc(value) ? (value ?? '') : '';\n}\n\n/**\n * Returns `value` if it passes {@link isSafeEmbedSrc}, otherwise an\n * empty string. Use at render time / payload boundaries so an unsafe URL\n * never reaches the DOM as a frame or subresource source.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeEmbedSrc(value: string | null | undefined): string {\n return isSafeEmbedSrc(value) ? (value ?? '') : '';\n}\n","/**\n * Recursively walks an unknown value and rewrites every URL-bearing\n * string field via the matching sanitizer. Used to strip dangerous URI\n * schemes (e.g. `javascript:`, `data:text/html`) from `url`,\n * `image_url`, `video_url`, rich-text link `url` and friends before a\n * Block Kit payload reaches a renderer or is handed back to the consumer.\n *\n * Allocates a new object whenever a child is rewritten; otherwise\n * returns the input unchanged so unaffected payloads are reference-stable.\n *\n * Defensive against prototype-polluted shapes: copies are made via\n * `Object.assign({}, ...)` over own enumerable keys returned by\n * `Object.keys`, which skips inherited properties.\n */\n\nimport type { SupportedBlock } from '../types';\nimport { sanitizeEmbedSrc, sanitizeHref, sanitizeImageSrc } from './url-safety';\n\n/**\n * Which allowlist applies to a URL-bearing field, by where its value\n * ends up in the DOM: an `<a href>`, an `<img src>`, or a frame /\n * subresource source such as `<iframe src>`.\n */\ntype UrlKind = 'href' | 'image' | 'embed';\n\n/**\n * Fields whose value the renderer puts in a nested browsing context.\n * `slack-blocks-to-jsx` renders the video block's `video_url` into\n * `<iframe src>`, which loads without a click — so these get the\n * http(s)-only allowlist rather than the link one.\n */\nconst EMBED_KEYS = new Set(['video_url']);\n\n/**\n * Key names whose value is a URL. Matched on the last `_`-delimited\n * segment so a field added by Slack later (`title_url`,\n * `provider_icon_url`, `author_link`, …) is covered on arrival.\n *\n * This is deliberately a name *shape* rather than an exact-match list:\n * two rounds of URL-sanitizer hardening each shipped with a\n * hand-maintained key set, and each silently missed a field Slack had\n * added. Over-matching is cheap — a key that looks like a URL but holds\n * something else keeps its value, because a string with no recognized\n * scheme is treated as a relative URL and passed through untouched.\n */\nconst URL_KEY = /(^|_)(url|uri|link|href|src)$/;\n\n/** Cheap pre-filter so the classifier's regexes skip ordinary keys. */\nconst MAYBE_URL_KEY = /url|uri|link|href|src/i;\n\n/**\n * Name fragments that mark a URL field as an image source. Image\n * sources are allowed to carry a `data:image/<safe-mime>` payload —\n * Slack emits those for some emoji and file thumbnails — which the\n * link allowlist rejects.\n */\nconst IMAGE_KEY_HINT = /image|thumb|icon|avatar|logo|photo|picture/;\n\n/**\n * Classifies a payload key by the kind of URL it carries, or `null`\n * when the key holds no URL at all.\n * @param key - the payload object's key\n * @returns which allowlist applies, or `null` to leave the value alone\n */\nfunction classifyUrlKey(key: string): UrlKind | null {\n if (!MAYBE_URL_KEY.test(key)) {\n return null;\n }\n // Fold camelCase to snake_case first so a consumer payload that isn't\n // strict Slack JSON (`videoUrl`, `iconUrl`) classifies the same way.\n const normalized = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();\n if (EMBED_KEYS.has(normalized)) {\n return 'embed';\n }\n if (!URL_KEY.test(normalized)) {\n return null;\n }\n return IMAGE_KEY_HINT.test(normalized) ? 'image' : 'href';\n}\n\n/**\n * Read-only metadata Slack attaches when a message is *retrieved* via the\n * API, but rejects on *send*, keyed by the `type` of the object it appears\n * on. Blocks loaded from an existing message carry these; drop them only\n * from the matching object type — some are common field names (`fallback`)\n * that are valid elsewhere — so a round-tripped payload stays send-valid\n * without over-scrubbing. Add an entry to extend this to other block types.\n */\nconst RETRIEVAL_ONLY_KEYS = new Map<string, Set<string>>([\n // Covers both the image block and the image element — both `type: 'image'`.\n ['image', new Set(['image_width', 'image_height', 'image_bytes', 'fallback', 'is_animated'])],\n // Slack renders the chart server-side and attaches the rendered previews on\n // retrieval; sending them back is rejected as `unknown property 'preview_images'`.\n ['data_visualization', new Set(['preview_images'])]\n]);\n\n/**\n * Recursively sanitize every URL-bearing string field inside a Slack\n * Block Kit payload fragment. Returns a value with the same structural\n * shape, where any field whose name reads as a URL has been replaced by\n * the safe variant (`''` if the original scheme was unsafe for the kind\n * of URL that field carries — see {@link classifyUrlKey}).\n * @param value - any payload fragment (object, array, primitive)\n * @returns the sanitized payload fragment\n */\nfunction sanitizeValue(value: unknown): unknown {\n if (value === null || typeof value !== 'object') {\n return value;\n }\n if (Array.isArray(value)) {\n let changed = false;\n const out = new Array(value.length);\n for (let i = 0; i < value.length; i++) {\n const next = sanitizeValue(value[i]);\n if (next !== value[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : value;\n }\n const src = value as Record<string, unknown>;\n const dropKeys = typeof src.type === 'string' ? RETRIEVAL_ONLY_KEYS.get(src.type) : undefined;\n let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\n if (dropKeys?.has(key)) {\n copy ??= { ...src };\n delete copy[key];\n continue;\n }\n const original = src[key];\n let next: unknown = original;\n if (typeof original === 'string') {\n const kind = classifyUrlKey(key);\n if (kind === 'href') {\n next = sanitizeHref(original);\n } else if (kind === 'image') {\n next = sanitizeImageSrc(original);\n } else if (kind === 'embed') {\n next = sanitizeEmbedSrc(original);\n }\n } else if (typeof original === 'object' && original !== null) {\n next = sanitizeValue(original);\n }\n if (next !== original) {\n if (!copy) {\n copy = { ...src };\n }\n copy[key] = next;\n }\n }\n return copy ?? src;\n}\n\n/**\n * Sanitize a single Block Kit block, scrubbing dangerous URI schemes\n * from every URL-bearing field anywhere in the payload tree (`url`,\n * `image_url`, `video_url`, `thumbnail_url`, `title_url`, …).\n * @param block - the block payload to sanitize\n * @returns the sanitized block (same reference if nothing changed)\n */\nexport function sanitizeBlock<T extends SupportedBlock>(block: T): T {\n return sanitizeValue(block) as T;\n}\n\n/**\n * Sanitize an array of Block Kit blocks. See {@link sanitizeBlock}.\n * @param blocks - the block payloads to sanitize\n * @returns the sanitized blocks\n */\nexport function sanitizeBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n let changed = false;\n const out = new Array<SupportedBlock>(blocks.length);\n for (let i = 0; i < blocks.length; i++) {\n const next = sanitizeBlock(blocks[i]);\n if (next !== blocks[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : blocks;\n}\n","import type { SupportedBlock } from '../types';\nimport { sanitizeBlock } from './sanitize-blocks';\n\n/**\n * Prepare blocks for the Slack API: scrub dangerous URI schemes from any\n * `url`/`image_url` fields and drop the read-only metadata Slack attaches\n * on retrieval but rejects on send. Every URL/image-url is routed through\n * the allowlist in `lib/url-safety.ts` so a payload that round-trips\n * through the builder cannot carry `javascript:`/`data:text/html` URIs to\n * a downstream consumer or to the Slack API.\n *\n * Header `level` is passed through: it is a real Slack field (an integer\n * 1-4, see https://docs.slack.dev/reference/block-kit/blocks/header-block),\n * not a builder-only extension. Earlier versions stripped it here, which\n * silently dropped the heading level from every sent message. Out-of-range\n * values are left intact so they surface as validation errors rather than\n * being quietly discarded.\n *\n * @param blocks - the working draft blocks\n * @returns blocks with retrieval-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => sanitizeBlock(block));\n}\n","import type { SupportedBlock } from '../types';\n\n/**\n * Upper bound on the encoded string length we accept on decode. Block\n * Kit JSON in practice is well under 100 KB; we cap at 1 MiB of encoded\n * input so a pathologically large URL hash cannot freeze the tab in\n * `atob` + `JSON.parse` before the validator gets a look. Encoded\n * (base64url) is ~33% larger than the binary, so this bounds the decoded\n * JSON at roughly 768 KiB.\n */\nconst MAX_ENCODED_BYTES = 1024 * 1024;\n\n/**\n * Encodes a block list to a base64url string suitable for use as a URL\n * search param. The consumer chooses where to put it (search param, hash,\n * localStorage, etc).\n * Returns an empty string for an empty list so the consumer can omit the\n * param entirely.\n * @param blocks - the blocks to encode\n * @returns base64url-encoded JSON, or '' if blocks is empty\n */\nexport function encodeBlocksToString(blocks: SupportedBlock[]): string {\n if (blocks.length === 0) {\n return '';\n }\n const json = JSON.stringify(blocks);\n const utf8Bytes = new TextEncoder().encode(json);\n const chars = new Array<string>(utf8Bytes.length);\n for (let i = 0; i < utf8Bytes.length; i++) {\n chars[i] = String.fromCharCode(utf8Bytes[i]);\n }\n return toBase64Url(btoa(chars.join('')));\n}\n\n/**\n * Decodes a string produced by {@link encodeBlocksToString} back to a\n * block list. Returns null on any decode/parse error.\n * @param encoded - the encoded string\n * @returns decoded blocks, or null if input is empty/invalid\n */\nexport function decodeBlocksFromString(encoded: string | undefined | null): SupportedBlock[] | null {\n if (!encoded) {\n return null;\n }\n if (encoded.length > MAX_ENCODED_BYTES) {\n return null;\n }\n try {\n const binary = atob(fromBase64Url(encoded));\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const json = new TextDecoder().decode(bytes);\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) {\n return null;\n }\n return parsed as SupportedBlock[];\n } catch {\n return null;\n }\n}\n\n/**\n * Converts a standard base64 string to base64url (URL-safe alphabet, no padding).\n * @param b64 - standard base64-encoded string\n * @returns the base64url-encoded equivalent\n */\nfunction toBase64Url(b64: string): string {\n return b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n/**\n * Converts a base64url string back to standard base64 with padding restored.\n * @param b64url - base64url-encoded string\n * @returns the standard base64 equivalent\n */\nfunction fromBase64Url(b64url: string): string {\n const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');\n const padLen = (4 - (b64.length % 4)) % 4;\n return b64 + '='.repeat(padLen);\n}\n"]}
package/dist/helpers.js CHANGED
@@ -2,6 +2,7 @@
2
2
  var SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;
3
3
  var SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;
4
4
  var SAFE_IMAGE_DATA_PREFIX = /^data:image\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\.microsoft\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;
5
+ var SAFE_EMBED_PROTOCOLS = /^(https?)$/i;
5
6
  function getScheme(value) {
6
7
  const trimmed = value.replace(/^\s+/, "");
7
8
  const colon = trimmed.indexOf(":");
@@ -51,16 +52,47 @@ function isSafeImageSrc(value) {
51
52
  }
52
53
  return SAFE_IMAGE_PROTOCOLS.test(scheme);
53
54
  }
55
+ function isSafeEmbedSrc(value) {
56
+ if (typeof value !== "string") {
57
+ return false;
58
+ }
59
+ if (value.length === 0) {
60
+ return true;
61
+ }
62
+ const scheme = getScheme(value);
63
+ if (scheme === null) {
64
+ return false;
65
+ }
66
+ return SAFE_EMBED_PROTOCOLS.test(scheme);
67
+ }
54
68
  function sanitizeHref(value) {
55
69
  return isSafeHref(value) ? value ?? "" : "";
56
70
  }
57
71
  function sanitizeImageSrc(value) {
58
72
  return isSafeImageSrc(value) ? value ?? "" : "";
59
73
  }
74
+ function sanitizeEmbedSrc(value) {
75
+ return isSafeEmbedSrc(value) ? value ?? "" : "";
76
+ }
60
77
 
61
78
  // src/lib/sanitize-blocks.ts
62
- var HREF_KEYS = /* @__PURE__ */ new Set(["url"]);
63
- var IMAGE_KEYS = /* @__PURE__ */ new Set(["image_url"]);
79
+ var EMBED_KEYS = /* @__PURE__ */ new Set(["video_url"]);
80
+ var URL_KEY = /(^|_)(url|uri|link|href|src)$/;
81
+ var MAYBE_URL_KEY = /url|uri|link|href|src/i;
82
+ var IMAGE_KEY_HINT = /image|thumb|icon|avatar|logo|photo|picture/;
83
+ function classifyUrlKey(key) {
84
+ if (!MAYBE_URL_KEY.test(key)) {
85
+ return null;
86
+ }
87
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
88
+ if (EMBED_KEYS.has(normalized)) {
89
+ return "embed";
90
+ }
91
+ if (!URL_KEY.test(normalized)) {
92
+ return null;
93
+ }
94
+ return IMAGE_KEY_HINT.test(normalized) ? "image" : "href";
95
+ }
64
96
  var RETRIEVAL_ONLY_KEYS = /* @__PURE__ */ new Map([
65
97
  // Covers both the image block and the image element — both `type: 'image'`.
66
98
  ["image", /* @__PURE__ */ new Set(["image_width", "image_height", "image_bytes", "fallback", "is_animated"])],
@@ -96,10 +128,13 @@ function sanitizeValue(value) {
96
128
  const original = src[key];
97
129
  let next = original;
98
130
  if (typeof original === "string") {
99
- if (HREF_KEYS.has(key)) {
131
+ const kind = classifyUrlKey(key);
132
+ if (kind === "href") {
100
133
  next = sanitizeHref(original);
101
- } else if (IMAGE_KEYS.has(key)) {
134
+ } else if (kind === "image") {
102
135
  next = sanitizeImageSrc(original);
136
+ } else if (kind === "embed") {
137
+ next = sanitizeEmbedSrc(original);
103
138
  }
104
139
  } else if (typeof original === "object" && original !== null) {
105
140
  next = sanitizeValue(original);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/lib/url-safety.ts","../src/lib/sanitize-blocks.ts","../src/lib/to-slack-blocks.ts","../src/lib/url-state.ts"],"names":[],"mappings":"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","file":"helpers.js","sourcesContent":["/**\n * URL-scheme allowlists used as a defense-in-depth layer against\n * DOM-XSS via user-supplied URLs that flow into `<a href>` and `<img src>`\n * in the preview (rendered by `slack-blocks-to-jsx`, which does not\n * filter URI schemes itself) or in the builder's own chrome (e.g. the\n * \"Sign in with Slack\" link in `SendDialog`).\n *\n * `format: \"uri\"` on the validator schema (AJV / ajv-formats) accepts\n * any RFC-3986 URI including `javascript:` and `data:`, so the validator\n * cannot be relied on for this purpose.\n *\n * Mirrors react-markdown 9's `defaultUrlTransform` approach: parse the\n * scheme, allow the safe set, and treat anything without a recognized\n * scheme as a relative URL (preserved as-is).\n */\n\n/**\n * Schemes safe to use as an `<a href>` target.\n * Mirrors react-markdown's `safeProtocol` set (`https?|ircs?|mailto|xmpp`)\n * plus `tel` and `sms` (Slack's documented link types).\n */\nconst SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;\n\n/**\n * Schemes safe to use as an `<img src>` source. We allow `data:image/*`\n * because Slack itself emits inline images that way for some emoji and\n * file thumbnails; everything else (e.g. `data:text/html`, `data:image/svg+xml`\n * — which can carry script in some contexts) is rejected.\n */\nconst SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;\n// Allow `data:image/<safe-mime>` followed by any sequence of media-type\n// parameters. Each parameter is `;<name>` or `;<name>=<value>` (RFC 2397\n// allows the bare `;base64` token, which has no `=`). The comma\n// terminates the parameter list and starts the payload.\nconst SAFE_IMAGE_DATA_PREFIX =\n /^data:image\\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\\.microsoft\\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;\n\n/**\n * Splits a URL into its scheme prefix and tail without invoking the\n * `URL` constructor (which throws on relative URLs and varies in how\n * it normalizes whitespace and unicode). Matches react-markdown's\n * approach: look for the first `:`, `?`, `#`, or `/`; if `:` comes\n * first, that prefix is the scheme.\n * @param value - the candidate URL\n * @returns the lowercase scheme if the URL is absolute, or `null` if relative\n */\nfunction getScheme(value: string): string | null {\n // Strip leading whitespace so `\\tjavascript:` and ` javascript:`\n // still resolve to the `javascript` scheme; browsers ignore leading\n // whitespace when following an href, so the sanitizer must too.\n const trimmed = value.replace(/^\\s+/, '');\n const colon = trimmed.indexOf(':');\n if (colon === -1) {\n return null;\n }\n const slash = trimmed.indexOf('/');\n const question = trimmed.indexOf('?');\n const hash = trimmed.indexOf('#');\n // If any of `/`, `?`, `#` appears before the `:`, then the `:` is\n // inside the path/query/fragment, not a scheme delimiter — treat the\n // URL as relative.\n if ((slash !== -1 && slash < colon) || (question !== -1 && question < colon) || (hash !== -1 && hash < colon)) {\n return null;\n }\n return trimmed.slice(0, colon).toLowerCase();\n}\n\n/**\n * Returns true when `value` is safe to use as an `<a href>` target.\n * Accepts:\n * - absolute URLs whose scheme is in the safe-link allowlist\n * (`http`, `https`, `ircs?`, `mailto`, `tel`, `sms`, `xmpp`).\n * - relative URLs (no scheme).\n * - empty string (caller decides whether to render).\n *\n * Rejects `javascript:`, `data:`, `vbscript:`, `file:`, `about:`,\n * and any other unrecognized scheme.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as a link\n */\nexport function isSafeHref(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n return SAFE_LINK_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns true when `value` carries an explicit, safe URL scheme\n * (e.g. `https://example.com`, `mailto:a@b.com`).\n *\n * Used to gate the TipTap link extension's autolinker. TipTap's default\n * `shouldAutoLink` links any token containing a dot whose host isn't a\n * bare IPv4 — so `2.xyz`, `report.zip`, or `logo.png` all become links\n * just because the suffix happens to be a real gTLD. Requiring an\n * explicit scheme means only URLs the user actually typed as links\n * (`https://…`) auto-link; bare host-like text stays plain text, and\n * deliberate links can still be added via the link button or by typing\n * the scheme. Schemes outside the safe-link allowlist are rejected here\n * too, so an unsafe scheme can never be auto-linked.\n * @param value - the candidate URL (the linkifier's matched token text)\n * @returns true when the value has an explicit, allowlisted scheme\n */\nexport function hasExplicitSafeScheme(value: string | null | undefined): boolean {\n if (typeof value !== 'string' || value.length === 0) {\n return false;\n }\n return getScheme(value) !== null && isSafeHref(value);\n}\n\n/**\n * Returns true when `value` is safe to use as an `<img src>`.\n * Accepts http(s), relative URLs, empty string, and `data:image/*`\n * URLs whose MIME subtype is in a tight allowlist. Rejects\n * `data:image/svg+xml` (can carry script) and everything else.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as an image\n */\nexport function isSafeImageSrc(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n if (scheme === 'data') {\n return SAFE_IMAGE_DATA_PREFIX.test(value);\n }\n return SAFE_IMAGE_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns `value` if it passes {@link isSafeHref}, otherwise an empty\n * string. Use at render time / payload boundaries so an unsafe URL\n * never reaches the DOM as an `href` attribute.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeHref(value: string | null | undefined): string {\n return isSafeHref(value) ? (value ?? '') : '';\n}\n\n/**\n * Returns `value` if it passes {@link isSafeImageSrc}, otherwise an\n * empty string. Use at render time / payload boundaries.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeImageSrc(value: string | null | undefined): string {\n return isSafeImageSrc(value) ? (value ?? '') : '';\n}\n","import type { SupportedBlock } from '../types';\nimport { sanitizeHref, sanitizeImageSrc } from './url-safety';\n\n/**\n * Recursively walks an unknown value and rewrites string values at the\n * specified keys via the matching sanitizer. Used to strip dangerous\n * URI schemes (e.g. `javascript:`) from `url`, `image_url`, and\n * rich-text link `url` fields before a Block Kit payload reaches a\n * renderer or is handed back to the consumer.\n *\n * Allocates a new object whenever a child is rewritten; otherwise\n * returns the input unchanged so unaffected payloads are reference-stable.\n *\n * Defensive against prototype-polluted shapes: copies are made via\n * `Object.assign({}, ...)` over own enumerable keys returned by\n * `Object.keys`, which skips inherited properties.\n */\nconst HREF_KEYS = new Set(['url']);\nconst IMAGE_KEYS = new Set(['image_url']);\n\n/**\n * Read-only metadata Slack attaches when a message is *retrieved* via the\n * API, but rejects on *send*, keyed by the `type` of the object it appears\n * on. Blocks loaded from an existing message carry these; drop them only\n * from the matching object type — some are common field names (`fallback`)\n * that are valid elsewhere — so a round-tripped payload stays send-valid\n * without over-scrubbing. Add an entry to extend this to other block types.\n */\nconst RETRIEVAL_ONLY_KEYS = new Map<string, Set<string>>([\n // Covers both the image block and the image element — both `type: 'image'`.\n ['image', new Set(['image_width', 'image_height', 'image_bytes', 'fallback', 'is_animated'])],\n // Slack renders the chart server-side and attaches the rendered previews on\n // retrieval; sending them back is rejected as `unknown property 'preview_images'`.\n ['data_visualization', new Set(['preview_images'])]\n]);\n\n/**\n * Recursively sanitize all known URL-bearing string fields inside a\n * Slack Block Kit payload fragment. Returns a value with the same\n * structural shape, where any field whose name matches an href or\n * image-src key has been replaced by the safe variant (`''` if the\n * original scheme was unsafe).\n * @param value - any payload fragment (object, array, primitive)\n * @returns the sanitized payload fragment\n */\nfunction sanitizeValue(value: unknown): unknown {\n if (value === null || typeof value !== 'object') {\n return value;\n }\n if (Array.isArray(value)) {\n let changed = false;\n const out = new Array(value.length);\n for (let i = 0; i < value.length; i++) {\n const next = sanitizeValue(value[i]);\n if (next !== value[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : value;\n }\n const src = value as Record<string, unknown>;\n const dropKeys = typeof src.type === 'string' ? RETRIEVAL_ONLY_KEYS.get(src.type) : undefined;\n let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\n if (dropKeys?.has(key)) {\n copy ??= { ...src };\n delete copy[key];\n continue;\n }\n const original = src[key];\n let next: unknown = original;\n if (typeof original === 'string') {\n if (HREF_KEYS.has(key)) {\n next = sanitizeHref(original);\n } else if (IMAGE_KEYS.has(key)) {\n next = sanitizeImageSrc(original);\n }\n } else if (typeof original === 'object' && original !== null) {\n next = sanitizeValue(original);\n }\n if (next !== original) {\n if (!copy) {\n copy = { ...src };\n }\n copy[key] = next;\n }\n }\n return copy ?? src;\n}\n\n/**\n * Sanitize a single Block Kit block, scrubbing dangerous URI schemes\n * from `url` and `image_url` fields anywhere in the payload tree.\n * @param block - the block payload to sanitize\n * @returns the sanitized block (same reference if nothing changed)\n */\nexport function sanitizeBlock<T extends SupportedBlock>(block: T): T {\n return sanitizeValue(block) as T;\n}\n\n/**\n * Sanitize an array of Block Kit blocks. See {@link sanitizeBlock}.\n * @param blocks - the block payloads to sanitize\n * @returns the sanitized blocks\n */\nexport function sanitizeBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n let changed = false;\n const out = new Array<SupportedBlock>(blocks.length);\n for (let i = 0; i < blocks.length; i++) {\n const next = sanitizeBlock(blocks[i]);\n if (next !== blocks[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : blocks;\n}\n","import type { SupportedBlock } from '../types';\nimport { sanitizeBlock } from './sanitize-blocks';\n\n/**\n * Prepare blocks for the Slack API: scrub dangerous URI schemes from any\n * `url`/`image_url` fields and drop the read-only metadata Slack attaches\n * on retrieval but rejects on send. Every URL/image-url is routed through\n * the allowlist in `lib/url-safety.ts` so a payload that round-trips\n * through the builder cannot carry `javascript:`/`data:text/html` URIs to\n * a downstream consumer or to the Slack API.\n *\n * Header `level` is passed through: it is a real Slack field (an integer\n * 1-4, see https://docs.slack.dev/reference/block-kit/blocks/header-block),\n * not a builder-only extension. Earlier versions stripped it here, which\n * silently dropped the heading level from every sent message. Out-of-range\n * values are left intact so they surface as validation errors rather than\n * being quietly discarded.\n *\n * @param blocks - the working draft blocks\n * @returns blocks with retrieval-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => sanitizeBlock(block));\n}\n","import type { SupportedBlock } from '../types';\n\n/**\n * Upper bound on the encoded string length we accept on decode. Block\n * Kit JSON in practice is well under 100 KB; we cap at 1 MiB of encoded\n * input so a pathologically large URL hash cannot freeze the tab in\n * `atob` + `JSON.parse` before the validator gets a look. Encoded\n * (base64url) is ~33% larger than the binary, so this bounds the decoded\n * JSON at roughly 768 KiB.\n */\nconst MAX_ENCODED_BYTES = 1024 * 1024;\n\n/**\n * Encodes a block list to a base64url string suitable for use as a URL\n * search param. The consumer chooses where to put it (search param, hash,\n * localStorage, etc).\n * Returns an empty string for an empty list so the consumer can omit the\n * param entirely.\n * @param blocks - the blocks to encode\n * @returns base64url-encoded JSON, or '' if blocks is empty\n */\nexport function encodeBlocksToString(blocks: SupportedBlock[]): string {\n if (blocks.length === 0) {\n return '';\n }\n const json = JSON.stringify(blocks);\n const utf8Bytes = new TextEncoder().encode(json);\n const chars = new Array<string>(utf8Bytes.length);\n for (let i = 0; i < utf8Bytes.length; i++) {\n chars[i] = String.fromCharCode(utf8Bytes[i]);\n }\n return toBase64Url(btoa(chars.join('')));\n}\n\n/**\n * Decodes a string produced by {@link encodeBlocksToString} back to a\n * block list. Returns null on any decode/parse error.\n * @param encoded - the encoded string\n * @returns decoded blocks, or null if input is empty/invalid\n */\nexport function decodeBlocksFromString(encoded: string | undefined | null): SupportedBlock[] | null {\n if (!encoded) {\n return null;\n }\n if (encoded.length > MAX_ENCODED_BYTES) {\n return null;\n }\n try {\n const binary = atob(fromBase64Url(encoded));\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const json = new TextDecoder().decode(bytes);\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) {\n return null;\n }\n return parsed as SupportedBlock[];\n } catch {\n return null;\n }\n}\n\n/**\n * Converts a standard base64 string to base64url (URL-safe alphabet, no padding).\n * @param b64 - standard base64-encoded string\n * @returns the base64url-encoded equivalent\n */\nfunction toBase64Url(b64: string): string {\n return b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n/**\n * Converts a base64url string back to standard base64 with padding restored.\n * @param b64url - base64url-encoded string\n * @returns the standard base64 equivalent\n */\nfunction fromBase64Url(b64url: string): string {\n const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');\n const padLen = (4 - (b64.length % 4)) % 4;\n return b64 + '='.repeat(padLen);\n}\n"]}
1
+ {"version":3,"sources":["../src/lib/url-safety.ts","../src/lib/sanitize-blocks.ts","../src/lib/to-slack-blocks.ts","../src/lib/url-state.ts"],"names":[],"mappings":"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","file":"helpers.js","sourcesContent":["/**\n * URL-scheme allowlists used as a defense-in-depth layer against\n * DOM-XSS via user-supplied URLs that flow into `<a href>` and `<img src>`\n * in the preview (rendered by `slack-blocks-to-jsx`, which does not\n * filter URI schemes itself) or in the builder's own chrome (e.g. the\n * \"Sign in with Slack\" link in `SendDialog`).\n *\n * `format: \"uri\"` on the validator schema (AJV / ajv-formats) accepts\n * any RFC-3986 URI including `javascript:` and `data:`, so the validator\n * cannot be relied on for this purpose.\n *\n * Mirrors react-markdown 9's `defaultUrlTransform` approach: parse the\n * scheme, allow the safe set, and treat anything without a recognized\n * scheme as a relative URL (preserved as-is).\n */\n\n/**\n * Schemes safe to use as an `<a href>` target.\n * Mirrors react-markdown's `safeProtocol` set (`https?|ircs?|mailto|xmpp`)\n * plus `tel` and `sms` (Slack's documented link types).\n */\nconst SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;\n\n/**\n * Schemes safe to use as an `<img src>` source. We allow `data:image/*`\n * because Slack itself emits inline images that way for some emoji and\n * file thumbnails; everything else (e.g. `data:text/html`, `data:image/svg+xml`\n * — which can carry script in some contexts) is rejected.\n */\nconst SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;\n// Allow `data:image/<safe-mime>` followed by any sequence of media-type\n// parameters. Each parameter is `;<name>` or `;<name>=<value>` (RFC 2397\n// allows the bare `;base64` token, which has no `=`). The comma\n// terminates the parameter list and starts the payload.\nconst SAFE_IMAGE_DATA_PREFIX =\n /^data:image\\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\\.microsoft\\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;\n\n/**\n * Schemes safe to use as the source of a nested browsing context or an\n * auto-loaded subresource — `<iframe src>` (the video block's\n * `video_url`), `<embed src>`, `<object data>`, `<source src>`.\n *\n * Same schemes as {@link SAFE_IMAGE_PROTOCOLS}, but kept separate\n * because the surrounding predicate is the narrowest of the three in\n * this file and the two policies must be free to diverge: a frame\n * source is fetched and rendered with no click, so it admits neither\n * the `data:image/*` payloads an `<img>` may carry, the relative URLs\n * both other predicates pass through, nor the `mailto`/`tel`/`sms`/\n * `xmpp`/`irc` schemes that are harmless in a link the user chooses to\n * follow.\n */\nconst SAFE_EMBED_PROTOCOLS = /^(https?)$/i;\n\n/**\n * Splits a URL into its scheme prefix and tail without invoking the\n * `URL` constructor (which throws on relative URLs and varies in how\n * it normalizes whitespace and unicode). Matches react-markdown's\n * approach: look for the first `:`, `?`, `#`, or `/`; if `:` comes\n * first, that prefix is the scheme.\n * @param value - the candidate URL\n * @returns the lowercase scheme if the URL is absolute, or `null` if relative\n */\nfunction getScheme(value: string): string | null {\n // Strip leading whitespace so `\\tjavascript:` and ` javascript:`\n // still resolve to the `javascript` scheme; browsers ignore leading\n // whitespace when following an href, so the sanitizer must too.\n const trimmed = value.replace(/^\\s+/, '');\n const colon = trimmed.indexOf(':');\n if (colon === -1) {\n return null;\n }\n const slash = trimmed.indexOf('/');\n const question = trimmed.indexOf('?');\n const hash = trimmed.indexOf('#');\n // If any of `/`, `?`, `#` appears before the `:`, then the `:` is\n // inside the path/query/fragment, not a scheme delimiter — treat the\n // URL as relative.\n if ((slash !== -1 && slash < colon) || (question !== -1 && question < colon) || (hash !== -1 && hash < colon)) {\n return null;\n }\n return trimmed.slice(0, colon).toLowerCase();\n}\n\n/**\n * Returns true when `value` is safe to use as an `<a href>` target.\n * Accepts:\n * - absolute URLs whose scheme is in the safe-link allowlist\n * (`http`, `https`, `ircs?`, `mailto`, `tel`, `sms`, `xmpp`).\n * - relative URLs (no scheme).\n * - empty string (caller decides whether to render).\n *\n * Rejects `javascript:`, `data:`, `vbscript:`, `file:`, `about:`,\n * and any other unrecognized scheme.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as a link\n */\nexport function isSafeHref(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n return SAFE_LINK_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns true when `value` carries an explicit, safe URL scheme\n * (e.g. `https://example.com`, `mailto:a@b.com`).\n *\n * Used to gate the TipTap link extension's autolinker. TipTap's default\n * `shouldAutoLink` links any token containing a dot whose host isn't a\n * bare IPv4 — so `2.xyz`, `report.zip`, or `logo.png` all become links\n * just because the suffix happens to be a real gTLD. Requiring an\n * explicit scheme means only URLs the user actually typed as links\n * (`https://…`) auto-link; bare host-like text stays plain text, and\n * deliberate links can still be added via the link button or by typing\n * the scheme. Schemes outside the safe-link allowlist are rejected here\n * too, so an unsafe scheme can never be auto-linked.\n * @param value - the candidate URL (the linkifier's matched token text)\n * @returns true when the value has an explicit, allowlisted scheme\n */\nexport function hasExplicitSafeScheme(value: string | null | undefined): boolean {\n if (typeof value !== 'string' || value.length === 0) {\n return false;\n }\n return getScheme(value) !== null && isSafeHref(value);\n}\n\n/**\n * Returns true when `value` is safe to use as an `<img src>`.\n * Accepts http(s), relative URLs, empty string, and `data:image/*`\n * URLs whose MIME subtype is in a tight allowlist. Rejects\n * `data:image/svg+xml` (can carry script) and everything else.\n * @param value - the candidate URL\n * @returns true when the URL is safe to render as an image\n */\nexport function isSafeImageSrc(value: string | null | undefined): boolean {\n if (value === null || value === undefined) {\n return false;\n }\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return true;\n }\n if (scheme === 'data') {\n return SAFE_IMAGE_DATA_PREFIX.test(value);\n }\n return SAFE_IMAGE_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns true when `value` is safe to use as an `<iframe src>` or any\n * other attribute that makes the browser load a document/subresource on\n * its own (`<embed src>`, `<object data>`, `<source src>`).\n *\n * Accepts http(s) and the empty string (an empty `src` loads\n * `about:blank` per the HTML spec, so the caller may leave it in place).\n * Everything else is rejected, including two cases the link and image\n * predicates accept:\n * - `data:` of any media type. `data:text/html,<script>…</script>` in a\n * frame renders and executes in an opaque origin on every React\n * version — the variant of this bug that no React runtime guard stops.\n * - scheme-less (relative) URLs. A relative frame source points at a\n * page of the embedding app itself, which is a UI-redress vector\n * rather than a legitimate embed; Slack requires `video_url` to be an\n * https URL on one of the app's configured unfurl domains.\n * @param value - the candidate URL\n * @returns true when the URL is safe to load as a frame or subresource\n */\nexport function isSafeEmbedSrc(value: string | null | undefined): boolean {\n if (typeof value !== 'string') {\n return false;\n }\n if (value.length === 0) {\n return true;\n }\n const scheme = getScheme(value);\n if (scheme === null) {\n return false;\n }\n return SAFE_EMBED_PROTOCOLS.test(scheme);\n}\n\n/**\n * Returns `value` if it passes {@link isSafeHref}, otherwise an empty\n * string. Use at render time / payload boundaries so an unsafe URL\n * never reaches the DOM as an `href` attribute.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeHref(value: string | null | undefined): string {\n return isSafeHref(value) ? (value ?? '') : '';\n}\n\n/**\n * Returns `value` if it passes {@link isSafeImageSrc}, otherwise an\n * empty string. Use at render time / payload boundaries.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeImageSrc(value: string | null | undefined): string {\n return isSafeImageSrc(value) ? (value ?? '') : '';\n}\n\n/**\n * Returns `value` if it passes {@link isSafeEmbedSrc}, otherwise an\n * empty string. Use at render time / payload boundaries so an unsafe URL\n * never reaches the DOM as a frame or subresource source.\n * @param value - the candidate URL\n * @returns the URL if safe, or `''`\n */\nexport function sanitizeEmbedSrc(value: string | null | undefined): string {\n return isSafeEmbedSrc(value) ? (value ?? '') : '';\n}\n","/**\n * Recursively walks an unknown value and rewrites every URL-bearing\n * string field via the matching sanitizer. Used to strip dangerous URI\n * schemes (e.g. `javascript:`, `data:text/html`) from `url`,\n * `image_url`, `video_url`, rich-text link `url` and friends before a\n * Block Kit payload reaches a renderer or is handed back to the consumer.\n *\n * Allocates a new object whenever a child is rewritten; otherwise\n * returns the input unchanged so unaffected payloads are reference-stable.\n *\n * Defensive against prototype-polluted shapes: copies are made via\n * `Object.assign({}, ...)` over own enumerable keys returned by\n * `Object.keys`, which skips inherited properties.\n */\n\nimport type { SupportedBlock } from '../types';\nimport { sanitizeEmbedSrc, sanitizeHref, sanitizeImageSrc } from './url-safety';\n\n/**\n * Which allowlist applies to a URL-bearing field, by where its value\n * ends up in the DOM: an `<a href>`, an `<img src>`, or a frame /\n * subresource source such as `<iframe src>`.\n */\ntype UrlKind = 'href' | 'image' | 'embed';\n\n/**\n * Fields whose value the renderer puts in a nested browsing context.\n * `slack-blocks-to-jsx` renders the video block's `video_url` into\n * `<iframe src>`, which loads without a click — so these get the\n * http(s)-only allowlist rather than the link one.\n */\nconst EMBED_KEYS = new Set(['video_url']);\n\n/**\n * Key names whose value is a URL. Matched on the last `_`-delimited\n * segment so a field added by Slack later (`title_url`,\n * `provider_icon_url`, `author_link`, …) is covered on arrival.\n *\n * This is deliberately a name *shape* rather than an exact-match list:\n * two rounds of URL-sanitizer hardening each shipped with a\n * hand-maintained key set, and each silently missed a field Slack had\n * added. Over-matching is cheap — a key that looks like a URL but holds\n * something else keeps its value, because a string with no recognized\n * scheme is treated as a relative URL and passed through untouched.\n */\nconst URL_KEY = /(^|_)(url|uri|link|href|src)$/;\n\n/** Cheap pre-filter so the classifier's regexes skip ordinary keys. */\nconst MAYBE_URL_KEY = /url|uri|link|href|src/i;\n\n/**\n * Name fragments that mark a URL field as an image source. Image\n * sources are allowed to carry a `data:image/<safe-mime>` payload —\n * Slack emits those for some emoji and file thumbnails — which the\n * link allowlist rejects.\n */\nconst IMAGE_KEY_HINT = /image|thumb|icon|avatar|logo|photo|picture/;\n\n/**\n * Classifies a payload key by the kind of URL it carries, or `null`\n * when the key holds no URL at all.\n * @param key - the payload object's key\n * @returns which allowlist applies, or `null` to leave the value alone\n */\nfunction classifyUrlKey(key: string): UrlKind | null {\n if (!MAYBE_URL_KEY.test(key)) {\n return null;\n }\n // Fold camelCase to snake_case first so a consumer payload that isn't\n // strict Slack JSON (`videoUrl`, `iconUrl`) classifies the same way.\n const normalized = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();\n if (EMBED_KEYS.has(normalized)) {\n return 'embed';\n }\n if (!URL_KEY.test(normalized)) {\n return null;\n }\n return IMAGE_KEY_HINT.test(normalized) ? 'image' : 'href';\n}\n\n/**\n * Read-only metadata Slack attaches when a message is *retrieved* via the\n * API, but rejects on *send*, keyed by the `type` of the object it appears\n * on. Blocks loaded from an existing message carry these; drop them only\n * from the matching object type — some are common field names (`fallback`)\n * that are valid elsewhere — so a round-tripped payload stays send-valid\n * without over-scrubbing. Add an entry to extend this to other block types.\n */\nconst RETRIEVAL_ONLY_KEYS = new Map<string, Set<string>>([\n // Covers both the image block and the image element — both `type: 'image'`.\n ['image', new Set(['image_width', 'image_height', 'image_bytes', 'fallback', 'is_animated'])],\n // Slack renders the chart server-side and attaches the rendered previews on\n // retrieval; sending them back is rejected as `unknown property 'preview_images'`.\n ['data_visualization', new Set(['preview_images'])]\n]);\n\n/**\n * Recursively sanitize every URL-bearing string field inside a Slack\n * Block Kit payload fragment. Returns a value with the same structural\n * shape, where any field whose name reads as a URL has been replaced by\n * the safe variant (`''` if the original scheme was unsafe for the kind\n * of URL that field carries — see {@link classifyUrlKey}).\n * @param value - any payload fragment (object, array, primitive)\n * @returns the sanitized payload fragment\n */\nfunction sanitizeValue(value: unknown): unknown {\n if (value === null || typeof value !== 'object') {\n return value;\n }\n if (Array.isArray(value)) {\n let changed = false;\n const out = new Array(value.length);\n for (let i = 0; i < value.length; i++) {\n const next = sanitizeValue(value[i]);\n if (next !== value[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : value;\n }\n const src = value as Record<string, unknown>;\n const dropKeys = typeof src.type === 'string' ? RETRIEVAL_ONLY_KEYS.get(src.type) : undefined;\n let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\n if (dropKeys?.has(key)) {\n copy ??= { ...src };\n delete copy[key];\n continue;\n }\n const original = src[key];\n let next: unknown = original;\n if (typeof original === 'string') {\n const kind = classifyUrlKey(key);\n if (kind === 'href') {\n next = sanitizeHref(original);\n } else if (kind === 'image') {\n next = sanitizeImageSrc(original);\n } else if (kind === 'embed') {\n next = sanitizeEmbedSrc(original);\n }\n } else if (typeof original === 'object' && original !== null) {\n next = sanitizeValue(original);\n }\n if (next !== original) {\n if (!copy) {\n copy = { ...src };\n }\n copy[key] = next;\n }\n }\n return copy ?? src;\n}\n\n/**\n * Sanitize a single Block Kit block, scrubbing dangerous URI schemes\n * from every URL-bearing field anywhere in the payload tree (`url`,\n * `image_url`, `video_url`, `thumbnail_url`, `title_url`, …).\n * @param block - the block payload to sanitize\n * @returns the sanitized block (same reference if nothing changed)\n */\nexport function sanitizeBlock<T extends SupportedBlock>(block: T): T {\n return sanitizeValue(block) as T;\n}\n\n/**\n * Sanitize an array of Block Kit blocks. See {@link sanitizeBlock}.\n * @param blocks - the block payloads to sanitize\n * @returns the sanitized blocks\n */\nexport function sanitizeBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n let changed = false;\n const out = new Array<SupportedBlock>(blocks.length);\n for (let i = 0; i < blocks.length; i++) {\n const next = sanitizeBlock(blocks[i]);\n if (next !== blocks[i]) {\n changed = true;\n }\n out[i] = next;\n }\n return changed ? out : blocks;\n}\n","import type { SupportedBlock } from '../types';\nimport { sanitizeBlock } from './sanitize-blocks';\n\n/**\n * Prepare blocks for the Slack API: scrub dangerous URI schemes from any\n * `url`/`image_url` fields and drop the read-only metadata Slack attaches\n * on retrieval but rejects on send. Every URL/image-url is routed through\n * the allowlist in `lib/url-safety.ts` so a payload that round-trips\n * through the builder cannot carry `javascript:`/`data:text/html` URIs to\n * a downstream consumer or to the Slack API.\n *\n * Header `level` is passed through: it is a real Slack field (an integer\n * 1-4, see https://docs.slack.dev/reference/block-kit/blocks/header-block),\n * not a builder-only extension. Earlier versions stripped it here, which\n * silently dropped the heading level from every sent message. Out-of-range\n * values are left intact so they surface as validation errors rather than\n * being quietly discarded.\n *\n * @param blocks - the working draft blocks\n * @returns blocks with retrieval-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => sanitizeBlock(block));\n}\n","import type { SupportedBlock } from '../types';\n\n/**\n * Upper bound on the encoded string length we accept on decode. Block\n * Kit JSON in practice is well under 100 KB; we cap at 1 MiB of encoded\n * input so a pathologically large URL hash cannot freeze the tab in\n * `atob` + `JSON.parse` before the validator gets a look. Encoded\n * (base64url) is ~33% larger than the binary, so this bounds the decoded\n * JSON at roughly 768 KiB.\n */\nconst MAX_ENCODED_BYTES = 1024 * 1024;\n\n/**\n * Encodes a block list to a base64url string suitable for use as a URL\n * search param. The consumer chooses where to put it (search param, hash,\n * localStorage, etc).\n * Returns an empty string for an empty list so the consumer can omit the\n * param entirely.\n * @param blocks - the blocks to encode\n * @returns base64url-encoded JSON, or '' if blocks is empty\n */\nexport function encodeBlocksToString(blocks: SupportedBlock[]): string {\n if (blocks.length === 0) {\n return '';\n }\n const json = JSON.stringify(blocks);\n const utf8Bytes = new TextEncoder().encode(json);\n const chars = new Array<string>(utf8Bytes.length);\n for (let i = 0; i < utf8Bytes.length; i++) {\n chars[i] = String.fromCharCode(utf8Bytes[i]);\n }\n return toBase64Url(btoa(chars.join('')));\n}\n\n/**\n * Decodes a string produced by {@link encodeBlocksToString} back to a\n * block list. Returns null on any decode/parse error.\n * @param encoded - the encoded string\n * @returns decoded blocks, or null if input is empty/invalid\n */\nexport function decodeBlocksFromString(encoded: string | undefined | null): SupportedBlock[] | null {\n if (!encoded) {\n return null;\n }\n if (encoded.length > MAX_ENCODED_BYTES) {\n return null;\n }\n try {\n const binary = atob(fromBase64Url(encoded));\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const json = new TextDecoder().decode(bytes);\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) {\n return null;\n }\n return parsed as SupportedBlock[];\n } catch {\n return null;\n }\n}\n\n/**\n * Converts a standard base64 string to base64url (URL-safe alphabet, no padding).\n * @param b64 - standard base64-encoded string\n * @returns the base64url-encoded equivalent\n */\nfunction toBase64Url(b64: string): string {\n return b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n/**\n * Converts a base64url string back to standard base64 with padding restored.\n * @param b64url - base64url-encoded string\n * @returns the standard base64 equivalent\n */\nfunction fromBase64Url(b64url: string): string {\n const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');\n const padLen = (4 - (b64.length % 4)) % 4;\n return b64 + '='.repeat(padLen);\n}\n"]}
package/dist/index.cjs CHANGED
@@ -1868,6 +1868,7 @@ function groupValidatorErrors(errors, blocks) {
1868
1868
  var SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;
1869
1869
  var SAFE_IMAGE_PROTOCOLS = /^(https?)$/i;
1870
1870
  var SAFE_IMAGE_DATA_PREFIX = /^data:image\/(?:png|jpeg|jpg|gif|webp|avif|x-icon|vnd\.microsoft\.icon)(?:;[a-z0-9-]+(?:=[^,;]*)?)*,/i;
1871
+ var SAFE_EMBED_PROTOCOLS = /^(https?)$/i;
1871
1872
  function getScheme(value) {
1872
1873
  const trimmed = value.replace(/^\s+/, "");
1873
1874
  const colon = trimmed.indexOf(":");
@@ -1923,16 +1924,47 @@ function isSafeImageSrc(value) {
1923
1924
  }
1924
1925
  return SAFE_IMAGE_PROTOCOLS.test(scheme);
1925
1926
  }
1927
+ function isSafeEmbedSrc(value) {
1928
+ if (typeof value !== "string") {
1929
+ return false;
1930
+ }
1931
+ if (value.length === 0) {
1932
+ return true;
1933
+ }
1934
+ const scheme = getScheme(value);
1935
+ if (scheme === null) {
1936
+ return false;
1937
+ }
1938
+ return SAFE_EMBED_PROTOCOLS.test(scheme);
1939
+ }
1926
1940
  function sanitizeHref(value) {
1927
1941
  return isSafeHref(value) ? value ?? "" : "";
1928
1942
  }
1929
1943
  function sanitizeImageSrc(value) {
1930
1944
  return isSafeImageSrc(value) ? value ?? "" : "";
1931
1945
  }
1946
+ function sanitizeEmbedSrc(value) {
1947
+ return isSafeEmbedSrc(value) ? value ?? "" : "";
1948
+ }
1932
1949
 
1933
1950
  // src/lib/sanitize-blocks.ts
1934
- var HREF_KEYS = /* @__PURE__ */ new Set(["url"]);
1935
- var IMAGE_KEYS = /* @__PURE__ */ new Set(["image_url"]);
1951
+ var EMBED_KEYS = /* @__PURE__ */ new Set(["video_url"]);
1952
+ var URL_KEY = /(^|_)(url|uri|link|href|src)$/;
1953
+ var MAYBE_URL_KEY = /url|uri|link|href|src/i;
1954
+ var IMAGE_KEY_HINT = /image|thumb|icon|avatar|logo|photo|picture/;
1955
+ function classifyUrlKey(key) {
1956
+ if (!MAYBE_URL_KEY.test(key)) {
1957
+ return null;
1958
+ }
1959
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
1960
+ if (EMBED_KEYS.has(normalized)) {
1961
+ return "embed";
1962
+ }
1963
+ if (!URL_KEY.test(normalized)) {
1964
+ return null;
1965
+ }
1966
+ return IMAGE_KEY_HINT.test(normalized) ? "image" : "href";
1967
+ }
1936
1968
  var RETRIEVAL_ONLY_KEYS = /* @__PURE__ */ new Map([
1937
1969
  // Covers both the image block and the image element — both `type: 'image'`.
1938
1970
  ["image", /* @__PURE__ */ new Set(["image_width", "image_height", "image_bytes", "fallback", "is_animated"])],
@@ -1968,10 +2000,13 @@ function sanitizeValue(value) {
1968
2000
  const original = src[key];
1969
2001
  let next = original;
1970
2002
  if (typeof original === "string") {
1971
- if (HREF_KEYS.has(key)) {
2003
+ const kind = classifyUrlKey(key);
2004
+ if (kind === "href") {
1972
2005
  next = sanitizeHref(original);
1973
- } else if (IMAGE_KEYS.has(key)) {
2006
+ } else if (kind === "image") {
1974
2007
  next = sanitizeImageSrc(original);
2008
+ } else if (kind === "embed") {
2009
+ next = sanitizeEmbedSrc(original);
1975
2010
  }
1976
2011
  } else if (typeof original === "object" && original !== null) {
1977
2012
  next = sanitizeValue(original);
@@ -3284,6 +3319,7 @@ function ChannelCombobox({
3284
3319
  )
3285
3320
  ] });
3286
3321
  }
3322
+ var EMBED_SELECTOR = "iframe[src], embed[src], object[data], source[src], video[src], audio[src], track[src]";
3287
3323
  function SlackBlockPreview({
3288
3324
  block,
3289
3325
  hooks,
@@ -3316,6 +3352,13 @@ function SlackBlockPreview({
3316
3352
  img.setAttribute("data-bk-blocked-src", "1");
3317
3353
  }
3318
3354
  }
3355
+ for (const el of root.querySelectorAll(EMBED_SELECTOR)) {
3356
+ const attr = el.tagName === "OBJECT" ? "data" : "src";
3357
+ if (!isSafeEmbedSrc(el.getAttribute(attr))) {
3358
+ el.removeAttribute(attr);
3359
+ el.setAttribute("data-bk-blocked-src", "1");
3360
+ }
3361
+ }
3319
3362
  });
3320
3363
  return (
3321
3364
  // The library scopes all of its CSS under `#slack_blocks_to_jsx` and
@@ -9168,7 +9211,14 @@ function SourcesField({
9168
9211
  ] })
9169
9212
  ] });
9170
9213
  }
9214
+ function UnsafeUrlNote({ children }) {
9215
+ return /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-[11px] text-destructive", children });
9216
+ }
9171
9217
  function VideoEditor({ block, onChange }) {
9218
+ const videoUrlIsUnsafe = (block.video_url ?? "").length > 0 && !isSafeEmbedSrc(block.video_url);
9219
+ const thumbnailIsUnsafe = (block.thumbnail_url ?? "").length > 0 && !isSafeImageSrc(block.thumbnail_url);
9220
+ const titleUrlIsUnsafe = (block.title_url ?? "").length > 0 && !isSafeHref(block.title_url);
9221
+ const providerIconIsUnsafe = (block.provider_icon_url ?? "").length > 0 && !isSafeImageSrc(block.provider_icon_url);
9172
9222
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-4", children: [
9173
9223
  /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Title", help: "Up to 199 characters. Shown above the embedded player.", htmlFor: "video-title", children: /* @__PURE__ */ jsxRuntime.jsx(
9174
9224
  Input,
@@ -9192,50 +9242,62 @@ function VideoEditor({ block, onChange }) {
9192
9242
  onChange: (e) => onChange({ ...block, alt_text: e.target.value })
9193
9243
  }
9194
9244
  ) }),
9195
- /* @__PURE__ */ jsxRuntime.jsx(
9245
+ /* @__PURE__ */ jsxRuntime.jsxs(
9196
9246
  EditorField,
9197
9247
  {
9198
9248
  label: "Video URL",
9199
9249
  help: "HTTPS embed URL. Must match an unfurl domain on your Slack app.",
9200
9250
  htmlFor: "video-url",
9201
- children: /* @__PURE__ */ jsxRuntime.jsx(
9202
- Input,
9203
- {
9204
- id: "video-url",
9205
- type: "url",
9206
- value: block.video_url ?? "",
9207
- placeholder: "e.g. https://www.youtube.com/embed/dQw4w9WgXcQ",
9208
- onChange: (e) => onChange({ ...block, video_url: e.target.value })
9209
- }
9210
- )
9251
+ children: [
9252
+ /* @__PURE__ */ jsxRuntime.jsx(
9253
+ Input,
9254
+ {
9255
+ id: "video-url",
9256
+ type: "url",
9257
+ value: block.video_url ?? "",
9258
+ placeholder: "e.g. https://www.youtube.com/embed/dQw4w9WgXcQ",
9259
+ onChange: (e) => onChange({ ...block, video_url: e.target.value }),
9260
+ "aria-invalid": videoUrlIsUnsafe || void 0
9261
+ }
9262
+ ),
9263
+ videoUrlIsUnsafe && /* @__PURE__ */ jsxRuntime.jsx(UnsafeUrlNote, { children: "The embedded player only accepts a full http(s) URL. This URL will be stripped before send and preview." })
9264
+ ]
9211
9265
  }
9212
9266
  ),
9213
- /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Thumbnail URL", help: "Preview image shown before the video loads.", htmlFor: "video-thumbnail", children: /* @__PURE__ */ jsxRuntime.jsx(
9214
- Input,
9215
- {
9216
- id: "video-thumbnail",
9217
- type: "url",
9218
- value: block.thumbnail_url ?? "",
9219
- placeholder: "e.g. https://example.com/thumb.png",
9220
- onChange: (e) => onChange({ ...block, thumbnail_url: e.target.value })
9221
- }
9222
- ) }),
9223
- /* @__PURE__ */ jsxRuntime.jsx(
9267
+ /* @__PURE__ */ jsxRuntime.jsxs(EditorField, { label: "Thumbnail URL", help: "Preview image shown before the video loads.", htmlFor: "video-thumbnail", children: [
9268
+ /* @__PURE__ */ jsxRuntime.jsx(
9269
+ Input,
9270
+ {
9271
+ id: "video-thumbnail",
9272
+ type: "url",
9273
+ value: block.thumbnail_url ?? "",
9274
+ placeholder: "e.g. https://example.com/thumb.png",
9275
+ onChange: (e) => onChange({ ...block, thumbnail_url: e.target.value }),
9276
+ "aria-invalid": thumbnailIsUnsafe || void 0
9277
+ }
9278
+ ),
9279
+ thumbnailIsUnsafe && /* @__PURE__ */ jsxRuntime.jsx(UnsafeUrlNote, { children: "Only http(s) image URLs are allowed. This URL will be stripped before send and preview." })
9280
+ ] }),
9281
+ /* @__PURE__ */ jsxRuntime.jsxs(
9224
9282
  EditorField,
9225
9283
  {
9226
9284
  label: "Title link URL",
9227
9285
  help: "Optional. HTTPS link the title text opens (the non-embed page).",
9228
9286
  htmlFor: "video-title-url",
9229
- children: /* @__PURE__ */ jsxRuntime.jsx(
9230
- Input,
9231
- {
9232
- id: "video-title-url",
9233
- type: "url",
9234
- value: block.title_url ?? "",
9235
- placeholder: "e.g. https://www.youtube.com/watch?v=dQw4w9WgXcQ",
9236
- onChange: (e) => onChange({ ...block, title_url: e.target.value || void 0 })
9237
- }
9238
- )
9287
+ children: [
9288
+ /* @__PURE__ */ jsxRuntime.jsx(
9289
+ Input,
9290
+ {
9291
+ id: "video-title-url",
9292
+ type: "url",
9293
+ value: block.title_url ?? "",
9294
+ placeholder: "e.g. https://www.youtube.com/watch?v=dQw4w9WgXcQ",
9295
+ onChange: (e) => onChange({ ...block, title_url: e.target.value || void 0 }),
9296
+ "aria-invalid": titleUrlIsUnsafe || void 0
9297
+ }
9298
+ ),
9299
+ titleUrlIsUnsafe && /* @__PURE__ */ jsxRuntime.jsx(UnsafeUrlNote, { children: "Only http(s), mailto, tel, sms, and xmpp links are allowed. This URL will be stripped before send and preview." })
9300
+ ]
9239
9301
  }
9240
9302
  ),
9241
9303
  /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Description", help: "Optional. Up to 199 characters.", htmlFor: "video-description", children: /* @__PURE__ */ jsxRuntime.jsx(
@@ -9279,22 +9341,26 @@ function VideoEditor({ block, onChange }) {
9279
9341
  )
9280
9342
  }
9281
9343
  ),
9282
- /* @__PURE__ */ jsxRuntime.jsx(
9344
+ /* @__PURE__ */ jsxRuntime.jsxs(
9283
9345
  EditorField,
9284
9346
  {
9285
9347
  label: "Provider icon URL",
9286
9348
  help: "Optional. Small icon shown next to the provider name.",
9287
9349
  htmlFor: "video-provider-icon",
9288
- children: /* @__PURE__ */ jsxRuntime.jsx(
9289
- Input,
9290
- {
9291
- id: "video-provider-icon",
9292
- type: "url",
9293
- value: block.provider_icon_url ?? "",
9294
- placeholder: "e.g. https://example.com/youtube-favicon.png",
9295
- onChange: (e) => onChange({ ...block, provider_icon_url: e.target.value || void 0 })
9296
- }
9297
- )
9350
+ children: [
9351
+ /* @__PURE__ */ jsxRuntime.jsx(
9352
+ Input,
9353
+ {
9354
+ id: "video-provider-icon",
9355
+ type: "url",
9356
+ value: block.provider_icon_url ?? "",
9357
+ placeholder: "e.g. https://example.com/youtube-favicon.png",
9358
+ onChange: (e) => onChange({ ...block, provider_icon_url: e.target.value || void 0 }),
9359
+ "aria-invalid": providerIconIsUnsafe || void 0
9360
+ }
9361
+ ),
9362
+ providerIconIsUnsafe && /* @__PURE__ */ jsxRuntime.jsx(UnsafeUrlNote, { children: "Only http(s) image URLs are allowed. This URL will be stripped before send and preview." })
9363
+ ]
9298
9364
  }
9299
9365
  )
9300
9366
  ] });