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.
Zero-trust posture
Section titled “Zero-trust posture”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 / userconst 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.
Returned strings are React-escaped only
Section titled “Returned strings are React-escaped only”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 escapingconst html = `<div class="${dynamic(sz)}">`; // never do this with untrusted szUse stripSzProps when forwarding ...rest so a raw sz object never reaches
the DOM as sz="[object Object]".
SSR mangle-map integrity
Section titled “SSR mangle-map integrity”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.
Do not add 'wasm-unsafe-eval' for this
Section titled “Do not add 'wasm-unsafe-eval' for this”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.
Content Security Policy (CSP)
Section titled “Content Security Policy (CSP)”@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. Atype="application/json"block is data, not a script: the browser never evaluates it, and under an enforcedscript-src 'self'it produces no violation.verifyMangleChecksumreads 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 emitsszRecovertokens, 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, sosrc/Button.tsx:5:8does not ship, while the nameButtondoes.- the hydration checksum on
<html>— written asdata-sz-checksum, or asdata-sz-cswhenproduction.minifyis 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.
When a review flags the census tag
Section titled “When a review flags the census tag”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.
Enforce your CSP locally
Section titled “Enforce your CSP locally”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:
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.