- 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
273 lines
12 KiB
TypeScript
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 }
|
|
}
|