---
title: Security
---

# 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).

## 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](#security-boundaries) 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.

<table>
  <thead>
    <tr>
      <th>Context</th>
      <th>Characters escaped</th>
      <th>Function</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Text content</td>
      <td>
        <code>&amp;</code> <code>&lt;</code> <code>&gt;</code>
      </td>
      <td>
        <code>escapeContent</code>
      </td>
    </tr>
    <tr>
      <td>Attribute values</td>
      <td>
        <code>&amp;</code> <code>&lt;</code> <code>&quot;</code>
        <br />
        <small>
          <code>&gt;</code> is deliberately skipped. It can't end a double-quoted value
        </small>
      </td>
      <td>
        <code>escapeAttr</code>
      </td>
    </tr>
    <tr>
      <td>URL attributes</td>
      <td>
        Schemes off the allowlist replaced with <code>#blocked</code>
      </td>
      <td>
        <code>isSafeScheme</code>
      </td>
    </tr>
    <tr>
      <td>
        Rawtext content
        <br />
        <small>
          (<code>script</code>, <code>style</code>)
        </small>
      </td>
      <td>
        <code>\u003c/script&gt;</code> in <code>script</code>, <code>&lt;\/style&gt;</code> in{" "}
        <code>style</code>
        <br />
        <small>scripts escaped (unlike Preact), styles too (unlike React)</small>
      </td>
      <td>
        <code>escapeRawTagContent</code>
      </td>
    </tr>
  </tbody>
</table>

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.

```tsx
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>);
```

```html
<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:

```tsx
<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-raw-escape-hatch).

**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:

```tsx
<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](https://facebook.github.io/jsx/) 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:

```tsx
import { renderToString, raw } from "@vincle/core";

// Raw strings pass through verbatim: no escaping applied
const html = await renderToString(<div>{raw("<strong>Bold</strong>")}</div>);
```

```html
<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()`.

```tsx
// @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>);
```

```html
<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:

```tsx
// @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>,
);
```

```html
<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".

```tsx
// @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>);
```

```html
<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](#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](#svg-animation-a-url-and-a-handler-one-attribute-removed)) | `<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](#escaping-inside-script-and-style)) |

### 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.

```tsx
// @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>,
);
```

```html
<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:

```tsx
// @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.

```tsx
// @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} />);
```

```html
<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:

```bash
# 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](#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 prevention[^js]    |   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** |   ✅   |

[^js]:
    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.

## References

- [OWASP ASVS 5.0](https://asvs.dev/)
- [V1: Encoding and Sanitization](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x10-V1-Encoding-and-Sanitization.md)
- [V15: Secure Coding](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x1B-V15-Secure-Coding-and-Architecture.md)
- [V16: Error Handling](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x1C-V16-Logging-and-Error-Handling.md)
- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
