Files
vue-journal-layer/app/journal-pages/CategoryPage.vue
Jakub Zych 5beb1c5634 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
2026-10-10 00:01:01 +02:00

54 lines
2.5 KiB
Vue

<script setup lang="ts">
// journal-category: one category's header and rich content above its posts.
// 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 { sanitizeJournalHtml } from '../utils/htmlGate.ts'
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
import { listTitle } from '../utils/seo.ts'
import { toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
definePageMeta({ key: (route) => route.fullPath })
const route = useRoute()
const api = useJournalApi()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const { t } = useI18n()
const slug = String(route.params.category ?? '')
const page = parsePageParam(route.query.page)
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, async () => {
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 sanitized.
const { children: _children, ...category } = found
return {
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
posts: { ...posts, data: posts.data.map(toListPost) },
}
})
if (error.value) {
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
}
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const title = listTitle(data.value.category.name, page, t('journal.pagination.pageTitle', { page }))
const description = data.value.category.description ?? undefined
useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
</script>
<template>
<section v-if="data" class="journal-category">
<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>
</template>