Skip to content

Commit e69337e

Browse files
protect: input-handling robustness pass (canonicalization + edge cases) (#114)
* protect: input-handling robustness pass across the engine and runtime A batch of defensive hardening so matching stays correct on inputs that don't take the obvious shape. No public API changes. - Host/IP classification (internal_host) now canonicalizes before deciding: every IPv4 spelling inet_aton accepts, and expanded / IPv4-mapped IPv6 forms, are recognized (previously a string/prefix compare); adds 100.64.0.0/10. - Origin/redirect comparisons resolve against the request origin and normalize default ports (off_origin handles protocol-relative / backslash locations; cross_origin distinguishes an absent header from a present opaque one; cors_reflected covers ACAO: null + credentials). - Scalar matchers fan out over the leaves of a structured (nested / array-of- object) value instead of stringifying it; bounded + iterative so a pathological value can't fail a rule open. - Regex safety: the ReDoS detector catches nested quantified subgroups, and a rejected pattern now warns (the rule is unenforced) instead of failing silent. - Request body handling: permissive content-type parsing (+json / text/plain / no content-type still populate post.<field>); body inspection is no longer skipped on a declared Content-Length; `all` folds in the verbatim body. - Request normalization no longer deletes line-comment spans from the value it inspects (that hid payloads from parameter-scoped rules). - Response screening: exact content-type matching for live streams, a binary sniff so a textual octet-stream export is screened; a redactor whose rule decodes the body before matching now fails closed rather than serving a no-op mask; whitelist misconfig (no rule_id / unimplemented keys) warns. Adds tests/protect/security-hardening.test.ts plus updates to the normalizer / response-guards suites. 703 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * protect: bound normalizeObject recursion; fix Node-version-fragile depth test The request normalizer recursed into nested objects unbounded, so a pathologically deep value could overflow the stack before matching ran — the per-rule catch would swallow that into a fail-open. Cap the walk (values below the bound are left un-normalized, still matched, never crashing). The regression test built its deep value via a JSON string, which overflowed JSON.parse/stringify on Node 18/20/22 (but not 25) — a test artifact, not the engine. Rebuild it in memory and assert it still matches past the normalize cap (a fail-open crash would return blocked:false). Verified on Node 20 and 22. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci: also validate on Node 24 (the version we publish/release on) The CI matrix tested 18/20/22, but publish.yml and release.yml build on Node 24 — so releases ran on a version CI never exercised. Add 24.x to close that gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0389aa9 commit e69337e

10 files changed

Lines changed: 573 additions & 119 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
- 18.x
2727
- 20.x
2828
- 22.x
29+
- 24.x
2930

3031
steps:
3132
- name: Checkout

src/protect/engine/engine.js

Lines changed: 223 additions & 41 deletions
Large diffs are not rendered by default.

src/protect/engine/fetch.js

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,9 @@ export async function fromFetchRequest(request, options = {}) {
4242
let body = {};
4343
let files;
4444
if (rawBody) {
45-
if (contentType.includes('application/json')) {
46-
try {
47-
body = JSON.parse(rawBody);
48-
} catch {
49-
body = {};
50-
}
51-
} else if (contentType.includes('application/x-www-form-urlencoded')) {
52-
body = {};
53-
for (const [k, v] of new URLSearchParams(rawBody)) {
54-
body[k] = k in body ? [].concat(body[k], v) : v;
55-
}
56-
} else if (contentType.includes('multipart/form-data')) {
57-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
58-
if (boundary) {
59-
const parsed = parseMultipart(rawBody, boundary);
60-
body = parsed.body;
61-
files = parsed.files;
62-
}
63-
}
45+
const parsed = parseBody(rawBody, contentType);
46+
body = parsed.body;
47+
files = parsed.files;
6448
}
6549

6650
const uri = url.pathname + url.search;
@@ -90,9 +74,10 @@ export async function fromFetchRequest(request, options = {}) {
9074
// downstream handler keeps an intact body. (`max` is compared in bytes against Content-Length; the
9175
// prefix slice is by character, which can only over-scan a multibyte body — the safe direction.)
9276
async function readCappedText(request, max) {
93-
const ceiling = Math.max(max, max * 4);
94-
const declared = Number(request.headers?.get?.('content-length') || 0);
95-
if (declared && declared > ceiling) return '';
77+
// Do NOT skip scanning based on a declared Content-Length: an attacker can declare a huge length
78+
// (or none) to dodge inspection while sending a small exploit body. Always stream-scan the prefix
79+
// up to `max` (buffering is bounded to `max`; the rest is drained but not retained). Anything past
80+
// the cap is unscanned — the documented prefix-scan tradeoff — but the body is never skipped whole.
9681
let clone;
9782
try {
9883
clone = request.clone();
@@ -150,6 +135,41 @@ function concatChunks(chunks, total) {
150135
// Minimal multipart/form-data parser: enough to expose field names + values (so `post.<field>`
151136
// and `raw` rules match uploads, e.g. a `__proto__` field name) and file metadata (filename via
152137
// `files.<field>`). We only need the textual structure, not the binary file contents.
138+
// Parse a request body into { body, files } for parameter-scoped rules. Content-type detection is
139+
// deliberately permissive: many AI-built apps `JSON.parse(await req.text())` regardless of the
140+
// declared type, so a JSON body arriving as `application/vnd.api+json`, `application/ld+json`,
141+
// `text/plain`, `application/csp-report`, or with NO content-type must still populate post.<field>
142+
// (otherwise a field-scoped rule silently resolves to nothing). Unrecognized/binary bodies stay `{}`
143+
// and are still matchable via `raw`.
144+
export function parseBody(rawBody, contentType) {
145+
const ct = String(contentType || '').toLowerCase();
146+
const isJson = ct.includes('application/json') || /\+json\b/.test(ct);
147+
const isForm = ct.includes('application/x-www-form-urlencoded');
148+
const isMultipart = ct.includes('multipart/form-data');
149+
// "ambiguous" = a type an app commonly parses as JSON/form even though it isn't declared as such.
150+
const isAmbiguous = ct === '' || ct.startsWith('text/plain') || ct.includes('csp-report') || ct.includes('/json');
151+
152+
if (isMultipart) {
153+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
154+
if (boundary) return parseMultipart(rawBody, boundary);
155+
return { body: {}, files: undefined };
156+
}
157+
if (isForm) {
158+
const body = {};
159+
for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
160+
return { body, files: undefined };
161+
}
162+
if (isJson || isAmbiguous) {
163+
try {
164+
const parsed = JSON.parse(rawBody);
165+
if (parsed && typeof parsed === 'object') return { body: parsed, files: undefined };
166+
} catch {
167+
/* not JSON — leave body empty; `raw`/`all` still see the verbatim text */
168+
}
169+
}
170+
return { body: {}, files: undefined };
171+
}
172+
153173
export function parseMultipart(rawBody, boundary) {
154174
const body = {};
155175
const files = {};

src/protect/engine/node.js

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// are already populated) and the Web-Fetch adapter (Workers/edge). Mount it FIRST, before
88
// any body-parser — it consumes the stream and exposes the parsed body as `req.body`.
99
import { RuleEngine } from './engine.js';
10-
import { parseMultipart } from './fetch.js';
10+
import { parseBody } from './fetch.js';
1111

1212
// Build the engine's request shape from a Node IncomingMessage + its raw body text.
1313
export function fromNodeRequest(req, rawBody = '') {
@@ -45,27 +45,11 @@ export function fromNodeRequest(req, rawBody = '') {
4545
let body = {};
4646
let files;
4747
if (rawBody) {
48-
if (contentType.includes('application/json')) {
49-
try {
50-
body = JSON.parse(rawBody);
51-
} catch {
52-
body = {};
53-
}
54-
} else if (contentType.includes('application/x-www-form-urlencoded')) {
55-
body = {};
56-
for (const [k, v] of new URLSearchParams(rawBody)) {
57-
body[k] = k in body ? [].concat(body[k], v) : v;
58-
}
59-
} else if (contentType.includes('multipart/form-data')) {
60-
// Same parsing as the fetch adapter — expose field names/values via post.<field> and file
61-
// metadata via files.<field>, so field-scoped rules match uploads on a raw-Node server too.
62-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
63-
if (boundary) {
64-
const parsed = parseMultipart(rawBody, boundary);
65-
body = parsed.body;
66-
files = parsed.files;
67-
}
68-
}
48+
// Same permissive content-type handling as the fetch adapter (+json / text/plain / no-CT bodies
49+
// still populate post.<field>; multipart exposes field + file metadata) on a raw-Node server too.
50+
const parsed = parseBody(rawBody, contentType);
51+
body = parsed.body;
52+
files = parsed.files;
6953
}
7054

7155
const uri = url.pathname + url.search;

src/protect/engine/normalizer.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,16 @@ export function removeSqlComments(value) {
129129
return value;
130130
}
131131

132+
// Collapse inline block comments to a space (the anti-obfuscation goal). We must NOT strip the
133+
// line-comment forms (`--…`, `#…`) to end-of-line: on the WAF inspection path that DELETES
134+
// attacker-controlled spans from the value the engine sees while the app still processes the
135+
// original — e.g. `#<script>…` becomes empty and evades an XSS rule, though the browser still
136+
// runs it. Keeping the content only ever ADDS matches (more of the value is inspected), never
137+
// hides one. SQLi keyword detection is unaffected — the keywords remain visible.
132138
let result = value;
133139

134140
result = result.replace(/\/\*[\s\S]*?\*\//g, ' ');
135141
result = result.replace(/\/\*![\s\S]*?\*\//g, ' ');
136-
result = result.replace(/--[^\r\n]*/g, '');
137-
result = result.replace(/#[^\r\n]*/g, '');
138142

139143
return result;
140144
}
@@ -216,20 +220,29 @@ export function normalizeRequest(req, options = {}) {
216220
};
217221
}
218222

219-
export function normalizeObject(value, options = {}) {
223+
// Depth bound for the recursive walk: a pathologically deep object would otherwise overflow the
224+
// stack, and the engine's per-rule catch would swallow that into a fail-open. Beyond the bound the
225+
// sub-value is left un-normalized (still matched, just in its raw form) rather than crashing.
226+
const MAX_NORMALIZE_DEPTH = 200;
227+
228+
export function normalizeObject(value, options = {}, depth = 0) {
220229
if (typeof value === 'string') {
221230
return normalize(value, options);
222231
}
223232

233+
if (depth >= MAX_NORMALIZE_DEPTH) {
234+
return value;
235+
}
236+
224237
if (Array.isArray(value)) {
225-
return value.map(item => normalizeObject(item, options));
238+
return value.map(item => normalizeObject(item, options, depth + 1));
226239
}
227240

228241
if (typeof value === 'object' && value !== null) {
229242
const result = {};
230243

231244
for (const [key, val] of Object.entries(value)) {
232-
result[key] = normalizeObject(val, options);
245+
result[key] = normalizeObject(val, options, depth + 1);
233246
}
234247

235248
return result;

src/protect/engine/request.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,12 @@ export class RequestResolver {
306306
if (body) {
307307
parts.push(typeof body === 'string' ? body : JSON.stringify(body));
308308
}
309+
// Also fold in the verbatim body: a body the adapter couldn't structurally parse (an unusual
310+
// content-type, a non-JSON payload) leaves `body` empty, but the raw text must still be matchable
311+
// by an `all` rule — otherwise it's only visible via `raw`.
312+
if (typeof this.#req._rawBody === 'string' && this.#req._rawBody) {
313+
parts.push(this.#req._rawBody);
314+
}
309315

310316
const headers = this.#req.headers ?? {};
311317
const excludedHeaders = new Set([

src/protect/runtime.js

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ export async function createProtection(options = {}) {
133133
rule,
134134
engine: new RuleEngine({ firewall: [rule], onError }),
135135
redactors: rule.action === 'redact' || rule.action === 'encode' ? extractRedactors(rule) : null,
136+
// A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
137+
// json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
138+
// they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
139+
// fails such a rule CLOSED (block) instead of serving a no-op redaction.
140+
mutatedSpan: (rule.action === 'redact' || rule.action === 'encode') && hasSpanMutations(rule),
136141
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
137142
// have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
138143
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
@@ -170,20 +175,12 @@ export async function createProtection(options = {}) {
170175
// Response phase core: screen a text body → { verdict: 'pass'|'block'|'redact', body? }.
171176
// redact masks matched spans; block withholds; block wins over redact. Enforcement only in
172177
// block mode (dry-run records via onDetect but returns 'pass').
173-
const isTextCT = (ct) => {
174-
ct = (ct || '').toLowerCase();
175-
// Exclude live streams: a Server-Sent-Events / token stream must pass through unbuffered.
176-
// Screening buffers the whole body, so it would withhold every chunk until the stream ends —
177-
// breaking incremental LLM streaming, which AI-built apps lean on heavily.
178-
if (ct.includes('event-stream')) return false;
179-
return ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
180-
};
181178
const screenText = (text, meta, reqCtx) => {
182179
let blockRule = null;
183180
const redactions = [];
184181
const headerMutations = [];
185182
let lowerText = null; // lazily lowercased body, only if a rule uses a prefilter
186-
for (const { rule, engine: re, redactors, prefilter } of responseRuleSet) {
183+
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
187184
// Cheap pre-filter: if none of the rule's literal anchors is in the body, its regex can't
188185
// match — skip the full scan (the common no-secret case) before touching the engine.
189186
if (prefilter) {
@@ -204,8 +201,15 @@ export async function createProtection(options = {}) {
204201
if (!result.blocked) continue;
205202
onDetect({ phase: 'response', mode, category: rule.category, rule, message: result.message });
206203
if (mode !== 'block') continue; // dry-run: observe only
207-
if (redactors && redactors.length) redactions.push({ rule, redactors });
208-
else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
204+
if (redactors && redactors.length) {
205+
// Span redactors on a mutation-decoded rule can't map back to the raw body → fail closed.
206+
const spanRedactors = redactors.filter((r) => !r.jsonPath);
207+
if (mutatedSpan && spanRedactors.length) {
208+
if (!blockRule) blockRule = rule;
209+
} else {
210+
redactions.push({ rule, redactors });
211+
}
212+
} else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
209213
else if (!blockRule) blockRule = rule;
210214
}
211215
if (mode !== 'block' || (!blockRule && !redactions.length && !headerMutations.length)) return { verdict: 'pass' };
@@ -306,10 +310,16 @@ export async function createProtection(options = {}) {
306310
if (overflow) { if (chunk != null) origWrite(chunk, enc); return origEnd(cb); }
307311
collect(chunk, enc);
308312
if (overflow) return origEnd(cb); // collect just flushed head + final chunk on overflow
309-
const text = Buffer.concat(chunks).toString('utf8');
313+
const buffer = Buffer.concat(chunks);
310314
let ct = res.getHeader ? res.getHeader('content-type') : undefined;
311315
if (Array.isArray(ct)) ct = ct[0];
312-
if (!isTextCT(ct)) { for (const c of chunks) origWrite(c); return origEnd(cb); }
316+
const kind = screenableContentType(ct);
317+
// Skip live streams / binary bodies (incl. an octet-stream that sniffs as binary) — untouched.
318+
if (kind === 'skip' || (kind === 'sniff' && looksBinary(buffer))) {
319+
for (const c of chunks) origWrite(c);
320+
return origEnd(cb);
321+
}
322+
const text = buffer.toString('utf8');
313323
let r;
314324
try {
315325
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
@@ -595,14 +605,39 @@ function byPhase(rules, phase) {
595605
return (rules ?? []).filter((r) => (r.phase ?? 'request') === phase);
596606
}
597607

608+
// Classify a content-type for response screening: 'text' = screen; 'sniff' = screen only if the
609+
// bytes aren't binary (octet-stream is often a misdeclared JSON export/config); 'skip' = pass
610+
// through unscreened (live streams, known binary families). SSE is matched on the EXACT base type,
611+
// not a loose substring — `application/json; profile="event-stream"` is not a stream.
612+
function baseContentType(ct) {
613+
return String(ct || '').toLowerCase().split(';')[0].trim();
614+
}
615+
function screenableContentType(ct) {
616+
const base = baseContentType(ct);
617+
if (base === 'text/event-stream') return 'skip'; // live token/SSE stream — never buffer
618+
if (base === '') return 'text';
619+
if (/(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(base)) return 'text';
620+
if (base === 'application/octet-stream') return 'sniff'; // maybe a text/JSON export mislabeled
621+
return 'skip'; // image/video/audio/font/pdf/zip/wasm/… — don't buffer binary
622+
}
623+
// Cheap binary sniff over a byte prefix: a NUL byte, or many control chars, means "don't treat as text".
624+
function looksBinary(bytes) {
625+
const n = Math.min(bytes.length, 512);
626+
let ctrl = 0;
627+
for (let i = 0; i < n; i++) {
628+
const b = bytes[i];
629+
if (b === 0) return true;
630+
if (b < 9 || (b > 13 && b < 32)) ctrl++;
631+
}
632+
return n > 0 && ctrl / n > 0.1;
633+
}
634+
598635
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
599636
if (!response || typeof response.clone !== 'function') return null;
600-
const ct = (response.headers?.get?.('content-type') || '').toLowerCase();
601-
// Live stream (SSE / token stream): never buffer it — reading to completion would withhold the
602-
// response until the stream ends, breaking incremental streaming. Pass it through unscreened.
603-
if (ct.includes('event-stream')) return null;
604-
const isText = ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
605-
if (!isText) return null;
637+
const ct = response.headers?.get?.('content-type') || '';
638+
const kind = screenableContentType(ct);
639+
if (kind === 'skip') return null;
640+
const sniff = kind === 'sniff';
606641
const len = Number(response.headers?.get?.('content-length') || 0);
607642
if (len && len > cap) return null;
608643
let clone;
@@ -620,11 +655,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
620655
const chunks = [];
621656
let size = 0;
622657
let over = false;
658+
let sniffed = !sniff;
623659
try {
624660
for (;;) {
625661
const { done, value } = await reader.read();
626662
if (done) break;
627663
if (!value) continue;
664+
if (!sniffed) {
665+
sniffed = true;
666+
if (looksBinary(value)) return null; // octet-stream that's actually binary — skip
667+
}
628668
size += value.byteLength;
629669
if (over) continue; // keep draining, stop buffering
630670
if (size > cap) { over = true; continue; }
@@ -643,7 +683,9 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
643683

644684
try {
645685
const text = await clone.text();
646-
return text.length > cap ? null : text;
686+
if (text.length > cap) return null;
687+
if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return null;
688+
return text;
647689
} catch {
648690
return null;
649691
}
@@ -669,6 +711,24 @@ function headerObject(headers) {
669711
return out;
670712
}
671713

714+
// True if any of the rule's conditions carries a body-transforming mutation on a SPAN match
715+
// (regex/contains/stripos) — those decode the body before matching, so a span redactor derived from
716+
// the literal/regex can't be located in the raw body. (array_key_value structural redaction decodes
717+
// the JSON itself, so json_decode there is fine and doesn't count.)
718+
function hasSpanMutations(rule) {
719+
let found = false;
720+
const walk = (conds) => {
721+
for (const c of conds ?? []) {
722+
if (found) return;
723+
if (Array.isArray(c.rules)) walk(c.rules);
724+
const isSpan = c.match && (c.match.type === 'regex' || c.match.type === 'contains' || c.match.type === 'stripos');
725+
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
726+
}
727+
};
728+
walk(rule.rule_v2);
729+
return found;
730+
}
731+
672732
// Derive redaction targets from a rule's own conditions: regex → mask every match;
673733
// contains/stripos → mask the literal. (Other match types can't identify a span → the
674734
// rule falls back to block.)
@@ -707,7 +767,10 @@ function extractRedactors(rule) {
707767
return out;
708768
}
709769

710-
// HTML-entity escape, for the `encode` action (neutralize markup rather than mask it).
770+
// HTML-entity escape, for the `encode` action (neutralize markup rather than mask it). NOTE: this is
771+
// sound only for HTML text / attribute-VALUE contexts. It does NOT neutralize a `javascript:` / `data:`
772+
// URI or an event-handler name (those carry no HTML metacharacters) — use `block` for a rule that
773+
// targets a URL/scheme context. See the rule-authoring guidance in the triage-vpatch-npm skill.
711774
function htmlEscape(str) {
712775
return String(str).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
713776
}

0 commit comments

Comments
 (0)