Files
vue-journal-layer/app/utils/htmlGate.ts
Jakub Zych a6ee443885 feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes
- htmlGate.ts mirrors the four editor-output rules of the Go policy:
  figure data-embed (^youtube$) and data-id (^[A-Za-z0-9_-]{11}$),
  img alt with any text, ol start (integer)
- embeds.ts: EMBED_PROVIDERS (youtube on youtube-nocookie.com),
  EMBED_PLACEHOLDER and expandEmbeds
- JournalPostBody and JournalCategoryHeader render
  expandEmbeds(sanitizeJournalHtml(html)); payload transforms only sanitize
- .journal-embed styles (16 / 9 block, borderless iframe)
- tests: byte copy of the editor corpus, mirror and pipeline cases;
  acme hello-acme carries a placeholder and the SSR smoke checks the iframe
- README Embeds section and decision note Policy amendment
2026-10-10 12:19:00 +02:00

273 lines
12 KiB
TypeScript

// HTML sanitizer for every v-html sink (post body, category content, and
// any HTML copy an app renders the same way). It mirrors the Journal
// plugin's bluemonday policy (classes/format_html.go): the UGC element and
// attribute set, plus footer, class, schema.org microdata, a few validated
// inline styles, goldmark's footnote roles, dir="auto" and two chat
// transcript data attributes. URLs must be http, https, mailto or relative.
//
// The Blog Editor's own output adds exactly four rules, the same four the Go
// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id
// (an 11-character video id), which carry the embed placeholder that
// embeds.ts expands after sanitizing; img alt with any text (the editor's
// free-text alt field); and ol start (an integer). tests/fixtures/
// editor-corpus.json, a byte copy of the editor's corpus, pins them.
//
// The Journal API already sanitizes what FormatHTML writes. This is defence
// in depth for rows written another way (direct SQL, legacy imports) and for
// the API itself: anything outside the policy is stripped, never the whole
// value, so legitimate text such as "Data: 12 maja 2024" always renders.
//
// sanitize-html runs the same parser and serializer on the server (SSR) and
// in the browser, so a body sanitizes to the same string on both and
// hydration never sees a difference. The output is always the serializer's,
// never the input bytes: the browser parses exactly what the policy checked.
// See docs/decisions/0001-sanitize-html.md.
import sanitizeHtml from 'sanitize-html'
/** Elements of bluemonday's UGC policy, plus footer. */
export const JOURNAL_ALLOWED_TAGS: readonly string[] = [
// Sectioning, headings, grouping
'article', 'aside', 'details', 'figure', 'section', 'summary', 'footer',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
'blockquote', 'br', 'div', 'hr', 'p', 'span', 'wbr',
// Links and image maps
'a', 'map', 'area',
// Phrasing
'abbr', 'acronym', 'cite', 'code', 'dfn', 'em', 'figcaption', 'mark', 's', 'samp',
'strong', 'sub', 'sup', 'var', 'q', 'time',
'b', 'i', 'pre', 'small', 'strike', 'tt', 'u',
'bdi', 'bdo', 'rp', 'rt', 'ruby', 'del', 'ins',
// Lists and tables
'ol', 'ul', 'li', 'dl', 'dt', 'dd',
'table', 'caption', 'col', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
// Data display and images
'meter', 'progress', 'img',
]
/** URL schemes an href, src or cite may use; relative URLs are allowed too. */
export const JOURNAL_ALLOWED_SCHEMES: readonly string[] = ['http', 'https', 'mailto']
// bluemonday's value patterns (helpers.go), as JavaScript regexes. Several
// are unanchored in bluemonday (lang, id, scope, nowrap) and stay unanchored
// here, so the layer never strips what the API keeps.
const PARAGRAPH = /^[\p{L}\p{N}\s\-_',[\]!./\\()]*$/u
const SPACE_SEPARATED_TOKENS = /^[\s\p{L}\p{N}_-]+$/u
const INTEGER = /^[0-9]+$/
const NUMBER = /^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/
const NUMBER_OR_PERCENT = /^[0-9]+%?$/
const LIST_TYPE = /^(circle|disc|square|a|A|i|I|1)$/i
const CELL_ALIGN = /^(center|justify|left|right|char)$/i
const CELL_VERTICAL_ALIGN = /^(baseline|bottom|middle|top)$/i
const IMAGE_ALIGN = /^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$/i
const ISO8601 = /^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})?Z?([+-][0-9]{2}:[0-9]{2})?)?)?)?$/
const FOOTNOTE_ROLE = /^doc-(noteref|endnotes|backlink)$/
// The Blog Editor's alt field takes free text, which PARAGRAPH cuts at ':',
// '?', '"' and more. Any value is accepted, as bluemonday's p.AllowAttrs("alt")
// does; sanitize-html escapes attribute values on output and alt carries no URL.
const FREE_TEXT = /^[\s\S]*$/
// The embed placeholder written by the Blog Editor (see embeds.ts).
const EMBED_PROVIDER = /^youtube$/
const EMBED_ID = /^[A-Za-z0-9_-]{11}$/
// A value pattern; null marks a URL attribute (checked by allowedUrl) or
// style (filtered per property by allowedStyles).
type ValueRule = RegExp | null
// Attributes allowed on every element.
const GLOBAL_ATTRS: Record<string, ValueRule> = {
dir: /^(ltr|rtl|auto)$/i,
lang: /[a-zA-Z]{2,20}/,
id: /[a-zA-Z0-9:\-_.]+/,
title: PARAGRAPH,
class: SPACE_SEPARATED_TOKENS,
itemscope: /^(|itemscope)$/,
itemtype: /^https?:\/\/schema\.org\/[A-Za-z]+$/,
itemprop: SPACE_SEPARATED_TOKENS,
style: null,
}
const ALIGN_VALIGN: Record<string, ValueRule> = { align: CELL_ALIGN, valign: CELL_VERTICAL_ALIGN }
const SIZE: Record<string, ValueRule> = { height: NUMBER_OR_PERCENT, width: NUMBER_OR_PERCENT }
const CELL: Record<string, ValueRule> = {
...ALIGN_VALIGN,
...SIZE,
abbr: PARAGRAPH,
colspan: INTEGER,
rowspan: INTEGER,
headers: SPACE_SEPARATED_TOKENS,
scope: /(?:row|col)(?:group)?/i,
nowrap: /|nowrap/i,
}
// Attributes allowed on one element, on top of GLOBAL_ATTRS.
const ELEMENT_ATTRS: Record<string, Record<string, ValueRule>> = {
details: { open: /^(|open)$/i },
blockquote: { cite: null },
q: { cite: null },
a: { href: null, role: FOOTNOTE_ROLE },
div: {
role: FOOTNOTE_ROLE,
'data-message-author-role': /^[A-Za-z0-9_-]+$/,
'data-message-id': /^[A-Za-z0-9_-]+$/,
},
map: { name: /^[\p{L}\p{N}_-]+$/u },
area: {
alt: PARAGRAPH,
coords: /^([0-9]+,)+[0-9]+$/,
href: null,
rel: SPACE_SEPARATED_TOKENS,
shape: /^(default|circle|rect|poly)$/i,
},
figure: { 'data-embed': EMBED_PROVIDER, 'data-id': EMBED_ID },
img: { src: null, alt: FREE_TEXT, align: IMAGE_ALIGN, ...SIZE, usemap: /^#[\p{L}\p{N}_-]+$/iu },
time: { datetime: ISO8601 },
del: { cite: PARAGRAPH, datetime: ISO8601 },
ins: { cite: PARAGRAPH, datetime: ISO8601 },
ol: { type: LIST_TYPE, start: INTEGER },
ul: { type: LIST_TYPE },
li: { type: LIST_TYPE, value: INTEGER },
table: { ...SIZE, summary: PARAGRAPH },
col: { ...ALIGN_VALIGN, ...SIZE, span: INTEGER },
colgroup: { ...ALIGN_VALIGN, ...SIZE, span: INTEGER },
thead: ALIGN_VALIGN,
tbody: ALIGN_VALIGN,
tfoot: ALIGN_VALIGN,
tr: ALIGN_VALIGN,
td: CELL,
th: CELL,
meter: { value: NUMBER, min: NUMBER, max: NUMBER, low: NUMBER, high: NUMBER, optimum: NUMBER },
progress: { value: NUMBER, max: NUMBER },
}
// Inline styles: bluemonday's css handlers for the four allowed properties.
// A named colour is letters only, so it cannot carry url() or expression().
const CSS_LENGTH = /^-?([0-9]+|[0-9]*\.[0-9]+)(%|cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|deg|rad|turn)?$/
const RGB_PART = '(([0-9]{1,2}|100)%|([01]?[0-9]{1,2}|2[0-4][0-9]|25[0-5]))'
const ALLOWED_STYLES: Record<string, RegExp[]> = {
color: [
/^[a-z]+$/i,
/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i,
new RegExp(`^rgb\\(( *${RGB_PART},){2} *${RGB_PART}\\)$`),
new RegExp(`^rgba\\(( *${RGB_PART},){3} *(1(\\.0)?|0|0\\.[0-9]+)\\)$`),
/^hsl\( *([012]?[0-9]{1,2}|3[0-5][0-9]|360), *([0-9]{0,2}|100)%, *([0-9]{0,2}|100)%\)$/,
/^hsla\( *([012]?[0-9]{1,2}|3[0-5][0-9]|360), *([0-9]{0,2}|100)%, *([0-9]{0,2}|100)%, *(1|1\.0|0|0\.[0-9]+)\)$/,
],
'font-size': [CSS_LENGTH, /^(medium|xx-small|x-small|small|large|x-large|xx-large|smaller|larger|initial|inherit)$/i],
'font-weight': [/^(normal|bold|bolder|lighter|[1-9]00|initial|inherit)$/i],
'text-align': [/^(left|right|center|justify|initial|inherit)$/i],
}
// Characters a browser drops from a URL before it reads the scheme.
const URL_IGNORED = /[\x00-\x20\x7f]+/g
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
/**
* True when `url` (entity-decoded by the parser) is relative or uses an
* allowed scheme once the characters a browser ignores are removed, and
* parses as a URL (bluemonday requires a parseable URL too). sanitize-html
* checks the scheme as well; checking here keeps the layer's rule from
* depending on sanitize-html's defaults.
*/
function allowedUrl(url: string): boolean {
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
if (scheme && !JOURNAL_ALLOWED_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
// new URL, not URL.canParse, which Safari before 17 lacks.
try {
new URL(url, 'https://journal.invalid/')
return true
} catch {
return false
}
}
// Runs before sanitize-html's own attribute filter: drops every attribute
// whose value fails its pattern. sanitize-html then drops attributes that
// are not allowed on the element at all. Lookups are own-property only, so
// a tag or attribute named "constructor" or "toString" finds no rule.
function filterAttributeValues(tagName: string, attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
const own = Object.hasOwn(ELEMENT_ATTRS, tagName) ? ELEMENT_ATTRS[tagName] : undefined
const kept: sanitizeHtml.Attributes = {}
for (const [name, value] of Object.entries(attribs)) {
let rule: ValueRule | undefined
if (own && Object.hasOwn(own, name)) rule = own[name]
else if (Object.hasOwn(GLOBAL_ATTRS, name)) rule = GLOBAL_ATTRS[name]
if (rule === undefined) continue
if (rule === null ? name === 'style' || allowedUrl(value) : rule.test(value)) kept[name] = value
}
return { tagName, attribs: kept }
}
const allowedAttributes: Record<string, string[]> = { '*': Object.keys(GLOBAL_ATTRS) }
for (const [tag, attrs] of Object.entries(ELEMENT_ATTRS)) allowedAttributes[tag] = Object.keys(attrs)
// allowedEmptyAttributes is a sanitize-html option that @types/sanitize-html
// does not declare yet.
type Options = sanitizeHtml.IOptions & { allowedEmptyAttributes: string[] }
// Attributes whose pattern accepts an empty value. Both configs below write
// them as name="", so an empty value compares equal.
const EMPTY_ATTRS = ['alt', 'title', 'summary', 'abbr', 'itemscope', 'open', 'nowrap']
const POLICY: Options = {
allowedTags: [...JOURNAL_ALLOWED_TAGS],
allowedAttributes,
allowedStyles: { '*': ALLOWED_STYLES },
allowedSchemes: [...JOURNAL_ALLOWED_SCHEMES],
allowedSchemesByTag: {},
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
allowProtocolRelative: true,
allowedEmptyAttributes: EMPTY_ATTRS,
// A refused element is dropped and its text kept, except in these, whose
// content is never shown as text (bluemonday skips the same content).
disallowedTagsMode: 'discard',
nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'title', 'iframe', 'noembed', 'noframes', 'object', 'xmp', 'template'],
transformTags: { '*': filterAttributeValues },
}
// The same parser and serializer with nothing refused: the reference that
// isSafeJournalHtml compares the policy's output with.
const KEEP_ALL: Options = {
allowedTags: false,
allowedAttributes: false,
allowVulnerableTags: true,
allowedSchemes: [],
allowedSchemesAppliedToAttributes: [],
nonTextTags: [],
allowedEmptyAttributes: EMPTY_ATTRS,
nonBooleanAttributes: [],
}
/**
* Sanitizes journal HTML with the Journal policy. Whatever the policy does
* not allow is removed (the text of a removed element is kept, except for
* script, style and similar), so the result is never blank because of one
* stray attribute. Null or empty input gives "". Pure and deterministic: the
* same input gives the same string on the server and in the browser.
*/
export function sanitizeJournalHtml(html: string | null | undefined): string {
if (!html) return ''
return sanitizeHtml(html, POLICY)
}
/**
* True when the policy keeps `html` whole: sanitizing it removes no element,
* attribute or text and only normalizes how the markup is written. The
* counterpart of the plugin's CheckHTML. Null or empty input is safe.
*/
export function isSafeJournalHtml(html: string | null | undefined): boolean {
return gateJournalHtml(html).safe
}
/**
* Gate result for a v-html sink. `html` is always the sanitized value and is
* what the sink renders; `safe` reports whether the policy kept the input
* whole (isSafeJournalHtml). It never blanks: unsafe parts are removed, not
* the value. Null or empty input is safe and "".
*/
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
if (!html) return { safe: true, html: '' }
const clean = sanitizeHtml(html, POLICY)
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
}