fix: sanitize journal HTML instead of blanking it on a regex match

The three-regex gate could be bypassed (an attribute without a space,
<svg/onload>, entity-encoded or tab-split javascript: URLs, MathML
xlink:href, base, meta refresh, forms) and it blanked a whole post or
category when ordinary text such as "Data: 12 maja 2024" matched.

- sanitizeJournalHtml runs sanitize-html with the Journal plugin's
  bluemonday policy: UGC elements plus footer, class, schema.org
  microdata, four validated inline styles, footnote roles, dir=auto,
  two chat transcript data attributes; http, https, mailto and
  relative URLs only
- gateJournalHtml and isSafeJournalHtml keep their signatures; html is
  always the sanitized value and is never blanked
- post body, category content and both page payloads are sanitized;
  the blocked notice, its style and string, and the blocked props and
  content_blocked field are removed
- toPostView moves to postView.ts so list pages and cards do not load
  the sanitizer
- tests cover every bypass from the review, the Data:/metadata: text
  and an imported author footer; the SSR smoke expects the unsafe
  sample's safe paragraphs
This commit is contained in:
2026-10-10 00:01:01 +02:00
parent 3275a267f6
commit 5beb1c5634
13 changed files with 487 additions and 112 deletions

View File

@@ -22,7 +22,7 @@ Route paths are configuration, not files. The layer's page components live in `a
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL.
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
- **SEO**: per-page title and description, Open Graph tags (`og:image` from the featured image, article times), canonical URLs that keep `?page=N`, robots and `sitemap.xml` through `@nuxtjs/seo`.
- **HTML gate**: post bodies and category content pass the Journal API's three-pattern gate (raw script, iframe, object or embed tags; event-handler attributes; javascript, vbscript or data URLs) before `v-html`. A value that fails it renders a neutral notice and never reaches the page or the SSR payload.
- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`.
- **i18n**: English and Polish UI strings, registered only for the locales the app enables.
## Usage
@@ -56,7 +56,7 @@ export default defineNuxtConfig({
})
```
The layer declares `@nuxt/fonts`, `@nuxtjs/i18n` and `@nuxtjs/seo` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route.
The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `@nuxtjs/seo` and `sanitize-html` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route.
Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` at it.
@@ -111,7 +111,7 @@ Override tokens in the app's CSS. The app's `css` entries load after the layer's
}
```
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must keep the gate (`gateJournalHtml`) in front of `v-html`.
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `<style>` elements from the content.
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`), which return `null` for a disabled view.
@@ -143,7 +143,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
## Testing
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML gate (the Journal plugin's FormatHTML cases), sitemap entries, SEO values, and pl/en key and placeholder parity. They need no build, network or database.
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases and the bypasses from its code review), the post page view, sitemap entries, SEO values, and pl/en key and placeholder parity. They need no build, network or database.
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.

View File

@@ -170,13 +170,6 @@
text-align: left;
}
.journal-post-body--blocked {
padding: var(--journal-space-3);
border: 1px dashed var(--journal-color-border);
border-radius: var(--journal-radius-md);
color: var(--journal-color-muted);
}
/* Listings */
.journal-post-list {
display: grid;

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
// Heading of a category page: name, description and the category's rich
// content, which passes the Journal HTML gate before v-html.
// content, which goes through the Journal sanitizer before v-html.
import type { JournalCategory } from '../../utils/types.ts'
import { gateJournalHtml } from '../../utils/htmlGate.ts'
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ category: JournalCategory; page?: number; blocked?: boolean }>()
const content = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.category.content)))
const props = defineProps<{ category: JournalCategory; page?: number }>()
const content = computed(() => sanitizeJournalHtml(props.category.content))
</script>
<template>
@@ -14,7 +14,6 @@ const content = computed(() => (props.blocked ? { safe: false, html: '' } : gate
{{ category.name }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
</h1>
<p v-if="category.description" class="journal-category-header__description">{{ category.description }}</p>
<div v-if="content.safe && content.html" class="journal-category-header__content journal-post-body" v-html="content.html" />
<p v-else-if="!content.safe" class="journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
<div v-if="content" class="journal-category-header__content journal-post-body" v-html="content" />
</header>
</template>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
// The post body. v-html only ever receives HTML that passed the Journal gate;
// a blocked or unsafe body renders a neutral notice instead.
import { gateJournalHtml } from '../../utils/htmlGate.ts'
// The post body. v-html only ever receives HTML that went through the Journal
// sanitizer, which removes what the policy does not allow and keeps the rest.
// The page passes an already sanitized body; sanitizing again here guards a
// caller that does not, and gives the same string on server and client.
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ html: string | null | undefined; blocked?: boolean }>()
const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.html)))
const props = defineProps<{ html: string | null | undefined }>()
const html = computed(() => sanitizeJournalHtml(props.html))
</script>
<template>
<div v-if="gated.safe" class="journal-post-body" v-html="gated.html" />
<p v-else class="journal-post-body journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
<div class="journal-post-body" v-html="html" />
</template>

View File

@@ -3,7 +3,7 @@
// The slug resolves against GET /categories?include_empty=1, so an empty
// category answers 200 with its content; an unknown slug is a real 404 (this
// route may be a root-level catch-all in the app).
import { gateJournalHtml } from '../utils/htmlGate.ts'
import { sanitizeJournalHtml } from '../utils/htmlGate.ts'
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
@@ -24,12 +24,10 @@ const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, a
const found = await api.findCategory(slug)
if (!found) return null
const posts = await api.listPosts({ category: found.slug, page, perPage: cfg.perPage.category, sort: cfg.sort })
// Only this category reaches the payload, with its content already gated.
const gated = gateJournalHtml(found.content)
// Only this category reaches the payload, with its content already sanitized.
const { children: _children, ...category } = found
return {
category: { ...category, content: gated.html || null },
contentBlocked: !gated.safe,
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
posts: { ...posts, data: posts.data.map(toListPost) },
}
})
@@ -48,7 +46,7 @@ useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
<template>
<section v-if="data" class="journal-category">
<JournalCategoryHeader :category="data.category" :page="page" :blocked="data.contentBlocked" />
<JournalCategoryHeader :category="data.category" :page="page" />
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
</section>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
// journal-post: one published post by slug. An unknown, draft or future post
// is a real 404. The transform gates the body before it reaches the payload.
// is a real 404. The transform sanitizes the body before it reaches the payload.
// Related posts fall back to the latest posts when the API has none (it
// returns none for a post without a category).
import { postSeoMeta } from '../utils/seo.ts'
import { toListPost, toPostView } from '../utils/view.ts'
import { toPostView } from '../utils/postView.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
const route = useRoute()
@@ -46,7 +47,7 @@ useSeoMeta(postSeoMeta(post.value, useSiteConfig().url || useRequestURL().origin
<JournalPostMeta :post="post" />
</header>
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
<JournalPostBody :html="post.content_html" />
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
<NuxtLink
v-if="post.previous_post && links.postLink(post.previous_post.slug)"

View File

@@ -1,23 +1,257 @@
// HTML gate for every v-html sink (post body, category content, author bio).
// The three patterns are the verbatim regex sources of the Journal plugin's
// FormatHTML / CheckHTML gate (classes/format_html.go), with the i flag for
// Go's (?i). Imported content bypasses the Go gate, so the layer checks again.
// 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 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.
export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
export const EVENT_HANDLER = /\son[a-z]+\s*=/i
export const DANGEROUS_URL = /(?:javascript|vbscript|data):/i
import sanitizeHtml from 'sanitize-html'
/** True when `html` matches none of the three Journal patterns. */
export function isSafeJournalHtml(html: string | null | undefined): boolean {
if (!html) return true
return !(UNSAFE_TAG.test(html) || EVENT_HANDLER.test(html) || DANGEROUS_URL.test(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)$/
// 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,
},
img: { src: null, alt: PARAGRAPH, 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 },
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: [],
}
/**
* Gate result for a v-html sink: `html` is the input when safe and "" when
* not, so the unsafe value never reaches the DOM. Null input is safe and "".
* 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: '' }
return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' }
const clean = sanitizeHtml(html, POLICY)
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
}

21
app/utils/postView.ts Normal file
View File

@@ -0,0 +1,21 @@
// The post page's SSR payload: the post with its body sanitized, without the
// raw `content` source or `translations`, and with related posts as list
// rows. Kept apart from view.ts because it loads the sanitizer, which list
// pages and cards do not need.
import { sanitizeJournalHtml } from './htmlGate.ts'
import type { JournalPostDetail } from './types.ts'
import { toListPost } from './view.ts'
/** A post page's data: the post with its body sanitized and no source or translations. */
export type JournalPostView = JournalPostDetail
export function toPostView(post: JournalPostDetail): JournalPostView {
return {
...post,
content: null,
translations: null,
content_html: sanitizeJournalHtml(post.content_html),
related_posts: (post.related_posts ?? []).map(toListPost),
}
}

View File

@@ -1,21 +1,16 @@
// View shaping for SSR payloads. Pages run API rows through these in
// useAsyncData's transform, so the serialized payload carries only what the
// page renders: no raw `content` source, no `translations`, and never an HTML
// body that failed the Journal gate.
// page renders: no raw `content` source and no `translations`. The post
// page's view, which sanitizes the body, is in postView.ts, so list pages and
// cards that import this file do not load the sanitizer.
import { gateJournalHtml } from './htmlGate.ts'
import type { JournalPost, JournalPostDetail } from './types.ts'
import type { JournalPost } from './types.ts'
/** A post row as a list or card renders it: without body, source or translations. */
export function toListPost(post: JournalPost): JournalPost {
return { ...post, content: null, content_html: null, translations: null }
}
/** A post page's data: the gated body, with `content_blocked` set when the gate refused it. */
export interface JournalPostView extends JournalPostDetail {
content_blocked: boolean
}
/**
* Formats an API timestamp as a long date for `language` (a BCP 47 tag such
* as "pl-PL"). UTC on both server and browser, so hydration matches. Returns
@@ -31,15 +26,3 @@ export function formatJournalDate(iso: string | null | undefined, language: stri
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
}
}
export function toPostView(post: JournalPostDetail): JournalPostView {
const gated = gateJournalHtml(post.content_html)
return {
...post,
content: null,
translations: null,
content_html: gated.html,
content_blocked: !gated.safe,
related_posts: (post.related_posts ?? []).map(toListPost),
}
}

View File

@@ -16,8 +16,7 @@
"by": "By",
"related": "Related posts",
"previous": "Previous post",
"next": "Next post",
"contentUnavailable": "This content cannot be displayed."
"next": "Next post"
},
"category": {
"empty": "There are no posts in this category yet."

View File

@@ -16,8 +16,7 @@
"by": "Autor:",
"related": "Powiązane wpisy",
"previous": "Poprzedni wpis",
"next": "Następny wpis",
"contentUnavailable": "Nie można wyświetlić tej treści."
"next": "Następny wpis"
},
"category": {
"empty": "W tej kategorii nie ma jeszcze wpisów."

View File

@@ -6,7 +6,7 @@
# Single post
200 /blog/hello-acme contains=Hello Acme
404 /blog/missing-post
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
200 /blog/unsafe-sample contains=<p>Before</p><p>After</p> notcontains=alert(
404 /blog/draft-note
# Post index and pagination (9 per page, 13 published posts: 2 pages)

View File

@@ -1,64 +1,211 @@
// The HTML gate mirrors the Journal plugin's FormatHTML / CheckHTML gate.
// The unsafe cases are the six of TestFormatHTMLRejectsUnsafeHTML in
// sm-journal-plugin classes/format_html_test.go, as rendered HTML.
// The Journal HTML sanitizer mirrors the Journal plugin's bluemonday policy
// (classes/format_html.go). The unsafe cases are the six of
// TestFormatHTMLRejectsUnsafeHTML plus every bypass from the Phase 16.1 code
// review (CR-01), as rendered HTML. Each one must come out without the
// dangerous part while the safe text around it stays.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { DANGEROUS_URL, EVENT_HANDLER, UNSAFE_TAG, gateJournalHtml, isSafeJournalHtml } from '../app/utils/htmlGate.ts'
import { gateJournalHtml, isSafeJournalHtml, sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
const GO_CASES: Array<[string, string]> = [
['script', '<script>alert(1)</script>'],
['iframe', '<iframe src="https://evil.test"></iframe>'],
['event', '<img src=x onerror="alert(1)">'],
['javascript', '<a href="javascript:alert(1)">x</a>'],
['vbscript', '<a href="vbscript:msgbox(1)">x</a>'],
['data', '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>'],
// Signs that something executable or navigating survived.
const DANGER = [
/<script/i,
/<svg/i,
/<math/i,
/<iframe/i,
/<object/i,
/<embed/i,
/<base/i,
/<meta/i,
/<form/i,
/<input/i,
/<style/i,
/\son[a-z]+\s*=/i,
/javascript/i,
/vbscript/i,
/href="data:/i,
/src="data:/i,
/xlink:href/i,
/evil\.test/i,
]
test('the patterns are the Go regex sources, case-insensitive', () => {
assert.equal(UNSAFE_TAG.source, '<(?:script|iframe|object|embed)\\b')
assert.equal(EVENT_HANDLER.source, '\\son[a-z]+\\s*=')
assert.equal(DANGEROUS_URL.source, '(?:javascript|vbscript|data):')
for (const re of [UNSAFE_TAG, EVENT_HANDLER, DANGEROUS_URL]) assert.ok(re.flags.includes('i'))
})
function assertNeutralized(html: string, label: string): string {
const out = sanitizeJournalHtml(html)
for (const re of DANGER) assert.doesNotMatch(out, re, `${label}: ${out}`)
assert.equal(isSafeJournalHtml(html), false, `${label} reported safe`)
assert.deepEqual(gateJournalHtml(html), { safe: false, html: out }, label)
return out
}
for (const [name, html] of GO_CASES) {
test(`Go case ${name} is unsafe`, () => {
assert.equal(isSafeJournalHtml(html), false)
assert.deepEqual(gateJournalHtml(html), { safe: false, html: '' })
})
const GO_CASES: Array<[string, string]> = [
['script', '<p>keep</p><script>alert(1)</script>'],
['iframe', '<p>keep</p><iframe src="https://evil.test"></iframe>'],
['event', '<p>keep<img src=x onerror="alert(1)"></p>'],
['javascript', '<a href="javascript:alert(1)">keep</a>'],
['vbscript', '<a href="vbscript:msgbox(1)">keep</a>'],
['data', '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">keep</a>'],
]
test(`Go case ${name} in upper case is unsafe`, () => {
const upper = html.toUpperCase()
assert.equal(isSafeJournalHtml(upper), false)
assert.deepEqual(gateJournalHtml(upper), { safe: false, html: '' })
// CR-01: payloads the three-regex gate let through, and their neighbours.
const REVIEW_BYPASSES: Array<[string, string]> = [
['attribute without a space', '<p>keep<img src="x"onerror="alert(1)"></p>'],
['svg with slash separator', '<div>\n<svg/onload=alert(1)>\n</div><p>keep</p>'],
['numeric entity colon', '<a href="javascript&#58;alert(1)">keep</a>'],
['named entity colon', '<a href="javascript&colon;alert(1)">keep</a>'],
['entity in the scheme', '<a href="&#106;avascript:alert(1)">keep</a>'],
['tab in the scheme', '<a href="java\tscript:alert(1)">keep</a>'],
['newline in the scheme', '<a href="java\nscript:alert(1)">keep</a>'],
['leading control characters', '<a href="\u0001 javascript:alert(1)">keep</a>'],
['upper case scheme', '<a href="JAVASCRIPT:alert(1)">keep</a>'],
['MathML xlink:href', '<div>\n<math><maction actiontype=statusline xlink:href="javascript&#58;alert(1)">keep</maction></math>\n</div>'],
['base', '<base href="https://evil.test/"><p>keep</p>'],
['meta refresh', '<meta http-equiv="refresh" content="0;url=https://evil.test"><p>keep</p>'],
['form phish', '<form action="https://evil.test"><input type=password name=p></form><p>keep</p>'],
['embed', '<embed src="https://evil.test/x.swf"><p>keep</p>'],
['object', '<object data="https://evil.test/x"></object><p>keep</p>'],
['data image src', '<p>keep<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="></p>'],
['style element', '<style>body{background:url(https://evil.test/x)}</style><p>keep</p>'],
['css url in style attribute', '<p style="background:url(https://evil.test/x)">keep</p>'],
['css expression in colour', '<p style="color: expression(alert(1))">keep</p>'],
['noscript title breakout', '<noscript><p title="</noscript><img src=x onerror=alert(1)>"></p></noscript><p>keep</p>'],
['handler in upper case', '<P ONMOUSEOVER="alert(1)">keep</P>'],
]
for (const [name, html] of [...GO_CASES, ...REVIEW_BYPASSES]) {
test(`${name} is neutralized and the rest is kept`, () => {
const out = assertNeutralized(html, name)
assert.match(out, /keep/, `${name} lost the safe text: ${out}`)
})
}
test('object and embed tags and spaced handlers are unsafe', () => {
assert.equal(isSafeJournalHtml('<object data="x"></object>'), false)
assert.equal(isSafeJournalHtml('<embed src="x">'), false)
assert.equal(isSafeJournalHtml('<div\nonclick = "x">'), false)
test('the Go cases in upper case are neutralized', () => {
for (const [name, html] of GO_CASES) assertNeutralized(html.toUpperCase(), `${name} upper`)
})
test('headings, tables, footnotes and links are safe and pass unchanged', () => {
const safe = [
'<h1>Hello</h1>',
'<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>',
'<p>See footnote.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref">1</a></sup></p>',
'<p><a href="https://grzybyfunkcjonalne.pl/blog">Artykuły</a> <img src="/storage/app/media/las.webp" alt="las"></p>',
test('comments are dropped; a comment is not content, so the input still counts as safe', () => {
const html = '<!--<img src=x onerror=alert(1)>--><p>keep</p>'
assert.equal(sanitizeJournalHtml(html), '<p>keep</p>')
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '<p>keep</p>' })
})
test('unsafe parts are removed, not the whole value', () => {
assert.equal(sanitizeJournalHtml('<p>Before</p><script>alert(1)</script><p>After</p>'), '<p>Before</p><p>After</p>')
assert.equal(sanitizeJournalHtml('<p><img src="x"onerror="alert(1)"> tekst</p>'), '<p><img src="x" /> tekst</p>')
assert.equal(sanitizeJournalHtml('<a href="javascript&#58;alert(1)">link</a>'), '<a>link</a>')
})
test('text that names a scheme is ordinary text (WR-01)', () => {
const texts = [
'<p>Data: 12 maja 2024</p>',
'<p>Data publikacji: 3 czerwca 2025</p>',
'<p>The metadata: title, author and date.</p>',
'<p>Big data: what it means. JavaScript: a language. vbscript: legacy.</p>',
]
for (const html of safe) {
for (const html of texts) {
assert.equal(sanitizeJournalHtml(html), html)
assert.equal(isSafeJournalHtml(html), true, html)
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
}
})
test('words starting with "on" are not handlers without an =', () => {
assert.equal(isSafeJournalHtml('<p class="content">one of the onions</p>'), true)
// The author footer of an imported post (post 64), without its <style> block.
const AUTHOR_FOOTER = `<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
<div class="author-info">
<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image">
<div class="author-details">
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jane Doe</div>
<span style="color: #138c74;" itemprop="jobTitle">Ekspert Mykologii</span>
<p itemprop="description">Jestem entuzjastką grzybów, szczególnie tych funkcjonalnych.</p>
</div>
</div>
</footer>`
test('an imported author footer keeps classes, microdata and inline styles', () => {
const out = sanitizeJournalHtml(AUTHOR_FOOTER)
assert.equal(isSafeJournalHtml(AUTHOR_FOOTER), true)
for (const part of [
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">',
'<div class="author-info">',
'<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image" />',
'<div style="font-size:1.5em;font-weight:bold" itemprop="name">Jane Doe</div>',
'<span style="color:#138c74" itemprop="jobTitle">Ekspert Mykologii</span>',
'<p itemprop="description">',
]) {
assert.ok(out.includes(part), `missing ${part} in ${out}`)
}
})
test('the footer with its <style> block loses only the style block', () => {
const html = `${AUTHOR_FOOTER}\n<style>\n .author-footer { padding: 20px; }\n</style>\n<hr>`
const out = sanitizeJournalHtml(html)
assert.equal(isSafeJournalHtml(html), false)
assert.doesNotMatch(out, /<style|padding/)
assert.equal(out, `${sanitizeJournalHtml(AUTHOR_FOOTER)}\n\n<hr />`)
})
test('inline styles keep only the four allowed properties with valid values', () => {
assert.equal(
sanitizeJournalHtml('<p style="color: red; font-size: 12px; font-weight: 700; text-align: center; position: fixed">x</p>'),
'<p style="color:red;font-size:12px;font-weight:700;text-align:center">x</p>',
)
assert.equal(sanitizeJournalHtml('<p style="color: rgb(1, 2, 3)">x</p>'), '<p style="color:rgb(1, 2, 3)">x</p>')
assert.equal(sanitizeJournalHtml('<p style="font-size: calc(1px + 1em)">x</p>'), '<p>x</p>')
assert.equal(sanitizeJournalHtml('<p style="position: fixed">x</p>'), '<p>x</p>')
})
test('goldmark markup passes whole: headings, tables, footnotes, links and images', () => {
const safe = [
'<h1>Hello</h1>',
'<table><thead><tr><th>a</th><th style="text-align:right">b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>',
'<p>See footnote.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>',
'<div class="footnotes" role="doc-endnotes"><hr /><ol><li id="fn:1"><p>Note. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">↩︎</a></p></li></ol></div>',
'<p><a href="https://blog.acme.test/blog">Artykuły</a> <img src="/storage/app/media/las.webp" alt="las" /></p>',
'<p><a href="mailto:hello@acme.test">mail</a> <a href="//cdn.acme.test/x">cdn</a> <a href="?page=2">page</a></p>',
'<blockquote cite="https://acme.test/source"><p>Quote</p></blockquote>',
'<p class="content">one of the onions</p>',
]
for (const html of safe) {
assert.equal(sanitizeJournalHtml(html), html)
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
}
})
test('pasted chat transcripts keep dir="auto" and the two data attributes only', () => {
const html = '<div dir="auto" data-message-author-role="user" data-message-id="abc-123">Hi</div>'
assert.equal(sanitizeJournalHtml(html), html)
assert.equal(
sanitizeJournalHtml('<div data-message-author-role="a b" data-other="x" dir="sideways">Hi</div>'),
'<div>Hi</div>',
)
})
test('attribute values must match their patterns', () => {
assert.equal(sanitizeJournalHtml('<p itemtype="https://evil.test/Person">x</p>'), '<p>x</p>')
assert.equal(sanitizeJournalHtml('<p class="a&quot;b">x</p>'), '<p>x</p>')
assert.equal(sanitizeJournalHtml('<a role="button" href="/x">x</a>'), '<a href="/x">x</a>')
assert.equal(sanitizeJournalHtml('<td colspan="2;">x</td>'), '<td>x</td>')
assert.equal(sanitizeJournalHtml('<a href="http://[bad">x</a>'), '<a>x</a>')
assert.equal(sanitizeJournalHtml('<a target="_blank" rel="opener" href="/x">x</a>'), '<a href="/x">x</a>')
})
test('attribute and tag names from Object.prototype find no rule', () => {
assert.equal(
sanitizeJournalHtml('<a constructor="x" tostring="y" __proto__="z" href="/a">x</a><constructor>t</constructor>'),
'<a href="/a">x</a>t',
)
})
test('sanitizing is idempotent, so server and client agree on a sanitized body', () => {
const inputs = [AUTHOR_FOOTER, ...REVIEW_BYPASSES.map(([, html]) => html), ...GO_CASES.map(([, html]) => html)]
for (const html of inputs) {
const once = sanitizeJournalHtml(html)
assert.equal(sanitizeJournalHtml(once), once, html)
}
})
test('empty input is safe and empty', () => {
for (const html of [null, undefined, '']) {
assert.equal(sanitizeJournalHtml(html), '')
assert.equal(isSafeJournalHtml(html), true)
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '' })
}