From 5beb1c5634aeb253112a59b83cdbe117dd02efab Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 00:01:01 +0200 Subject: [PATCH] fix: sanitize journal HTML instead of blanking it on a regex match The three-regex gate could be bypassed (an attribute without a space, , 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 --- README.md | 8 +- app/assets/css/journal-base.css | 7 - app/components/Journal/CategoryHeader.vue | 11 +- app/components/Journal/PostBody.vue | 15 +- app/journal-pages/CategoryPage.vue | 10 +- app/journal-pages/PostPage.vue | 7 +- app/utils/htmlGate.ts | 262 ++++++++++++++++++++-- app/utils/postView.ts | 21 ++ app/utils/view.ts | 25 +-- i18n/locales/en.json | 3 +- i18n/locales/pl.json | 3 +- testing/expect-acme.txt | 2 +- tests/htmlGate.test.ts | 225 +++++++++++++++---- 13 files changed, 487 insertions(+), 112 deletions(-) create mode 100644 app/utils/postView.ts diff --git a/README.md b/README.md index b684a0f..afd5ddd 100644 --- a/README.md +++ b/README.md @@ -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 `

{{ category.description }}

-
-

{{ $t('journal.post.contentUnavailable') }}

+
diff --git a/app/components/Journal/PostBody.vue b/app/components/Journal/PostBody.vue index 26e30b5..047b37d 100644 --- a/app/components/Journal/PostBody.vue +++ b/app/components/Journal/PostBody.vue @@ -1,13 +1,14 @@ diff --git a/app/journal-pages/CategoryPage.vue b/app/journal-pages/CategoryPage.vue index a853ac1..d23e979 100644 --- a/app/journal-pages/CategoryPage.vue +++ b/app/journal-pages/CategoryPage.vue @@ -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 })