Skip to content

Commit 7d78eb3

Browse files
committed
feat: add HTML sanitization pipe (#114)
1 parent 9119123 commit 7d78eb3

4 files changed

Lines changed: 121 additions & 8 deletions

File tree

api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"compression": "^1.8.1",
2525
"reflect-metadata": "^0.1.13",
2626
"rxjs": "^7.8.1",
27+
"sanitize-html": "^2.17.4",
2728
"socket.io": "^4.8.3",
2829
"swagger-ui-express": "^5.0.1"
2930
},
@@ -32,6 +33,7 @@
3233
"@types/compression": "^1.8.1",
3334
"@types/express": "^5.0.6",
3435
"@types/node": "^20.10.0",
36+
"@types/sanitize-html": "^2.16.1",
3537
"typescript": "^5.3.0"
3638
}
3739
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Injectable, PipeTransform } from "@nestjs/common"
2+
import { sanitizeUserText } from "./sanitize"
3+
4+
/**
5+
* Strips HTML/script tags from every string value reachable through
6+
* the incoming payload. Numbers, booleans, Date instances, Buffers,
7+
* and other non-string scalars pass through untouched — the pipe is
8+
* intentionally narrow so it never coerces data types unexpectedly.
9+
*
10+
* Apply globally via:
11+
*
12+
* app.useGlobalPipes(new SanitizeStringsPipe(), new ValidationPipe(...))
13+
*
14+
* Order matters: sanitisation runs BEFORE class-validator so DTO
15+
* validators see already-stripped text and `forbidNonWhitelisted`
16+
* decisions stay deterministic.
17+
*/
18+
@Injectable()
19+
export class SanitizeStringsPipe implements PipeTransform {
20+
transform(value: unknown): unknown {
21+
return sanitizeValue(value)
22+
}
23+
}
24+
25+
function sanitizeValue(value: unknown): unknown {
26+
if (typeof value === "string") {
27+
return sanitizeUserText(value)
28+
}
29+
30+
if (Array.isArray(value)) {
31+
return value.map((item) => sanitizeValue(item))
32+
}
33+
34+
if (value && typeof value === "object") {
35+
// Skip well-known non-plain-object values so we don't tear apart
36+
// Buffers, Dates, or stream-like things if a future controller
37+
// ever receives them via the body / query pipeline.
38+
if (
39+
value instanceof Date ||
40+
value instanceof RegExp ||
41+
value instanceof Map ||
42+
value instanceof Set ||
43+
(typeof Buffer !== "undefined" && Buffer.isBuffer(value))
44+
) {
45+
return value
46+
}
47+
48+
const source = value as Record<string, unknown>
49+
const out: Record<string, unknown> = {}
50+
for (const key of Object.keys(source)) {
51+
out[key] = sanitizeValue(source[key])
52+
}
53+
return out
54+
}
55+
56+
return value
57+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import sanitizeHtml from "sanitize-html"
2+
3+
/**
4+
* Strip every HTML tag and script-bearing construct from a string while
5+
* leaving the human-readable text intact.
6+
*
7+
* "<b>Hello</b> <script>alert(1)</script> world" -> "Hello world"
8+
* "<a href='javascript:bad()'>click</a>" -> "click"
9+
* "café — naïve" -> "café — naïve"
10+
*
11+
* The sanitiser is configured to strip — not encode — tags so the
12+
* persisted value never carries entity-encoded markup that downstream
13+
* consumers would have to re-decode. Whitespace inside discarded tags
14+
* is preserved so adjacent words don't merge.
15+
*/
16+
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
17+
allowedTags: [],
18+
allowedAttributes: {},
19+
// `disallowedTagsMode: "discard"` (the default) strips the tag and
20+
// keeps the text content. We further block <style> / <script> body
21+
// text via `nonTextTags` so an inline <script>…</script> doesn't
22+
// leave its contents behind.
23+
nonTextTags: ["style", "script", "textarea", "option", "noscript"],
24+
// Preserve raw text characters without HTML-entity encoding so e.g.
25+
// an ampersand round-trips as "&" rather than "&amp;".
26+
disallowedTagsMode: "discard",
27+
parser: { decodeEntities: true },
28+
allowedSchemes: [],
29+
allowedSchemesByTag: {},
30+
}
31+
32+
/**
33+
* Decode the small set of HTML entities that `sanitize-html` re-emits
34+
* on output. We intentionally limit the set to characters the user
35+
* obviously typed as plain text (e.g. `&`, `<`, `>`, single + double
36+
* quotes) so the round-trip stays lossless for normal prose without
37+
* re-introducing the very markup we just stripped.
38+
*/
39+
function decodeBasicEntities(text: string): string {
40+
return text
41+
.replace(/&amp;/g, "&")
42+
.replace(/&lt;/g, "<")
43+
.replace(/&gt;/g, ">")
44+
.replace(/&quot;/g, '"')
45+
.replace(/&#x27;/g, "'")
46+
.replace(/&#39;/g, "'")
47+
}
48+
49+
export function sanitizeUserText(input: string): string {
50+
if (typeof input !== "string") return ""
51+
const stripped = sanitizeHtml(input, SANITIZE_OPTIONS)
52+
return decodeBasicEntities(stripped)
53+
// collapse the runs of whitespace introduced where tags used to be
54+
.replace(/[\s\u00a0]+/g, " ")
55+
.trim()
56+
}

api/src/main.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NestFactory } from "@nestjs/core"
33
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"
44
import compression from "compression"
55
import { AppModule } from "./app.module"
6+
import { SanitizeStringsPipe } from "./common/sanitization/sanitize-strings.pipe"
67

78
// Bypass compression when the response is smaller than this. Anything
89
// under ~1 KB doesn't benefit from gzip and the per-request CPU cost
@@ -18,12 +19,7 @@ async function bootstrap() {
1819
credentials: false,
1920
})
2021

21-
// Global response compression. The middleware honours the
22-
// `Accept-Encoding` request header (gzip/deflate/br when available)
23-
// and writes the matching `Content-Encoding` response header. Setting
24-
// `threshold` skips small payloads; setting `filter` keeps existing
25-
// `Content-Encoding` values intact and lets callers opt out via
26-
// `x-no-compression`.
22+
// Global response compression.
2723
app.use(
2824
compression({
2925
threshold: COMPRESSION_THRESHOLD_BYTES,
@@ -34,9 +30,11 @@ async function bootstrap() {
3430
}),
3531
)
3632

37-
// Global request validation: strip unknown properties, reject payloads
38-
// with non-whitelisted keys, and auto-transform primitives to DTO types.
33+
// Strip HTML/script tags from every string in the incoming payload
34+
// before any other pipe runs. Order matters: sanitisation runs BEFORE
35+
// ValidationPipe so DTO validators see already-stripped text.
3936
app.useGlobalPipes(
37+
new SanitizeStringsPipe(),
4038
new ValidationPipe({
4139
whitelist: true,
4240
forbidNonWhitelisted: true,

0 commit comments

Comments
 (0)