fix(16.2): WR-05 keep only view-safe attributes in the editor schema

captureAttributes and renderCaptured dropped only on* names, so id,
name, style, srcset, background and the like from stored HTML were
rendered by ProseMirror into the live admin DOM (DOM clobbering of
document.querySelector, fixed-position overlays, remote loads). htmlAttrs
now keeps an allowlist: class, title, alt, width, height, colspan,
rowspan, start, rel, target and data-*; href and src stay with the link
and image nodes. Other attributes are dropped from a block once it is
edited; untouched documents and untouched blocks keep their bytes.
This commit is contained in:
2026-10-10 16:38:25 +02:00
parent 2c961b8408
commit f1e6c11107
5 changed files with 138 additions and 60 deletions

View File

@@ -155,7 +155,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials.
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. Unmodelled attributes on paragraphs, headings, lists, blockquotes, tables, cells, images and links, and `span` elements with attributes, survive an edit; event handler attributes (`on*`) are never kept.
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. On paragraphs, headings, lists, blockquotes, tables, cells, images and links, and on `span` elements, the view-safe unmodelled attributes survive an edit of their block: `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*` (`href` and `src` are kept by the link and image nodes, which check them). Every other attribute, such as `id`, `name`, `style`, `srcset`, `form`, `contenteditable`, `popover`, `itemprop` or an event handler (`on*`), is never rendered into the admin page and is dropped from a block once that block is edited; a `span` left with none of the kept attributes is dropped and its text kept. Untouched documents and untouched blocks keep every stored attribute.
- **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `<figure data-embed="youtube" data-id="ID"></figure>`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer.
- **Preview.** The Preview button (`Podgląd` for `ui-locale` `pl`) is off by default, belongs to each element on its own and shows its state through `aria-pressed`. It splits the editor 50/50; when the element is narrower than about 720px, the pane wraps below the editor. The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS` in `preview.ts`) after the last change, and each update reloads the frame. The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images. The preview copy leaves out `script`, `iframe`, `frame`, `frameset`, `object`, `embed`, `meta`, `base` and `link` elements and event handler attributes. YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player; these and the post's own images are the only network traffic the pane makes. The styling is post-body typography, with the `--c-*` values read from the element at render time or neutral defaults. Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
- **Links.** The editor creates and keeps links only to `http`, `https`, `mailto` and relative URLs (`isPolicyUrl` in `extensions.ts`), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link has `href` only; stored links keep their own `target` and `rel`, which the server strips on an edited save.

File diff suppressed because one or more lines are too long

View File

@@ -63,9 +63,8 @@ export function isPolicyUrl(url: string | null | undefined): boolean {
}
/**
* The attributes each node or mark models itself; htmlAttrs captures every
* other attribute. Event handler attributes (on*) are never captured, so
* they never reach the live admin DOM.
* The attributes each node or mark models itself; htmlAttrs captures the
* view-safe others (KEPT_ATTRIBUTES).
*/
const MODELLED: Record<string, readonly string[]> = {
orderedList: ['start', 'type'],
@@ -92,23 +91,47 @@ const HTML_ATTRIBUTE_TYPES = [
'tableHeader',
'image',
'figure',
'figureImage',
'figcaption',
'link',
]
function isHandlerName(name: string): boolean {
return /^on/i.test(name)
/**
* KEPT_ATTRIBUTES are the unmodelled attributes htmlAttrs keeps (besides
* data-*): ones that are safe to render in the live admin DOM, where
* ProseMirror draws the document (WR-05). Every other attribute (id, name,
* style, srcset, form, contenteditable, popover, event handlers and the
* like) is dropped from a block once the block is edited; an untouched
* document and every untouched block keep their stored bytes. href and src
* are kept through the link and image nodes, which check them.
*/
export const KEPT_ATTRIBUTES: readonly string[] = [
'class',
'title',
'alt',
'width',
'height',
'colspan',
'rowspan',
'start',
'rel',
'target',
]
const DATA_ATTRIBUTE = /^data-[a-z0-9][a-z0-9._-]*$/
/** isKeptAttribute reports whether htmlAttrs keeps an attribute (lower-case name). */
export function isKeptAttribute(name: string): boolean {
return KEPT_ATTRIBUTES.includes(name) || DATA_ATTRIBUTE.test(name)
}
/** captureAttributes returns the element's attributes outside skip, in order. */
/** captureAttributes returns the element's kept attributes outside skip, in order. */
function captureAttributes(element: HTMLElement, skip: readonly string[]): Record<string, string> | null {
const out: Record<string, string> = {}
let any = false
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
if (skip.includes(name) || isHandlerName(name)) continue
out[attr.name] = attr.value
if (skip.includes(name) || !isKeptAttribute(name)) continue
out[name] = attr.value
any = true
}
return any ? out : null
@@ -118,17 +141,17 @@ function renderCaptured(captured: unknown): Record<string, string> {
if (!captured || typeof captured !== 'object') return {}
const out: Record<string, string> = {}
for (const [name, value] of Object.entries(captured as Record<string, unknown>)) {
if (isHandlerName(name) || typeof value !== 'string') continue
if (!isKeptAttribute(name.toLowerCase()) || typeof value !== 'string') continue
out[name] = value
}
return out
}
/**
* HtmlAttributes keeps the attributes the schema does not model (class,
* data-*, itemprop, style and others) on paragraphs, headings, lists, list
* items, blockquotes, tables, cells, images and links, so they survive an
* edit elsewhere in the document (D-03).
* HtmlAttributes keeps the view-safe attributes the schema does not model
* (KEPT_ATTRIBUTES and data-*) on paragraphs, headings, lists, list items,
* blockquotes, tables, cells, images and links, so they survive an edit
* elsewhere in the block (D-03).
*/
export const HtmlAttributes = Extension.create({
name: 'htmlAttributes',
@@ -148,7 +171,10 @@ export const HtmlAttributes = Extension.create({
},
})
/** Span keeps inline spans with their attributes (style, class) as a mark. */
/**
* Span keeps inline spans with kept attributes (class, data-*, title) as a
* mark; a span with none of them is dropped and its text kept.
*/
export const Span = Mark.create({
name: 'span',
priority: 40,
@@ -164,7 +190,7 @@ export const Span = Mark.create({
},
parseHTML() {
return [{ tag: 'span', getAttrs: (element) => ((element as HTMLElement).attributes.length > 0 ? null : false) }]
return [{ tag: 'span', getAttrs: (element) => (captureAttributes(element as HTMLElement, []) ? null : false) }]
},
renderHTML({ HTMLAttributes }) {

View File

@@ -139,27 +139,68 @@ describe('figures', () => {
})
describe('marks and global attributes', () => {
it('keeps a span with attributes as a mark and drops a bare span', () => {
expect(shape(model('<p>a <span class="hl" style="color: red">b</span> c</p>'))).toBe(shape('<p>a <span class="hl" style="color: red">b</span> c</p>'))
it('keeps a span with kept attributes as a mark and drops a bare span', () => {
expect(shape(model('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))).toBe(shape('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))
expect(model('<p>a <span>b</span> c</p>')).toBe('<p>a b c</p>')
})
it('drops a span whose attributes are all view-unsafe', () => {
expect(model('<p>a <span style="color: red" id="s">b</span> c</p>')).toBe('<p>a b c</p>')
expect(model('<p>a <span class="hl" style="color: red">b</span> c</p>')).toBe('<p>a <span class="hl">b</span> c</p>')
})
it.each([
['paragraph', '<p class="lead" data-x="1">a</p>'],
['heading', '<h2 id="intro" class="h">a</h2>'],
['heading', '<h2 class="h" title="Intro">a</h2>'],
['blockquote', '<blockquote class="q"><p>a</p></blockquote>'],
['bullet list and item', '<ul class="list"><li class="item"><p>a</p></li></ul>'],
['ordered list with start', '<ol start="3" class="steps"><li><p>a</p></li></ol>'],
['code block', '<pre class="code"><code>a</code></pre>'],
['horizontal rule', '<hr class="sep">'],
['image', '<p><img src="/a.jpg" alt="a" class="photo" loading="lazy"></p>'],
['image', '<p><img src="/a.jpg" alt="a" class="photo" data-id="7"></p>'],
['figcaption', '<figure><img src="/a.jpg" alt="a"><figcaption class="cap">c</figcaption></figure>'],
['link', '<p><a href="/a" class="more" data-id="7">a</a></p>'],
['table, row and cells', '<table class="t"><tbody><tr class="r"><td class="c" style="text-align: right">a</td></tr></tbody></table>'],
['table, row and cells', '<table class="t" width="100%"><tbody><tr class="r"><td class="c" data-x="1" width="50" height="20">a</td></tr></tbody></table>'],
])('keeps unmodelled attributes on a %s', (_name, html) => {
expect(shape(model(html))).toBe(shape(html))
})
// WR-05: ProseMirror renders captured attributes into the live admin DOM,
// so only view-safe ones are kept once a block is serialized from the model.
it.each([
['id', '<h2 id="intro" class="h">a</h2>', '<h2 class="h">a</h2>'],
['name', '<p><img src="/a.jpg" alt="a" name="querySelector"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
['style', '<p class="x" style="position:fixed;inset:0;z-index:99999">a</p>', '<p class="x">a</p>'],
['srcset and loading', '<p><img src="/a.jpg" alt="a" srcset="https://evil.test/a.jpg 2x" loading="lazy"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
['background', '<table background="https://evil.test/bg.png"><tbody><tr><td>a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
['form, contenteditable and popover', '<p form="f" contenteditable="true" popover="auto" class="k">a</p>', '<p class="k">a</p>'],
['itemprop, dir and lang', '<p itemprop="description" dir="rtl" lang="pl">a</p>', '<p>a</p>'],
['a cell style', '<table><tbody><tr><td style="text-align: right" id="c">a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
])('drops the view-unsafe attribute %s from a block serialized from the model', (_name, html, want) => {
expect(shape(model(html))).toBe(shape(want))
})
it('never renders a view-unsafe stored attribute into the live editor DOM', () => {
const html =
'<h2 id="intro">a</h2><p name="querySelector" style="position:fixed" class="k" data-x="1">b <span style="color:red" id="s" class="hl">c</span></p>' +
'<p><img src="/a.jpg" alt="a" name="img" srcset="https://evil.test/a.jpg 2x" class="photo"></p>' +
'<table background="https://evil.test/bg.png"><tbody><tr><td style="color:red" id="c">d</td></tr></tbody></table>'
const { el } = mount(html)
const view = el.querySelector('.ProseMirror')!
for (const attribute of ['id', 'name', 'srcset', 'background']) {
expect(Array.from(view.querySelectorAll(`[${attribute}]`)).map((node) => node.outerHTML), attribute).toEqual([])
}
// The table node view sets its own min-width style; no stored style
// reaches the view.
const styles = Array.from(view.querySelectorAll('[style]')).map((node) => node.getAttribute('style'))
expect(styles.filter((style) => /fixed|red/.test(style ?? ''))).toEqual([])
expect(view.querySelector('p.k[data-x="1"]')).not.toBeNull()
expect(view.querySelector('span.hl')).not.toBeNull()
expect(view.querySelector('img.photo')).not.toBeNull()
// Untouched, the element still returns the stored bytes (D-03).
expect(el.value).toBe(html)
})
it('never keeps an event handler attribute', () => {
const out = model('<p class="a" onclick="x()" ONMOUSEOVER="y()">a</p><p><img src="/a.jpg" alt="a" onerror="z()"></p>')
expect(out).toBe('<p class="a">a</p><p><img src="/a.jpg" alt="a"></p>')

View File

@@ -146,7 +146,7 @@ describe('preservation schema', () => {
expect(img.getAttributeNames().filter((name) => name.startsWith('on'))).toEqual([])
})
it('keeps unmodelled attributes on edited blocks and spans as marks', () => {
it('keeps view-safe unmodelled attributes on edited blocks and spans as marks', () => {
const html =
'<p class="lead" data-x="1" itemprop="description">Tekst <span style="color: red;" class="s">czerwony</span></p>' +
'<h2 id="h" class="c">Tytuł</h2>' +
@@ -157,17 +157,28 @@ describe('preservation schema', () => {
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">1</td></tr></tbody></table>'
const { el, editor } = mount(html)
insertAtEveryTextblock(editor, 'X')
// WR-05: itemprop, style and id are not view-safe, so an edited block
// drops them; class and data-* survive.
expect(el.value).toBe(
'<p class="lead" data-x="1" itemprop="description">XTekst <span style="color: red;" class="s">czerwony</span></p>' +
'<h2 id="h" class="c">XTytuł</h2>' +
'<p class="lead" data-x="1">XTekst <span class="s">czerwony</span></p>' +
'<h2 class="c">XTytuł</h2>' +
'<ul class="u"><li data-i="1"><p>XPunkt</p></li></ul>' +
'<ol class="o" start="3"><li><p>XTrzy</p></li></ol>' +
'<blockquote class="q"><p>XCytat</p></blockquote>' +
'<p><a class="lnk" data-t="1" href="/x">Xlink</a> <img class="photo" data-id="7" src="/a.png" alt="A"></p>' +
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">X1</td></tr></tbody></table>',
'<table class="t"><tbody><tr><td class="c">X1</td></tr></tbody></table>',
)
})
it('keeps every stored attribute of a block the edit does not touch', () => {
const kept = '<p id="keep" style="color: red;" itemprop="x">Bez zmian</p>'
const html = `<p>Edytowany</p>\n${kept}\n`
const { el, editor } = mount(html)
expect(el.value).toBe(html)
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe(`<p>XEdytowany</p>\n${kept}\n`)
})
it('leaves every other top-level block byte-identical after editing one paragraph', () => {
const { el, editor, changes } = mount(fixture)
insertAtBlock(editor, 1, 'X')