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:
@@ -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.
|
||||
|
||||
68
assets/dist/blogeditor.js
vendored
68
assets/dist/blogeditor.js
vendored
File diff suppressed because one or more lines are too long
@@ -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 }) {
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user