Skip to content

Security

csszyx is safe by default for authored sz — the styles you write in source are compiled to plain class strings at build time. The care is needed when sz comes from untrusted input (a JSON-driven UI, a CMS schema, end-user data), because sz lets a caller control both the keys and the values that reach the runtime CSS pipeline.

Treat all sz objects, DOM-embedded data, and config as untrusted by default. The library already embodies this — purifySz, the dynamic CSS-value sanitizer, isValidMangleMap, and the recursion-depth limit all assume hostile input.

dynamic() values are an untrusted CSS sink

Section titled “dynamic() values are an untrusted CSS sink”

dynamic() / useSz generate CSS at runtime. When their input may be attacker-controlled, pass it through purifySz first:

import { dynamic, purifySz } from '@csszyx/dynamic';
// JSON-driven styles from an API / CMS / user
const className = dynamic(purifySz(untrustedSzFromJson));

purifySz is allowlist-based: it drops keys the compiler doesn’t recognize, rejects values that could inject a second CSS declaration, blocks prototype-polluting keys (__proto__/constructor/prototype), and bounds nesting depth. In its default strict mode it also strips url() / image-set() / @import / expression() (legitimate for authored styles, but exfiltration/legacy vectors for untrusted input). Pass { strict: false } only for input you trust.

Built-in protections on this path (active regardless of purifySz):

  • CSS is injected via CSSStyleSheet.insertRule, which is atomic — a }/</style> rule breakout throws and is ignored (no second rule, no markup injection).
  • The declaration value + arbitrary property name are validated before injection (no ;/{/}/</>/control chars escaping the declaration).
  • sz recursion depth is capped (SzDepthError) so deeply nested input can’t overflow the stack at render time.

dynamic(), _sz, and _szMerge return a plain class string. In React (className={...}) that string is attribute-escaped and safe. Do not interpolate it into raw HTML:

// ✅ safe — React escapes the attribute
<div className={dynamic(sz)} />
// ❌ unsafe — raw HTML string interpolation bypasses escaping
const html = `<div class="${dynamic(sz)}">`; // never do this with untrusted sz

Use stripSzProps when forwarding ...rest so a raw sz object never reaches the DOM as sz="[object Object]".

The mangle map embedded in SSR HTML is validated (isValidMangleMap: plain string→string, no prototype keys, bounded size) before use. For real integrity verification without the WASM core, verifyMangleChecksumAsync recomputes the checksum via the Web Crypto API.

verifyMangleMapIntegrity() is synchronous, so the only way it can recompute a SHA-256 is through the Rust core’s verify_mangle_checksum global. The runtime never instantiates that core itself, so the global exists only if you wired it up. When it is absent the map is schema-validated and accepted, and today that degraded state is reported only in development.

Wiring the core up to close that gap would mean adding 'wasm-unsafe-eval' to script-src, because a strict policy blocks WebAssembly instantiation without it. Do not. verifyMangleChecksumAsync derives the same checksum through the Web Crypto API, which is allowed under plain script-src 'self', and it verifies rather than assumes. Widening a policy is the wrong price for a check that is explicitly tamper-detection.

import { verifyMangleChecksumAsync } from '@csszyx/runtime';
// Reads the map off the page when you do not pass one.
const trustworthy = await verifyMangleChecksumAsync(expectedChecksum);

Full signature in the runtime reference.

One deployment note: Web Crypto is a secure-context API, so crypto.subtle does not exist over plain HTTP on anything but localhost. Served that way, the verifier answers false and warns that the check did not run, rather than reporting a match it could not confirm. If you serve an intranet build over HTTP, that is the answer you will get.

@csszyx/dynamic’s primary path uses a constructable CSSStyleSheet + adoptedStyleSheets — no inline style text, so it is CSP-clean and needs no 'unsafe-inline'. The fallback creates an empty <style> element and adds rules via the CSSOM (still no inline content). Under a strict CSP that blocks <style> element creation, ensure adoptedStyleSheets is available (the primary path), or supply a nonce for the fallback element.

JavaScript: the inline <script> tags csszyx emits carry data

Section titled “JavaScript: the inline <script> tags csszyx emits carry data”

