fix(admin): escape user-controlled values in admin templates (XSS) - #1066
Draft
mmcintosh wants to merge 3 commits into
Draft
fix(admin): escape user-controlled values in admin templates (XSS)#1066mmcintosh wants to merge 3 commits into
mmcintosh wants to merge 3 commits into
Conversation
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.
19 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
escapeHtmlfromutils/sanitize(R8).Sinks fixed
admin-content-list.template.ts) —title,slug(stored; author → admin).admin-content-form.template.ts) — the reflected?ref=referrer param, in both the back-linkhrefand the hiddenreferrer_paramsinput.admin-users-list.template.ts) — avatarsrc(attribute) + first/last name (alt).media-grid.template.ts,media-file-details.template.ts) —original_name/filename,alt,caption,folder,tags.admin-media-library.template.ts) — folder sidebar:encodeURIComponentin thehref,escapeHtmlin the body.admin-settings.template.ts) —siteName,adminEmail,siteDescription.admin-plugin-settings.template.ts) — generic string setting value (via the file's existingescapeHtmlAttr; 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 (awindowflag 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)
onclick="…('${x}')"sinks — these need a newescapeJsAttrhelper that does not yet exist in the codebase (grep-confirmed). Notable instances: mediacopyToClipboard('${file.public_url}')andremoveMediaFromMultiple('${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.middleware/security-headers.tssets no CSP; a report-only-then-enforce CSP would be defense-in-depth over all of the above.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 to111-*.spec.ts) the@authfailures were invariant — only this PR's own spec dropped out once fixed.tests/e2e/111-admin-template-xss.spec.tspasses.