Skip to content

Commit 7267294

Browse files
Resolve request fields the way applications read them
Cookie values are split before they are normalised, and normalised the same way whether a framework parsed them or they come from the header. Quoted values lose their quotes, and a repeated name keeps every value. Field paths resolve in both spellings a form or query field can take: nested (`user.name`, `id`) and bracketed (`user[name]`, `id[]`), whichever parser produced the request. A structured value too large for the leaf walk is matched as serialised text beyond the walk's bound, and the bound is reported as a `container-cap` coverage skip. `internal_host` reads the host from the shorter URL spellings a URL parser still resolves (`http:/host`, `http:host`, backslashes, tabs and leading controls). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f5c268b commit 7267294

10 files changed

Lines changed: 511 additions & 72 deletions

File tree

‎src/protect/engine/cookies.js‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { setOwn } from './own.js';
2+
3+
/**
4+
* A `Cookie` header as name → value.
5+
*
6+
* A value wrapped in double quotes loses them, as cookie parsers strip them before an application reads
7+
* the value. A name sent more than once keeps every value, as an array in the order sent: parsers differ
8+
* on whether the first or the last one wins, so a rule on that cookie inspects all of them.
9+
*/
10+
export function parseCookieHeader(header) {
11+
const cookies = {};
12+
if (typeof header !== 'string' || header === '') return cookies;
13+
14+
const repeated = new Map();
15+
for (const pair of header.split(';')) {
16+
const eq = pair.indexOf('=');
17+
if (eq === -1) continue;
18+
const name = pair.slice(0, eq).trim();
19+
if (name === '') continue;
20+
let value = pair.slice(eq + 1).trim();
21+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
22+
23+
if (!Object.hasOwn(cookies, name)) {
24+
setOwn(cookies, name, value);
25+
} else {
26+
let values = repeated.get(name);
27+
if (values === undefined) {
28+
values = [cookies[name]];
29+
repeated.set(name, values);
30+
setOwn(cookies, name, values);
31+
}
32+
values.push(value);
33+
}
34+
}
35+
return cookies;
36+
}

