Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

Packages

Sanitize

Allowlist HTML sanitization with safe presets, validated custom policies and a Standard Schema wrapper for request validation.

On this page 5 sections
Terminal
bun add @knead-js/sanitize

@knead-js/sanitize cleans untrusted HTML (comments, rich-text fields, imported content) down to an allowlist of tags, attributes and URL schemes before you store or render it. It is built on sanitize-html, pinned to an exact version, and adds presets plus a policy check that refuses configurations that would let script back in.

Nothing is sanitized automatically: you call sanitize() yourself, or put sanitizedHtml() in a route schema. Sanitize only input that is meant to be rendered as HTML. For plain text, escape it when you render it instead. For request schemas in general, see validation; for the rest of the security model, security.

#sanitize()

sanitize(html: string, policy: SanitizePolicy | PresetName = 'basic'): string returns html with everything outside the policy removed. A string policy must be one of the preset names, exactly ('Basic' or 'toString' are not): any other name throws a TypeError that starts with Unknown sanitize preset.

TypeScript
export const preview = sanitize('<p onclick="steal()">Hi <b>there</b></p><script>steal()</script>');
// '<p>Hi <b>there</b></p>'

export const plain = sanitize('<p>Hi <b>there</b></p>', 'strict');
// 'Hi there'

What always happens, whatever the policy:

  • Tags outside allowedTags are removed and their text kept, except the contents of script, style, textarea and option, which are dropped with the tag.
  • Attributes outside allowedAttributes are removed.
  • Every <a> gets rel="noopener noreferrer", replacing any rel it had.
  • URL attributes (href, src, cite, action, background, poster, data, xlink:href, formaction, ping) keep a URL only if its scheme is allowed: http, https, mailto and tel by default, and only http and https on <img>. Relative URLs (/docs, #top) are kept; protocol-relative ones (//evil.example) and javascript:, vbscript: and data: URLs are removed.
  • An <img> left without a src is removed.

#Presets

POLICIES maps each PresetName ('strict' | 'basic' | 'rich') to a frozen SanitizePolicy:

PresetTagsAttributes
strictnone: text onlynone
basic (default)b, i, em, strong, a, p, br, codea: href
richeverything in basic, plus ul, ol, li, h1 to h6, blockquote, pre, imga: href; img: src, alt

The presets are frozen, arrays included. Build a custom policy from one by copying:

TypeScript
export const commentPolicy: SanitizePolicy = {
  allowedTags: [...POLICIES.basic.allowedTags, 'blockquote'],
  allowedAttributes: { a: ['href', 'title'] },
  allowedSchemes: ['https'],
};

export const comment = sanitize(
  '<a href="http://example.com" title="Docs">docs</a> <a href="https://example.com">docs</a>',
  commentPolicy,
);
// '<a title="Docs" rel="noopener noreferrer">docs</a> <a href="https://example.com" rel="noopener noreferrer">docs</a>'

#SanitizePolicy

FieldTypeDefaultDescription
allowedTagsreadonly string[]requiredTags that are kept.
allowedAttributesReadonly<Record<string, readonly string[]>>requiredAttributes kept, per tag name. Name each attribute: globs are rejected.
allowedSchemesreadonly string[]the whole allowlistNarrows the URL schemes. Every entry must be on the allowlist (http, https, mailto, tel, plus extraSchemes). <img> never gets mailto or tel.
extraSchemesreadonly string[]noneExtends the allowlist, for example ['ftp'].
allowClobberingAttributesbooleanfalseAllows id and name attributes.

#Policy checks

Every call to sanitize() (and sanitizedHtml() with a custom policy, when it is created) checks the policy first and throws a TypeError if it would re-enable a known XSS vector. The checks are case-insensitive, so SCRIPT is caught like script, and they run on every call, so a policy mutated after its first use is caught too.

RejectedWhy
Tags script, style, iframe, object, embed, svg, math, base, meta, link, form, input, textarea, select, button, noscript, templateThey run script, load content, or change how the page parses.
Tags xmp, noembed, noframes, plaintext, title, frame, frameset, applet, portalRaw-text and legacy elements behind parser differentials and mutation XSS, and embedding elements.
Attributes starting with on, and style, srcdoc, formactionEvent handlers, CSS and embedded documents.
Attribute names containing *sanitize-html expands globs, so '*' or 'o*' would match onclick.
id and name, unless allowClobberingAttributes: trueThey can shadow DOM globals (<a id="cookie">) in some page scripts.
javascript, vbscript and data in extraSchemes or allowedSchemesThey run script or embed arbitrary content.
An extraSchemes entry that is not a scheme nameOnly [a-z][a-z0-9+.-]* is accepted.
An allowedSchemes entry not on the allowlistAdd it to extraSchemes first.

#sanitizedHtml()

sanitizedHtml(inner: StandardSchemaV1, policy: SanitizePolicy | PresetName = 'basic') returns a Standard Schema whose input is inner's input and whose output is a string. It validates the value with inner; if that fails, its issues are returned unchanged. Otherwise it sanitizes the output with policy. An inner that outputs something other than a string produces the issue sanitizedHtml() requires the inner schema to output a string. The policy is checked when sanitizedHtml() is called, so a bad policy, or a preset name that is not one of 'strict', 'basic' and 'rich', fails at startup.

It works as a whole route schema, for example a body sent as text/plain:

notes.controller.ts
@Controller('/notes')
export class NotesController {
  // The whole body is the HTML: send it as text/plain.
  @Post('/', { body: sanitizedHtml(v.pipe(v.string(), v.maxLength(10_000)), 'rich') })
  create(ctx: Ctx<{ body: string }>) {
    return { html: ctx.body };
  }
}

Schema libraries such as valibot or zod accept only their own schemas inside an object, so for a field of an object body, call sanitize() in the library's transform step:

posts.controller.ts
const CreatePost = v.object({
  title: v.pipe(v.string(), v.maxLength(200)),
  body: v.pipe(
    v.string(),
    v.maxLength(50_000),
    v.transform((html) => sanitize(html, 'basic')),
  ),
});

@Controller('/posts')
export class PostsController {
  @Post('/', { body: CreatePost })
  create(ctx: Ctx<{ body: v.InferOutput<typeof CreatePost> }>) {
    return ctx.body;
  }
}

Limit the length before sanitizing, as above: the request body limit caps the size, but a smaller field limit keeps sanitizing cheap.