Skip to content

Commit 7fbb160

Browse files
[ENG-4059] Decode request values the way apps read them (#298)
* Decode request values the way apps read them Percent-decoding keeps decoding the escapes around a % that starts no escape, and decodes each run as UTF-8. The urldecode mutation reads + as a space, as form decoding does, and REQUEST_URI and all read + in the query as a space while keeping a + in the path literal. HTML character references decode the same way in normalization and in the htmlentitydecode mutation: named references such as &colon; and numeric references without a closing semicolon. Text mutations (urldecode, htmlentitydecode, base64_decode) applied to a structured value decode each string inside it and keep the structure, within the same bounds as the leaf walk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Normalize nested request values to the leaf walk's depth, and report the bound Request normalization walks nested values iteratively, to the same depth bound as the engine's leaf walk, so every value that walk reaches is normalized first. A value past the bound is still matched in its raw form, and reaching the bound is reported as a `container-cap` skip through `onSkip` and `coverage()`, once per request. An evaluation result carries the inspection limits it reached as `skips`, and the resolver records them with `noteSkip`. Adds coverage for `+` in a request target given only as `url`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 65a7b66 commit 7fbb160

6 files changed

Lines changed: 475 additions & 79 deletions

File tree

‎src/protect/engine/engine.js‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,13 @@ function collectLeafValues(root, nodeCap = 20000, maxDepth = 1000) {
536536
return out;
537537
}
538538

539+
// The inspection limits one evaluation reached, as `{ skips: [reason, …] }`, or nothing when it
540+
// reached none. Callers count each reason in their coverage.
541+
function skipsOf(resolver) {
542+
const skips = resolver?.skips;
543+
return skips && skips.length > 0 ? { skips } : {};
544+
}
545+
539546
// Emit a warning at most once per distinct key (keeps a persistent misconfiguration from spamming).
540547
const warnedKeys = new Set();
541548
function warnOnce(key, message) {
@@ -1202,8 +1209,11 @@ export class RuleEngine {
12021209
let normalizedReq;
12031210
let resolver;
12041211
try {
1205-
normalizedReq = { ...req, ...normalizeRequest(req) };
1212+
// Past the normalizer's depth bound a value is matched un-normalized; that is reported.
1213+
let limited = false;
1214+
normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => { limited = true; } }) };
12061215
resolver = new RequestResolver(normalizedReq);
1216+
if (limited) resolver.noteSkip('container-cap');
12071217
} catch (err) {
12081218
this.#reportError(err);
12091219
return { blocked: false, rule: null, message: null }; // fail open
@@ -1236,7 +1246,8 @@ export class RuleEngine {
12361246
// that re-read the request instead would be reading it a second time: a getter, a stream or
12371247
// anything else that answers once can give a different value, and evidence that disagrees
12381248
// with the match it belongs to is worse than none.
1239-
resolver
1249+
resolver,
1250+
...skipsOf(resolver)
12401251
};
12411252
}
12421253
} catch (err) {
@@ -1246,7 +1257,7 @@ export class RuleEngine {
12461257
}
12471258
}
12481259

1249-
return { blocked: false, rule: null, message: null };
1260+
return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
12501261
}
12511262

12521263
#evaluateRule(conditions, resolver) {

‎src/protect/engine/index.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface EvaluateResult {
4444
blocked: boolean;
4545
rule: FirewallRule | null;
4646
message: string | null;
47+
/** Inspection limits this evaluation reached (e.g. `container-cap`), when it reached any. */
48+
skips?: string[];
4749
}
4850

4951
export declare class RuleEngine {
@@ -56,6 +58,8 @@ export declare class RequestResolver {
5658
constructor(req: any);
5759
resolve(parameter: string): any[];
5860
applyMutations(mutations: string[], value: any): any;
61+
noteSkip(reason: string): void;
62+
readonly skips: string[];
5963
}
6064

6165
// Middleware
@@ -111,6 +115,8 @@ export interface NormalizeOptions {
111115
sqlComments?: boolean;
112116
nullBytes?: boolean;
113117
whitespace?: boolean;
118+
/** Called when a nested value is past the depth bound and is kept un-normalized. */
119+
onLimit?: () => void;
114120
}
115121

116122
export declare function normalize(value: string, options?: NormalizeOptions): string;

‎src/protect/engine/normalizer.js‎

Lines changed: 105 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -80,28 +80,45 @@ export function urlDecode(value) {
8080
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
8181
previous = result;
8282
iterations++;
83-
84-
try {
85-
result = decodeURIComponent(result);
86-
} catch {
87-
result = safeUrlDecode(result);
88-
break;
89-
}
83+
result = safeUrlDecode(result);
9084
}
9185

9286
return result;
9387
}
9488

95-
function safeUrlDecode(value) {
96-
return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
89+
const utf8 = new TextDecoder();
90+
91+
/**
92+
* Percent-decode every well-formed escape, whatever else the value contains.
93+
*
94+
* A `%` that does not start an escape is kept as it is, and does not stop the escapes around it from being
95+
* decoded. Each run of escapes is decoded as UTF-8; bytes that are not valid UTF-8 become U+FFFD.
96+
*/
97+
export function safeUrlDecode(value) {
98+
return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
9799
try {
98-
return String.fromCharCode(parseInt(hex, 16));
100+
return decodeURIComponent(run);
99101
} catch {
100-
return match;
102+
const bytes = new Uint8Array(run.length / 3);
103+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
104+
return utf8.decode(bytes);
101105
}
102106
});
103107
}
104108