‎src/protect/engine/engine.js‎

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -514,26 +514,48 @@ const WHOLE_VALUE_MATCH_TYPES = new Set(['isset', 'array_in_array', 'array_key_v
514514
// Iteratively collect every scalar (non-object) leaf of a structured value. Iterative + bounded
515515
// (depth and node caps) so a pathologically deep/large attacker payload STOPS at the bound rather
516516
// than throwing a RangeError that the per-rule catch would swallow into a fail-open bypass.
517+
// `truncated` says the bound was reached: containers past it contributed no leaves.
517518
function collectLeafValues(root, nodeCap = 20000, maxDepth = 1000) {
518-
const out = [];
519+
const leaves = [];
519520
const stack = [[root, 0]];
520521
let visited = 0;
522+
let truncated = false;
521523
while (stack.length) {
522524
const [node, depth] = stack.pop();
523525
if (node === null || node === undefined) continue;
524526
if (typeof node !== 'object') {
525-
out.push(node);
527+
leaves.push(node);
528+
continue;
529+
}
530+
if (depth >= maxDepth || visited >= nodeCap) {
531+
truncated = true;
526532
continue;
527533
}
528-
if (depth >= maxDepth || visited >= nodeCap) continue;
529534
visited++;
530535
if (Array.isArray(node)) {
531536
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
532537
} else {
533538
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
534539
}
535540
}
536-
return out;
541+
return { leaves, truncated };
542+
}
543+
544+
// The inspection limits one evaluation reached, as `{ skips: [reason, …] }`, or nothing when it
545+
// reached none. Callers count each reason in their coverage.
546+
function skipsOf(resolver) {
547+
const skips = resolver?.skips;
548+
return skips && skips.length > 0 ? { skips } : {};
549+
}
550+
551+
// The whole value as text, for a container too large to walk leaf by leaf. `undefined` when it has
552+
// no text form (a cycle, or nesting deeper than the serialiser allows).
553+
function serialisedValue(value) {
554+
try {
555+
return JSON.stringify(value);
556+
} catch {
557+
return undefined;
558+
}
537559
}
538560

539561
// Emit a warning at most once per distinct key (keeps a persistent misconfiguration from spamming).
@@ -578,16 +600,34 @@ function warnUnsupportedMatchType(type) {
578600
* value that is already a bare host passes through untouched, which is what keeps the egress path and
579601
* the built-in default rule behaving exactly as before.
580602
*/
603+
// Schemes a URL parser treats as hierarchical whatever follows the colon: `http:x`, `http:/x` and
604+
// `http:\\x` all name host `x`.
605+
const SPECIAL_SCHEMES = new Set(['http', 'https', 'ws', 'wss', 'ftp', 'file']);
606+
607+
// Strip C0 controls and spaces (U+0000–U+0020) from both ends, in linear time.
608+
function trimControls(text) {
609+
let start = 0;
610+
let end = text.length;
611+
while (start < end && text.charCodeAt(start) <= 0x20) start++;
612+
while (end > start && text.charCodeAt(end - 1) <= 0x20) end--;
613+
return text.slice(start, end);
614+
}
615+
581616
function hostFromValue(value) {
582-
const raw = String(value ?? '').trim();
617+
// A URL parser drops tabs and newlines anywhere, and C0 controls and spaces at either end, before it
618+
// reads anything else — so they are dropped here too, or they would hide the scheme.
619+
const raw = trimControls(String(value ?? '').replace(/[\t\n\r]/g, '')).trim();
583620
if (raw === '') return '';
584621

585-
// A scheme (`http://`, and deliberately any other) or a protocol-relative URL. Parsing rather than
586-
// string-slicing is what makes the userinfo evasion (`http://trusted@169.254.169.254/`) resolve to the
587-
// host actually contacted, and keeps `http://evil.com#@127.0.0.1` resolving to evil.com.
588-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith('//')) {
622+
// A scheme (`http://`, and deliberately any other), a special scheme in any of the shorter spellings a
623+
// URL parser still resolves to a host, or a protocol-relative URL (either slash direction). Parsing
624+
// rather than string-slicing is what makes the userinfo evasion (`http://trusted@169.254.169.254/`)
625+
// resolve to the host actually contacted, and keeps `http://evil.com#@127.0.0.1` resolving to evil.com.
626+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
627+
const relative = /^[\\/]{2}/.test(raw);
628+
if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || (scheme !== undefined && SPECIAL_SCHEMES.has(scheme))) {
589629
try {
590-
return new URL(raw.startsWith('//') ? `http:${raw}` : raw).hostname;
630+
return new URL(relative ? `http:${raw}` : raw).hostname;
591631
} catch {
592632
// Unparseable: hand the raw value on, where the host check rejects it rather than guessing.
593633
return raw;
@@ -1219,7 +1259,8 @@ export class RuleEngine {
12191259
// that re-read the request instead would be reading it a second time: a getter, a stream or
12201260
// anything else that answers once can give a different value, and evidence that disagrees
12211261
// with the match it belongs to is worse than none.
1222-
resolver
1262+
resolver,
1263+
...skipsOf(resolver)
12231264
};
12241265
}
12251266
} catch (err) {
@@ -1229,7 +1270,7 @@ export class RuleEngine {
12291270
}
12301271
}
12311272

1232-
return { blocked: false, rule: null, message: null };
1273+
return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
12331274
}
12341275

12351276
#evaluateRule(conditions, resolver) {
@@ -1317,9 +1358,17 @@ export class RuleEngine {
13171358
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
13181359
if (matchValue(match.type, value, match.value, match)) return true;
13191360
} else {
1320-
for (const leaf of collectLeafValues(value)) {
1361+
const { leaves, truncated } = collectLeafValues(value);
1362+
for (const leaf of leaves) {
13211363
if (matchValue(match.type, leaf, match.value, match)) return true;
13221364
}
1365+
// Past the walk's bound, the rest of the value is matched as its serialised text, and the
1366+
// bound is reported: the leaves beyond it were not inspected individually.
1367+
if (truncated) {
1368+
resolver.noteSkip('container-cap');
1369+
const text = serialisedValue(value);
1370+
if (text !== undefined && matchValue(match.type, text, match.value, match)) return true;
1371+
}
13231372
}
13241373
continue;
13251374
}

‎src/protect/engine/fetch.js‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { resolveClientIp } from '../client-ip.js';
1010
import { RuleEngine } from './engine.js';
1111
import { notify } from '../notify.js';
12+
import { parseCookieHeader } from './cookies.js';
1213
import { appendOwn, setOwn } from './own.js';
1314

1415
// Cap how much request body we buffer for inspection. A larger body is left UNSCANNED
@@ -67,7 +68,7 @@ export async function fromFetchRequest(request, options = {}) {
6768
// that guessed differently would attribute one request to two addresses.
6869
ip: client.ip ?? '',
6970
_clientIp: client,
70-
cookies: parseCookies(headers.cookie),
71+
cookies: parseCookieHeader(headers.cookie),
7172
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
7273
// drops, so prototype-pollution rules on `raw` are robust.
7374
_rawBody: rawBody,
@@ -272,20 +273,6 @@ function decodeExtendedValue(value) {
272273
}
273274
}
274275

275-
function parseCookies(header) {
276-
const cookies = {};
277-
if (!header) {
278-
return cookies;
279-
}
280-
for (const pair of header.split(';')) {
281-
const idx = pair.indexOf('=');
282-
if (idx === -1) {
283-
continue;
284-
}
285-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
286-
}
287-
return cookies;
288-
}
289276

290277
function defaultBlockResponse(result) {
291278
return new Response(
@@ -319,6 +306,9 @@ export function createFetchMiddleware(rulesData, options = {}) {
319306
notify(options.onSkip, { phase: 'request', reason: req._bodyInspectionSkip }, 'onSkip');
320307
}
321308
result = engine.evaluate(req);
309+
for (const reason of result.skips ?? []) {
310+
notify(options.onSkip, { phase: 'request', reason }, 'onSkip');
311+
}
322312
} catch (err) {
323313
notify(options.onError, err, 'onError');
324314
return null; // fail open

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

Lines changed: 5 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
@@ -118,6 +122,7 @@ export declare function normalizeRequest(req: any, options?: NormalizeOptions):
118122
query: Record<string, any>;
119123
body: Record<string, any>;
120124
headers: Record<string, string>;
125+
cookies: Record<string, any>;
121126
url: string;
122127
originalUrl: string;
123128
_rawBody: string;

‎src/protect/engine/node.js‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolveClientIp } from '../client-ip.js';
1010
import { RuleEngine } from './engine.js';
1111
import { parseBody } from './fetch.js';
1212
import { notify } from '../notify.js';
13+
import { parseCookieHeader } from './cookies.js';
1314
import { appendOwn, setOwn } from './own.js';
1415

1516
// Build the engine's request shape from a Node IncomingMessage + its raw body text.
@@ -70,26 +71,12 @@ export function fromNodeRequest(req, rawBody = '', options = {}) {
7071
headers,
7172
ip: client.ip ?? '',
7273
_clientIp: client,
73-
cookies: parseCookies(headers.cookie),
74+
cookies: parseCookieHeader(headers.cookie),
7475
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
7576
_rawBody: rawBody
7677
};
7778
}
7879

79-
function parseCookies(header) {
80-
const cookies = {};
81-
if (!header) {
82-
return cookies;
83-
}
84-
for (const pair of header.split(';')) {
85-
const idx = pair.indexOf('=');
86-
if (idx === -1) {
87-
continue;
88-
}
89-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
90-
}
91-
return cookies;
92-
}
9380

9481
function defaultBlock(res, result) {
9582
res.statusCode = 403;

‎src/protect/engine/normalizer.js‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* obfuscation techniques that attackers use to evade pattern matching.
77
*/
88
import { setOwn } from './own.js';
9+
import { parseCookieHeader } from './cookies.js';
910

1011

1112
const HTML_ENTITIES = {
@@ -348,16 +349,29 @@ export function normalizeRequest(req, options = {}) {
348349
? req._rawBody
349350
: serializeForRawDetection(body ?? null);
350351

352+
const headers = requestField(req, 'headers') || {};
353+
351354
return {
352355
query: normalizeObject(requestField(req, 'query') || {}, options),
353356
body: normalizeObject(body || {}, options),
354-
headers: normalizeObject(requestField(req, 'headers') || {}, options),
357+
headers: normalizeObject(headers, options),
358+
// Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
359+
// same way whether a framework parsed them or they come from the header — so a rule on a cookie
360+
// sees one value, independent of which cookie parser (if any) ran first.
361+
cookies: normalizeObject(cookieValues(requestField(req, 'cookies'), headers), options),
355362
url: normalize(url || '', options),
356363
originalUrl: normalize(requestField(req, 'originalUrl') || url || '', options),
357364
_rawBody: rawBody
358365
};
359366
}
360367

368+
// The request's cookies as name → value: the ones a cookie parser already produced when there are any,
369+
// otherwise the `Cookie` header's pairs.
370+
function cookieValues(parsed, headers) {
371+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
372+
return parseCookieHeader(headers?.cookie);
373+
}
374+
361375
// Depth bound for the recursive walk: a pathologically deep object would otherwise overflow the
362376
// stack, and the engine's per-rule catch would swallow that into a fail-open. Beyond the bound the
363377
// sub-value is left un-normalized (still matched, just in its raw form) rather than crashing.

0 commit comments

Comments
 (0)