typescript
43 lines · 7 steps
Building a configurable HTML sanitizer allowlist
A whitelist-driven wrapper around sanitize-html that opts into links and images while hardening every anchor tag.
Explained by
highlit
1import sanitizeHtml from "sanitize-html";
2
3interface RichTextOptions {
4 allowImages?: boolean;
5 allowLinks?: boolean;
6}
7
8const BASE_TAGS = ["p", "br", "strong", "em", "u", "ul", "ol", "li", "blockquote", "code", "pre", "h2", "h3"];
9
10export function sanitizeRichText(dirty: string, opts: RichTextOptions = {}): string {
11 const allowedTags = [...BASE_TAGS];
12 const allowedAttributes: sanitizeHtml.IOptions["allowedAttributes"] = {};
13
14 if (opts.allowLinks) {
15 allowedTags.push("a");
16 allowedAttributes.a = ["href", "title", "target", "rel"];
17 }
18
19 if (opts.allowImages) {
20 allowedTags.push("img");
21 allowedAttributes.img = ["src", "alt", "width", "height"];
22 }
23
24 return sanitizeHtml(dirty, {
25 allowedTags,
26 allowedAttributes,
27 allowedSchemes: ["http", "https", "mailto"],
28 allowedSchemesByTag: { img: ["http", "https", "data"] },
29 disallowedTagsMode: "discard",
30 transformTags: {
31 a: (tagName, attribs) => ({
32 tagName,
33 attribs: {
34 ...attribs,
35 target: "_blank",
36 rel: "noopener noreferrer nofollow",
37 },
38 }),
39 },
40 exclusiveFilter: (frame) =>
41 frame.tag === "a" && !frame.text.trim() && !frame.attribs.href,
42 });
43}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1An allowlist approach is safer than blocklisting because anything unlisted is dropped by default.
- 2Optional feature flags let you widen the permitted markup only when the caller explicitly needs it.
- 3Rewriting attributes on the way out lets you enforce security defaults like noopener on every link.
Related explainers
typescript
import { useCallback, useEffect, useRef, useState } from "react"; interface Page<T> { items: T[];
A cursor-based infinite scroll hook in React
custom-hooks
pagination
intersection-observer
Intermediate
9 steps
typescript
type NestedValue = string | NestedValue[] | { [key: string]: NestedValue }; function parseFieldPath(name: string): string[] { const match = name.match(/^([^\[\]]+)((?:\[[^\[\]]*\])*)$/);
Parsing bracketed form field names into nested objects
parsing
recursive-types
regex
Intermediate
8 steps
typescript
import { Component } from '@angular/core'; import { NgForm } from '@angular/forms'; interface SignupModel {
How template-driven forms validate in Angular
forms
two-way-binding
validation
Intermediate
9 steps
typescript
import { Injectable, signal, computed } from '@angular/core'; export type ToastKind = 'success' | 'error' | 'info' | 'warning';
Building a signal-based toast service in Angular
signals
state-management
dependency-injection
Intermediate
8 steps
typescript
import { Controller } from '@nestjs/common'; import { MessagePattern, Payload,
Manual RabbitMQ acks in a NestJS controller
microservices
message-queue
acknowledgement
Intermediate
8 steps
typescript
type LazyImageOptions = { rootMargin?: string; loadedClass?: string; };
Lazy-loading images with IntersectionObserver
intersectionobserver
lazy-loading
performance
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-configurable-html-sanitizer-allowlist-explained-typescript-d65e/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.