A build puts no executable inline JavaScript into your HTML on any lane. What it does add is inert — type="application/json" data blocks the browser parses and never runs, plus one attribute:

  • <script id="__CSSZYX_MANGLE_MAP__" type="application/json"> — the hydration census, the original-name-to-token map. A type="application/json" block is data, not a script: the browser never evaluates it, and under an enforced script-src 'self' it produces no violation. verifyMangleChecksum reads it back from the DOM, and it is what lets devtools name a mangled class on a build you cannot rebuild. It ships when the build renamed something — a class or a CSS variable — and not otherwise, so a build with mangling off carries no such tag at all.
  • <script id="__SZ_RECOVERY_MANIFEST__" type="application/json"> — only in a build that emits szRecover tokens, and omitted entirely when it emits none. It maps each token to the recovery mode and the component name the runtime checks a recovery against; a production build strips the source path from every entry, so src/Button.tsx:5:8 does not ship, while the name Button does.
  • the hydration checksum on <html> — written as data-sz-checksum, or as data-sz-cs when production.minify is on, which is the default.

The runtime mangle map — what szr, szv, szcn and dynamic() need when production.mangle is on — is registered from a module inside your own JS bundle, on every lane: Vite and Rollup import it through a virtual module, webpack through a generated file under .csszyx/ prepended to every entrypoint. It is covered by whatever already allows your bundle: script-src 'self', or your nonce or hash. csszyx never needs 'unsafe-inline'; do not add it for csszyx.

Neither carrier is configurable. The production.mangleMapDelivery option that used to choose between an inline installer script and the bundle has been removed — a build that still sets it is warned once and the value ignored. The census has one, production.hydrationCensus, for the reader who mangles and still cannot carry the tag; what it costs is the ability to decode a page you have already deployed. The checksum attribute is written either way, because the guard that reads it weighs the document against the bundle rather than against the census.

A reviewer grepping your dist/ finds <script id="__CSSZYX_MANGLE_MAP__"> and asks three things.

Does it execute? No. It is a JSON data block, and the browser exposes it only as the text content of an element. csszyx’s own end-to-end suite serves a real production build through vite preview with script-src 'self' in the response header and fails on any securitypolicyviolation the browser reports; the census raises none.

Does it disclose anything? It lists csszyx’s own utility class names against their tokens — {"p-4":"z","bg-red-500":"y"}. The stylesheet beside it already says the same thing in readable form (.z{padding:1rem}), so the census adds no information that the page was keeping. It contains no application data, no route and no source path. It does contain your theme token names — a class such as bg-brand-500 is yours — but every one of them is already a selector in the stylesheet next to it, so the census repeats them rather than reveals them. Mangling is name obfuscation, and it is not a confidentiality control — do not present it as one to a reviewer. The recovery manifest is the tag with something of yours in it: it names components. Drop szRecover from a component and its name leaves the manifest with it.

Can we remove it? No, and no CSP asks you to. Be careful with this answer if the standard being applied is “no inline <script> elements”, because that is not a CSP rule and cannot be written as one: script-src governs what executes, and a data block never reaches the check, so a policy has no way to express it. A rule like that comes from a scanner or a review checklist, and it is answered by inventory, not by policy — the tag is inert JSON, it is listed above with what it holds, and its content changes on every build because it is derived from that build’s class set. A build that must not carry the map at all is a build without production.mangle: the census then carries no class entries and the class names in the HTML are the readable ones.

csszyx cannot know your application’s full policy, so mirror the deployment header in Vite itself. Then a violation fails vite preview and CI instead of surfacing in production:

vite.config.ts
import { randomBytes } from 'node:crypto';
import { defineConfig } from 'vite';
// The production policy, verbatim. `style-src 'unsafe-inline'` is for your own
// inline `style` attributes; csszyx needs nothing here either way.
const csp =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:";
// `vite dev` injects its own inline client and the React refresh preamble.
// Those get a nonce — never 'unsafe-inline' — and csszyx still gets nothing.
const devNonce = randomBytes(16).toString('base64');
export default defineConfig({
preview: { headers: { 'Content-Security-Policy': csp } },
html: { cspNonce: devNonce },
server: {
headers: {
'Content-Security-Policy': csp
.replace("script-src 'self'", `script-src 'self' 'nonce-${devNonce}'`)
.concat('; connect-src \'self\' ws: wss:'),
},
},
});

preview is the regression gate: it serves the real build under the enforced policy. The dev nonce exists only because Vite’s own tooling is inline; a fixed nonce is not a substitute for the production header, and Content-Security- Policy-Report-Only is an inventory, not a gate.