Skip to content

Security

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 eval or new 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>

Script gadgets: injected markup can still call registered actions

Section titled “Script gadgets: injected markup can still call registered actions”

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:

  1. Wrap user-generated content in cw-ignore. Nothing inside it activates, even an element that carries its own attributes: no event bindings, no load or visible triggers, 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>
  2. 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;
    });
  3. 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* and srcdoc attributes, comments, and raw-text elements such as <script> and <style>,
  • refuses javascript: and vbscript: 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 SafeHTML by 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, so style-src must allow their URLs.

  • cyclewire/early is the one inline script you may add: give it the page’s nonce, or allow its hash.

Stylesheet URLs come from the registry too

Section titled “Stylesheet URLs come from the registry too”

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 cyclewire

The 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.