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.
| Context | Characters escaped | Function |
|---|---|---|
| Text content | &<> | escapeContent |
| Attribute values | &<"
| escapeAttr |
| URL attributes | Schemes off the allowlist replaced with | isSafeScheme |
Rawtext content ( |
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 automaticallyconst userInput = '<script>alert("xss")</script>';const html = await renderToString(<p>{userInput}</p>);
// Use raw() only for trusted HTML you generated yourselfconst trustedHtml = "<em>rendered from your own markdown</em>";const html2 = await renderToString(<article>{raw(trustedHtml)}</article>);<p><script>alert("xss")</script></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 && 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 & b <script></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 appliedconst 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/coreimport { renderToString, raw } from "@vincle/core";
const userInput = '<script>alert("XSS")</script>';
// ❌ NEVER do this with untrusted inputconst 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><script>alert("XSS")</script></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/coreimport { 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/coreimport { 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/coreimport { 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/coreimport { renderToString } from "@vincle/core";
await renderToString( <svg> <a> <set attributeName="onclick" to="alert(1)" /> </a> </svg>,);// Error: attributeName="onclick" animates an event handlerA 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/coreimport { 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#
- Never pass user input to
raw(). If you need to render user HTML, sanitize it with a dedicated library (DOMPurify, sanitize-html) first. - Prefer JSX string values over
raw(). Default escaping is safe. - Use
RawStringonly as a return type for functions that generate trusted HTML (Markdown renderers, helpers). - 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 judgeraw(): nothing can decide from the syntax whether a value is trusted; that call is yours, which is whyraw(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:
# All raw() and rawUrl() call sites across the projectgrep -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#
- OWASP ASVS 5.0
- V1: Encoding and Sanitization
- V15: Secure Coding
- V16: Error Handling
- OWASP XSS Prevention Cheat Sheet
Footnotes
-
The element boundary, in the form the sub-language reads back. The JavaScript string context is the author's: serialize untrusted data with
JSON.stringifyrather than concatenating it into source. ↩