Typlet

Security

Typlet renders formulas that pages often take from untrusted people: chat messages, comments, issues and shared links. This document describes what Typlet guarantees for such input, how the guarantees are tested, and what a page using Typlet still needs to do.

Reporting a vulnerability

If a formula can break one of the guarantees below, by putting markup into the output that Typlet didn't make, or by making a render run without bound, please report it privately: use Report a vulnerability on the repository's Security tab, and don't open a public issue. Include the formula, the options, and Typlet's version. The latest release gets the fix.

Threat model

An attacker controls the formula's source. They do not control the page's code, so Typlet's options, such as trust, errorColor, preamble and fallback, are trusted. Typlet aims to guarantee two things for any formula:

  1. No markup injection. The output contains only the elements and attributes Typlet makes, with no scripts, event handlers, styles from the formula, or links the page didn't allow.
  2. Bounded work. Rendering stops quickly with a TypletError of kind limit, whatever the formula computes.

Markup

Every output is built from escaped text and fixed element and attribute names:

test/security.test.ts renders hostile formulas in every output, inline and in display, with and without throwOnError. It parses each output as a browser does, and checks it against the lists of elements and attributes, the safe style characters, and the allowed links. It also renders 400 random formulas made of characters that matter to HTML, CSS, URLs and Typst.

Work

Formulas can compute: Typst's code has functions, recursion and growing values. Typlet bounds each formula's work (docs/DESIGN.md §6.6):

Budget Default Option
Function calls 30,000 maxCalls
Call depth 80, as in Typst —
Parser nesting 256, as in Typst —
Expression depth 1,024 nested expressions, counted across the function calls in progress, including the rest of a block after a rule and the body of a closure being defined —
Nesting depth 256 levels of content being resolved, laid out or converted, and of values being compared or shown —
Value steps 100,000 per formula, comparing and showing values —
Items in an array or dictionary 10,000 maxCollectionSize
Characters in a string 100,000 maxStringLength
Content elements, and parts of stretched glyphs 10,000 each maxElements
Sizes of spacing, boxes and text, and every length in the output 1,000 em maxSize
Output 1,000,000 characters maxOutputSize

The first three of the limits without an option bound recursion, which follows the formula's structure: a chain of thousands of operators, definitions that nest content thousands deep, a chain of attachments, a closure whose body is such a chain, a style that feeds an element its own body, such as #set box(box()), and values that share parts, such as (x, x) built thirty times over, all stop with a limit error rather than overflowing the native stack or taking exponential time. The parser follows a chain of else if branches in a loop, where typst-syntax recurses. Every length the HTML and the MathML carry is checked against the size budget as it is written, whatever computed it, so no gap, stroke, tracking or shift reaches the page beyond it.

npm run check:budgets checks that the built bundle stops each abusive formula in test/budget-cases.mjs within 50 ms: recursion bombs, repetition, content that doubles, deep nesting and huge sizes, chains of operators, attachments and else if branches, content nested by definitions, styles that feed content into itself, values that share parts, chains of ever taller delimiters, and huge lengths from gaps, strokes and tracking. It also checks that formulas whose work must stay linear in their size render within 50 ms, such as raw code tagged as Typst, which Typlet parses and highlights again, with a long chain of field accesses. Typlet has no loops, file access, packages, eval or introspection (docs/DESIGN.md §6.3), so no formula reaches outside its own evaluation.

Differential fuzzing

Two fuzzers compare Typlet with the real Typst compiler, through the oracle in oracle/:

Before the 0.1.0 release, 40,000 formulas of fuzz:eval, seeds 20 to 27, and 100,000 inputs of fuzz rendered with no divergence, crash or hang. Earlier sweeps found divergences in how Typst's HTML export treats content that isn't math, in whitespace, paragraphs, smart quotes and boxes, and in tracking, which are now fixed, and kept as tests.

What pages need to do