Skip to content
Vincle

Loading…

    Security

    Vincle's security model has one job: prevent HTML injection (XSS) at every possible surface. You do not need to opt in, configure, or remember to escape. It happens automatically for every string, attribute, and URL. Where an attribute's purpose is to carry HTML or JavaScript, the guarantee narrows to escaping — see The rule.

    The rule#

    Two sentences, and every case below is one of them.

    1. Escaping is unconditional. Sanitizing is not the job. Some tags and attributes exist to carry HTML or JavaScript — that is their purpose, not a mistake: on* event handlers, <iframe srcdoc> (a whole document), <script>/<style> bodies, <script type="text/template">, a style declaration list. Vincle never blocks you from writing them, and never tries to guess whether the code you wrote is hostile. It only guarantees the value is escaped: it cannot end its attribute, reopen the tag, or close its element early. Code you wrote is your code; the boundary around it is not yours to cross by accident.

    2. raw() is the one thing that turns escaping off. Not "off for attributes" or "off for URLs" — off. A RawString is emitted verbatim, and where its position has a further check (a URL scheme, say), that check is skipped too. That is the deal: raw() is an assertion of trust, not an escaping helper.

    Everything else in this page is a consequence of those two. The table below lists the surfaces rule 1 deliberately does not inspect.

    Auto-escaping#

    Every value embedded in JSX, whether text content, an attribute, or a style value, is HTML-escaped before it reaches the output. This is not a mode you enable; it is the only mode.

    ContextCharacters escapedFunction
    Text content&<>escapeContent
    Attribute values&<"

    > is deliberately skipped. It can't end a double-quoted value

    escapeAttr
    URL attributes

    Schemes off the allowlist replaced with #blocked

    isSafeScheme

    Rawtext content


    (script, style)

    \u003c/script> in script, </style> in style


    scripts escaped (unlike Preact), styles too (unlike React)
    escapeRawTagContent

    The functions named in the table are internal implementation details, not public API. isSafeScheme, for example, is exported from nowhere; the scheme reader it is built on, schemeOf, is available to build-time tooling from @vincle/core/html, not from @vincle/core itself.

    import { renderToString, raw } from "@vincle/core";
    // User input is HTML-escaped automatically
    const userInput = '<script>alert("xss")</script>';
    const html = await renderToString(<p>{userInput}</p>);
    // Use raw() only for trusted HTML you generated yourself
    const trustedHtml = "<em>rendered from your own markdown</em>";
    const html2 = await renderToString(<article>{raw(trustedHtml)}</article>);
    <p>&lt;script&gt;alert("xss")&lt;/script&gt;</p>
    <article><em>rendered from your own markdown</em></article>

    Escaping inside <script> and <style>#

    Rawtext elements have no HTML escaping at all: the parser decodes nothing between <script> and its closing tag, it only scans for the sequence that ends the element. Entity-escaping there, as React does, protects nothing and corrupts the code, since a &amp;&amp; b is not JavaScript.

    The escape therefore has to be borrowed from the language inside the element, in a form that language reads back as the original text:

    Element Sub-language Escape Why it round-trips
    <style> CSS, always: type is obsolete <\/style> in CSS, \ before a non-hex char is that char
    <script> JavaScript, or JSON in a data block \u003c/script> a unicode escape both languages decode to <

    <script> is a container, not a language. With no type, a JavaScript MIME type, or module, it holds JavaScript; with any other type it is a data block. In practice, these data blocks hold JSON, including application/ld+json, importmap, speculationrules. A unicode escape is the one form both read back, which is why JSON needs no special handling:

    <script type="application/ld+json">
    {JSON.stringify({
    "@context": "https://schema.org",
    "@type": "WebSite",
    name: title,
    })}
    </script>

    That block stays parseable whatever the data contains: a </script> inside a value is emitted as \u003c/script>, which JSON.parse decodes back to the original string. The HTML spec suggests <\/script> instead, but that is a JavaScript escape: "\s" is "s", and JSON rejects it, so it would break every JSON data block.

    A data block in a third language is not covered. A Mustache source in <script type="text/template"> holds HTML, which has no escape to borrow: neither form reads back there, and </script cannot appear in such a template at all. That content is yours to guarantee: pass it through raw().

    The rule protects the HTML boundary, not the JavaScript one. It guarantees the element cannot be closed early; it says nothing about the code you built. Untrusted data concatenated into JS source is still an injection: the quote ends the string, ; starts a statement:

    <script>{`const name = "${user.name}";`}</script> // ❌ user.name = '";alert(1);//'
    <script>{`const name = ${JSON.stringify(user.name)};`}</script> // ✅

    dangerouslySetInnerHTML on a <script> turns the rule off entirely. It is trusted HTML by definition, so nothing is neutralised. It is the React idiom for inline scripts because React entity-escapes script children; vincle does not, so the plain child form is both simpler and safer here.

    "Off" is worth taking literally: the rawtext rule above defends the element boundary, and this path skips it, so the content can close the <script> and land markup outside it — <!-- plus <script puts the HTML parser in script-data-double-escaped state, where the first </script> stops closing and the second one closes for real. The plain child form emits the same bytes fully neutralised. It is a RawString, so rule 2 applies: you are vouching for the whole string, element boundary included.

    What the compiler guarantees (and what it doesn't)#

    A common question: if the JSX compiler already turned <div> into a jsx() call, why re-escape anything? Because there is no semantic specification of the JSX transform. The JSX spec defines syntax only. It opens with "JSX is an XML-like syntax extension to ECMAScript without any defined semantics." The jsx/jsxs protocol is a React convention (RFC #107). Toolchains such as TypeScript, esbuild, SWC, Bun, and oxc reimplemented it from scratch, aligning on Babel's behavior by testing, not by a standard. So the runtime cannot assume any particular compiler ran, or that it behaved a certain way.

    The dividing line is sharp: the compiler guarantees the shape of the calls, never the content of the values.

    Guaranteed by the grammar / transform Guaranteed by nothing: the runtime must enforce it
    A tag/attribute name written in JSX is well-formed Names coming from a {...spread} (may be attacker-controlled)
    A literal < cannot appear raw in JSX source text The string values themselves: including static text
    Components are ordinary function calls Attribute values (URL schemes, style, custom attributes)
    key is lifted out of props (3rd jsx argument) Anything reaching jsx(dynamicTag, …) called programmatically

    The subtle trap is HTML entities: the compiler decodes them, so <div>a &amp; b &lt;script&gt;</div> in your source arrives at the runtime as the literal string a & b <script>: the raw <script> is back. This is verified behavior across Bun, TypeScript, and esbuild. Escaping static text is therefore not redundant; it is the only thing standing between that decoded string and the output.

    And critically, jsx("div", props) is a public API: the runtime cannot distinguish a call the compiler emitted from a hand-written jsx(userInput, …). Any "the compiler already checked this" assumption would be unverifiable, which is why vincle validates tag/attribute names and escapes every value regardless of origin.

    The raw() Escape Hatch#

    raw() is the only way to inject unescaped HTML:

    import { renderToString, raw } from "@vincle/core";
    // Raw strings pass through verbatim: no escaping applied
    const html = await renderToString(<div>{raw("<strong>Bold</strong>")}</div>);
    <div><strong>Bold</strong></div>

    Use raw() only when you know the value is safe HTML: typically the output of a trusted Markdown renderer, a template engine, or your own hardcoded markup. Never pass user input directly to raw().

    // @jsxImportSource @vincle/core
    import { renderToString, raw } from "@vincle/core";
    const userInput = '<script>alert("XSS")</script>';
    // ❌ NEVER do this with untrusted input
    const unsafe = await renderToString(<div>{raw(userInput)}</div>);
    // ✅ This is safe (default behavior - always escaped)
    const safe = await renderToString(<div>{userInput}</div>);
    // ✅ raw() is safe with trusted sources (markdown, templates, your code)
    const trustedHtml = "<strong>Hello</strong>";
    const result = await renderToString(<p>{raw(trustedHtml)}</p>);
    <div>
    <script>
    alert("XSS");
    </script>
    </div>
    <div>&lt;script&gt;alert("XSS")&lt;/script&gt;</div>
    <p><strong>Hello</strong></p>

    rawUrl(): a trusted scheme, and nothing else#

    The scheme filter is an allowlist: a relative URL, http:, https:, mailto:, tel:, sms:, and data: carrying an image. Any other scheme is replaced with #blocked, so one the list does not know fails closed rather than open.

    That includes URLs that are neither executable nor documents, which no filter can tell from a dangerous one without being told: a custom protocol handler — phpstorm://, vscode://, slack://, obsidian:// — or geo:. The application tells it with rawUrl(), and the opt-out is narrower than raw() on purpose: a false positive in a security control is only as safe as its workaround, and raw() would hand a single value every guarantee at once:

    // @jsxImportSource @vincle/core
    import { renderToString, rawUrl } from "@vincle/core";
    const html = await renderToString(
    <a href={rawUrl("phpstorm://open?file=src/app.ts")}>open in the IDE</a>,
    );
    <a href="phpstorm://open?file=src/app.ts">open in the IDE</a>

    What rawUrl gives up is the scheme check, and nothing else: the value is still escaped, so unlike raw() it cannot end its attribute, and in content position it is text like any other. That is why it is a separate type and not a flag on raw() — an audit can tell which one a call site reached for, and a RawUrl handed to a plain title by mistake is inert rather than an injection primitive. The type surface agrees: rawUrl() is accepted on the attributes whose value is judged as a URL, and on no others.

    raw() in an attribute#

    raw() promises trusted markup, which is not the same promise as a trusted attribute value. One character separates them: the quote that ends the value. It is therefore the one thing escaped on that path: everything else stays verbatim.

    Escaping it changes nothing a parser reads back, since an attribute value is entity-decoded before it reaches CSS, JS or the DOM: style={raw('font-family:"Inter"')} still means what it says. What it removes is the breakout: an attribute cannot be closed and the tag reopened, even through raw().

    A URL attribute is the deliberate exception in the other direction: a RawString there skips the scheme check, because raw() means "I vouch for this value".

    // @jsxImportSource @vincle/core
    import { renderToString, raw } from "@vincle/core";
    // In content position, raw() is verbatim: that is the whole point.
    const content = await renderToString(<div>{raw("<strong>Bold</strong>")}</div>);
    // In attribute position it is verbatim too, except `"`: a value that carried one
    // would end the attribute and reopen the tag. Escaping it changes nothing a
    // parser reads back: the value is entity-decoded before CSS, JS or the DOM see it.
    const quoted = await renderToString(<div style={raw('font-family:"Inter"')} />);
    // So an attribute cannot be broken out of, even through raw().
    const attack = await renderToString(<a title={raw('" onmouseover="alert(1)')}>x</a>);
    // A URL attribute holding a RawString still skips the scheme check, by design:
    // raw() means "I vouch for this value".
    const vouched = await renderToString(<a href={raw("javascript:doIt()")}>run</a>);
    <div><strong>Bold</strong></div>
    <div style='font-family:"Inter"'></div>
    <a title='" onmouseover="alert(1)'>x</a>
    <a href="javascript:doIt()">run</a>

    Sanitized HTML is still not an attribute value. DOMPurify output contains quotes by construction; it belongs in element content, not in a title.

    Defense in Depth#

    Ten independent layers protect your output. Only raw() bypasses all of them.

    # Layer Mechanism Stops
    1 Type System RawString branded type: explicit trust boundary Accidental trust of untrusted strings
    2 HTML Escaping escapeContent / escapeAttr &, <, > in text; &, <, " in attributes
    3 URL Filtering isSafeScheme: an allowlist of schemes javascript:, vbscript:, unsafe data:, any unknown
    4 Build-time Sanitization @vincle/precompile sanitizes static attributes Literal javascript: and unsafe data: URLs
    static attributes run through the same jsxAttr at build time, before the browser parses any
    as dynamic values, at build time, not at runtime HTML
    5 Tag & Attr Validation isValidTag / isValidAttrName Injection through malformed tag or attribute names
    6 Rawtext Escaping escapeRawTagContent: sub-language, not entities </script>/</style> breakout: unlike Preact/React
    7 Own properties only Props and style bags are read with Object.hasOwn A polluted Object.prototype adding an attribute or a
    declaration to every element rendered
    8 Attribute delimiter A RawString attribute value is verbatim but " A trusted value ending its attribute and reopening the
    is escaped tag
    9 Animated URLs On an animation element, values/to/from/by A javascript: URL reaching href one attribute late
    are URL-judged
    10 Animated handlers attributeName naming on* is refused outright A value becoming a handler the moment the animation runs

    Each layer is independently verifyable: see Invariants.

    Security Boundaries#

    What Vincle protects and what it does not. Every row on the right is rule 1 above: a surface whose purpose is to carry HTML or JavaScript, where the value is escaped but not inspected.

    Protected Not covered
    HTML injection via text content, attributes, URLs Input sanitization (use DOMPurify for untrusted HTML)
    Malformed tag / attribute names CSRF tokens, authentication, session management
    A polluted Object.prototype reaching props or a style bag The pollution itself: fix the merge that allows it
    CSS injection through a style object: names carrying syntax are dropped, values are CSS-escaped CSS injection through a style string: style={untrusted} is an author-written declaration list, escaped for the attribute, not parsed
    </script> / </style> breakout, whatever shape the child is The JavaScript you built: `"${untrusted}"` inside <script>: serialize with JSON.stringify
    A value cannot end its own attribute, whatever the attribute is for Inline event-handler JavaScript: on* strings are serialized like any other attribute and their code is not inspected or sanitized
    Every ordinary attribute value <iframe srcdoc={untrusted}>: the value is decoded and then parsed as a document, so escaping it is not enough
    A URL, or a handler, reached through an SVG animation: values/to/from/by are URL-judged, and an on* target is refused (see below) <meta http-equiv="refresh" content={untrusted}>: the URL lives inside content, which is not a URL attribute
    <script type="text/template"> and other third-language data blocks: no escape exists to borrow (see above)

    SVG animation: a URL, and a handler, one attribute removed#

    <animate attributeName="href" values="javascript:…"> is a href the source never spells: the URL lands on a navigable attribute when the animation runs rather than when the page is parsed, and every browser honours it.

    That is not a name question, it is an element one: values/to/from/by are ordinary data everywhere else — <div to="…"> is as inert as <div id="…"> — and only on the five elements a browser animates with do they carry the URL of whatever attributeName names. So the check is scoped to those five, which is also what makes it free: no real SMIL value ("0;1;0", "rotate(0,360)", "M0,0 L10,10") carries a scheme, so a value that is not a URL is untouched.

    The value is judged item by item: values is a ;-separated list the animation applies in turn, so "#;javascript:…" is blocked whole. Tags and names are matched without regard to case, as the browser's parser does: <animateMotion>, attributename and VALUES get the same check.

    A target that is not a literal is judged as a URL too, rather than skipped: the element says it is animating something, and treating the unknown target as the dangerous one is the direction a bypass would not take.

    // @jsxImportSource @vincle/core
    import { renderToString } from "@vincle/core";
    const html = await renderToString(
    <svg>
    <a>
    <animate attributeName="href" values={untrusted} />
    <text>click me</text>
    </a>
    </svg>,
    );
    <svg>
    <a>
    <animate attributeName="href" values="#blocked"></animate>
    <text>click me</text>
    </a>
    </svg>

    The other half cannot be judged from a name: to="alert(1)" has no scheme, so it passes every URL gate while becoming a handler the moment the animation runs — and attributeName="onmouseover" is the only trace of it. That one needs two attributes to answer, so buildAttrs refuses the pair:

    // @jsxImportSource @vincle/core
    import { renderToString } from "@vincle/core";
    await renderToString(
    <svg>
    <a>
    <set attributeName="onclick" to="alert(1)" />
    </a>
    </svg>,
    );
    // Error: attributeName="onclick" animates an event handler

    A handler written as a literal (<div onclick="alert(1)" />) is still yours, escaped like any other attribute. The distinction is provenance, and it is visible: one is code in the source, the other is a value indistinguishable from a field of user data by the time it arrives.

    @vincle/precompile asks the same question before inlining such an element, and leaves it to the runtime when the answer is yes — so the two paths cannot disagree about it.

    If you need to render untrusted user-generated HTML, run it through a sanitizer first, then pass the result via raw(): in element content, never as an attribute value.

    // @jsxImportSource @vincle/core
    import { renderToString } from "@vincle/core";
    const untrusted = "red;position:fixed;top:0;left:0;width:100vw;height:100vw";
    // A style object is inspected: a property name carrying CSS syntax is dropped,
    // and a value carrying `;` or `\` is CSS-escaped: the browser reads `\;` as a
    // literal `;`, so the smuggled declarations stay inside the value.
    const bag = await renderToString(<div style={{ color: untrusted }} />);
    // Legitimate values survive that escaping unchanged in meaning.
    const dataUri = await renderToString(
    <div style={{ background: "url(data:image/png;base64,iVBORw0KGgo=)" }} />,
    );
    // A style *string* is an author-written declaration list: it is escaped for the
    // attribute, not parsed as CSS. Never build one from untrusted input.
    const asString = await renderToString(<div style={untrusted} />);
    <div style="color:red\;position:fixed\;top:0\;left:0\;width:100vw\;height:100vw"></div>
    <div style="background:url(data:image/png\;base64,iVBORw0KGgo=)"></div>
    <div style="red;position:fixed;top:0;left:0;width:100vw;height:100vw"></div>

    Best Practices#

    1. Never pass user input to raw(). If you need to render user HTML, sanitize it with a dedicated library (DOMPurify, sanitize-html) first.
    2. Prefer JSX string values over raw(). Default escaping is safe.
    3. Use RawString only as a return type for functions that generate trusted HTML (Markdown renderers, helpers).
    4. Enable the ESLint plugin to catch React-only patterns, javascript: URLs and function-valued attributes at the source, before they are a render-time serialization error. It does not judge raw(): nothing can decide from the syntax whether a value is trusted; that call is yours, which is why raw( is deliberately greppable.

    Auditing raw() call sites#

    Because raw() is the only escape hatch, every call site is a trust boundary that an audit must cover. The function name is stable and grepable:

    Terminal window
    # All raw() and rawUrl() call sites across the project
    grep -rn 'raw(' --include='*.tsx' --include='*.ts' src/
    grep -rn 'rawUrl(' --include='*.tsx' --include='*.ts' src/

    Two distinct types carry two different promises:

    Function In content In attribute Grep target
    raw() Emitted as verbatim markup Escaped as a value, trust for scheme raw(
    rawUrl() N/A (type error) Trust for scheme only, still escaped rawUrl(

    A RawString is markup — it bypasses escaping entirely. A RawUrl is a URL — it bypasses only the scheme filter. The distinction is enforced at both the type level (RawString and RawUrl are nominally distinct) and runtime (instanceof checks treat them differently). An audit should list every raw() call and verify the value originates from a trusted source (sanitizer output, template engine, hardcoded markup), never from user input.

    Invariants#

    Each invariant is enforced by property-based (fuzz) or unit tests:

    # Guarantee Enforced by
    I‑01 No raw < or > from untrusted input escape.test.ts
    I‑02 No scheme off the allowlist passes through, obfuscated or not escape.test.ts: differential vs new URL()
    I‑04 Precompile ≡ VNode path, value kind by value kind precompile-equivalence.test.ts (1000 values)
    I‑05 Static path ≡ tree walk: byte-identical path-equivalence.test.ts (1000 trees)
    I‑06 Concurrent renders isolated: no cross-request leakage context.test.ts, execution-order.test.ts
    I‑07 The document never depends on component latency execution-order.test.ts
    I‑08 No implicit RawString creation Code review
    I‑09 Invalid tag names fail fast, at construction (throw TypeError) serialize-static.test.ts, types.test.ts
    I‑10 Every production regex is declared, safe, and matches the source redos-audit.test.ts
    I‑11 An inherited property is never an attribute or a declaration attrs.test.ts
    I‑12 A RawString attribute value cannot end its attribute attrs.test.ts
    I‑13 Children on a void element are refused, never reparented serialize.test.ts, path-equivalence.test.ts
    I‑14 An SVG animation cannot deliver a URL or a handler attrs.test.ts, no-javascript-urls.test.ts

    ASVS Compliance#

    Vincle targets ASVS Level 3 (OWASP Application Security Verification Standard). Each row below is held by the tests listed in Invariants.

    # Requirement Lvl Status
    1.1.2 Output encoding as final step 2 ✅
    1.2.1 Context-relevant output encoding (HTML, attr, URL) 1 ✅
    1.2.2 Safe URL protocols only 1 ✅
    1.2.3 JS context: </script> breakout prevention1 1 ✅
    1.3.7 Template injection protection: no dynamic templates 2 ✅
    1.3.12 ReDoS-free regex: inventory derived from the source 3 ✅
    15.1.4 Zero runtime dependencies 3 ✅
    15.1.5 raw() documented as dangerous 3 ✅
    16.5.4 Fail-stop on error: no partial/corrupted output 3 ✅

    References#

    Footnotes

    1. The element boundary, in the form the sub-language reads back. The JavaScript string context is the author's: serialize untrusted data with JSON.stringify rather than concatenating it into source. ↩