Files
vue-journal-layer/app/utils/htmlGate.ts
Jakub Zych 1010a603f0 feat: body links, outbound rules and image attributes in the post body
- renderJournalBody runs the UI-SPEC L7 order on the sanitized body:
  h1 demotion, the CMS phrase auto-links, the outbound rules and lazy,
  async images, with vue-seo-layer's autolink, applyOutbound and
  imageAttrs; PostBody feeds it the page's resolve answer (autolinks,
  autolink_max, outbound, url) and the seo.links.newTabHint option
- the sanitizer keeps a link rel of only nofollow, sponsored and ugc
  (the Blog Editor's link form, as the Journal plugin's policy does);
  target is never kept, the outbound rules add it
- editor-corpus.json copied again from sm-journal-plugin (link-rel and
  image-decorative entries)
- smoke:ssr script; expectations for auto-links, both new-tab modes and
  the hint switched off
2026-11-04 13:49:36 +01:00

345 lines
16 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 five rules, the same five the Go
// policy has: 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, ""
// for a decorative image); ol start (an integer); and a rel holding only the
// tokens nofollow, sponsored and ugc (the editor's link form, Phase 33).
// tests/fixtures/editor-corpus.json, a byte copy of the editor's corpus,
// pins them. target is never kept: the outbound rules add it after
// sanitizing (renderJournalBody).
//
// 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 editor's link rel (format_html.go): the three tokens, single spaces.
const LINK_REL = /^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$/
// 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, rel: LINK_REL },
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: [],
}
// H1 demotion (the Journal H1 setting, UI-SPEC L7 step 1): every h1 becomes
// an h2 with the same attributes. A tag's own transform replaces the '*'
// one in sanitize-html, so the policy's demotion still filters the values.
const demoted = (filter: (tag: string, attribs: sanitizeHtml.Attributes) => sanitizeHtml.Tag) => (_tag: string, attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag => ({ tagName: 'h2', attribs: filter('h1', attribs).attribs })
const POLICY_DEMOTE_H1: Options = { ...POLICY, transformTags: { ...POLICY.transformTags, h1: demoted(filterAttributeValues) } }
const KEEP_ALL_DEMOTE_H1: Options = { ...KEEP_ALL, transformTags: { h1: demoted((tagName, attribs) => ({ tagName, attribs })) } }
/** Options of the gate. */
export interface JournalHtmlGateOptions {
/**
* Turn every h1 into an h2: true while the Journal API reports its H1
* setting on (content_h1_to_h2), so the page title is the page's only
* H1. Default false, the HTML as written.
*/
demoteH1?: boolean
}
/**
* 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. With demoteH1, h1 headings come out as h2. 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, options: JournalHtmlGateOptions = {}): string {
if (!html) return ''
return sanitizeHtml(html, options.demoteH1 ? POLICY_DEMOTE_H1 : 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; the H1 demotion is not a removal and never makes
* it false). It never blanks: unsafe parts are removed, not the value. Null
* or empty input is safe and "".
*/
export function gateJournalHtml(html: string | null | undefined, options: JournalHtmlGateOptions = {}): { safe: boolean; html: string } {
if (!html) return { safe: true, html: '' }
const demote = options.demoteH1 === true
const clean = sanitizeHtml(html, demote ? POLICY_DEMOTE_H1 : POLICY)
return { safe: clean === sanitizeHtml(html, demote ? KEEP_ALL_DEMOTE_H1 : KEEP_ALL), html: clean }
}
/** The body link steps of vue-seo-layer (app/utils/bodyLinks.ts), passed in by the component. */
export interface JournalBodyLinkSteps {
autolink: (html: string, links: ReadonlyArray<{ phrase: string; url: string }>, max: number, self: string) => string
applyOutbound: (
html: string,
opts: { siteHost: string; newTab: boolean; nofollowDomains?: readonly string[] | null; sponsoredDomains?: readonly string[] | null; hint?: string | null },
) => string
imageAttrs: (html: string) => string
}
/** What the CMS resolve answer and the layer options give the body pipeline. */
export interface JournalBodyOptions extends JournalHtmlGateOptions {
/** The resolve answer's autolinks; none adds no link. */
autolinks?: ReadonlyArray<{ phrase: string; url: string }> | null
/** The resolve answer's autolink_max. */
autolinkMax?: number | null
/** The page's own path (or URL): never auto-linked. */
selfPath?: string
/** The outbound rules (the resolve answer's outbound); null leaves links as they are. */
outbound?: {
siteHost: string
newTab: boolean
nofollowDomains?: readonly string[] | null
sponsoredDomains?: readonly string[] | null
/** The new-tab hint text; '' adds none. */
hint?: string | null
} | null
}
/**
* A post body for its v-html sink, in the UI-SPEC L7 order: sanitize (h1 to
* h2 while demoteH1), phrase auto-links, outbound rules, image attributes.
* The link steps are vue-seo-layer's, passed in by the caller (they are
* auto-imported in components); without them the sanitized HTML is
* returned, and a step without CMS data is skipped. Every step only adds
* escaped markup to sanitized output, never re-reads the raw input.
*/
export function renderJournalBody(html: string | null | undefined, options: JournalBodyOptions = {}, steps?: JournalBodyLinkSteps | null): string {
let out = sanitizeJournalHtml(html, { demoteH1: options.demoteH1 === true })
if (!out || !steps) return out
const links = Array.isArray(options.autolinks) ? options.autolinks : []
const max = Number(options.autolinkMax) || 0
if (links.length > 0 && max > 0) out = steps.autolink(out, links, max, options.selfPath ?? '')
if (options.outbound) out = steps.applyOutbound(out, options.outbound)
return steps.imageAttrs(out)
}