Securing author-written JS: gate execution, not the UI
The question: the Motion Snippet (rung 5 of the GSAP ladder) lets a user type GSAP that executes on the front end. That's arbitrary JavaScript — the same trust surface as a Custom HTML block or a theme's custom-JS box. How do we make it safe without breaking it, and where does the capability check actually belong?
Context
WordPress already has a bar for "this user may supply raw markup/JS": the unfiltered_html
capability (admins, and editors on single-site; nobody below). The naive design is "only show the
code field to users who have it." But two things went wrong with that, discovered by building it:
- Gating the choice corrupted saved data. The snippet lived as a
customchoice in the Scroll Effect multi-picker. When the choice was only registered for capable users, any server-side re-validation that ran without that user context — notablyjson_to_shortcodes, which regenerates post_content — didn't seecustomas a valid choice and silently reset the value tonone. The snippet vanished on the next content regeneration. - Hiding UI is not a trust boundary. A hidden field is still POST-able; a custom role could expose it; and the value has to survive round-trips regardless of who's looking. "They can't see it" was never actually the security property.
Options considered
- UI gate — only capable users see the field. Fragile (above) and not a real boundary.
- Save-time sanitize — strip the code on save for users without the cap (kses-style). Correct as defense in depth, but on its own it's brittle: it depends on the exact save path running kses, and the field's value shape isn't plain markup.
- Execution gate (chosen) — always allow the field and store the code; only ever run it when
the current page's author has
unfiltered_html, checked per request at output.
Decision
The custom choice is always registered (so the value is never reset by re-validation). The code
is base64-stamped into post_content as a data-upw-snip attribute — static, exactly like every
other data-upw-g* attribute, so it survives without re-running shortcodes. Execution is gated at
render: a footer script emits window.upwSnippetsOK = 1 only when the queried singular's author
can unfiltered_html, and the runtime runs a snippet only if that flag is set. This mirrors the
theme's existing page_custom_js, which checks the author capability at wp_footer output.
kses stripping the attribute from a non-capable user's save remains true as a second layer, but the primary guarantee is the per-request execution gate.
Why
- The capability question is really "does the responsible author get to run JS on this page?" — and that's an author-and-request property, not a "who is currently editing" property. Checking the author at output is the only place that answer is stable and correct. It's also precisely how WordPress already treats page-level scripts, so it's a known-good model, not a bespoke one.
- Separating storage from execution fixes the fragility. The value is plain data that always round-trips; safety doesn't ride on the value being present or absent. A contributor-authored page can contain a snippet (e.g. pasted by an admin earlier) and it simply won't run until a trusted author owns the page — no data loss, no silent reset.
- Defense in depth without depending on any single layer. Execution gate (primary) + kses on
untrusted saves (secondary) + reduced-motion skip + a
new Functionboundary that can't reach the options-render AJAX. Any one failing doesn't open the door.
Status
Accepted. The rule generalizes: for any author-supplied executable content, store it as inert data and gate execution on the responsible author's capability per request — never rely on hiding the input. Applies to future snippet-like surfaces (custom cursors, custom shaders, etc.).
