@tightknitai/block-kitchen 0.8.4 → 0.8.5

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.
@@ -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` 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 * 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 let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\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 * Strip builder-only fields from blocks so they conform to Slack's\n * Block Kit schema, and scrub dangerous URI schemes from any\n * `url`/`image_url` fields. Currently removes the cosmetic `level`\n * field from header blocks (a builder-only extension Slack would\n * reject) and routes every URL/image-url through the allowlist in\n * `lib/url-safety.ts` so a payload that round-trips through the\n * builder cannot carry `javascript:`/`data:text/html` URIs to a\n * downstream consumer or to the Slack API.\n * @param blocks - the working draft blocks\n * @returns blocks with builder-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => {\n const safe = sanitizeBlock(block);\n if (safe.type === 'header' && 'level' in safe) {\n const { level: _omit, ...rest } = safe;\n return rest as SupportedBlock;\n }\n return safe;\n });\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 * 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 * 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 let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\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 * Strip builder-only fields from blocks so they conform to Slack's\n * Block Kit schema, and scrub dangerous URI schemes from any\n * `url`/`image_url` fields. Currently removes the cosmetic `level`\n * field from header blocks (a builder-only extension Slack would\n * reject) and routes every URL/image-url through the allowlist in\n * `lib/url-safety.ts` so a payload that round-trips through the\n * builder cannot carry `javascript:`/`data:text/html` URIs to a\n * downstream consumer or to the Slack API.\n * @param blocks - the working draft blocks\n * @returns blocks with builder-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => {\n const safe = sanitizeBlock(block);\n if (safe.type === 'header' && 'level' in safe) {\n const { level: _omit, ...rest } = safe;\n return rest as SupportedBlock;\n }\n return safe;\n });\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 +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` 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 * 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 let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\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 * Strip builder-only fields from blocks so they conform to Slack's\n * Block Kit schema, and scrub dangerous URI schemes from any\n * `url`/`image_url` fields. Currently removes the cosmetic `level`\n * field from header blocks (a builder-only extension Slack would\n * reject) and routes every URL/image-url through the allowlist in\n * `lib/url-safety.ts` so a payload that round-trips through the\n * builder cannot carry `javascript:`/`data:text/html` URIs to a\n * downstream consumer or to the Slack API.\n * @param blocks - the working draft blocks\n * @returns blocks with builder-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => {\n const safe = sanitizeBlock(block);\n if (safe.type === 'header' && 'level' in safe) {\n const { level: _omit, ...rest } = safe;\n return rest as SupportedBlock;\n }\n return safe;\n });\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 * 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 * 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 let copy: Record<string, unknown> | null = null;\n for (const key of Object.keys(src)) {\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 * Strip builder-only fields from blocks so they conform to Slack's\n * Block Kit schema, and scrub dangerous URI schemes from any\n * `url`/`image_url` fields. Currently removes the cosmetic `level`\n * field from header blocks (a builder-only extension Slack would\n * reject) and routes every URL/image-url through the allowlist in\n * `lib/url-safety.ts` so a payload that round-trips through the\n * builder cannot carry `javascript:`/`data:text/html` URIs to a\n * downstream consumer or to the Slack API.\n * @param blocks - the working draft blocks\n * @returns blocks with builder-only fields removed and URLs scrubbed\n */\nexport function toSlackBlocks(blocks: SupportedBlock[]): SupportedBlock[] {\n return blocks.map((block) => {\n const safe = sanitizeBlock(block);\n if (safe.type === 'header' && 'level' in safe) {\n const { level: _omit, ...rest } = safe;\n return rest as SupportedBlock;\n }\n return safe;\n });\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
@@ -1670,6 +1670,12 @@ function isSafeHref(value) {
1670
1670
  }
1671
1671
  return SAFE_LINK_PROTOCOLS.test(scheme);
1672
1672
  }
