diff --git a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextParser.java b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextParser.java index 0f25822ec..278126b14 100644 --- a/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextParser.java +++ b/xtraplatform-cql/src/main/java/de/ii/xtraplatform/cql/infra/CqlTextParser.java @@ -22,6 +22,12 @@ public class CqlTextParser { + // Guard the recursive-descent parse (and the subsequent tree visit) against deeply nested or + // oversized input: both recurse with the nesting depth of the expression, so a filter with a few + // thousand nested parentheses would otherwise overflow the stack and crash the request thread. + private static final int MAX_LENGTH = 100_000; + private static final int MAX_NESTING_DEPTH = 250; + private CqlParser.CqlFilterContext parseToTree(String cql) { CqlLexer lexer = new CqlLexer(CharStreams.fromString(cql)); lexer.removeErrorListeners(); @@ -43,12 +49,57 @@ public Cql2Expression parse(String cql, EpsgCrs defaultCrs) throws CqlParseExcep @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures") public Cql2Expression parse(String cql, CqlTextVisitor visitor) throws CqlParseException { + checkComplexity(cql); try { CqlParser.CqlFilterContext cqlFilterContext = parseToTree(cql); return (Cql2Expression) visitor.visit(cqlFilterContext); } catch (ParseCancellationException e) { throw new CqlParseException(e.getMessage(), e); + } catch (StackOverflowError e) { + // Safety net in case a construct recurses past the stack despite the depth guard. + throw new CqlParseException("the filter expression is nested too deeply"); + } + } + + // Reject an over-long or too-deeply-nested filter before parsing. Nesting depth is measured by + // counting parentheses outside string literals (single-quoted, with '' as an escaped quote), so + // parentheses inside a literal value do not count toward the limit. + private static void checkComplexity(String cql) throws CqlParseException { + if (cql == null) { + return; + } + if (cql.length() > MAX_LENGTH) { + throw new CqlParseException( + "the filter expression exceeds the maximum length of " + MAX_LENGTH + " characters"); + } + int depth = 0; + int maxDepth = 0; + boolean inString = false; + for (int i = 0; i < cql.length(); i++) { + char c = cql.charAt(i); + if (c == '\'') { + if (inString && i + 1 < cql.length() && cql.charAt(i + 1) == '\'') { + i++; // skip an escaped '' quote, staying inside the string + } else { + inString = !inString; + } + } else if (!inString) { + if (c == '(') { + depth++; + if (depth > maxDepth) { + maxDepth = depth; + } + } else if (c == ')' && depth > 0) { + depth--; + } + } + } + if (maxDepth > MAX_NESTING_DEPTH) { + throw new CqlParseException( + "the filter expression is nested too deeply (maximum " + + MAX_NESTING_DEPTH + + " levels of parentheses)"); } } diff --git a/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/infra/CqlTextParserComplexitySpec.groovy b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/infra/CqlTextParserComplexitySpec.groovy new file mode 100644 index 000000000..07945743a --- /dev/null +++ b/xtraplatform-cql/src/test/groovy/de/ii/xtraplatform/cql/infra/CqlTextParserComplexitySpec.groovy @@ -0,0 +1,60 @@ +/* + * Copyright 2025 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.cql.infra + +import de.ii.xtraplatform.cql.domain.CqlParseException +import de.ii.xtraplatform.crs.domain.OgcCrs +import spock.lang.Specification + +class CqlTextParserComplexitySpec extends Specification { + + CqlTextParser parser = new CqlTextParser() + + def "a deeply nested filter is rejected with CqlParseException, not a StackOverflowError"() { + given: + String cql = ("(" * 300) + "foo = 1" + (")" * 300) + + when: + parser.parse(cql, OgcCrs.CRS84) + + then: + CqlParseException e = thrown() + e.message.toLowerCase().contains("nested too deeply") + } + + def "an over-long filter is rejected"() { + given: + String cql = "a" * 100_001 + + when: + parser.parse(cql, OgcCrs.CRS84) + + then: + CqlParseException e = thrown() + e.message.toLowerCase().contains("maximum length") + } + + def "parentheses inside a string literal do not count toward the nesting limit"() { + given: + String cql = "foo = '" + ("(" * 300) + "'" + + when: + parser.parse(cql, OgcCrs.CRS84) + + then: + noExceptionThrown() + } + + def "a normal filter parses"() { + when: + parser.parse("foo = 'bar'", OgcCrs.CRS84) + + then: + noExceptionThrown() + } +}