Security
Markup can only reach code you registered
Section titled “Markup can only reach code you registered”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>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:
-
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 escapes by context
Section titled “html escapes by context”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.
Parsing is inert
Section titled “Parsing is inert”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.
Content Security Policy
Section titled “Content Security Policy”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.
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.
Streams open only the channels you list
Section titled “Streams open only the channels you list”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.
Requests stay on your origin
Section titled “Requests stay on your origin”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.
Trusted Types
Section titled “Trusted Types”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.
Preventing defaults safely
Section titled “Preventing defaults safely”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.
Reporting
Section titled “Reporting”See SECURITY.md.