Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion src/__tests__/core/sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { describe, it, expect } from 'bun:test'
import { sanitizeRichtext, isRichtextPropKey, PLAIN_TEXT_CONFIG } from '@core/sanitize'
import { sanitizeRichtext, sanitizePostBody, isRichtextPropKey, PLAIN_TEXT_CONFIG } from '@core/sanitize'

// ---------------------------------------------------------------------------
// XSS prevention — the core contract
Expand Down Expand Up @@ -230,6 +230,131 @@ describe('sanitizeRichtext() in server runtime', () => {
})
})

// ---------------------------------------------------------------------------
// sanitizePostBody() — 2026-08-13 blog round-2 fix. Post/page body content
// (base.outlet's markdown-rendered html) needs a wider allowlist than
// sanitizeRichtext(), including iframe embeds scoped to a trusted-host
// allowlist. See @core/sanitize POST_BODY_CONFIG.
// ---------------------------------------------------------------------------

describe('sanitizePostBody() — trusted-host iframe embeds', () => {
it('keeps an iframe embed from youtube.com', () => {
const result = sanitizePostBody(
'<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" allowfullscreen></iframe>',
)
expect(result).toContain('<iframe')
expect(result).toContain('youtube.com/embed/dQw4w9WgXcQ')
})

it('keeps an iframe embed from youtube-nocookie.com', () => {
const result = sanitizePostBody(
'<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>',
)
expect(result).toContain('<iframe')
expect(result).toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')
})

it('keeps a bare (non-www) youtube.com host', () => {
const result = sanitizePostBody('<iframe src="https://youtube.com/embed/dQw4w9WgXcQ"></iframe>')
expect(result).toContain('<iframe')
})

it('strips an iframe embed from an untrusted host entirely (not just the src)', () => {
const result = sanitizePostBody('<iframe src="https://evil.com/phish"></iframe>')
expect(result).not.toContain('<iframe')
expect(result).not.toContain('evil.com')
})

it('strips an iframe with no src at all', () => {
const result = sanitizePostBody('<iframe></iframe>')
expect(result).not.toContain('<iframe')
})

it('strips a lookalike host that merely contains "youtube.com" (not a real subdomain)', () => {
// e.g. "youtube.com.evil.com" or "evilyoutube.com" must NOT pass a naive
// substring check — isTrustedIframeHost requires exact match or a
// genuine `.` + trusted-host suffix.
const lookalikes = [
'https://youtube.com.evil.com/embed/x',
'https://evilyoutube.com/embed/x',
'https://notyoutube.com/embed/x',
]
for (const src of lookalikes) {
const result = sanitizePostBody(`<iframe src="${src}"></iframe>`)
expect(result).not.toContain('<iframe')
}
})

it('strips javascript: and data: iframe src', () => {
expect(sanitizePostBody('<iframe src="javascript:alert(1)"></iframe>')).not.toContain('<iframe')
expect(sanitizePostBody('<iframe src="data:text/html,<script>alert(1)</script>"></iframe>')).not.toContain('<iframe')
})

it('still strips <script> tags and event-handler attributes (iframe allowlisting is not a blanket HTML-open)', () => {
const result = sanitizePostBody('<p onclick="alert(1)">hi</p><script>alert(2)</script>')
expect(result).not.toContain('onclick')
expect(result).not.toContain('<script')
expect(result).not.toContain('alert')
expect(result).toContain('hi')
})

it('preserves images, tables, and video — the elements a real post body needs that plain richtext strips', () => {
const result = sanitizePostBody(
'<img src="https://example.com/a.png" alt="a"><table><tbody><tr><td>x</td></tr></tbody></table><video controls src="https://example.com/v.mp4"></video>',
)
expect(result).toContain('<img')
expect(result).toContain('<table')
expect(result).toContain('<video')
})

it('preserves a <video poster> with a <source> child — the form real editors/importers emit', () => {
// A bare `<video src=…>` is the exception, not the rule: most editors
// (Webflow among them) emit `<video controls poster=…><source src=…
// type=…></video>` and rely on <source> to carry the actual playable
// file. Before `source`/`poster`/`type` were allowlisted this published
// as an empty, unplayable `<video controls>`.
const result = sanitizePostBody(
'<video controls poster="https://example.com/thumb.avif"><source src="https://example.com/clip.mp4" type="video/mp4"></video>',
)
expect(result).toContain('<video')
expect(result).toContain('poster="https://example.com/thumb.avif"')
expect(result).toContain('<source')
expect(result).toContain('src="https://example.com/clip.mp4"')
expect(result).toContain('type="video/mp4"')
})

it('preserves playsinline, loop, muted, and preload on <video>', () => {
const result = sanitizePostBody(
'<video controls playsinline loop muted preload="auto" src="https://example.com/v.mp4"></video>',
)
expect(result).toContain('playsinline')
expect(result).toContain('loop')
expect(result).toContain('muted')
expect(result).toContain('preload="auto"')
})

it('preserves figure/figcaption — the wrapper rich-text editors put around images', () => {
const result = sanitizePostBody(
'<figure><img src="https://example.com/a.png" alt="a"><figcaption>A caption</figcaption></figure>',
)
expect(result).toContain('<figure')
expect(result).toContain('<figcaption')
expect(result).toContain('A caption')
})

it('preserves the same safe formatting tags sanitizeRichtext does', () => {
const result = sanitizePostBody('<p><strong>Bold</strong> <em>italic</em> <a href="https://example.com">link</a></p>')
expect(result).toContain('<strong>Bold</strong>')
expect(result).toContain('<em>italic</em>')
expect(result).toContain('rel="noopener noreferrer"')
})

it('plain sanitizeRichtext() (used by every OTHER richtext field) still strips iframe — confirms the wider allowlist is scoped to post-body only', () => {
const result = sanitizeRichtext('<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>')
expect(result).not.toContain('<iframe')
})
})

