From 1c2b72abafab5cbfdd4c123f7da1d1b64f6ac4ef Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Mon, 12 Oct 2026 23:27:05 +0200 Subject: [PATCH 1/6] fix(content): strip unsafe keys from content data --- server/utils/content/cache.ts | 2 +- server/utils/content/plugins/index.ts | 4 +- server/utils/content/plugins/sanitize-data.ts | 89 ++++++++++ test/unit/content-sanitize-data.spec.ts | 154 ++++++++++++++++++ utils/factory.ts | 4 +- 5 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 server/utils/content/plugins/sanitize-data.ts create mode 100644 test/unit/content-sanitize-data.spec.ts diff --git a/server/utils/content/cache.ts b/server/utils/content/cache.ts index 871f35618..3f74c8bca 100644 --- a/server/utils/content/cache.ts +++ b/server/utils/content/cache.ts @@ -17,7 +17,7 @@ function cacheAvailable(): boolean { /** * Bump when content parser/plugin changes (TODO: automate this) */ -export const CONTENT_PARSER_VERSION = 'v7' +export const CONTENT_PARSER_VERSION = 'v8' /** * Driver backing one instance's manifest, parsed bodies and snapshot artifacts. diff --git a/server/utils/content/plugins/index.ts b/server/utils/content/plugins/index.ts index de5c8c928..f79fff581 100644 --- a/server/utils/content/plugins/index.ts +++ b/server/utils/content/plugins/index.ts @@ -12,6 +12,8 @@ import { configDocs } from './config-docs' import type { DocVersion } from '../../../../shared/utils/docs' import type { ContentInstanceKey } from '../../../../shared/utils/content' +export const BLOCKED_TAGS = ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style'] + /** * The comark plugin chain every instance parses markdown with. * Bump `CONTENT_PARSER_VERSION` when it changes: cached bodies keep the old output. @@ -21,7 +23,7 @@ export const comarkPlugins = [ toc({ depth: 3 }), emoji(), security({ - blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style'], + blockedTags: BLOCKED_TAGS, allowDataImages: false }) ] diff --git a/server/utils/content/plugins/sanitize-data.ts b/server/utils/content/plugins/sanitize-data.ts new file mode 100644 index 000000000..3a9409e94 --- /dev/null +++ b/server/utils/content/plugins/sanitize-data.ts @@ -0,0 +1,89 @@ +import type { ContentPlugin } from 'comark-content' + +/** + * Pages spread content data into components (`v-bind="fm"`): Vue binds unknown keys to the DOM. + * Apply comark's `security()` rules on Markdown bodies to every file's data. + * Mirrors `comark/dist/internal/props-validation.js` + */ + +/** Keys that write markup or redirect a form whatever their value: comark's list, plus `outerHTML` and `innerText`. */ +const UNSAFE_KEYS = new Set(['innerhtml', 'outerhtml', 'innertext', 'textcontent', 'dangerouslysetinnerhtml', 'srcdoc', 'formaction']) + +/** Keys a URL is bound through. */ +const URL_KEYS = new Set(['to', 'href', 'src', 'action', 'xlinkhref']) + +/** comark's protocol floor. */ +const UNSAFE_URL_PREFIXES = ['javascript:', 'vbscript:', 'data:text/html', 'data:text/javascript', 'data:text/vbscript', 'data:text/css', 'data:text/plain', 'data:text/xml'] + +/** Tags comark strips the props of, blocked or not. */ +const UNSAFE_TAGS = ['object'] + +function isUnsafeUrl(value: string): boolean { + // Vue binds values verbatim, so no entity decoding; `new URL()` drops the tabs and spaces browsers ignore. + try { + const href = new URL(value).href.toLowerCase() + return UNSAFE_URL_PREFIXES.some(prefix => href.startsWith(prefix)) + } catch { + // Relative: no protocol to abuse. + return false + } +} + +function isUnsafeEntry(key: string, value: unknown, blockedTags: Set): boolean { + const name = key.toLowerCase().replace(/:/g, '') + if (name.startsWith('on') || UNSAFE_KEYS.has(name)) return true + if (typeof value !== 'string') return false + if (URL_KEYS.has(name)) return isUnsafeUrl(value) + + // `as` picks the element a component renders. + return name === 'as' && blockedTags.has(value.toLowerCase()) +} + +/** + * Delete unsafe keys from `data` in place, at any depth. + * Returns the dotted paths it removed. + */ +export function stripUnsafeData(data: unknown, blockedTags: string[]): string[] { + const blocked = new Set([...blockedTags, ...UNSAFE_TAGS].map(tag => tag.toLowerCase())) + const removed: string[] = [] + // YAML aliases can share or nest an object. + const seen = new WeakSet() + + const walk = (value: unknown, path: string) => { + if (value === null || typeof value !== 'object' || seen.has(value)) return + seen.add(value) + + for (const [key, child] of Object.entries(value)) { + const at = path ? `${path}.${key}` : key + if (isUnsafeEntry(key, child, blocked)) { + Reflect.deleteProperty(value, key) + removed.push(at) + } else { + walk(child, at) + } + } + } + walk(data, '') + + return removed +} + +/** + * Strip markup, event handler and script URL keys from every file's data. + * Register it last: `file:parsed` hooks run in order, so it sees what the other plugins produced. + */ +export function sanitizeData(options: { blockedTags: string[] }): ContentPlugin { + return { + name: 'nuxt-sanitize-data', + setup(content) { + content.hooks.hook('file:parsed', ({ file }) => { + if (!file) return + + const removed = stripUnsafeData(file.data, options.blockedTags) + if (removed.length) { + content.logger.warn('sanitize-data', `${file.meta.key}: removed unsafe ${removed.map(path => `\`${path}\``).join(', ')}`) + } + }) + } + } +} diff --git a/test/unit/content-sanitize-data.spec.ts b/test/unit/content-sanitize-data.spec.ts new file mode 100644 index 000000000..1375a04b3 --- /dev/null +++ b/test/unit/content-sanitize-data.spec.ts @@ -0,0 +1,154 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import fs from 'comark-content/sources/fs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBuildInstance } from '../../utils/factory' +import { BLOCKED_TAGS } from '../../server/utils/content/plugins' +import { sanitizeData, stripUnsafeData } from '../../server/utils/content/plugins/sanitize-data' + +/** Strip `data` with the chain's tags, returning what's left and what went. */ +function strip(data: T) { + const removed = stripUnsafeData(data, BLOCKED_TAGS) + return { data, removed } +} + +describe('stripUnsafeData', () => { + it('drops keys that write markup, whatever their case or depth', () => { + expect(strip({ + title: 'Page', + innerHTML: 'x', + links: [{ label: 'Docs', TextContent: 'x' }], + hero: { card: { outerHTML: 'x', srcdoc: 'x' } } + })).toEqual({ + data: { title: 'Page', links: [{ label: 'Docs' }], hero: { card: {} } }, + removed: ['innerHTML', 'links.0.TextContent', 'hero.card.outerHTML', 'hero.card.srcdoc'] + }) + }) + + it('drops event handler keys, lowercase or not', () => { + expect(strip({ onclick: 'x()', onMouseOver: 'x()', links: [{ label: 'Docs', onfocus: 'x()' }] })) + .toEqual({ data: { links: [{ label: 'Docs' }] }, removed: ['onclick', 'onMouseOver', 'links.0.onfocus'] }) + }) + + it('drops script URLs in link and resource keys, however they are spelled', () => { + const { data, removed } = strip({ + links: [ + { label: 'a', to: 'javascript:void(0)' }, + { label: 'b', href: ' JavaScript:void(0)' }, + { label: 'c', to: 'java\tscript:void(0)' }, + { 'label': 'd', 'xlink:href': 'vbscript:x' }, + { label: 'e', src: 'data:text/html,x' } + ] + }) + + expect(data.links).toEqual([{ label: 'a' }, { label: 'b' }, { label: 'c' }, { label: 'd' }, { label: 'e' }]) + expect(removed).toEqual(['links.0.to', 'links.1.href', 'links.2.to', 'links.3.xlink:href', 'links.4.src']) + }) + + it('keeps every URL a page links to', () => { + const links = [ + { to: '/docs/4.x/getting-started' }, + { to: '#usage' }, + { to: 'https://github.com/nuxt/nuxt' }, + { href: 'mailto:hello@nuxt.com' }, + { src: '/assets/blog/cover.png' }, + { src: 'https://avatars.githubusercontent.com/u/1?v=4' } + ] + + expect(strip({ links: structuredClone(links) })).toEqual({ data: { links }, removed: [] }) + }) + + it('only checks URLs under URL keys', () => { + const data = { title: 'JavaScript: the good parts', description: 'javascript:void(0) is a no-op' } + + expect(strip(structuredClone(data))).toEqual({ data, removed: [] }) + }) + + it('drops an `as` naming a blocked tag, keeping any other', () => { + expect(strip({ hero: { as: 'SCRIPT' }, card: { as: 'object' }, section: { as: 'section' } })) + .toEqual({ data: { hero: {}, card: {}, section: { as: 'section' } }, removed: ['hero.as', 'card.as'] }) + }) + + it('walks a shared or nested object once', () => { + const link: Record = { label: 'Docs', onclick: 'x()' } + link.self = link + + expect(strip({ links: [link, link] }).removed).toEqual(['links.0.onclick']) + }) + + it('leaves scalars and safe data untouched', () => { + const page = { title: 'Installation', navigation: { icon: 'i-lucide-play' }, links: [{ label: 'Source', to: 'https://github.com/nuxt/nuxt', icon: 'i-simple-icons-github' }], date: new Date(0) } + + expect(strip(structuredClone(page))).toEqual({ data: page, removed: [] }) + expect(strip('hello')).toEqual({ data: 'hello', removed: [] }) + expect(strip(null)).toEqual({ data: null, removed: [] }) + }) +}) + +describe('sanitizeData() plugin', () => { + function setupPlugin() { + let onParsed: ((ctx: any) => void) | undefined + const warn = vi.fn() + sanitizeData({ blockedTags: BLOCKED_TAGS }).setup!({ + logger: { warn }, + hooks: { hook: (name: string, fn: (ctx: any) => void) => void (name === 'file:parsed' && (onParsed = fn)) } + } as never) + + return { warn, fireFileParsed: (ctx: any) => onParsed!(ctx) } + } + + it('strips a parsed file\'s data and names what it removed', () => { + const { warn, fireFileParsed } = setupPlugin() + const file = { data: { title: 'Page', innerHTML: 'x' }, meta: { key: 'docs/1.page.md' } } + + fireFileParsed({ file }) + + expect(file.data).toEqual({ title: 'Page' }) + expect(warn).toHaveBeenCalledWith('sanitize-data', 'docs/1.page.md: removed unsafe `innerHTML`') + }) + + it('stays quiet on safe data, and ignores a rejected parse', () => { + const { warn, fireFileParsed } = setupPlugin() + + fireFileParsed({ file: { data: { title: 'Page' }, meta: { key: 'docs/1.page.md' } } }) + expect(() => fireFileParsed({ file: null })).not.toThrow() + expect(warn).not.toHaveBeenCalled() + }) +}) + +describe('sanitizeData() in the instance chain', () => { + let dir: string | undefined + + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }) + dir = undefined + }) + + it('runs after the other plugins, on the data a page reads', async () => { + dir = await mkdtemp(join(tmpdir(), 'nuxt-sanitize-')) + // A dotted key only becomes `navigation.onclick` once `dottedFrontmatter()` has run. + await writeFile(join(dir, '1.page.md'), [ + '---', + 'title: Page', + 'innerHTML: "x"', + 'navigation.onclick: "x()"', + 'links:', + ' - label: Docs', + ' to: "javascript:void(0)"', + '---', + '', + '# Page', + '' + ].join('\n')) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const content = createBuildInstance('docs:5.x', { source: fs(dir, { prefix: '/docs/5.x' }) }) + const page = await content.get('/docs/5.x/page') + + expect(page?.data).toEqual({ title: 'Page', navigation: {}, links: [{ label: 'Docs' }] }) + expect(warn).toHaveBeenCalledWith('[comark-content@sanitize-data]', expect.stringContaining('`navigation.onclick`')) + warn.mockRestore() + await content.dispose() + }) +}) diff --git a/utils/factory.ts b/utils/factory.ts index a14a749fa..79e49bacb 100644 --- a/utils/factory.ts +++ b/utils/factory.ts @@ -7,7 +7,8 @@ import yaml from 'comark-content/plugins/yaml' import json from 'comark-content/plugins/json' import tracingOtel from 'comark-content/plugins/tracing/otel' import type { Tracer } from '@opentelemetry/api' -import { comarkPlugins, instancePlugins } from '../server/utils/content/plugins' +import { BLOCKED_TAGS, comarkPlugins, instancePlugins } from '../server/utils/content/plugins' +import { sanitizeData } from '../server/utils/content/plugins/sanitize-data' import { instanceSource, type InstanceSource } from '../server/utils/content/instances' import { instanceBasePath, type ContentInstanceKey } from '../shared/utils/content' import { contentTracer } from '../server/utils/tracer' @@ -73,6 +74,7 @@ function create(key: ContentInstanceKey, options: InstanceOptions, tracer?: Trac yaml({ listingFields: definition.listingFields }), json({ listingFields: definition.listingFields }), ...instancePlugins(key), + sanitizeData({ blockedTags: BLOCKED_TAGS }), tracer && tracingOtel({ tracer }) ], cache: options.cache From 94b1ad6dd437afea4894c68d743d28644846da95 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Mon, 12 Oct 2026 23:53:50 +0200 Subject: [PATCH 2/6] handle more cases --- server/utils/content/plugins/sanitize-data.ts | 24 ++++++++----- test/unit/content-sanitize-data.spec.ts | 36 ++++++++++++++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/server/utils/content/plugins/sanitize-data.ts b/server/utils/content/plugins/sanitize-data.ts index 3a9409e94..f245fb988 100644 --- a/server/utils/content/plugins/sanitize-data.ts +++ b/server/utils/content/plugins/sanitize-data.ts @@ -6,11 +6,8 @@ import type { ContentPlugin } from 'comark-content' * Mirrors `comark/dist/internal/props-validation.js` */ -/** Keys that write markup or redirect a form whatever their value: comark's list, plus `outerHTML` and `innerText`. */ -const UNSAFE_KEYS = new Set(['innerhtml', 'outerhtml', 'innertext', 'textcontent', 'dangerouslysetinnerhtml', 'srcdoc', 'formaction']) - -/** Keys a URL is bound through. */ -const URL_KEYS = new Set(['to', 'href', 'src', 'action', 'xlinkhref']) +/** Keys that write text or a document, or redirect a form, whatever their value: comark's list, plus `innerText`. */ +const UNSAFE_KEYS = new Set(['innertext', 'textcontent', 'srcdoc', 'formaction']) /** comark's protocol floor. */ const UNSAFE_URL_PREFIXES = ['javascript:', 'vbscript:', 'data:text/html', 'data:text/javascript', 'data:text/vbscript', 'data:text/css', 'data:text/plain', 'data:text/xml'] @@ -19,7 +16,8 @@ const UNSAFE_URL_PREFIXES = ['javascript:', 'vbscript:', 'data:text/html', 'data const UNSAFE_TAGS = ['object'] function isUnsafeUrl(value: string): boolean { - // Vue binds values verbatim, so no entity decoding; `new URL()` drops the tabs and spaces browsers ignore. + if (!value.includes(':')) return false + try { const href = new URL(value).href.toLowerCase() return UNSAFE_URL_PREFIXES.some(prefix => href.startsWith(prefix)) @@ -30,10 +28,11 @@ function isUnsafeUrl(value: string): boolean { } function isUnsafeEntry(key: string, value: unknown, blockedTags: Set): boolean { - const name = key.toLowerCase().replace(/:/g, '') - if (name.startsWith('on') || UNSAFE_KEYS.has(name)) return true + // Vue binds a leading `.` as a DOM prop and `^` as an attribute: `^onclick` is `onclick`. + const name = key.toLowerCase().replace(/^[.^]+/, '').replace(/:/g, '') + if (name.startsWith('on') || name.endsWith('html') || UNSAFE_KEYS.has(name)) return true if (typeof value !== 'string') return false - if (URL_KEYS.has(name)) return isUnsafeUrl(value) + if (isUnsafeUrl(value)) return true // `as` picks the element a component renders. return name === 'as' && blockedTags.has(value.toLowerCase()) @@ -53,15 +52,22 @@ export function stripUnsafeData(data: unknown, blockedTags: string[]): string[] if (value === null || typeof value !== 'object' || seen.has(value)) return seen.add(value) + let deleted = false for (const [key, child] of Object.entries(value)) { const at = path ? `${path}.${key}` : key if (isUnsafeEntry(key, child, blocked)) { Reflect.deleteProperty(value, key) removed.push(at) + deleted = true } else { walk(child, at) } } + + // A deleted item leaves a hole, which serialises as `null` and breaks `v-for` templates. + if (deleted && Array.isArray(value)) { + value.splice(0, value.length, ...value.filter(() => true)) + } } walk(data, '') diff --git a/test/unit/content-sanitize-data.spec.ts b/test/unit/content-sanitize-data.spec.ts index 1375a04b3..67bea1d38 100644 --- a/test/unit/content-sanitize-data.spec.ts +++ b/test/unit/content-sanitize-data.spec.ts @@ -18,11 +18,11 @@ describe('stripUnsafeData', () => { expect(strip({ title: 'Page', innerHTML: 'x', - links: [{ label: 'Docs', TextContent: 'x' }], + links: [{ label: 'Docs', TextContent: 'x', labelHtml: 'x' }], hero: { card: { outerHTML: 'x', srcdoc: 'x' } } })).toEqual({ data: { title: 'Page', links: [{ label: 'Docs' }], hero: { card: {} } }, - removed: ['innerHTML', 'links.0.TextContent', 'hero.card.outerHTML', 'hero.card.srcdoc'] + removed: ['innerHTML', 'links.0.TextContent', 'links.0.labelHtml', 'hero.card.outerHTML', 'hero.card.srcdoc'] }) }) @@ -31,6 +31,18 @@ describe('stripUnsafeData', () => { .toEqual({ data: { links: [{ label: 'Docs' }] }, removed: ['onclick', 'onMouseOver', 'links.0.onfocus'] }) }) + it('sees through Vue\'s `.prop` and `^attr` key prefixes', () => { + expect(strip({ + '^onclick': 'x()', + '^title': 'Page', + 'links': [{ 'label': 'Docs', '.innerHTML': 'x', '^href': 'javascript:void(0)', '.^srcdoc': 'x' }], + 'hero': { '^as': 'script' } + })).toEqual({ + data: { '^title': 'Page', 'links': [{ label: 'Docs' }], 'hero': {} }, + removed: ['^onclick', 'links.0..innerHTML', 'links.0.^href', 'links.0..^srcdoc', 'hero.^as'] + }) + }) + it('drops script URLs in link and resource keys, however they are spelled', () => { const { data, removed } = strip({ links: [ @@ -59,10 +71,24 @@ describe('stripUnsafeData', () => { expect(strip({ links: structuredClone(links) })).toEqual({ data: { links }, removed: [] }) }) - it('only checks URLs under URL keys', () => { - const data = { title: 'JavaScript: the good parts', description: 'javascript:void(0) is a no-op' } + it('drops script URLs under any key, since pages bind them as links', () => { + expect(strip({ courses: [{ title: 'Course', url: 'javascript:void(0)' }], agency: { link: 'vbscript:x', website: 'https://nuxt.com' } })) + .toEqual({ + data: { courses: [{ title: 'Course' }], agency: { website: 'https://nuxt.com' } }, + removed: ['courses.0.url', 'agency.link'] + }) + }) + + it('keeps prose, unless it opens with a script scheme', () => { + expect(strip({ title: 'JavaScript: the good parts', description: 'Use javascript:void(0) sparingly', note: 'Note: none' })) + .toEqual({ data: { description: 'Use javascript:void(0) sparingly', note: 'Note: none' }, removed: ['title'] }) + }) + + it('closes the hole a dropped array item leaves', () => { + const { data, removed } = strip({ links: ['/docs', 'javascript:void(0)', '/blog'] }) - expect(strip(structuredClone(data))).toEqual({ data, removed: [] }) + expect(data.links).toEqual(['/docs', '/blog']) + expect(removed).toEqual(['links.1']) }) it('drops an `as` naming a blocked tag, keeping any other', () => { From 2049fba36213ba59d10ff3b1e01aa1edd7697f70 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 13 Oct 2026 10:32:33 +0200 Subject: [PATCH 3/6] reject every data URL --- server/utils/content/plugins/sanitize-data.ts | 6 ++---- test/unit/content-sanitize-data.spec.ts | 8 +++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/utils/content/plugins/sanitize-data.ts b/server/utils/content/plugins/sanitize-data.ts index f245fb988..3909e6339 100644 --- a/server/utils/content/plugins/sanitize-data.ts +++ b/server/utils/content/plugins/sanitize-data.ts @@ -9,8 +9,7 @@ import type { ContentPlugin } from 'comark-content' /** Keys that write text or a document, or redirect a form, whatever their value: comark's list, plus `innerText`. */ const UNSAFE_KEYS = new Set(['innertext', 'textcontent', 'srcdoc', 'formaction']) -/** comark's protocol floor. */ -const UNSAFE_URL_PREFIXES = ['javascript:', 'vbscript:', 'data:text/html', 'data:text/javascript', 'data:text/vbscript', 'data:text/css', 'data:text/plain', 'data:text/xml'] +const UNSAFE_PROTOCOLS = new Set(['javascript:', 'vbscript:', 'data:']) /** Tags comark strips the props of, blocked or not. */ const UNSAFE_TAGS = ['object'] @@ -19,8 +18,7 @@ function isUnsafeUrl(value: string): boolean { if (!value.includes(':')) return false try { - const href = new URL(value).href.toLowerCase() - return UNSAFE_URL_PREFIXES.some(prefix => href.startsWith(prefix)) + return UNSAFE_PROTOCOLS.has(new URL(value).protocol) } catch { // Relative: no protocol to abuse. return false diff --git a/test/unit/content-sanitize-data.spec.ts b/test/unit/content-sanitize-data.spec.ts index 67bea1d38..6aac1ff04 100644 --- a/test/unit/content-sanitize-data.spec.ts +++ b/test/unit/content-sanitize-data.spec.ts @@ -50,12 +50,14 @@ describe('stripUnsafeData', () => { { label: 'b', href: ' JavaScript:void(0)' }, { label: 'c', to: 'java\tscript:void(0)' }, { 'label': 'd', 'xlink:href': 'vbscript:x' }, - { label: 'e', src: 'data:text/html,x' } + { label: 'e', src: 'data:text/html,x' }, + { label: 'f', src: 'data: text/html,x' }, + { label: 'g', src: 'data:image/svg+xml,x' } ] }) - expect(data.links).toEqual([{ label: 'a' }, { label: 'b' }, { label: 'c' }, { label: 'd' }, { label: 'e' }]) - expect(removed).toEqual(['links.0.to', 'links.1.href', 'links.2.to', 'links.3.xlink:href', 'links.4.src']) + expect(data.links).toEqual([{ label: 'a' }, { label: 'b' }, { label: 'c' }, { label: 'd' }, { label: 'e' }, { label: 'f' }, { label: 'g' }]) + expect(removed).toEqual(['links.0.to', 'links.1.href', 'links.2.to', 'links.3.xlink:href', 'links.4.src', 'links.5.src', 'links.6.src']) }) it('keeps every URL a page links to', () => { From 2990d8a8691eeb64442fb74310c110c0b6118aba Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 13 Oct 2026 10:48:40 +0200 Subject: [PATCH 4/6] review: only log in dev --- server/utils/content/plugins/sanitize-data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/content/plugins/sanitize-data.ts b/server/utils/content/plugins/sanitize-data.ts index 3909e6339..7b2a37816 100644 --- a/server/utils/content/plugins/sanitize-data.ts +++ b/server/utils/content/plugins/sanitize-data.ts @@ -84,7 +84,7 @@ export function sanitizeData(options: { blockedTags: string[] }): ContentPlugin if (!file) return const removed = stripUnsafeData(file.data, options.blockedTags) - if (removed.length) { + if (import.meta.dev && removed.length) { content.logger.warn('sanitize-data', `${file.meta.key}: removed unsafe ${removed.map(path => `\`${path}\``).join(', ')}`) } }) From 3243d58324cb79272b7c9254cbf399e5e613d104 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 13 Oct 2026 22:29:45 +0200 Subject: [PATCH 5/6] fix(content): sanitize content data with comark and stop spreading it --- .../support/EnterpriseSupportClientCard.vue | 2 +- .../support/EnterpriseSupportFormSection.vue | 2 +- app/pages/blog/[slug].vue | 2 +- app/pages/blog/index.vue | 2 +- app/pages/design-kit.vue | 2 +- app/pages/docs/[...slug].vue | 7 +- app/pages/enterprise/agencies/index.vue | 2 +- app/pages/enterprise/jobs.vue | 2 +- app/pages/enterprise/sponsors.vue | 2 +- app/pages/enterprise/support.vue | 29 +++- app/pages/index.vue | 14 +- app/pages/newsletter.vue | 7 +- app/pages/templates.vue | 2 +- app/pages/video-courses.vue | 2 +- app/utils/content.ts | 48 ++++++ pnpm-lock.yaml | 93 ++++++----- pnpm-workspace.yaml | 7 + server/utils/content/plugins/index.ts | 20 ++- server/utils/content/plugins/sanitize-data.ts | 49 +++--- test/unit/content-sanitize-data.spec.ts | 146 ++++++++---------- utils/factory.ts | 5 +- 21 files changed, 262 insertions(+), 183 deletions(-) diff --git a/app/components/content/enterprise/support/EnterpriseSupportClientCard.vue b/app/components/content/enterprise/support/EnterpriseSupportClientCard.vue index cf266723a..151180cad 100644 --- a/app/components/content/enterprise/support/EnterpriseSupportClientCard.vue +++ b/app/components/content/enterprise/support/EnterpriseSupportClientCard.vue @@ -46,7 +46,7 @@ defineProps<{
  • - +
diff --git a/app/components/content/enterprise/support/EnterpriseSupportFormSection.vue b/app/components/content/enterprise/support/EnterpriseSupportFormSection.vue index 567437697..f34e517fe 100644 --- a/app/components/content/enterprise/support/EnterpriseSupportFormSection.vue +++ b/app/components/content/enterprise/support/EnterpriseSupportFormSection.vue @@ -115,7 +115,7 @@ async function onSubmit(event: FormSubmitEvent) {
- +
diff --git a/app/pages/blog/index.vue b/app/pages/blog/index.vue index c50254140..a6d0767ef 100644 --- a/app/pages/blog/index.vue +++ b/app/pages/blog/index.vue @@ -84,7 +84,7 @@ await fetchList() alt: `${article.title} image` }" :date="formatDateByLocale('en', article.date)" - :authors="article.authors.map(author => ({ ...author, avatar: { ...author.avatar, alt: `${author.name} avatar` } }))" + :authors="article.authors.map(author => pickUser({ ...author, avatar: { ...author.avatar, alt: `${author.name} avatar` } }))" :badge="{ label: article.category, color: 'primary', variant: 'subtle' }" :variant="index === 0 ? 'outline' : 'subtle'" :orientation="index === 0 ? 'horizontal' : 'vertical'" diff --git a/app/pages/design-kit.vue b/app/pages/design-kit.vue index 065b5db7a..b9727d8a9 100644 --- a/app/pages/design-kit.vue +++ b/app/pages/design-kit.vue @@ -35,7 +35,7 @@ defineOgImage('Docs.takumi', {