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:
@@ -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/`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
21
app/utils/postView.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:alert(1)">keep</a>'],
|
||||
['named entity colon', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['entity in the scheme', '<a href="javascript: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: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: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"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: '' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user