diff --git a/README.md b/README.md
index 0df0c55..83c43bf 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,23 @@
# workflow-plugin-cms
+The editor accepts harmless HTML normalization (entities, attribute quoting and
+empty data attributes) without forcing source mode. Its original source remains
+authoritative until a visual edit. Removed or advanced markup stays in the
+source editor; the visual DOM always uses the pinned local sanitizer.
+
+The opt-in native editor browser regression covers both modes, source
+preservation, unsafe payloads, and save/reload through the actual CMS API:
+
+```sh
+CMS_NORMALIZATION_BROWSER=1 CMS_PLAYWRIGHT_MODULE=/path/to/playwright/package.json \
+ go test ./host -run '^TestEditorNormalizationBrowserScenario$' -count=1 -v
+```
+
+The module path must resolve an installed Playwright package; optionally set
+`CMS_BROWSER_CHANNEL=msedge`. This loopback fixture uses isolated memory stores
+and a test cookie auth hook. It does not exercise production authorization or
+PostgreSQL restart durability.
+
> ⚠️ **Experimental** — This plugin compiles and passes its unit tests but has not been validated in any active GoCodeAlone-internal production deployment. Use with caution. Please [open an issue](https://github.com/GoCodeAlone/workflow-plugin-cms/issues/new) if you adopt it so we can promote it to **verified** status.
[](LICENSE)
diff --git a/adminui/static/admin.js b/adminui/static/admin.js
index 526d9a9..bf4118e 100644
--- a/adminui/static/admin.js
+++ b/adminui/static/admin.js
@@ -49,6 +49,9 @@ let navigationGeneration = 0, historyPosition = 0, saving = false, restoringHist
let frozenDraftControls = null;
let pagePermissions = {create:false, edit:false, delete:false};
const savedPageBodies = new WeakMap();
+// Classification metadata only: visual DOM always comes from the sanitizer.
+// It never authorizes markup or changes the canonical source on its own.
+const editorSanitizationChanges = new WeakMap();
const pageField = (form,name) => form.querySelector(':scope > input[data-page-field][name="'+name+'"], :scope > .settings-pane > label > [data-page-field][name="'+name+'"]');
function pagePayload(form) {
@@ -396,15 +399,10 @@ function setRichEditorHTML(root, html) {
}
function hasAdvancedMarkup(raw, safe) {
- // Conservative comparison keeps canonical source authoritative. Never parse
- // unsanitized source again merely to decide which editing mode to display.
- return /]/i.test(raw) || String(raw).trim() !== serializeEditorDOM(safe).trim();
-}
-
-function serializeEditorDOM(fragment) {
- const holder = document.createElement("div");
- holder.append(fragment.cloneNode(true));
- return holder.innerHTML;
+ // Entity escaping, quoting and empty attributes normalize during safe parsing.
+ // Only a removed element/attribute (or a full document/form) requires source
+ // editing. Keep the original source authoritative until a real visual edit.
+ return /<\s*(?:!doctype|html|head|body|title|noscript|script|style|link|meta|base|iframe|object|embed|svg|math|template|form|input|textarea|select|option|video|audio|source)(?:[\s/>]|$)/i.test(raw) || editorSanitizationChanges.get(safe) !== false;
}
function setSourceNotice(editor, advanced) {
@@ -466,16 +464,21 @@ function sanitizeEditorDOM(value) {
FORBID_TAGS:["script","style","link","meta","base","iframe","object","embed","svg","math","template","form","input","textarea","select","option","video","audio","source"],
FORBID_ATTR:["srcdoc","style","action","formaction","srcset","autofocus","autoplay","poster","background","ping","lowsrc","dynsrc","archive","codebase","data"],
});
+ let removedMarkup = DOMPurify.removed.length > 0;
fragment.querySelectorAll("*").forEach(el => {
[...el.attributes].forEach(attr => {
const name = attr.name.toLowerCase();
if (name.startsWith("on") ||
(name === "target" && !["_blank", "_self"].includes(attr.value)) ||
(name === "href" && !safeEditorURL(attr.value)) ||
- (name === "src" && (el.tagName !== "IMG" || !/^\/assets\/[a-zA-Z0-9._/-]+$/.test(attr.value) || attr.value.split("/").some(part => part === "." || part === "..") || !safeEditorURL(attr.value)))) el.removeAttribute(attr.name);
+ (name === "src" && (el.tagName !== "IMG" || !/^\/assets\/[a-zA-Z0-9._/-]+$/.test(attr.value) || attr.value.split("/").some(part => part === "." || part === "..") || !safeEditorURL(attr.value)))) {
+ removedMarkup = true;
+ el.removeAttribute(attr.name);
+ }
});
if (el.tagName === "A" && el.target === "_blank") el.rel = "noopener noreferrer";
});
+ editorSanitizationChanges.set(fragment, removedMarkup);
return fragment;
}
diff --git a/host/editor_browser_test.go b/host/editor_browser_test.go
new file mode 100644
index 0000000..1783647
--- /dev/null
+++ b/host/editor_browser_test.go
@@ -0,0 +1,53 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GoCodeAlone/workflow-plugin-cms/store"
+)
+
+// The editor is served by the actual CMS host/API. This fixture's local cookie
+// gate models the host-owned auth hook; it is not evidence of production auth.
+func TestEditorNormalizationBrowserScenario(t *testing.T) {
+ if os.Getenv("CMS_NORMALIZATION_BROWSER") != "1" {
+ t.Skip("set CMS_NORMALIZATION_BROWSER=1 and CMS_PLAYWRIGHT_MODULE")
+ }
+ ctx := context.Background()
+ tenants, pages := store.NewMemoryTenantAdminStore(), store.NewMemoryPageStore()
+ tenant := &store.Tenant{Slug: "normalization-fixture"}
+ if err := tenants.CreateTenant(ctx, tenant); err != nil {
+ t.Fatal(err)
+ }
+ page := &store.Page{TenantID: tenant.ID, Path: "/", Title: "Safe normalization", BodyHTML: ` Second sectionVoice & music
Voice & music & teaching
', false], + ['attribute quotes and empty data attributes', "Source-only style
', true], + ['event handler', '
', true],
+ ['script URL', 'Unsafe link', true],
+ ['remote image', 'Text
', true], + ] + for (const [name, html, advanced] of cases) { + await page.getByRole('link', { name: 'HTML', exact: true }).click() + await page.getByRole('textbox', { name: 'Page HTML source', exact: true }).fill(html) + await page.getByRole('link', { name: 'Content', exact: true }).click() + assert.equal(await page.locator('[data-source-notice]').isVisible(), advanced, name) + assert.equal(await page.locator('[data-editor-surface]').getAttribute('contenteditable'), String(!advanced), name) + assert.equal(await page.locator('[data-editor-source]').inputValue(), html, name + ' canonical source retained') + assert.equal(await page.evaluate(() => window.cmsUnsafeRan === true), false, name + ' did not execute') + } + await page.getByRole('link', { name: 'HTML', exact: true }).click() + await page.getByRole('textbox', { name: 'Page HTML source', exact: true }).fill(fixture.body) + await page.getByRole('link', { name: 'Content', exact: true }).click() + await page.getByRole('textbox', { name: 'Page body', exact: true }).fill('Edited safe content') + assert.match((await save()).BodyHTML, /Edited safe content/) + await page.reload() + await page.getByRole('textbox', { name: 'Page body', exact: true }).waitFor() + assert.match(await page.getByRole('textbox', { name: 'Page body', exact: true }).textContent(), /Edited safe content/) + assert.deepEqual(external, []) + assert.deepEqual(errors, []) + process.stdout.write(JSON.stringify({ pass: true, boundary: 'actual native CMS UI/API; loopback TLS; memory stores; fixture cookie auth hook', cases: cases.length, checks: ['safe-markup-visual-editing', 'no-op-source-preservation', 'unsafe-markup-readonly-source', 'no-payload-execution', 'no-remote-media-request', 'visual-save-reload'] }) + '\n') + } finally { await browser.close() } +} +main().catch(error => { process.stderr.write(error.message + '\n'); process.exitCode = 1 })