Skip to content

Commit 02e55f2

Browse files
committed
feat(security): add global input sanitization for string fields
Add a SanitizationPipe registered after the global ValidationPipe so every request value is first rejected when malformed, then recursively cleaned of script-injection vectors (HTML/script tags, stray angle brackets and non-printable control characters) before reaching route handlers. Sanitization rules live in a shared sanitize util and apply to all string fields in bodies, queries and route params. Closes #83.
1 parent 42d7953 commit 02e55f2

3 files changed

Lines changed: 89 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Injectable, PipeTransform } from '@nestjs/common';
2+
import { sanitizeDeep } from '../sanitization/sanitize.util';
3+
4+
/**
5+
* Issue #83 - global input sanitization pipe.
6+
*
7+
* Registered after the ValidationPipe so it operates on values that have
8+
* already been validated and transformed into their DTO shape (malformed
9+
* objects are therefore rejected before they ever reach this pipe). It then
10+
* recursively strips dangerous characters from every string field, removing
11+
* script-injection payloads before they reach route handlers or persistence.
12+
*/
13+
@Injectable()
14+
export class SanitizationPipe implements PipeTransform {
15+
transform(value: unknown): unknown {
16+
return sanitizeDeep(value);
17+
}
18+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Issue #83 - shared text sanitization rules.
3+
*
4+
* These helpers neutralize the characters most commonly used in script
5+
* injection / XSS payloads so that no user-supplied string reaches a route
6+
* handler (or the database) with an embedded markup or control sequence.
7+
*/
8+
9+
// Any `<...>` sequence - strips full HTML/script tags.
10+
const HTML_TAGS = /<[^>]*>/g;
11+
// Stray angle brackets left after tag removal.
12+
const ANGLE_BRACKETS = /[<>]/g;
13+
14+
/**
15+
* Drops non-printable control characters (code points 0x00-0x1F and 0x7F)
16+
* while preserving tab (0x09), newline (0x0A) and carriage return (0x0D),
17+
* which are legitimate text content.
18+
*/
19+
function stripControlChars(value: string): string {
20+
let out = '';
21+
for (const ch of value) {
22+
const code = ch.codePointAt(0) ?? 0;
23+
const isControl =
24+
(code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
25+
code === 0x7f;
26+
if (!isControl) {
27+
out += ch;
28+
}
29+
}
30+
return out;
31+
}
32+
33+
/** Removes dangerous characters from a single string value. */
34+
export function sanitizeString(value: string): string {
35+
return stripControlChars(value)
36+
.replace(HTML_TAGS, '')
37+
.replace(ANGLE_BRACKETS, '');
38+
}
39+
40+
/**
41+
* Recursively sanitizes every string contained in a value, mutating objects
42+
* and arrays in place so DTO class instances keep their type while their
43+
* string fields are cleaned. Non-string primitives are returned untouched.
44+
*/
45+
export function sanitizeDeep<T>(value: T): T {
46+
if (typeof value === 'string') {
47+
return sanitizeString(value) as unknown as T;
48+
}
49+
50+
if (Array.isArray(value)) {
51+
for (let i = 0; i < value.length; i++) {
52+
value[i] = sanitizeDeep(value[i]);
53+
}
54+
return value;
55+
}
56+
57+
if (value !== null && typeof value === 'object') {
58+
const record = value as Record<string, unknown>;
59+
for (const key of Object.keys(record)) {
60+
record[key] = sanitizeDeep(record[key]);
61+
}
62+
return value;
63+
}
64+
65+
return value;
66+
}

src/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import compression from 'compression';
55
import { AppModule } from './app.module';
66
import { ConfigService } from './config/config.service';
77
import { JsonLoggerService } from './common/logger/json-logger.service';
8+
import { SanitizationPipe } from './common/pipes/sanitization.pipe';
89

910
async function bootstrap() {
1011
// Bootstrap with a temporary console logger so early errors are visible,
@@ -65,14 +66,17 @@ async function bootstrap() {
6566
// (1 KB) avoids the overhead for tiny payloads that wouldn't benefit.
6667
app.use(compression({ threshold: 1024 }));
6768

68-
// ── Validation pipe ────────────────────────────────────────────────────────
69+
// ── Validation + sanitization pipes (issue #83) ───────────────────────────
70+
// ValidationPipe rejects malformed objects before they reach handlers, then
71+
// SanitizationPipe strips dangerous characters from every string field.
6972
app.useGlobalPipes(
7073
new ValidationPipe({
7174
whitelist: true,
7275
transform: true,
7376
forbidNonWhitelisted: true,
7477
transformOptions: { enableImplicitConversion: true },
7578
}),
79+
new SanitizationPipe(),
7680
);
7781

7882
// ── Graceful shutdown ──────────────────────────────────────────────────────

0 commit comments

Comments
 (0)