1673
+ function hasExplicitSafeScheme(value) {
1674
+ if (typeof value !== "string" || value.length === 0) {
1675
+ return false;
1676
+ }
1677
+ return getScheme(value) !== null && isSafeHref(value);
1678
+ }
1673
1679
  function isSafeImageSrc(value) {
1674
1680
  if (value === null || value === void 0) {
1675
1681
  return false;
@@ -5402,6 +5408,17 @@ function blockElementToPM(el, resolveEmoji) {
5402
5408
  }
5403
5409
  return null;
5404
5410
  }
5411
+ function pushTextWithHardBreaks(out, text, marks) {
5412
+ const segments = text.split("\n");
5413
+ segments.forEach((segment, i) => {
5414
+ if (i > 0) {
5415
+ out.push({ type: "hardBreak" });
5416
+ }
5417
+ if (segment) {
5418
+ out.push({ type: "text", text: segment, marks });
5419
+ }
5420
+ });
5421
+ }
5405
5422
  function inlinesToPM(inlines, resolveEmoji) {
5406
5423
  const out = [];
5407
5424
  for (const inline of inlines) {
@@ -5409,8 +5426,7 @@ function inlinesToPM(inlines, resolveEmoji) {
5409
5426
  if (!inline.text) {
5410
5427
  continue;
5411
5428
  }
5412
- const marks = styleToMarks(inline.style);
5413
- out.push({ type: "text", text: inline.text, marks });
5429
+ pushTextWithHardBreaks(out, inline.text, styleToMarks(inline.style));
5414
5430
  } else if (inline.type === "emoji") {
5415
5431
  const attrs = resolveEmoji(inline);
5416
5432
  if (!attrs.name) {
@@ -5547,6 +5563,10 @@ function proseMirrorInlinesToRichTextElements(nodes) {
5547
5563
  out.push(emoji);
5548
5564
  continue;
5549
5565
  }
5566
+ if (node.type === "hardBreak") {
5567
+ out.push({ type: "text", text: "\n" });
5568
+ continue;
5569
+ }
5550
5570
  if (node.type !== "text" || !node.text) {
5551
5571
  continue;
5552
5572
  }
@@ -6234,6 +6254,43 @@ function LossyBanner({ reasons }) {
6234
6254
  ] })
6235
6255
  ] });
6236
6256
  }
6257
+ var RICH_TEXT_INPUT_RULE_ALLOWLIST = ["bold", "italic", "strike", "code"];
6258
+ function buildRichTextExtensions() {
6259
+ return [
6260
+ StarterKit__default.default.configure({
6261
+ heading: false,
6262
+ horizontalRule: false,
6263
+ // StarterKit bundles its own Link extension (enabled by default),
6264
+ // which would register a second, unconfigured `link` mark and a
6265
+ // duplicate autolinker alongside the hardened one we add below.
6266
+ // Disable it so our single, security-configured Link is the only
6267
+ // link extension in the schema.
6268
+ link: false
6269
+ }),
6270
+ EmojiNode,
6271
+ Link__default.default.configure({
6272
+ openOnClick: false,
6273
+ autolink: true,
6274
+ defaultProtocol: "https",
6275
+ // Pin TipTap's link allowlist to the same set as our shared
6276
+ // `isSafeHref` helper. The upstream default also includes
6277
+ // ftp/cid/callto which we don't expect inside Slack content.
6278
+ protocols: ["http", "https", "mailto", "tel", "sms", "xmpp"],
6279
+ // Belt-and-suspenders: even if a downstream contributor widens
6280
+ // `protocols` later, our isSafeHref guard rejects everything
6281
+ // outside the safe-link set. setLink and toggleLink both gate
6282
+ // on this hook before applying the mark.
6283
+ isAllowedUri: (url) => isSafeHref(url),
6284
+ // Only auto-link tokens that carry an explicit, safe scheme. TipTap's
6285
+ // default would link any `host.tld` (e.g. `2.xyz`, `report.zip`,
6286
+ // `logo.png`) because the suffix is a real gTLD, which is wrong for
6287
+ // free-form Slack message text. Deliberate links can still be added
6288
+ // via the link button.
6289
+ shouldAutoLink: hasExplicitSafeScheme,
6290
+ HTMLAttributes: { rel: "noreferrer noopener", target: "_blank" }
6291
+ })
6292
+ ];
6293
+ }
6237
6294
  function RichTextWysiwygEditor({ block, onChange }) {
6238
6295
  const lastEmittedRef = React.useRef(null);
6239
6296
  const { byName: customByName } = useCustomEmojis();
@@ -6259,28 +6316,8 @@ function RichTextWysiwygEditor({ block, onChange }) {
6259
6316
  [onChange]
6260
6317
  );
6261
6318
  const editor = react.useEditor({
6262
- extensions: [
6263
- StarterKit__default.default.configure({
6264
- heading: false,
6265
- horizontalRule: false
6266
- }),
6267
- EmojiNode,
6268
- Link__default.default.configure({
6269
- openOnClick: false,
6270
- autolink: true,
6271
- defaultProtocol: "https",
6272
- // Pin TipTap's link allowlist to the same set as our shared
6273
- // `isSafeHref` helper. The upstream default also includes
6274
- // ftp/cid/callto which we don't expect inside Slack content.
6275
- protocols: ["http", "https", "mailto", "tel", "sms", "xmpp"],
6276
- // Belt-and-suspenders: even if a downstream contributor widens
6277
- // `protocols` later, our isSafeHref guard rejects everything
6278
- // outside the safe-link set. setLink and toggleLink both gate
6279
- // on this hook before applying the mark.
6280
- isAllowedUri: (url) => isSafeHref(url),
6281
- HTMLAttributes: { rel: "noreferrer noopener", target: "_blank" }
6282
- })
6283
- ],
6319
+ extensions: buildRichTextExtensions(),
6320
+ enableInputRules: RICH_TEXT_INPUT_RULE_ALLOWLIST,
6284
6321
  content: richTextToProseMirror(block, resolveEmoji),
6285
6322
  editorProps: {
6286
6323
  attributes: {