Skip to content

fix(admin): escape user-controlled values in admin templates (XSS) - #1066

Draft
mmcintosh wants to merge 3 commits into
mainfrom
fix/escape-admin-template-xss
Draft

fix(admin): escape user-controlled values in admin templates (XSS)#1066
mmcintosh wants to merge 3 commits into
mainfrom
fix/escape-admin-template-xss

Conversation

@mmcintosh

@mmcintosh mmcintosh commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Escapes user-controlled values that were interpolated raw into admin-side HTML templates, closing a set of stored/reflected XSS sinks. The highest-value one is stored XSS via the content list: a content author controls a document's title/slug, which are rendered unescaped into the admin content list that a higher-privileged admin views — so an author could run script in an admin's session.

All escaping uses escapeHtml from utils/sanitize (R8).

Sinks fixed

  • Content list (admin-content-list.template.ts) — title, slug (stored; author → admin).
  • Content form (admin-content-form.template.ts) — the reflected ?ref= referrer param, in both the back-link href and the hidden referrer_params input.
  • Users list (admin-users-list.template.ts) — avatar src (attribute) + first/last name (alt).
  • Media grid + file details (media-grid.template.ts, media-file-details.template.ts) — original_name/filename, alt, caption, folder, tags.
  • Media library (admin-media-library.template.ts) — folder sidebar: encodeURIComponent in the href, escapeHtml in the body.
  • Settings (admin-settings.template.ts) — siteName, adminEmail, siteDescription.
  • Plugin settings (admin-plugin-settings.template.ts) — generic string setting value (via the file's existing escapeHtmlAttr; the number branch is left as-is — numbers are safe).

Tests

  • admin-templates-xss.test.ts (unit) — renders content-list, users-list, media-file-details, and content-form with element-text and attribute-breakout payloads (<script>…</script>, "><script>…) and asserts the raw payload is absent and the escaped form present. Verified non-vacuous: reverting the content-list escape makes the matching test fail, then restored. Full core suite green; the payload-free "safe value renders unchanged" case guards against over-escaping.
  • tests/e2e/111-admin-template-xss.spec.ts (@content @smoke) — drives the reflected ?ref= sink on an existing content's edit page with an attribute-breakout payload (chosen over a stored-content path because it is deterministic — the payload comes straight from the URL, not from flaky content-create + list pagination on the shared preview). Asserts it never executes (a window flag stays unset, no dialog fires, no live element is injected) and is HTML-escaped in the served markup.

Deferred to a follow-up PR (called out, not fixed here)

  • JS-string onclick="…('${x}')" sinks — these need a new escapeJsAttr helper that does not yet exist in the codebase (grep-confirmed). Notable instances: media copyToClipboard('${file.public_url}') and removeMediaFromMultiple('${fieldId}','${url}') (dynamic-field.template.ts). Escaping for HTML context (this PR) does not make a value safe inside a JS string literal, so these are intentionally left for a dedicated change with the right helper + tests.
  • Content-Security-Policymiddleware/security-headers.ts sets no CSP; a report-only-then-enforce CSP would be defense-in-depth over all of the above.

Draft pending maintainer review of the disclosure/coordination approach (part of the security-hardening batch). Do not merge without sign-off.

CI note

Unit tests + type-check pass. The E2E-against-preview job is red from pre-existing failures unrelated to this change — all in @auth/session/RBAC specs (02c-otp-login, 02d-magic-link-auth, 38/68/80-user-profile*, 85-admin-panel-roles, 02-authentication, 67-rbac). This is a templates-only diff; it touches no auth, route, or middleware code. The consistently-failing set is better-auth (OTP / magic-link), matching the known better-auth version drift; the rest shift run-to-run (shared-preview flakiness). Evidence: across two pushes here (a test-only change to 111-*.spec.ts) the @auth failures were invariant — only this PR's own spec dropped out once fixed. tests/e2e/111-admin-template-xss.spec.ts passes.

Wraps unescaped interpolations of user-controlled data in escapeHtml across the
admin templates, closing stored/reflected XSS that detonates in an admin session:

- content list: content title + slug (any content author -> admin)
- content form: the reflected ?ref= referrer param (back-link href + hidden input)
- users list: avatar alt (first/last name) + src
- media grid + file details: filename/original_name, alt, caption, folder, tags
- media library: folder sidebar (URL-encode in href, escape in body)
- settings: siteName / adminEmail / siteDescription
- plugin settings: generic string setting value

Deferred to a follow-up: the JS-string onclick sinks (need an escapeJsAttr
helper) and a Content-Security-Policy.
Guards the escapeHtml sweep in 3d3f260:
- admin-templates-xss.test.ts: renders content-list (title/slug), users-list
  (avatar src + name), media-file-details (filename/alt/caption/folder/tags), and
  content-form (reflected ?ref=) with breakout payloads and asserts the raw payload
  is absent and the escaped form present; verified non-vacuous by reverting the
  content-list escape and confirming the test fails.
- 111-admin-template-xss.spec.ts (@content @smoke): plants an onerror payload in a
  content title, loads /admin/content, and asserts it never executes (window flag +
  dialog listener + no live <img>) and is HTML-escaped in the DOM.
The first E2E cut created content through the admin UI then asserted on the content
list, which is flaky on the shared preview (content-create can fail validation / abort
navigation, and list pagination may hide the new row). Rewrite to drive the reflected
?ref= sink on an existing seeded content's edit page: the payload comes straight from
the URL we control, so the assertion is deterministic. Skips gracefully if the preview
has no content to open. Stored content-list/media/users sinks stay covered by the
admin-templates-xss unit test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant