Attributes name actions, never files. The registry is the only way from a name to a module, and CycleWire:
- never imports a URL taken from markup,
- never uses
evalornew Function, - only accepts names made of letters, digits,
_,-and..
<!-- Does nothing: neither is a registered name. -->
<button cw-action="../../evil.js">x</button>
<button cw-action="https://attacker.example/x.js">x</button>If an attacker can inject HTML into your page, for example through a comment field
rendered without sanitization, they can add cw-* attributes that call actions you
registered. load and visible triggers even run without a click. Treat every export of
an action module as reachable from markup (module#export) and defend in depth:
-
Wrap user-generated content in
cw-ignore. Nothing inside it activates, even an element that carries its own attributes: no event bindings, noloadorvisibletriggers, no preloads, and with signals no bindings, no two-way inputs and no store seeds. Clicks inside it do not reach an outer binding either. This holds across shadow roots.<article class="comment" cw-ignore>{{ comment.html }}</article>
-
Strip CycleWire attributes when you sanitize. With DOMPurify:
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { if (data.attrName.startsWith('cw-') || data.attrName === 'data-cyclewire') data.keepAttr = false; });
-
Keep exports intentional. Do not put destructive helpers in modules you register. An action that changes data should verify on the server, like any request.
html from cyclewire/dom:
- escapes text and quoted attribute values,
- throws for interpolations in tag or attribute names, unquoted values,
on*andsrcdocattributes, comments, and raw-text elements such as<script>and<style>, - refuses
javascript:andvbscript:URLs in URL attributes, checking the whole value as the browser will read it: fixed text and every interpolation together, even disguised with case, whitespace, control characters or character references, - refuses templates that end inside a tag, a comment or a raw-text element, which would change the meaning of markup they are nested into,
- recognises
SafeHTMLby a symbol brand, so a plain object from JSON can never pass as markup.
html.raw() is the one door for trusted markup, such as HTML your own server rendered.
Never pass user input to it.
fragment(), swap() and morph() parse through a <template>. Until insertion,
nothing in the parsed markup runs: no onerror, no image loads. The classic XSS path of
assigning to a detached div.innerHTML, where <img onerror> fires immediately, is
closed. <script> elements in parsed markup never run, even after insertion.
CycleWire needs no unsafe-eval and no inline script. The optional
<script type="application/json" data-cyclewire> block is data, which CSP does not
block.
-
Same-origin action modules:
script-src 'self'covers them. -
Nonce-based policies: with
'strict-dynamic', modules that trusted scripts import are trusted too.Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none' -
CDN: allow the host (
https://cdn.jsdelivr.net), pin an exact version and add the SRI hash from the release notes. -
Action stylesheets (
cyclewire/css) are ordinary<link rel="stylesheet">elements, sostyle-srcmust allow their URLs. -
cyclewire/earlyis the one inline script you may add: give it the page's nonce, or allow its hash.
CSS can leak data (attribute selectors that request a background image per character)
and can restyle a page to mislead. That is why stylesheet URLs follow the same rule as
modules: cyclewire/css takes them from { module, css } entries or from css() calls in
your code, and never from markup.
cw-stream names a channel, never a URL: the streams() plugin of
cyclewire/stream maps channel names to same-origin URLs, so injected markup
can only subscribe to streams you chose. Stream messages are HTML your server wrote,
applied like html.raw(): <script> elements in them never run, and <cw-stream> is not
a custom element, so a message that reaches the page any other way does nothing.
cyclewire/request refuses a URL on another origin before sending
anything, and an answer a redirect brought from one: markup never sends the page's data
to another site, nor puts another site's HTML into the page. Like any action, injected
markup can reach it once you register it, so keep user content inside cw-ignore.
When the page enforces require-trusted-types-for 'script', cyclewire/dom,
cyclewire/morph and cyclewire/stream parse markup through a Trusted Types policy named
cyclewire. Allow it:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types cyclewireThe core never touches an HTML sink.
CycleWire only calls preventDefault() once it knows the action is registered. If an
action is missing or failed to load, links and forms keep their native behaviour instead
of silently doing nothing.
See SECURITY.md.