diff --git a/.planning/notes/journal-html-sanitizing-bluemonday.md b/.planning/notes/journal-html-sanitizing-bluemonday.md index 70e05bd..2c58c10 100644 --- a/.planning/notes/journal-html-sanitizing-bluemonday.md +++ b/.planning/notes/journal-html-sanitizing-bluemonday.md @@ -31,6 +31,15 @@ Everything else that UGC refuses is refused: `script`, `style`, `iframe`, `objec - **Amended 2026-10-10 (Phase 16.2 D-10): category content strips too, and admin saves report what was stripped.** The user chose one rule for both: post and category admin writes store the sanitized output and never fail. `CheckHTML`'s 422 contract for categories and the `TestCategoriesAdminContent` 422 assertion are retired in Phase 16.2. Winter stored category content unsanitized; that was a gap, not a contract. When the policy removes anything, the admin save response names what was removed. The policy (and the layer's sanitize-html mirror) is extended only for the Blog Editor's own output, such as the `figure data-embed`/`data-id` embed placeholder. + Phase 16.2 plan 03 implements this. The Blog Editor field types decode every locale's value with `classes.SanitizeHTMLReport`, store its output (the submitted bytes when nothing is removed) and return what was removed as admin notices; an admin save of post content writes the same HTML to `content` and `content_html` without goldmark. `newHTMLPolicy` gains exactly four rules for the editor's own output, and nothing else (no iframe, no other `data-*` attribute, no `target`, `rel`, style property or URL scheme). Plan 02's `tests/blogeditor/fixtures/editor-corpus.json` pins them: `TestEditorOutputPassesPolicy` requires every corpus entry to come back from `SanitizeHTML` byte for byte with an empty report, and checks that other providers, malformed ids and other attributes are still stripped. Plan 04 adds the same four rules to the layer's sanitize-html mirror and checks the same corpus. A fifth rule is added only to both policies together. + + | Change (Phase 16.2) | Why | + |---|---| + | `data-embed` matching `^youtube$` on `figure` | The editor's embed placeholder `
`; the layer expands it, no iframe is stored. | + | `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` | The YouTube video id of the same placeholder. | + | `alt` with any text on `img` | The editor's alt field takes free text; UGC's `Paragraph` pattern drops `:`, `?`, `"` and more. The value is escaped on output and carries no URL. | + | `start` matching `^[0-9]+$` on `ol` | TipTap's ordered-list input rule writes it when a list does not start at 1. | + - **Translations follow the same rule (2026-10-10, audit W-1).** `classes.FormatTranslation` runs on every locale of every API and import write: a non-blank `content` re-derives `content_html` with `FormatHTML` (a supplied `content_html` is replaced, as for the host column); a `content_html` sent alone goes through `classes.SanitizeHTML`, the same policy. A translation keyed on the default locale, which `translate.SetTranslated` writes straight into the host column, is covered too. PHP stored a client-supplied translated `content_html` as-is on the API and import paths; the port follows the PHP admin widget, which always derived it. - **Duplicate attributes count as a change (audit W-2).** `golang.org/x/net/html` drops a repeated attribute while tokenizing, so the byte-stable comparison could not see `href="https://x" href="javascript:..."`. `htmlTokens` compares the raw attribute names of each start tag (`rawAttrNames`) with the attributes the tokenizer kept; any difference stores the sanitized output and makes `CheckHTML` refuse.