/** * HTML-export sanitisation policy (defence-in-depth for `/:room.html`). * * SocialCalc emits `valueformat===text-html` cells verbatim into the export * `` (the same render sink the live editor uses). The export response * already carries a strict `Content-Security-Policy` (see * `routes/exports.ts`), but the CSP only protects user agents that honour it * and only when the document is loaded top-level — a downloaded `.html` * opened from disk, or an embed in a context that strips response headers, * sees no CSP. So we ALSO strip the dangerous markup from the bytes before * they leave the worker, using Cloudflare's native `HTMLRewriter` in the * route layer. The CSP stays as the outer layer. * * This module is the PURE policy — the element/attribute decision rules — * so it is fully Node-testable and coverage-gated. The `HTMLRewriter` * wiring that consumes it lives in `routes/exports.ts` (workerd-only, * istanbul-ignored). Keeping the predicates here means the security-relevant * decisions are unit-tested rather than hidden inside an uncovered route. * * Why strip rather than escape: `text-html` is a legitimate feature. The * policy mirrors the client DOMPurify allowlist: safe formatting survives, * unknown tags are unwrapped, and active/raw-text elements are removed. */ /** * Elements removed with their contents. This includes executable/embedded * content plus parser-state and document-control elements that are unsafe * when a downloaded export is opened without response CSP headers. */ export const DANGEROUS_ELEMENTS: readonly string[] = [ 'applet', 'base', 'embed', 'frame', 'frameset', 'iframe', 'link', 'math', 'meta', 'noembed', 'noframes', 'noscript', 'object', 'plaintext', 'script', 'style', 'svg', 'template', 'textarea', 'title', 'xmp', ]; /** Formatting elements permitted by the live client sanitizer. */ export const ALLOWED_ELEMENTS: readonly string[] = [ 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'div', 'em', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul', ]; /** Safe presentation/link attributes retained on allowed elements. */ export const ALLOWED_ATTRIBUTES: readonly string[] = [ 'align', 'alt', 'border', 'cellpadding', 'cellspacing', 'class', 'color', 'colspan', 'face', 'height', 'href', 'rel', 'rowspan', 'size', 'src', 'style', 'target', 'title', 'valign', 'width', ]; /** * True for an `on*` inline event-handler attribute (`onerror`, `onclick`, * `onload`, …). Case-insensitive — HTML attribute names are ASCII * case-insensitive and an attacker may use mixed case to dodge a naive * filter. We treat the bare two-character name `on` as not a handler (it * carries no event), matching how browsers ignore it. */ export function isEventHandlerAttribute(name: string): boolean { return /^on[a-z]/i.test(name); } /** * URL-bearing attributes retained by the allowlist and scheme-checked. */ export const URL_ATTRIBUTES: readonly string[] = ['href', 'src']; /** * True unless a URL uses http(s), mailto, tel, ftp, or a relative/reference * form. C0 controls are removed before classification to catch obfuscated * schemes such as `java\tscript:`. */ export function isUnsafeUrlValue(value: string): boolean { // `+` vs a single-char class is a wash under the `g` flag (every occurrence // is replaced either way) — the quantifier is kept for intent. // eslint-disable-next-line no-control-regex // Stryker disable next-line Regex const collapsed = value.replace(/[\x00-\x20]+/g, ''); return !/^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i.test( collapsed, ); } /** * Image sources in exported user content must stay local. Served exports * already have CSP, but downloaded HTML loses response headers; allowing an * absolute or protocol-relative source there would turn a sheet into a * viewer-tracking beacon. */ export function isExternalImageSource(value: string): boolean { // eslint-disable-next-line no-control-regex // Stryker disable next-line Regex const collapsed = value.replace(/[\x00-\x20]+/g, ''); return /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i.test(collapsed); } /** * Inline styles preserve spreadsheet formatting, but CSS can also issue * network requests. Strip the whole attribute when it contains a fetch-capable * construct (including common escaped/comment-obfuscated spellings) so a * downloaded HTML export cannot become a tracking beacon after response CSP * headers are gone. */ export function isUnsafeInlineStyle(value: string): boolean { // Backslash escapes and comments can splice attacker-controlled CSS tokens. if (value.includes('\\') || value.includes('/*') || value.includes('@')) { return true; } // eslint-disable-next-line no-control-regex // Stryker disable next-line Regex const compact = value.toLowerCase().replace(/[\x00-\x20]+/g, ''); return ( /(?:url|image(?:-set)?|cross-fade|element|paint|expression|behavior|-moz-binding)\s*\(/.test( compact, ) || /(?:https?|ftp|file|data|blob):|[\\/]{2}/.test(compact) ); } /** * Decide what to do with a single attribute during sanitisation. * Returns `'remove'` when the attribute must be dropped, `'keep'` otherwise. * * Factored out so the route's `HTMLRewriter` element handler is a thin * adapter and every branch is exercised by the Node unit suite. */ export function attributeAction(name: string, value: string): 'keep' | 'remove' { // Defence in depth: no allowlisted attribute name starts with `on`, so the // allowlist check below already rejects handlers. This keeps the intent // explicit if the allowlist ever grows. // Stryker disable next-line ConditionalExpression if (isEventHandlerAttribute(name)) return 'remove'; const normalized = name.toLowerCase(); if (!ALLOWED_ATTRIBUTES.includes(normalized)) return 'remove'; if (URL_ATTRIBUTES.includes(normalized) && isUnsafeUrlValue(value)) { return 'remove'; } if (normalized === 'src' && isExternalImageSource(value)) return 'remove'; if (normalized === 'style' && isUnsafeInlineStyle(value)) return 'remove'; return 'keep'; }