Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
Expand Down
23 changes: 13 additions & 10 deletions adminui/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 /<!doctype|<(?:html|head|body|form)[\s>]/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) {
Expand Down Expand Up @@ -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;
}

Expand Down
53 changes: 53 additions & 0 deletions host/editor_browser_test.go
Original file line number Diff line number Diff line change
@@ -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: `<section><h2>Voice & music</h2><div data-event-list></div></section><section><p title='Quoted text'>Second section</p></section>`, Status: store.StatusDraft}
if err := pages.Create(ctx, tenant.ID, page); err != nil {
t.Fatal(err)
}
const cookieValue = "isolated-editor-browser-cookie"
authorized := func(r *http.Request) bool {
cookie, err := r.Cookie("cms-browser-fixture")
return err == nil && cookie.Value == cookieValue
}
server := httptest.NewTLSServer(New(Config{TenantsAdmin: tenants, Pages: pages, AdminAuth: authorized, AdminTenantAccess: func(_ *http.Request, id int64) bool { return id == tenant.ID }, AdminPlatformAccess: func(*http.Request) bool { return false }}))
t.Cleanup(server.Close)
payload, _ := json.Marshal(map[string]any{"url": server.URL, "cookie": "cms-browser-fixture", "token": cookieValue, "tenant": tenant.ID, "page": page.ID, "body": page.BodyHTML})
deadline, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
command := exec.CommandContext(deadline, "node", "testdata/editor-normalization-browser.cjs")
command.Stdin = strings.NewReader(string(payload))
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("native editor browser scenario failed: %v\n%s", err, output)
}
if saved, err := pages.Get(ctx, tenant.ID, page.ID); err != nil || !strings.Contains(saved.BodyHTML, "Edited safe content") {
t.Fatal("native editor save did not reach page store")
}
t.Log(strings.TrimSpace(string(output)))
}
84 changes: 84 additions & 0 deletions host/testdata/editor-normalization-browser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict')
const path = require('node:path')
const { createRequire } = require('node:module')

async function main() {
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
const fixture = JSON.parse(Buffer.concat(chunks).toString())
assert.equal(new URL(fixture.url).hostname, '127.0.0.1')
if (!process.env.CMS_PLAYWRIGHT_MODULE) throw new Error('CMS_PLAYWRIGHT_MODULE is required')
const { chromium } = createRequire(path.resolve(process.env.CMS_PLAYWRIGHT_MODULE))('playwright')
const browser = await chromium.launch({ headless: true, ...(process.env.CMS_BROWSER_CHANNEL ? { channel: process.env.CMS_BROWSER_CHANNEL } : {}) })
try {
const context = await browser.newContext({ ignoreHTTPSErrors: true, serviceWorkers: 'block' })
await context.addCookies([{ name: fixture.cookie, value: fixture.token, url: fixture.url, secure: true, httpOnly: true }])
const external = [], errors = []
await context.route('**/*', route => {
if (new URL(route.request().url()).origin !== fixture.url) { external.push('blocked'); return route.abort() }
return route.continue()
})
const page = await context.newPage()
page.setDefaultTimeout(10000)
page.on('pageerror', error => errors.push(error.message))
const route = fixture.url + '/admin/tenants/' + fixture.tenant + '/pages/' + fixture.page + '/content'
const api = '/api/v1/admin/tenants/' + fixture.tenant + '/pages/' + fixture.page
await page.goto(route)
await page.locator('[data-editor-surface]').waitFor()
if (!(await page.getByRole('button', { name: 'Event', exact: true }).isVisible())) {
const removed = await page.evaluate(() => {
sanitizeEditorDOM(document.querySelector('[data-editor-source]').value)
return DOMPurify.removed.map(item => ({ element: item.element?.nodeName, attribute: item.attribute?.name, from: item.from?.nodeName }))
})
throw new Error('safe fixture classified advanced: ' + JSON.stringify(removed))
}
await page.getByRole('button', { name: 'Event', exact: true }).waitFor()
assert.equal(await page.getByRole('textbox', { name: 'Page body', exact: true }).getAttribute('contenteditable'), 'true')
// A no-op save preserves the exact source, including entities, quotes and
// empty data attributes; parser normalization does not rewrite the record.
async function save() {
const response = page.waitForResponse(response => response.url().endsWith(api) && response.request().method() === 'PUT')
await page.getByRole('button', { name: 'Save page', exact: true }).click()
const saved = await response
assert.equal(saved.status(), 200)
await page.waitForFunction(() => document.getElementById('save-state').textContent === 'Saved')
return saved.json()
}
assert.equal((await save()).BodyHTML, fixture.body)
const cases = [
['entities', '<p>Voice & music &amp; teaching</p>', false],
['attribute quotes and empty data attributes', "<section title='Voice'><div data-event-list></div><br></section>", false],
['safe blank-target link', '<p><a href="/local" target="_blank">Local link</a></p>', false],
['form', '<form><input required></form>', true],
['style', '<p style="color:red">Source-only style</p>', true],
['event handler', '<img src="/assets/absent.jpg" onerror="window.cmsUnsafeRan=true">', true],
['script URL', '<a href="javascript:window.cmsUnsafeRan=true">Unsafe link</a>', true],
['remote image', '<img src="https://outside.example.test/track">', true],
['invalid target', '<a href="/local" target="unknown">Unknown target</a>', true],
['iframe', '<iframe src="https://outside.example.test/frame"></iframe>', true],
['SVG payload', '<svg onload="window.cmsUnsafeRan=true"></svg>', true],
['script', '<script>window.cmsUnsafeRan=true</script><p>Text</p>', 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 })
Loading