@@ -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.
517518function 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+
581616function 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 - z 0 - 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 - z 0 - 9 + . - ] * ) : / i. exec ( raw ) ?. [ 1 ] ?. toLowerCase ( ) ;
627+ const relative = / ^ [ \\ / ] { 2 } / . test ( raw ) ;
628+ if ( relative || / ^ [ a - z ] [ a - z 0 - 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 }
0 commit comments