109+
/**
110+
* A request target with `+` in its query read as a space, the way query-string parsers read it.
111+
*
112+
* Only the query is affected: a `+` in the path is a literal `+`. Apply this before percent-decoding, so
113+
* an encoded `%2B` still becomes a literal `+`.
114+
*/
115+
export function decodeQueryPlus(target) {
116+
if (typeof target !== 'string') return target;
117+
const query = target.indexOf('?');
118+
if (query === -1) return target;
119+
return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, ' ');
120+
}
121+
105122
export function htmlEntityDecode(value) {
106123
if (typeof value !== 'string') {
107124
return value;
@@ -113,17 +130,40 @@ export function htmlEntityDecode(value) {
113130
result = result.split(entity).join(char);
114131
}
115132

116-
result = result.replace(/&#(\d+);/g, (match, code) => {
117-
const num = parseInt(code, 10);
118-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
119-
});
133+
return decodeHtmlEntities(result);
134+
}
120135

121-
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
122-
const num = parseInt(hex, 16);
123-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
124-
});
136+
/**
137+
* Named entities decoded by `decodeHtmlEntities`: the ones that matter in injection contexts plus the
138+
* handful every encoder emits. Numeric references are decoded generally, decimal and hex.
139+
*/
140+
const NAMED_ENTITIES = {
141+
lt: '<', gt: '>', amp: '&', quot: '"', apos: "'",
142+
nbsp: '\u00a0', sol: '/', bsol: '\\', colon: ':', lpar: '(', rpar: ')', equals: '=', grave: '`',
143+
Tab: '\t', NewLine: '\n', semi: ';', excl: '!', num: '#', dollar: '$', percnt: '%', ast: '*',
144+
};
125145

126-
return result;
146+
/**
147+
* Decode HTML character references in one pass.
148+
*
149+
* The terminating `;` is optional, as it is for a browser reading a numeric reference: `&#58` and
150+
* `&#x3a` decode like `&#58;`. One pass means `&amp;lt;` becomes `&lt;`, not `<`.
151+
*/
152+
export function decodeHtmlEntities(input) {
153+
return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
154+
if (body[0] === '#') {
155+
const hex = body[1] === 'x' || body[1] === 'X';
156+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
157+
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return whole;
158+
try {
159+
return String.fromCodePoint(code);
160+
} catch {
161+
return whole;
162+
}
163+
}
164+
165+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
166+
});
127167
}
128168

129169
export function removeSqlComments(value) {
@@ -352,41 +392,69 @@ export function normalizeRequest(req, options = {}) {
352392
query: normalizeObject(requestField(req, 'query') || {}, options),
353393
body: normalizeObject(body || {}, options),
354394
headers: normalizeObject(requestField(req, 'headers') || {}, options),
355-
url: normalize(url || '', options),
356-
originalUrl: normalize(requestField(req, 'originalUrl') || url || '', options),
395+
url: normalize(decodeQueryPlus(url || ''), options),
396+
originalUrl: normalize(decodeQueryPlus(requestField(req, 'originalUrl') || url || ''), options),
357397
_rawBody: rawBody
358398
};
359399
}
360400

361-
// Depth bound for the recursive walk: a pathologically deep object would otherwise overflow the
362-
// stack, and the engine's per-rule catch would swallow that into a fail-open. Beyond the bound the
363-
// sub-value is left un-normalized (still matched, just in its raw form) rather than crashing.
364-
const MAX_NORMALIZE_DEPTH = 200;
401+
// The same depth bound as the engine's leaf walk, so every value that walk reaches is normalized. Past
402+
// it a sub-value is kept as it is (still matched, in its raw form) and `options.onLimit` is called.
403+
const MAX_NORMALIZE_DEPTH = 1000;
365404

405+
/**
406+
* A copy of `value` with every string inside it normalized.
407+
*
408+
* Iterative, so depth cannot overflow the stack; the work is linear in the size of the value. A shared
409+
* or cyclic node is copied once, and array holes stay holes.
410+
*/
366411
export function normalizeObject(value, options = {}, depth = 0) {
367412
if (typeof value === 'string') {
368413
return normalize(value, options);
369414
}
370415

371-
if (depth >= MAX_NORMALIZE_DEPTH) {
416+
if (value === null || typeof value !== 'object') {
372417
return value;
373418
}
374419

375-
if (Array.isArray(value)) {
376-
return value.map(item => normalizeObject(item, options, depth + 1));
420+
if (depth >= MAX_NORMALIZE_DEPTH) {
421+
options.onLimit?.();
422+
return value;
377423
}
378424

379-
if (typeof value === 'object' && value !== null) {
380-
const result = {};
381-
382-
for (const [key, val] of Object.entries(value)) {
383-
setOwn(result, key, normalizeObject(val, options, depth + 1));
425+
const copies = new Map();
426+
const copyOf = (node) => {
427+
const copy = Array.isArray(node) ? new Array(node.length) : {};
428+
copies.set(node, copy);
429+
return copy;
430+
};
431+
const top = copyOf(value);
432+
const stack = [[value, top, depth]];
433+
434+
while (stack.length > 0) {
435+
const [node, copy, level] = stack.pop();
436+
437+
for (const key of Object.keys(node)) {
438+
const child = node[key];
439+
440+
if (typeof child === 'string') {
441+
setOwn(copy, key, normalize(child, options));
442+
} else if (child === null || typeof child !== 'object') {
443+
setOwn(copy, key, child);
444+
} else if (copies.has(child)) {
445+
setOwn(copy, key, copies.get(child));
446+
} else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
447+
setOwn(copy, key, child);
448+
options.onLimit?.();
449+
} else {
450+
const childCopy = copyOf(child);
451+
setOwn(copy, key, childCopy);
452+
stack.push([child, childCopy, level + 1]);
453+
}
384454
}
385-
386-
return result;
387455
}
388456

389-
return value;
457+
return top;
390458
}
391459

392460
export function createMatchVariants(value) {

0 commit comments

Comments
 (0)