- htmlGate.ts mirrors the four editor-output rules of the Go policy:
figure data-embed (^youtube$) and data-id (^[A-Za-z0-9_-]{11}$),
img alt with any text, ol start (integer)
- embeds.ts: EMBED_PROVIDERS (youtube on youtube-nocookie.com),
EMBED_PLACEHOLDER and expandEmbeds
- JournalPostBody and JournalCategoryHeader render
expandEmbeds(sanitizeJournalHtml(html)); payload transforms only sanitize
- .journal-embed styles (16 / 9 block, borderless iframe)
- tests: byte copy of the editor corpus, mirror and pipeline cases;
acme hello-acme carries a placeholder and the SSR smoke checks the iframe
- README Embeds section and decision note Policy amendment
121 lines
5.7 KiB
TypeScript
121 lines
5.7 KiB
TypeScript
// Embed expansion (Phase 16.2, D-17). The Blog Editor stores the placeholder
|
|
// <figure data-embed="youtube" data-id="ID"></figure>; the sinks render
|
|
// expandEmbeds(sanitizeJournalHtml(html)), so the sanitizer runs first and the
|
|
// only iframe in the output is the one built from a validated placeholder.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import { EMBED_PLACEHOLDER, EMBED_PROVIDERS, expandEmbeds } from '../app/utils/embeds.ts'
|
|
import { sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
|
|
|
|
const ID = 'dQw4w9WgXcQ'
|
|
const PLACEHOLDER = `<figure data-embed="youtube" data-id="${ID}"></figure>`
|
|
const IFRAME =
|
|
'<figure class="journal-embed journal-embed--youtube">' +
|
|
`<iframe src="https://www.youtube-nocookie.com/embed/${ID}" title="YouTube video" loading="lazy" ` +
|
|
'referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>' +
|
|
'</figure>'
|
|
|
|
// The sink: sanitize, then expand last.
|
|
const sink = (html: string) => expandEmbeds(sanitizeJournalHtml(html))
|
|
// The payload transform sanitizes, and the sink sanitizes again before expanding.
|
|
const payloadThenSink = (html: string) => expandEmbeds(sanitizeJournalHtml(sanitizeJournalHtml(html)))
|
|
|
|
const iframeCount = (html: string) => (html.match(/<iframe/g) ?? []).length
|
|
|
|
test('the placeholder expands to the privacy-enhanced YouTube iframe', () => {
|
|
assert.equal(expandEmbeds(PLACEHOLDER), IFRAME)
|
|
assert.equal(expandEmbeds(`<p>a</p>${PLACEHOLDER}<p>b</p>`), `<p>a</p>${IFRAME}<p>b</p>`)
|
|
})
|
|
|
|
test('the provider table holds YouTube only, on youtube-nocookie.com', () => {
|
|
assert.deepEqual(Object.keys(EMBED_PROVIDERS), ['youtube'])
|
|
assert.equal(EMBED_PROVIDERS.youtube!.src(ID), `https://www.youtube-nocookie.com/embed/${ID}`)
|
|
assert.ok(EMBED_PLACEHOLDER.global)
|
|
})
|
|
|
|
test('malformed or unknown placeholders stay unchanged and inert', () => {
|
|
const inert = [
|
|
`<figure data-embed="youtube" data-id="${ID.slice(0, 10)}"></figure>`,
|
|
`<figure data-embed="youtube" data-id="${ID}Q"></figure>`,
|
|
`<figure data-embed="youtube" data-id="dQw4w9WgX/Q"></figure>`,
|
|
`<figure data-embed="vimeo" data-id="${ID}"></figure>`,
|
|
`<figure data-embed="constructor" data-id="${ID}"></figure>`,
|
|
`<figure data-embed="__proto__" data-id="${ID}"></figure>`,
|
|
`<figure data-embed="youtube" data-id="${ID}" class="x"></figure>`,
|
|
`<figure class="x" data-embed="youtube" data-id="${ID}"></figure>`,
|
|
`<figure data-id="${ID}" data-embed="youtube"></figure>`,
|
|
`<figure data-embed="youtube" data-id="${ID}"><figcaption>c</figcaption></figure>`,
|
|
`<figure data-embed='youtube' data-id='${ID}'></figure>`,
|
|
]
|
|
for (const html of inert) assert.equal(expandEmbeds(html), html, html)
|
|
})
|
|
|
|
test('empty input gives ""', () => {
|
|
for (const html of [null, undefined, '']) assert.equal(expandEmbeds(html), '')
|
|
})
|
|
|
|
test('the sink turns a stored placeholder into the iframe', () => {
|
|
assert.equal(sink(PLACEHOLDER), IFRAME)
|
|
assert.equal(sink(`<h2>Title</h2>\n${PLACEHOLDER}\n<p>After</p>`), `<h2>Title</h2>\n${IFRAME}\n<p>After</p>`)
|
|
})
|
|
|
|
test('a raw iframe in stored HTML is stripped; only the placeholder becomes an iframe', () => {
|
|
const stored =
|
|
'<p>Before</p>' +
|
|
'<iframe src="https://evil.test/"></iframe>' +
|
|
`<iframe src="https://www.youtube-nocookie.com/embed/${ID}"></iframe>` +
|
|
PLACEHOLDER +
|
|
'<p>After</p>'
|
|
for (const run of [sink, payloadThenSink]) {
|
|
const out = run(stored)
|
|
assert.equal(out, `<p>Before</p>${IFRAME}<p>After</p>`)
|
|
assert.equal(iframeCount(out), 1)
|
|
assert.doesNotMatch(out, /evil\.test/)
|
|
}
|
|
})
|
|
|
|
test('a placeholder smuggling an iframe or extra attributes expands only its validated parts', () => {
|
|
const stored = `<figure data-embed="youtube" data-id="${ID}" data-src="https://evil.test/" onload="alert(1)"><iframe src="https://evil.test/"></iframe></figure>`
|
|
assert.equal(sink(stored), IFRAME)
|
|
assert.equal(payloadThenSink(stored), IFRAME)
|
|
})
|
|
|
|
test('a malformed placeholder stays an empty figure after the sink', () => {
|
|
assert.equal(sink(`<figure data-embed="youtube" data-id="short"></figure>`), '<figure data-embed="youtube"></figure>')
|
|
assert.equal(sink(`<figure data-embed="vimeo" data-id="${ID}"></figure>`), `<figure data-id="${ID}"></figure>`)
|
|
assert.equal(iframeCount(sink(`<figure data-embed="vimeo" data-id="${ID}"></figure>`)), 0)
|
|
})
|
|
|
|
test('the payload-then-sink pipeline gives the same result as the sink', () => {
|
|
const stored = `<p>x</p>${PLACEHOLDER}<script>alert(1)</script><p>y</p>`
|
|
assert.equal(payloadThenSink(stored), sink(stored))
|
|
assert.equal(sink(stored), `<p>x</p>${IFRAME}<p>y</p>`)
|
|
})
|
|
|
|
test('two placeholders in one body both expand', () => {
|
|
const other = 'abcdefghijk'
|
|
const out = sink(`${PLACEHOLDER}<p>between</p><figure data-embed="youtube" data-id="${other}"></figure>`)
|
|
assert.equal(iframeCount(out), 2)
|
|
assert.equal(out, `${IFRAME}<p>between</p>${IFRAME.replace(ID, other)}`)
|
|
})
|
|
|
|
test('the editor corpus embed entries expand after sanitizing', () => {
|
|
const corpus = JSON.parse(
|
|
readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'),
|
|
) as Array<{ name: string; html: string }>
|
|
const embed = corpus.find((e) => e.name === 'embed-youtube')
|
|
assert.ok(embed)
|
|
assert.equal(payloadThenSink(embed.html), IFRAME)
|
|
const combined = corpus.find((e) => e.name === 'combined')
|
|
assert.ok(combined)
|
|
const out = payloadThenSink(combined.html)
|
|
assert.ok(out.startsWith(IFRAME), out)
|
|
assert.equal(iframeCount(out), 1)
|
|
// Entries without a placeholder pass through the expansion unchanged.
|
|
for (const entry of corpus.filter((e) => !e.html.includes('data-embed'))) {
|
|
const clean = sanitizeJournalHtml(entry.html)
|
|
assert.equal(expandEmbeds(clean), clean, entry.name)
|
|
}
|
|
})
|