doxa-maps-cdn/server/utils/tiptapValidate.ts

510 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Sanitizes a Tiptap JSON document against an explicit per-node /
// per-mark policy. MCP and admin write inputs are treated as hostile —
// the body is later rendered to public HTML via renderTiptap.ts and
// injected into the page, so this pass is the only line of defense
// against stored XSS and unknown-node smuggling.
//
// Policy buckets:
// - preserve: per-node allowlist passes attrs through unchanged
// - strip-and-warn: unknown attrs and bad attr *values* are dropped
// in place; a SanitizationWarning is collected so the caller can
// surface "some formatting was simplified" to the author
// - reject: only structural violations (doc shape, DoS limits, and
// forbidden attr names like script/srcdoc/on*) throw
//
// The function mutates `doc` in place and returns the warning list.
import { PRESERVED_DATA_ATTRS } from '~~/app/utils/tiptapDiv'
import { isSafeHttpUrl as isSafeUrl } from './urlValidation'
// ── Allowlists ──────────────────────────────────────────────────────
// Registered Tiptap node names (matches buildTiptapExtensions()).
export const NODE_NAMES: ReadonlySet<string> = new Set([
'doc',
'paragraph',
'heading',
'bulletList',
'orderedList',
'listItem',
'blockquote',
'codeBlock',
'horizontalRule',
'hardBreak',
'text',
'image',
'youtube',
'vimeo',
'div',
'verse',
'uupgsList',
'generalResources'
])
export const MARK_NAMES: ReadonlySet<string> = new Set([
'bold',
'italic',
'strike',
'code',
'link',
'textStyle',
'highlight',
'subscript',
'superscript',
'underline'
])
// CUBE-CSS primitives the editor and the WP scrape produce. Any other
// class on a div is dropped.
const DIV_CLASS_SAFELIST: ReadonlySet<string> = new Set([
'wp-block-spacer',
'card',
'grid',
'switcher',
'stack',
'cluster',
'sidebar',
'reel',
'frame',
'cover',
'imposter',
'icon',
'wrapper',
'box',
'center',
'resource-card'
])
// Per-node attribute allowlist. Only listed attrs are preserved; any
// other attr is stripped + warned. Values like `image.align` round-
// trip through here even though the lossy detector flags them — the
// sanitizer is structural defense, the lossy flag is informational.
const NODE_ATTRS: Readonly<Record<string, ReadonlyArray<string>>> = {
doc: [],
paragraph: ['textAlign'],
heading: ['level', 'textAlign'],
bulletList: [],
orderedList: ['start', 'type'],
listItem: [],
blockquote: [],
codeBlock: ['language'],
horizontalRule: [],
hardBreak: [],
text: [],
image: ['src', 'alt', 'title', 'align', 'width', 'height'],
youtube: ['src', 'width', 'height', 'start'],
vimeo: ['src', 'hash', 'width', 'height'],
div: ['class', 'style', ...PRESERVED_DATA_ATTRS],
verse: ['reference'],
uupgsList: [
'languageCode', 'selectUrl', 'researchUrl', 'initialSearchTerm', 't',
'perPage', 'morePerPage',
'useSelectCard', 'useHighlightedUUPGs', 'hideSeeAllLink', 'dontShowListOnLoad', 'randomizeList'
],
generalResources: ['useDocuments', 'layout']
}
// Per-mark attribute allowlist. Mark attrs are validated with explicit
// per-attr checks below — entries here are just the structural set.
const MARK_ATTRS: Readonly<Record<string, ReadonlyArray<string>>> = {
bold: [],
italic: [],
strike: [],
code: [],
link: ['href', 'target', 'rel', 'class', 'title'],
textStyle: ['color', 'fontFamily'],
highlight: ['color'],
subscript: [],
superscript: [],
underline: []
}
// ── URL allowlist ───────────────────────────────────────────────────
const NAMED_COLORS = new Set([
'black', 'white', 'red', 'green', 'blue', 'yellow',
'orange', 'purple', 'pink', 'gray', 'grey', 'brown',
'transparent', 'inherit', 'currentcolor'
])
// YouTube embeds: only the canonical embed/watch URLs.
function isSafeYoutubeUrl(value: unknown): boolean {
if (typeof value !== 'string') return false
try {
const parsed = new URL(value)
if (parsed.protocol !== 'https:') return false
const host = parsed.hostname.toLowerCase()
if (host === 'www.youtube.com' || host === 'youtube.com') {
return parsed.pathname.startsWith('/embed/') || parsed.pathname === '/watch'
}
if (host === 'youtu.be') return true
return false
} catch {
return false
}
}
// Vimeo embeds store only the numeric video id (see app/utils/tiptapVimeo.ts);
// the renderer builds the player.vimeo.com URL from it, so a digits-only
// check is sufficient — no URL can be smuggled through.
function isSafeVimeoSrc(value: unknown): boolean {
return typeof value === 'string' && /^\d+$/.test(value)
}
// Optional Vimeo privacy hash. Absent is fine; when present it must be
// alphanumeric so it can be appended to the embed URL as `?h=<hash>`
// without smuggling extra query params or markup.
function isSafeVimeoHash(value: unknown): boolean {
if (value === null || value === undefined) return true
return typeof value === 'string' && /^[a-z0-9]+$/i.test(value)
}
// CSS color values that pass into a `color: <value>` declaration.
// Strict regexes — digits, decimal points, %, commas, parens, whitespace
// only — so even though the value lands inside a style attribute, no
// CSS escape characters can slip through.
const RGB_BYTE = '(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)' // 0–255
const PCT = '(?:100(?:\\.0+)?|\\d{1,2}(?:\\.\\d+)?)%' // 0–100%
const ALPHA = '(?:0|1|0?\\.\\d+|' + PCT + ')' // 0–1 or 0–100%
const HUE = '(?:360(?:\\.0+)?|3[0-5]\\d(?:\\.\\d+)?|[12]?\\d{1,2}(?:\\.\\d+)?)' // 0–360
const RGB_RE = new RegExp(`^rgb\\(\\s*(?:${RGB_BYTE}|${PCT})(?:\\s*,\\s*(?:${RGB_BYTE}|${PCT})){2}\\s*\\)$`)
const RGBA_RE = new RegExp(`^rgba\\(\\s*(?:${RGB_BYTE}|${PCT})(?:\\s*,\\s*(?:${RGB_BYTE}|${PCT})){2}\\s*,\\s*${ALPHA}\\s*\\)$`)
const HSL_RE = new RegExp(`^hsl\\(\\s*${HUE}\\s*,\\s*${PCT}\\s*,\\s*${PCT}\\s*\\)$`)
const HSLA_RE = new RegExp(`^hsla\\(\\s*${HUE}\\s*,\\s*${PCT}\\s*,\\s*${PCT}\\s*,\\s*${ALPHA}\\s*\\)$`)
function isSafeColor(value: unknown): boolean {
if (typeof value !== 'string') return true // null is fine, missing is fine
const v = value.trim()
if (v === '') return true
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) return true
if (NAMED_COLORS.has(v.toLowerCase())) return true
if (RGB_RE.test(v) || RGBA_RE.test(v)) return true
if (HSL_RE.test(v) || HSLA_RE.test(v)) return true
return false
}
// ── DoS limits ──────────────────────────────────────────────────────
// Caps on the doc-tree shape, independent of serialized size. The schema
// layer caps body_json at 1 MB; these stop pathological structure (deep
// nesting or huge node count) that fits inside that budget but still
// chews CPU at validate/render time.
const MAX_DEPTH = 50
const MAX_NODES = 25_000
// ── Errors ──────────────────────────────────────────────────────────
export class TiptapValidationError extends Error {
constructor(public reason: string, public path: string) {
super(`${reason} at ${path}`)
this.name = 'TiptapValidationError'
}
}
export interface SanitizationWarning {
path: string
reason: string
}
interface JsonMark {
type?: string
attrs?: Record<string, unknown>
}
interface JsonNode {
type?: string
attrs?: Record<string, unknown>
content?: JsonNode[]
marks?: JsonMark[]
text?: string
}
// ── Walker ──────────────────────────────────────────────────────────
export interface ValidateOptions {
// When true (the default), unknown node types are dropped from the
// doc and a warning is recorded. When false, unknown nodes are
// silently kept (used internally by the conversion pipeline for
// forward-compat). The "throw on unknown node type" mode the older
// validator had is gone — sanitize-then-store is the contract.
rejectUnknownNodes?: boolean
}
export interface ValidateResult {
warnings: SanitizationWarning[]
}
export function tiptapValidate(doc: unknown, opts: ValidateOptions = {}): ValidateResult {
const dropUnknown = opts.rejectUnknownNodes ?? true
const warnings: SanitizationWarning[] = []
if (!doc || typeof doc !== 'object') {
throw new TiptapValidationError('Document must be an object', '$')
}
const root = doc as JsonNode
if (root.type !== 'doc') {
throw new TiptapValidationError('Root node type must be "doc"', '$.type')
}
if (!Array.isArray(root.content) || root.content.length === 0) {
throw new TiptapValidationError('Document content must be a non-empty array', '$.content')
}
// Doc-tree shape budgets (see MAX_DEPTH / MAX_NODES). Single counter
// shared across the whole walk; depth tracked per recursion arm.
const counter = { nodes: 1 } // root counts as one
walkContentArray(root.content, '$.content', warnings, dropUnknown, 1, counter)
return { warnings }
}
// Walks a parent's content array, mutating it in place: bad nodes are
// removed via splice, and recursion continues into surviving nodes.
function walkContentArray(
content: JsonNode[],
pathPrefix: string,
warnings: SanitizationWarning[],
dropUnknown: boolean,
depth: number,
counter: { nodes: number }
): void {
for (let i = 0; i < content.length; i++) {
const path = `${pathPrefix}[${i}]`
const action = walkNode(content[i], path, warnings, dropUnknown, depth, counter)
if (action === 'remove') {
content.splice(i, 1)
i--
}
}
}
function walkNode(
node: JsonNode | undefined,
path: string,
warnings: SanitizationWarning[],
dropUnknown: boolean,
depth: number,
counter: { nodes: number }
): 'keep' | 'remove' {
if (!node || typeof node !== 'object') {
throw new TiptapValidationError('Node must be an object', path)
}
counter.nodes++
if (counter.nodes > MAX_NODES) {
throw new TiptapValidationError(`Document exceeds ${MAX_NODES}-node cap`, path)
}
if (depth > MAX_DEPTH) {
throw new TiptapValidationError(`Document exceeds ${MAX_DEPTH}-level depth cap`, path)
}
const type = node.type
if (!type || typeof type !== 'string') {
throw new TiptapValidationError('Node missing type', path)
}
if (!NODE_NAMES.has(type)) {
if (dropUnknown) {
warnings.push({ path, reason: `unknown node type "${type}"; dropped` })
return 'remove'
}
return 'keep'
}
// Text nodes carry a non-empty `text` and optional marks; no content.
if (type === 'text') {
if (typeof node.text !== 'string' || node.text.length === 0) {
throw new TiptapValidationError('Text node requires non-empty text', path)
}
if (Array.isArray(node.marks)) {
walkMarksArray(node.marks, `${path}.marks`, warnings)
}
return 'keep'
}
// Validate per-node attributes (mutates attrs)
validateNodeAttrs(type, node.attrs, path, warnings)
// Custom URL validation per node type — bad URLs drop the node.
if (type === 'image') {
const src = node.attrs?.src
if (typeof src !== 'string' || !isSafeUrl(src)) {
warnings.push({ path: `${path}.attrs.src`, reason: 'image.src is not a safe URL; image dropped' })
return 'remove'
}
}
if (type === 'youtube') {
const src = node.attrs?.src
if (typeof src !== 'string' || !isSafeYoutubeUrl(src)) {
warnings.push({ path: `${path}.attrs.src`, reason: 'youtube.src is not a YouTube embed URL; node dropped' })
return 'remove'
}
}
if (type === 'vimeo') {
const src = node.attrs?.src
if (!isSafeVimeoSrc(src)) {
warnings.push({ path: `${path}.attrs.src`, reason: 'vimeo.src is not a valid Vimeo video id; node dropped' })
return 'remove'
}
if (!isSafeVimeoHash(node.attrs?.hash)) {
warnings.push({ path: `${path}.attrs.hash`, reason: 'vimeo.hash is not a valid Vimeo privacy hash; node dropped' })
return 'remove'
}
}
// Recurse into content
if (Array.isArray(node.content)) {
walkContentArray(node.content, `${path}.content`, warnings, dropUnknown, depth + 1, counter)
}
return 'keep'
}
function validateNodeAttrs(
type: string,
attrs: Record<string, unknown> | undefined,
path: string,
warnings: SanitizationWarning[]
): void {
if (!attrs || typeof attrs !== 'object') return
const allowed = NODE_ATTRS[type] ?? []
const allowedSet = new Set(allowed)
for (const [key, value] of Object.entries(attrs)) {
// Tiptap declares optional attributes with `default: null`, so the
// attrs object always contains every declared key — null/undefined
// means "attribute not set," not "attribute present with null value."
// Treat these as absent; the per-key rules below assume a real value.
if (value === null || value === undefined) continue
// Reject any script/srcdoc/on* slip-through (they shouldn't be on
// the allowlist anyway, but defense in depth — these stay throws).
if (key === 'srcdoc' || key.startsWith('on') || key === 'script') {
throw new TiptapValidationError(`forbidden attr ${key}`, `${path}.attrs.${key}`)
}
if (key === 'style') {
if (type !== 'div') {
warnings.push({ path: `${path}.attrs.style`, reason: `style attr not allowed on ${type}; dropped` })
delete attrs[key]
continue
}
const cls = typeof attrs.class === 'string' ? attrs.class : ''
if (!/\bwp-block-spacer\b/.test(cls)) {
warnings.push({ path: `${path}.attrs.style`, reason: 'div.style only allowed on wp-block-spacer; dropped' })
delete attrs[key]
continue
}
if (typeof value !== 'string' || !/^height:\s*\d+(\.\d+)?(px|em|rem|%)?\s*;?\s*$/.test(value)) {
warnings.push({ path: `${path}.attrs.style`, reason: 'div.style on wp-block-spacer must be height:<n><unit>; dropped' })
delete attrs[key]
continue
}
continue
}
if (key === 'class' && type === 'div') {
if (typeof value !== 'string') {
warnings.push({ path: `${path}.attrs.class`, reason: 'div.class must be a string; dropped' })
delete attrs[key]
continue
}
const classes = value.trim().split(/\s+/).filter(Boolean)
const kept: string[] = []
const dropped: string[] = []
for (const cls of classes) {
if (DIV_CLASS_SAFELIST.has(cls)) kept.push(cls)
else dropped.push(cls)
}
if (dropped.length > 0) {
warnings.push({
path: `${path}.attrs.class`,
reason: `div.class: dropped ${dropped.length} non-safelisted class${dropped.length === 1 ? '' : 'es'} (${dropped.slice(0, 3).join(', ')}${dropped.length > 3 ? '…' : ''})`
})
}
if (kept.length === 0) delete attrs[key]
else attrs[key] = kept.join(' ')
continue
}
if (!allowedSet.has(key)) {
warnings.push({ path: `${path}.attrs.${key}`, reason: `unknown attr "${key}" on ${type}; dropped` })
delete attrs[key]
continue
}
}
}
// Walks a text node's marks array, mutating it in place: bad marks are
// removed via splice; bad attr values within a mark drop just the attr
// (or the whole mark, if removing the attr leaves it semantically empty).
function walkMarksArray(
marks: JsonMark[],
pathPrefix: string,
warnings: SanitizationWarning[]
): void {
for (let i = 0; i < marks.length; i++) {
const path = `${pathPrefix}[${i}]`
const action = walkMark(marks[i], path, warnings)
if (action === 'remove') {
marks.splice(i, 1)
i--
}
}
}
function walkMark(
mark: JsonMark | undefined,
path: string,
warnings: SanitizationWarning[]
): 'keep' | 'remove' {
if (!mark || typeof mark !== 'object') {
throw new TiptapValidationError('Mark must be an object', path)
}
const type = mark.type
if (!type || typeof type !== 'string' || !MARK_NAMES.has(type)) {
warnings.push({ path: `${path}.type`, reason: `unknown mark type "${type ?? '<missing>'}"; dropped` })
return 'remove'
}
const allowed = MARK_ATTRS[type] ?? []
const allowedSet = new Set(allowed)
if (mark.attrs && typeof mark.attrs === 'object') {
for (const [key, value] of Object.entries(mark.attrs)) {
// Same null/undefined semantics as node attrs.
if (value === null || value === undefined) continue
// Forbidden attr names stay throws (defense in depth).
if (key === 'srcdoc' || key.startsWith('on') || key === 'script') {
throw new TiptapValidationError(`forbidden attr ${key}`, `${path}.attrs.${key}`)
}
if (!allowedSet.has(key)) {
warnings.push({ path: `${path}.attrs.${key}`, reason: `unknown attr "${key}" on ${type} mark; dropped` })
delete mark.attrs[key]
continue
}
// Per-attr value validation
if (type === 'link') {
if (key === 'href' && !isSafeUrl(value)) {
warnings.push({ path: `${path}.attrs.href`, reason: 'link.href is not a safe URL; link dropped' })
return 'remove'
}
if (key === 'target' && value !== '_blank' && value !== '_self') {
warnings.push({ path: `${path}.attrs.target`, reason: `link.target "${String(value)}" not allowed; dropped` })
delete mark.attrs[key]
continue
}
}
if ((type === 'textStyle' || type === 'highlight') && key === 'color' && !isSafeColor(value)) {
warnings.push({ path: `${path}.attrs.color`, reason: `${type}.color "${String(value)}" not a safe color; dropped` })
delete mark.attrs[key]
continue
}
}
}
// textStyle and highlight are only meaningful via their attrs. If
// we've stripped them all, the mark is a no-op — drop it.
if (type === 'textStyle' || type === 'highlight') {
const remaining = mark.attrs ? Object.entries(mark.attrs).filter(([, v]) => v !== null && v !== undefined) : []
if (remaining.length === 0) return 'remove'
}
return 'keep'
}