Skip to content

Commit ed95b99

Browse files
committed
Implement request sanitizer middleware
1 parent abbe04d commit ed95b99

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

app/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { randomBytes } from 'crypto';
99

1010
import { version as appVersion } from './package.json';
1111
import { getLogger, httpLogger } from './src/components/log';
12+
import { requestSanitizer } from './src/middleware/requestSanitizer';
1213
import router from './src/routes';
1314
import { Problem } from './src/utils';
1415
import { DEFAULTCORS } from './src/utils/constants/application';
@@ -26,6 +27,7 @@ app.use(compression());
2627
app.use(cors(DEFAULTCORS));
2728
app.use(express.json({ limit: config.get('server.bodyLimit') }));
2829
app.use(express.urlencoded({ extended: true }));
30+
app.use(requestSanitizer);
2931
app.set('query parser', 'extended');
3032

3133
app.use((_req, res, next) => {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
3+
/**
4+
* Removes all NUL characters from a string
5+
* @param str The string to sanitize
6+
* @returns The sanitized string if it includes NUL character(s), otherwise the original string
7+
*/
8+
function stripNullCharactersFromString(str: string): string {
9+
// eslint-disable-next-line no-control-regex
10+
return str.replace(/\u0000/g, '');
11+
}
12+
13+
/**
14+
* Recursively sanitize JSON-like data (strings, arrays, plain objects)
15+
* @param value The value to sanitize
16+
* @returns The sanitized value if a string otherwise the original value
17+
*/
18+
function sanitize<T>(value: T): T | string {
19+
// Strip NUL characters from strings
20+
if (typeof value === 'string') return stripNullCharactersFromString(value);
21+
22+
// Recursively sanitize arrays items
23+
if (Array.isArray(value)) {
24+
value.forEach((item, index) => {
25+
value[index] = sanitize(item);
26+
});
27+
return value;
28+
}
29+
30+
// Recursively sanitize plain objects
31+
if (value && typeof value === 'object') {
32+
for (const [k, v] of Object.entries(value)) (value as Record<string, unknown>)[k] = sanitize(v);
33+
return value;
34+
}
35+
36+
return value;
37+
}
38+
39+
/**
40+
* Express middleware to sanitize incoming request bodies
41+
* @param req The Express request object
42+
* @param _res The Express response object
43+
* @param next The next middleware function
44+
*/
45+
export function requestSanitizer(req: Request, _res: Response, next: NextFunction): void {
46+
if (req.body !== undefined) {
47+
req.body = sanitize(req.body);
48+
}
49+
next();
50+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import express, { Request, Response } from 'express';
2+
import request from 'supertest';
3+
4+
import { requestSanitizer } from '../../../src/middleware/requestSanitizer';
5+
6+
// A tiny echo route to observe what the handler receives AFTER the sanitizer runs
7+
function buildApp() {
8+
const app = express();
9+
// Body parsers (same order you use in app.ts)
10+
app.use(express.json());
11+
app.use(express.urlencoded({ extended: true }));
12+
13+
// The sanitizer under test
14+
app.use(requestSanitizer);
15+
16+
// Echo endpoint to inspect sanitized body
17+
app.post('/echo', (req: Request, res: Response) => {
18+
res.status(200).json(req.body);
19+
});
20+
21+
// GET route (no body)
22+
app.get('/ping', (_req, res) => {
23+
res.status(200).json({ ok: true });
24+
});
25+
26+
return app;
27+
}
28+
29+
describe('requestSanitizer middleware', () => {
30+
let app: express.Express;
31+
beforeEach(() => {
32+
app = buildApp();
33+
});
34+
35+
test('removes NUL written as \\0 (octal) inside JSON body', async () => {
36+
const res = await request(app).post('/echo').send({ s: 'hello\0world' });
37+
38+
expect(res.status).toBe(200);
39+
expect(res.body).toEqual({ s: 'helloworld' });
40+
});
41+
42+
test('removes NUL written as \\x00 (hex) inside JSON body', async () => {
43+
const res = await request(app).post('/echo').send({ s: 'hello\x00world' });
44+
45+
expect(res.status).toBe(200);
46+
expect(res.body).toEqual({ s: 'helloworld' });
47+
});
48+
49+
test('removes NUL written as \\u0000 (unicode escape) inside JSON body', async () => {
50+
const res = await request(app).post('/echo').send({ s: 'hello\u0000world' });
51+
52+
expect(res.status).toBe(200);
53+
expect(res.body).toEqual({ s: 'helloworld' });
54+
});
55+
56+
test('removes NUL written as \\u{0} (unicode code point) inside JSON body', async () => {
57+
const res = await request(app).post('/echo').send({ s: 'hello\u{0}world' });
58+
59+
expect(res.status).toBe(200);
60+
expect(res.body).toEqual({ s: 'helloworld' });
61+
});
62+
63+
test('strips NULs from flat string fields (application/json)', async () => {
64+
const payload = { name: 'a\u0000b\u0000c', city: 'Victoria' };
65+
66+
const res = await request(app).post('/echo').set('content-type', 'application/json').send(payload);
67+
68+
expect(res.status).toBe(200);
69+
expect(res.body).toEqual({ name: 'abc', city: 'Victoria' });
70+
});
71+
72+
test('recursively strips NULs inside nested objects/arrays', async () => {
73+
const payload = {
74+
id: 123,
75+
meta: {
76+
note: 'he\u0000llo',
77+
tags: ['x', 'y\u0000', 'z'],
78+
nested: [{ a: '1\u0000' }, { b: '2' }]
79+
},
80+
flags: { active: true, count: 0, nullable: null }
81+
};
82+
83+
const res = await request(app).post('/echo').send(payload);
84+
85+
expect(res.status).toBe(200);
86+
expect(res.body).toEqual({
87+
id: 123,
88+
meta: {
89+
note: 'hello',
90+
tags: ['x', 'y', 'z'],
91+
nested: [{ a: '1' }, { b: '2' }]
92+
},
93+
flags: { active: true, count: 0, nullable: null }
94+
});
95+
});
96+
97+
test('recursively removes NULs from arrays/objects using mixed notations', async () => {
98+
const payload = {
99+
arr: ['a\0', 'b\x00', 'c\u0000', 'd\u{0}'],
100+
obj: {
101+
x: '\0x',
102+
y: 'y\x00',
103+
z: 'z\u0000',
104+
w: 'w\u{0}'
105+
}
106+
};
107+
108+
const res = await request(app).post('/echo').send(payload);
109+
110+
expect(res.status).toBe(200);
111+
expect(res.body).toEqual({
112+
arr: ['a', 'b', 'c', 'd'],
113+
obj: { x: 'x', y: 'y', z: 'z', w: 'w' }
114+
});
115+
});
116+
117+
test('does not alter numbers/booleans/null/undefined fields', async () => {
118+
const payload = {
119+
n: 42,
120+
f: false,
121+
t: true,
122+
nil: null,
123+
undef: undefined,
124+
s: '\u0000ok\u0000'
125+
};
126+
127+
const res = await request(app).post('/echo').send(payload);
128+
129+
expect(res.status).toBe(200);
130+
expect(res.body).toEqual({ n: 42, f: false, t: true, nil: null, s: 'ok' });
131+
expect('undef' in res.body).toBe(false);
132+
});
133+
134+
test('works with application/x-www-form-urlencoded', async () => {
135+
const res = await request(app)
136+
.post('/echo')
137+
.set('content-type', 'application/x-www-form-urlencoded')
138+
.send('a=a%00a&b=b&arr=x&arr=y%00');
139+
140+
expect(res.status).toBe(200);
141+
expect(res.body).toEqual({ a: 'aa', b: 'b', arr: ['x', 'y'] });
142+
});
143+
144+
test('GET with no body is a no-op (does not throw)', async () => {
145+
const res = await request(app).get('/ping');
146+
expect(res.status).toBe(200);
147+
expect(res.body).toEqual({ ok: true });
148+
});
149+
150+
test('large strings with no NULs are returned as-is', async () => {
151+
const big = 'x'.repeat(10000);
152+
const res = await request(app).post('/echo').send({ big });
153+
154+
expect(res.status).toBe(200);
155+
expect(res.body.big).toHaveLength(10000);
156+
expect(res.body.big).toBe(big);
157+
});
158+
159+
test('multiple NULs in a single string are all removed', async () => {
160+
const res = await request(app).post('/echo').send({ s: '\u0000\u0000A\u0000B\u0000\u0000' });
161+
162+
expect(res.status).toBe(200);
163+
expect(res.body).toEqual({ s: 'AB' });
164+
});
165+
});

0 commit comments

Comments
 (0)