Skip to content

Commit f1255a7

Browse files
sparql: guard FILTER(REGEX) against ReDoS
Audit finding: the SELECT endpoint compiled a user-supplied regex pattern (new RegExp) and ran it against pod-plantable strings with no bound — (a+)+$ against a long string pins the event loop and hangs the whole server. No regex-timeout in node and no deps allowed, so bound the worst case: reject patterns over config.maxRegexLength (512), reject catastrophic nested-quantifier shapes via a static heuristic, and truncate the tested string to config.maxRegexInput (10000) before test(). Normal REGEX filters (anchors, case-insensitive substring) unaffected — proven by test; (a+)+$ now returns 400 in ~1ms.
1 parent ca52373 commit f1255a7

3 files changed

Lines changed: 121 additions & 9 deletions

File tree

sparql/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ plugins: [{
2727
// maxDepth: 3, // container recursion below the scope
2828
// maxResources: 200, // loopback fetches per query
2929
// resultCap: 1000, // bindings cap even without LIMIT
30+
// maxRegexLength: 512, // reject FILTER(REGEX) patterns longer than this
31+
// maxRegexInput: 10000, // test a REGEX against at most this many chars
3032
},
3133
}]
3234
```
@@ -296,3 +298,25 @@ that asserts exactly the invariants the host guarantees (see Findings 7).
296298
loudly above instead. (Also mildly convenient: the loader's wildcard
297299
`parseAs: 'buffer'` content-type parser passed `application/
298300
sparql-update` bodies through untouched — no parser gap this time.)
301+
9. **User-supplied `FILTER(REGEX(...))` is a ReDoS surface, mitigated by
302+
bounding not by a real timeout.** The pattern is caller-controlled and
303+
run with `RegExp.test` against strings the caller can plant in their own
304+
pod, so a catastrophic-backtracking pattern like `(a+)+$` over a long
305+
crafted string would pin Node's single event loop and hang the whole
306+
server. Node has no built-in regex timeout and the import rule forbids a
307+
new dep (no RE2), so the worst case is bounded honestly instead, in three
308+
layers: (a) cap the **pattern length** (`config.maxRegexLength`, default
309+
512) → 400; (b) cap the **input length** the pattern is tested against
310+
(`config.maxRegexInput`, default 10 000 chars — oversized inputs are
311+
truncated before `test`, so normal-length strings are unaffected), which
312+
bounds the backtracking exponent × input; (c) a best-effort **static
313+
heuristic** (`\(.*[+*].*\)[+*{]`) that rejects the obvious nested-unbounded-
314+
quantifier shapes (`(a+)+`, `(a*)*`, `(a|b*)+`…) with a 400 before ever
315+
compiling them. The guard runs at BOTH parse time (literal patterns) and
316+
eval time (patterns bound from a variable, which are never seen at parse
317+
time). This is deliberately conservative — it can over-reject and is not a
318+
complete safe-regex analyzer — and the real bound is the two length caps;
319+
the finding is that **without a host- or runtime-level regex timeout, a
320+
plugin exposing user regex can only bound the worst case, not eliminate
321+
it.** A genuine per-request CPU/time budget (or RE2) is the seam a core
322+
implementation would want.

sparql/plugin.js

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,41 @@ const DEFAULT_RESULT_CAP = 1000; // bindings, even when LIMIT is higher/absent
5353
const MAX_QUERY_LENGTH = 20_000;
5454
const 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+
5674
class 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

6093
const 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;

sparql/test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,46 @@ describe('sparql plugin', () => {
186186
assert.deepStrictEqual(rxBody.results.bindings.map((b) => b.name.value), ['harbor at sunset']);
187187
});
188188

189+
// --------------------------------------------------- ReDoS guards on REGEX
190+
191+
it('ReDoS guard: a normal case-insensitive REGEX filter still matches (no regression)', async () => {
192+
const res = await sparql(`
193+
PREFIX schema: <https://schema.org/>
194+
SELECT ?name
195+
WHERE { ?s schema:name ?name . FILTER(REGEX(?name, "BOAT", "i")) }
196+
`, { token: alice.access_token, container: '/alice/photos/' });
197+
assert.strictEqual(res.status, 200);
198+
const body = await res.json();
199+
assert.deepStrictEqual(body.results.bindings.map((b) => b.name.value), ['old fishing boat']);
200+
});
201+
202+
it('ReDoS guard: a catastrophic REGEX pattern is rejected with 400, promptly (no hang)', async () => {
203+
// `(a+)+$` is the classic catastrophic-backtracking shape. Without the
204+
// guard, running it over a long crafted string pins the event loop; with
205+
// it, the request returns a 400 at parse time. The test completing quickly
206+
// (well under the node:test timeout) is itself the "no hang" proof.
207+
const started = Date.now();
208+
const res = await sparql(`
209+
PREFIX schema: <https://schema.org/>
210+
SELECT ?name
211+
WHERE { ?s schema:name ?name . FILTER(REGEX(?name, "(a+)+$")) }
212+
`, { token: alice.access_token, container: '/alice/photos/' });
213+
assert.strictEqual(res.status, 400);
214+
assert.match((await res.json()).error, /catastrophic REGEX/);
215+
assert.ok(Date.now() - started < 5000, 'the guarded request must return promptly, not hang');
216+
});
217+
218+
it('ReDoS guard: an over-length REGEX pattern is rejected with 400', async () => {
219+
const huge = 'a'.repeat(600); // > default maxRegexLength (512)
220+
const res = await sparql(`
221+
PREFIX schema: <https://schema.org/>
222+
SELECT ?name
223+
WHERE { ?s schema:name ?name . FILTER(REGEX(?name, "${huge}")) }
224+
`, { token: alice.access_token, container: '/alice/photos/' });
225+
assert.strictEqual(res.status, 400);
226+
assert.match((await res.json()).error, /too long/);
227+
});
228+
189229
it('predicate lists (;) and FILTER(=) find exactly one photo', async () => {
190230
const res = await sparql(`
191231
PREFIX schema: <https://schema.org/>

0 commit comments

Comments
 (0)