@@ -53,8 +53,41 @@ const DEFAULT_RESULT_CAP = 1000; // bindings, even when LIMIT is higher/absent
5353const MAX_QUERY_LENGTH = 20_000 ;
5454const MAX_JOIN_ROWS = 100_000 ; // abort pathological joins
5555
56+ // ReDoS guards for user-supplied FILTER(REGEX(...)) patterns. Node has no
57+ // built-in regex timeout and no new deps are allowed (no RE2), so the worst
58+ // case is bounded honestly instead: cap the PATTERN length, cap the length
59+ // of the INPUT the pattern is tested against (backtracking blows up as a
60+ // function of input length), and statically reject the obvious catastrophic
61+ // shapes before ever compiling/running them. Both caps are config-overridable
62+ // (config.maxRegexLength / config.maxRegexInput). See README ## Findings.
63+ const DEFAULT_MAX_REGEX_LENGTH = 512 ; // reject patterns longer than this
64+ const DEFAULT_MAX_REGEX_INPUT = 10_000 ; // regex tested against at most this many chars
65+
66+ // Best-effort static heuristic for catastrophic backtracking: an unbounded
67+ // quantifier (+ or *) applied to a parenthesised group that itself contains
68+ // an unbounded quantifier — the classic (a+)+, (a*)*, (a+)*, (a|b*)+ shape.
69+ // This is intentionally conservative (it can over-reject, never a complete
70+ // safe-regex analyzer) and errs toward rejecting; the length caps above are
71+ // the real bound. Cheap on a length-capped pattern (O(n^2) at worst).
72+ const CATASTROPHIC_REGEX_RE = / \( .* [ + * ] .* \) [ + * { ] / ;
73+
5674class QueryError extends Error { }
5775
76+ /**
77+ * Reject a user-supplied REGEX pattern that is over-long or matches the
78+ * catastrophic-backtracking heuristic, BEFORE it is compiled or run. Throws
79+ * QueryError (→ 400) so a normal filter still compiles and a pathological one
80+ * is refused promptly instead of pinning the event loop.
81+ */
82+ function assertSafeRegexPattern ( pattern , maxRegexLength ) {
83+ if ( pattern . length > maxRegexLength ) {
84+ throw new QueryError ( `REGEX pattern too long (max ${ maxRegexLength } chars)` ) ;
85+ }
86+ if ( CATASTROPHIC_REGEX_RE . test ( pattern ) ) {
87+ throw new QueryError ( 'potentially catastrophic REGEX rejected (nested unbounded quantifier)' ) ;
88+ }
89+ }
90+
5891// --------------------------------------------------------------- tokenizer
5992
6093const IRI_RE = / ^ < ( [ ^ < > " { } | ^ ` \\ \s ] * ) > / ;
@@ -229,8 +262,9 @@ function parseTriplesGroup(r, sink) {
229262 if ( r . isPunc ( r . peek ( ) , '.' ) ) r . next ( ) ;
230263}
231264
232- function parseQuery ( src ) {
265+ function parseQuery ( src , opts = { } ) {
233266 if ( src . length > MAX_QUERY_LENGTH ) throw new QueryError ( 'query too long' ) ;
267+ const maxRegexLength = opts . maxRegexLength ?? DEFAULT_MAX_REGEX_LENGTH ;
234268 const r = reader ( src ) ;
235269 r . parsePrologue ( ) ;
236270
@@ -280,6 +314,7 @@ function parseQuery(src) {
280314 r . expectPunc ( ')' ) ;
281315 const constraint = { kind : fn . toLowerCase ( ) , a, b, flags } ;
282316 if ( fn === 'REGEX' && b . t === 'lit' ) {
317+ assertSafeRegexPattern ( b . v , maxRegexLength ) ; // ReDoS guard (literal pattern)
283318 try { constraint . re = new RegExp ( b . v , flags ) ; }
284319 catch ( e ) { throw new QueryError ( `bad REGEX pattern: ${ e . message } ` ) ; }
285320 }
@@ -613,7 +648,9 @@ function compare(x, y, op) {
613648 }
614649}
615650
616- function applyFilter ( f , row ) {
651+ function applyFilter ( f , row , opts = { } ) {
652+ const maxRegexLength = opts . maxRegexLength ?? DEFAULT_MAX_REGEX_LENGTH ;
653+ const maxRegexInput = opts . maxRegexInput ?? DEFAULT_MAX_REGEX_INPUT ;
617654 const A = f . a . t === 'var' ? row [ f . a . v ] : f . a ;
618655 const B = f . b . t === 'var' ? row [ f . b . v ] : f . b ;
619656 if ( ! A || ! B ) return false ; // unbound → filter fails (SPARQL error semantics)
@@ -626,10 +663,19 @@ function applyFilter(f, row) {
626663 case 'contains' :
627664 return a . includes ( b ) ;
628665 case 'regex' : {
629- try {
630- const re = f . re ?? new RegExp ( b , f . flags ) ;
631- return re . test ( a ) ;
632- } catch { return false ; }
666+ let re = f . re ;
667+ if ( ! re ) {
668+ // Pattern came from a variable binding (not a compile-time literal),
669+ // so it was never vetted at parse time — guard it here before it can
670+ // backtrack. A rejected/invalid dynamic pattern fails the filter.
671+ try { assertSafeRegexPattern ( b , maxRegexLength ) ; } catch { return false ; }
672+ try { re = new RegExp ( b , f . flags ) ; } catch { return false ; }
673+ }
674+ // Cap the INPUT length: catastrophic backtracking grows with input
675+ // size, so bound it. Normal-length strings (< maxRegexInput) are
676+ // unaffected; only oversized inputs are truncated before testing.
677+ const input = a . length > maxRegexInput ? a . slice ( 0 , maxRegexInput ) : a ;
678+ try { return re . test ( input ) ; } catch { return false ; }
633679 }
634680 default :
635681 return false ;
@@ -671,6 +717,8 @@ export async function activate(api) {
671717 const maxDepth = api . config . maxDepth ?? DEFAULT_MAX_DEPTH ;
672718 const maxResources = api . config . maxResources ?? DEFAULT_MAX_RESOURCES ;
673719 const resultCap = api . config . resultCap ?? DEFAULT_RESULT_CAP ;
720+ const maxRegexLength = api . config . maxRegexLength ?? DEFAULT_MAX_REGEX_LENGTH ;
721+ const maxRegexInput = api . config . maxRegexInput ?? DEFAULT_MAX_REGEX_INPUT ;
674722 const origin = new URL ( baseUrl ) . origin ;
675723
676724 /** Map any loopback-origin IRI back to the public origin. */
@@ -921,7 +969,7 @@ export async function activate(api) {
921969 subset : 'SELECT [DISTINCT] (?vars|*) [FROM <iri>] WHERE { BGP, FILTER(cmp|CONTAINS|REGEX) } [LIMIT n]' ,
922970 updateSubset : 'INSERT DATA / DELETE DATA with ground triples; target via GRAPH <iri> {} or ?resource=/path' ,
923971 scope : 'FROM <iri> | ?container=/path/ | config.defaultContainer' ,
924- limits : { maxDepth, maxResources, resultCap } ,
972+ limits : { maxDepth, maxResources, resultCap, maxRegexLength , maxRegexInput } ,
925973 } ) ;
926974 } ) ;
927975
@@ -939,7 +987,7 @@ export async function activate(api) {
939987
940988 let query ;
941989 try {
942- query = parseQuery ( body ) ;
990+ query = parseQuery ( body , { maxRegexLength } ) ;
943991 } catch ( err ) {
944992 if ( err instanceof QueryError ) return reply . code ( 400 ) . send ( { error : err . message } ) ;
945993 throw err ;
@@ -978,7 +1026,7 @@ export async function activate(api) {
9781026 let rows ;
9791027 try {
9801028 rows = evalBGP ( triples , query . patterns )
981- . filter ( ( row ) => query . filters . every ( ( f ) => applyFilter ( f , row ) ) ) ;
1029+ . filter ( ( row ) => query . filters . every ( ( f ) => applyFilter ( f , row , { maxRegexLength , maxRegexInput } ) ) ) ;
9821030 } catch ( err ) {
9831031 if ( err instanceof QueryError ) return reply . code ( 400 ) . send ( { error : err . message } ) ;
9841032 throw err ;
0 commit comments