// ---------------------------------------------------------------------------
// isRichtextPropKey — prop key detection
// ---------------------------------------------------------------------------
Expand Down
55 changes: 51 additions & 4 deletions src/__tests__/publisher/outletEntryBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ const bodyModule = makeModule('base.body', {
render: (_props, children) => ({ html: `<main>${children.join('')}</main>` }),
})

// Mirrors the real base.outlet render: a hidden richtext `html` prop (so
// `escapeProps` sanitises rather than HTML-escapes it) emitted inside the
// content-region marker.
// Mirrors the real base.outlet render: a hidden richtextBody `html` prop (so
// `escapeProps` sanitises via the wider POST_BODY_CONFIG rather than
// HTML-escaping it) emitted inside the content-region marker.
const outletModule = makeModule('base.outlet', {
schema: { html: { type: 'richtext', label: 'Content', hidden: true } },
schema: { html: { type: 'richtextBody', label: 'Content', hidden: true } },
render: (props) => ({
html: `<section data-instatic-content-region>${String((props as { html?: string }).html ?? '')}</section>`,
}),
Expand Down Expand Up @@ -66,4 +66,51 @@ describe('entry outlet body binding', () => {
expect(html).toContain('data-instatic-content-region')
expect(html).not.toContain('Hello world')
})

// 2026-08-13 blog round-2 regression: a raw <iframe> YouTube embed pasted
// into a post's markdown body (the CMS has no @[video]-style syntax for
// iframe embeds — authors paste the platform's real embed HTML) was being
// silently stripped by the outlet's sanitizer end-to-end. This is the same
// path the real blog posts hit: markdown body -> renderMarkdownToHtml
// (passes raw HTML blocks through untouched) -> escapeProps ->
// sanitizePostBody (trusted-host allowlist, not a blanket strip).
it('renders a YouTube iframe embed pasted into the entry body, end-to-end', () => {
const page = makePage({
root: { moduleId: 'base.body', children: ['outlet'] },
outlet: { moduleId: 'base.outlet' },
})

const body = [
'## How to Run MCP Servers',
'',
'<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" allowfullscreen></iframe>',
'',
'More text after the embed.',
].join('\n')

const { html } = publishPage(page, makeSite(), registry, {
templateContext: { entryStack: [entry(body)] },
})

expect(html).toContain('<iframe')
expect(html).toContain('youtube.com/embed/dQw4w9WgXcQ')
expect(html).toContain('More text after the embed.')
})

it('strips an iframe embed from an untrusted host in the entry body, end-to-end', () => {
const page = makePage({
root: { moduleId: 'base.body', children: ['outlet'] },
outlet: { moduleId: 'base.outlet' },
})

const body = '<iframe src="https://evil.com/phish"></iframe>\n\nSafe text.'

const { html } = publishPage(page, makeSite(), registry, {
templateContext: { entryStack: [entry(body)] },
})

expect(html).not.toContain('<iframe')
expect(html).not.toContain('evil.com')
expect(html).toContain('Safe text.')
})
})
5 changes: 5 additions & 0 deletions src/admin/shared/DataBindingPicker/bindingCompatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export const BINDING_COMPATIBILITY: Record<PropertyControlKind, readonly DataFie
text: ['text', 'longText', 'richText', 'url', 'email', 'select', 'multiSelect', 'relation', 'number', 'boolean', 'date', 'dateTime'],
textarea: ['text', 'longText', 'richText'],
richtext: ['richText', 'longText', 'text'],
// richtextBody is the implicit body-content binding target on base.outlet —
// the publisher wires it programmatically (dynamicBindings.ts's
// OUTLET_BODY_BINDING), never through this picker, and the control is
// `hidden: true` so it never reaches the UI this map serves.
richtextBody: [],
// svg holds raw inline-SVG markup — edited in the code editor, never wired
// to a data field.
svg: [],
Expand Down
12 changes: 12 additions & 0 deletions src/core/module-engine/propertySchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ export const PropertyControlSchema = Type.Recursive((Self) => Type.Union([
{ ...PropertyControlBaseSchema, type: Type.Literal('richtext') },
{ additionalProperties: false },
),
Type.Object(
// Distinct from `richtext`: full post/page body content rendered from
// markdown (base.outlet's `html` binding target), which needs a wider
// safe-tag allowlist than short-form richtext fields — images, tables,
// and iframe embeds scoped to a trusted-host allowlist (see
// `POST_BODY_CONFIG` in `@core/sanitize`). Kept separate from `richtext`
// rather than widening that config globally, so every other richtext
// field in the CMS (CTA copy, etc.) stays on the narrower default.
{ ...PropertyControlBaseSchema, type: Type.Literal('richtextBody') },
{ additionalProperties: false },
),
Type.Object(
{ ...PropertyControlBaseSchema, type: Type.Literal('svg') },
{ additionalProperties: false },
Expand Down Expand Up @@ -208,6 +219,7 @@ const CONTENT_CONTROL_TYPES: ReadonlySet<PropertyControl['type']> = new Set([
'text',
'textarea',
'richtext',
'richtextBody',
'svg',
'url',
'image',
Expand Down
10 changes: 9 additions & 1 deletion src/core/publisher/escapeProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

import type { PropertyControl, PropertySchema } from '@core/module-engine'
import { escapeHtml, isSafeUrl } from './utils'
import { sanitizeRichtext, sanitizeSvg } from '@core/sanitize'
import { sanitizeRichtext, sanitizeSvg, sanitizePostBody } from '@core/sanitize'

/**
* Resolve the control declared for `key`. Top-level keys are a direct lookup;
Expand Down Expand Up @@ -95,6 +95,14 @@ export function escapeProps(
// sanitizeRichtext falls back to conservative tag stripping only in
// runtimes that have not installed DOMPurify (for example one-off scripts).
escaped[key] = sanitizeRichtext(value)
} else if (type === 'richtextBody') {
// Post/page body content (base.outlet's markdown-rendered `html`):
// wider allowlist than plain `richtext` (images, tables, iframe embeds
// scoped to a trusted-host allowlist) — see POST_BODY_CONFIG in
// @core/sanitize. Kept as its own control type rather than widening
// RICHTEXT_CONFIG globally, so short-form richtext fields elsewhere in
// the CMS stay on the narrower default.
escaped[key] = sanitizePostBody(value)
} else if (type === 'url' || type === 'image' || type === 'media') {
// URLs: block javascript: and vbscript: schemes; pass safe URLs through raw
// so that module render() functions can HTML-escape them via safeUrl() from
Expand Down
Loading
Loading