All findings · Lucene finding · LFV-919 · surround-queryparser-unbounded-group-recursion

Surround QueryParser.parse throws StackOverflowError instead of its declared ParseException on deeply nested groups

The surround query parser's QueryParser.parse(String) declares only ParseException, but a valid query with many nested groups makes it throw java.lang.StackOverflowError: as few as 736 groups (1,473 chars) on a 1 MB thread stack after shallow warm-up parses. Every release since 1.9 (2006) is affected.

Summary

Priority
Low, rank 821The niche surround parser also lets StackOverflowError escape on deeply nested groups, and it is the least used of the four parsers, so it closes the parser StackOverflowError group above the parser-unreachable hang LFV-030.
Impact
wrong error
Since
1.9 (2006): the parser's first commit, 2005introduced 2005-07-05 · over 21 years
Affected
every release 1.9 to 10.6.0, and main
Status
documented
Found by
semi-formal: a static recursion-guard check over the bytecode, then a depth sweep

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

How the surround parser reads nested groups

  1. The surround parser (lucene/queryparser, package surround.parser) reads a query language with boolean operators (AND, OR, NOT) and positional ones (3W(a, b), N), and builds span queries. Parentheses group subqueries: (a OR b) AND c.
  2. It is generated by JavaCC as a recursive-descent parser: every grammar rule is a Java method, and a ( starts the rules again from the top, one level deeper, until the matching ).
  3. Every method call takes a frame on the calling thread's stack, which has a fixed size: 1 MB by default for a JVM on Linux x86_64, 2 MB on Linux aarch64, or whatever -Xss sets. When it is full the JVM throws StackOverflowError, an Error, not an Exception.
Diagram of the bug: the surround QueryParser reads a query of nested groups ((((a)))). Each '(' calls seven grammar methods (FieldsQuery, OrQuery, AndQuery, NotQuery, NQuery, WQuery, PrimaryQuery), each a frame on the thread stack. With no depth limit the stack fills (as few as 736 groups on a 1 MB stack) and java.lang.StackOverflowError escapes parse instead of a query or the declared ParseException.
Each ( adds seven parser method frames to the thread's stack; nothing counts them, so a deep enough query fills the stack and StackOverflowError, not a ParseException, comes out of parse. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You pass query text to the surround parser, QueryParser.parse(String). It declares only ParseException, and it already turns its lexer's errors into one: (a) and ((a)) parse to the same query as a, and (a gives a ParseException.
  2. On a JVM with a 1 MB thread stack (-Xss1m, measured on aarch64; 1 MB is also the default on Linux x86_64) that has already parsed 3,000 shallow queries, you parse 900 nested groups around one term, "(".repeat(900) + "a" + ")".repeat(900): 1,801 chars of valid syntax. You expect the same query as a, or at worst a ParseException.
  3. You get java.lang.StackOverflowError (4 of 4 JVMs); 800 groups parsed in 3 of 4. The limit moves with the stack size and with what the JVM has parsed before: as few as 736 groups overflowed at 1 MB, about 1,550 to 2,000 on the 2 MB aarch64 default, and roughly 9 to 16 times more in later deep parses in the same JVM. 100,000 groups fail every time.
  4. A program that builds queries by wrapping every binary OR in parentheses, ((t0 OR t1) OR t2) OR ..., hits it too: after 20,000 warm-up parses, 800 terms (7,884 chars) overflowed a 1 MB stack (a fresh JVM parsed them), though the default BasicQueryFactory allows up to 1,024 basic term queries.
  5. Why: the parser calls seven grammar methods for every ( and never counts how deep it is. parse converts only the lexer's TokenMgrError into a ParseException, so the stack overflow escapes as an Error.

Who is affected

Applications that pass query text, without a length or nesting limit, to the opt-in and rarely used surround parser and handle bad queries by catching its ParseException: deep nesting gives them an undeclared Error instead. Machine-built queries reach it too. Nothing else goes wrong: no wrong query, no index effect, and later parses work.

Mechanism, terms and numbers820 words

org.apache.lucene.queryparser.surround.parser.QueryParser.parse(String), the surround parser's documented client entry point, declares only ParseException, and its parse2 turns the generated lexer's TokenMgrError (a java.lang.Error) into one. The JavaCC grammar is recursive descent with no depth bound: FieldsQuery -> OrQuery -> AndQuery -> NotQuery -> NQuery -> WQuery -> PrimaryQuery('(' FieldsQuery ')') costs seven method frames per group, and prefix operators such as OR(a, b) re-enter FieldsQuery the same way. So valid input with deep nesting exhausts the thread stack and java.lang.StackOverflowError escapes parse. On a 1 MB stack, as few as 736 nested groups (1,473 chars) overflowed (the first overflow of a depth search after 3,000 shallow parses); on the 2 MB aarch64 default stack about 1,550; in later deep parses in the same JVM the limit rose roughly 9 to 16 times, and 100,000 groups always fail. Nothing else goes wrong: no wrong query, no index effect, and later parses work. The bug is in the parser's first commit (2005) and in every release from 1.9 to 10.6.0. A nesting counter in QueryParser.jj, as #16423 added to the expressions parser, fixes it.

Terms

  • Group: a parenthesised subquery, ( ... ), or the argument list of a prefix operator, OR(a, b).
  • ParseException: the checked exception the surround parser declares for input it cannot parse (ParseException.java:5-7: "thrown when parse errors are encountered").
  • TokenMgrError: what the generated lexer throws on a bad character; it extends Error (TokenMgrError.java:7). parse2 converts it into ParseException.

What happens (QueryParser.java, QueryParser.jj at 645d4fe14a20)

  1. QueryParser.parse(String) (QueryParser.java:53-56) declares only throws ParseException, builds a new parser and calls parse2. The class javadoc (:21-22) says it is "The only method that clients should need to call".
  2. parse2 (:62-69; grammar source QueryParser.jj:97-104) wraps TopSrndQuery() in catch (TokenMgrError tme) { throw new ParseException(tme.getMessage()); } and catches nothing else.
  3. Every group costs seven frames: FieldsQuery (:151, calls OrQuery at :155) -> OrQuery (:186, :190) -> AndQuery (:218, :222) -> NotQuery (:250, :254) -> NQuery (:282, :286) -> WQuery (:313, :317) -> PrimaryQuery (:344), whose ( branch calls FieldsQuery again (:351; QueryParser.jj:379, <LPAREN> q = FieldsQuery() <RPAREN>). Prefix operators take a second path, PrefixOperatorQuery -> FieldsQueryList -> FieldsQuery (QueryParser.jj:388-420). Each FieldsQuery also runs a short lookahead (OptionalFields -> jj_2_1 -> jj_3_1 -> jj_scan_token, :162-167, :577-590, :700-704), where many overflow traces end.
  4. Nothing counts the depth: the surround package has no depth or nesting check and no StackOverflowError handling. The module's only complexity limit, the default BasicQueryFactory's 1,024 basic queries (BasicQueryFactory.java:25, :39-41), counts the term queries made when the query is rewritten against an index for search (checkMax, :68-71, called from SimpleTermRewriteQuery.rewrite, SimpleTermRewriteQuery.java:36-53), long after parsing.
  5. So deep enough nesting fills the thread stack, and the StackOverflowError passes through parse2 to the caller.

The numbers (stock main, aarch64)

inputstackresult
(a), ((a)), up to 50 nested groupsanythe same query as a
(aanyParseException
736 nested groups (1,473 chars): the first overflow of a depth search after 3,000 shallow parses1 MB (-Xss1m)StackOverflowError (736, 801 and 736 in three JVMs)
900 nested groups (1,801 chars) after 3,000 shallow parses; 800 parsed in 3 of 4 such JVMs1 MBStackOverflowError (4 of 4 JVMs)
740 and 800 parse, 1,000 overflow (ascending depths in one fresh JVM); 1,025 parse, 2,000 overflow (one parse per fresh JVM)1 MBStackOverflowError
about 1,550 nested groups (3.1 KB): first overflow of a depth search after warm-up2 MB defaultStackOverflowError
2,000 nested groups (4,001 chars)2 MB defaultStackOverflowError in the JUnit test JVM, after one warm-up and in an ascending probe; parses as a fresh JVM's only parse and after another warm-up, so it depends on what ran before
binary OR chain of 740 terms (7,284 chars) after 100- to 700-term chains in the same JVM (ascending probe); of 800 terms (7,884 chars) after 20,000 warm-up parses1 MBStackOverflowError
the same 740- and 800-term chains as a fresh JVM's only deep parse1 MBparse (740 terms in 4 of 4 JVMs, 800 in 2 of 2)
the same chains up to 1,020 terms2 MB defaultparse
nested groups in later depth searches in the same JVM (consistent with JIT compilation; not traced)1 MB / 2 MBfirst overflow at 6,401-11,751 / 23,200-24,751 groups
100,000 nested groupsevery stack and JVM state triedStackOverflowError

x86_64 was not measured, so these thresholds are aarch64 figures; the JVM's default thread stack on Linux x86_64 is 1 MB (the platform default). Lucene's gradle test JVMs run with -XX:TieredStopAtLevel=1, so JUnit thresholds can differ from standalone runs.

Exposure and likelihood234 words
  • Who. Code that calls org.apache.lucene.queryparser.surround.parser.QueryParser.parse(String) (or parse2) on text it does not bound: a search box or request parameter that accepts surround syntax, or a program that generates surround queries.
  • Default vs opt-in. The surround parser itself is opt-in and niche, but it fails with its default settings: there is no setting or API to limit nesting.
  • Scale. Hundreds to a few thousand nested groups, depending on the stack size and on what the JVM has parsed before: as few as 736 (1,473 chars) on a 1 MB stack after shallow warm-up parses, about 1,550-2,000 on the 2 MB aarch64 default. A query builder that parenthesises every binary OR reached it at 740-800 terms on a 1 MB stack after earlier parses in the same JVM (a fresh JVM parsed 800), within the default 1,024-basic-query limit; on the 2 MB stack chains up to 1,020 terms parsed.
  • Effect. The caller gets java.lang.StackOverflowError where it expects a query or ParseException. A handler that catches only ParseException or Exception misses it, so the error reaches whatever handles Errors on that thread. No wrong query is built, nothing is written to an index, and the parser keeps no state across calls (parse builds a new parser; STATIC=false, QueryParser.jj:24), so the next parse works.
  • How likely. Rare: nobody types hundreds of nested groups, and the recorded search found no prior public report.

Minimal repro

One JUnit test, public API only: QueryParser.parse on 100,000 nested groups around one term must return a query or throw ParseException. It uses 100,000 levels because smaller thresholds depend on the stack and on what the JVM parsed before; 100,000 overflow on every stack tried (up to about 2 MB). It runs in about a second.

TestSurroundDeepNesting.java (JUnit, public API)33 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.queryparser.surround.parser;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSurroundDeepNesting extends LuceneTestCase {

  public void testDeeplyNestedGroups() {
    String query = "(".repeat(100_000) + "a" + ")".repeat(100_000);
    try {
      QueryParser.parse(query);
    } catch (ParseException expected) {
      // the declared failure mode, acceptable for too-deep input
    } catch (StackOverflowError e) {
      throw new AssertionError("expected a query or ParseException, got StackOverflowError", e);
    }
  }
}
Command
mkdir -p $LUCENE/lucene/queryparser/src/test/org/apache/lucene/queryparser/surround/parser
cp $REPRO/TestSurroundDeepNesting.java $LUCENE/lucene/queryparser/src/test/org/apache/lucene/queryparser/surround/parser/
cd $LUCENE && ./gradlew :lucene:queryparser:test --tests TestSurroundDeepNesting -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
Expected
QueryParser.parse returns a query (the same as a) or throws ParseException.
Observed
AssertionError: expected a query or ParseException, got StackOverflowError, with assertions on and off.

All files of this repro: repro/LFV-919/

More files, environment, full output256 words

One test class on Lucene's test framework (LuceneTestCase only as the base class), one method, queryparser module, deterministic, single thread, no index. It accepts either declared outcome, a query or a ParseException, so it also passes with any fix that rejects too-deep input. Create lucene/queryparser/src/test/org/apache/lucene/queryparser/surround/parser/ (it does not exist on main) and copy it there (the package parse2 lives in; the test uses only the public static parse).

The small inputs show it better than 100,000 levels, each with its condition: 900 nested groups (1,801 chars) overflowed a 1 MB stack (-Xss1m) after 3,000 shallow parses (4 of 4 JVMs), and an 800-term chain ((t0 OR t1) OR t2) ... overflowed it after 20,000 warm-up parses. They are measurements, not part of the repro, because whether they overflow depends on the stack size and on what the JVM has parsed before; a fresh JVM parsed both.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the finder's earlier two-method JUnit fails the same way on 10.6.0 (release-tag commit), 10.5.2 and 9.12.3; upstream main 2f424fd428da has the same parser code
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform run)

Observed output

TestSurroundDeepNesting > testDeeplyNestedGroups FAILED
    java.lang.AssertionError: expected a query or ParseException, got StackOverflowError
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6401D84E5BDA89A4]:0)
        at org.apache.lucene.queryparser.surround.parser.TestSurroundDeepNesting.testDeeplyNestedGroups(TestSurroundDeepNesting.java:30)

        Caused by:
        java.lang.StackOverflowError
            at org.apache.lucene.queryparser.charstream.FastCharStream.readChar(FastCharStream.java:51)
            at org.apache.lucene.queryparser.charstream.FastCharStream.BeginToken(FastCharStream.java:84)
            at org.apache.lucene.queryparser.surround.parser.QueryParserTokenManager.getNextToken(QueryParserTokenManager.java:506)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.jj_scan_token(QueryParser.java:704)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.jj_3_1(QueryParser.java:590)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.jj_2_1(QueryParser.java:581)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.OptionalFields(QueryParser.java:167)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.FieldsQuery(QueryParser.java:154)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.PrimaryQuery(QueryParser.java:351)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.WQuery(QueryParser.java:317)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.NQuery(QueryParser.java:286)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.NotQuery(QueryParser.java:254)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.AndQuery(QueryParser.java:222)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.OrQuery(QueryParser.java:190)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.FieldsQuery(QueryParser.java:155)
            at org.apache.lucene.queryparser.surround.parser.QueryParser.PrimaryQuery(QueryParser.java:351)
            ... (the seven-frame cycle repeats)

:lucene:queryparser:test (FAILURE): 1 test, 1  failure

Why it looks unintended

  • parse declares only ParseException and already turns the lexer's TokenMgrError, also an Error, into one; the input is valid, with no documented limit.
  • The module's flexible StandardSyntaxParser converts any Error into its declared exception; #16423 (2026) did the same for the expressions parser.
  • No API bounds nesting; the 1,024-basic-query limit acts only when the query is rewritten for search.
The full argument260 words
  • The declared failure mode is ParseException. parse(String) declares only throws ParseException (QueryParser.java:53), and parse2 converts the generated lexer's TokenMgrError, an Error, into one (:62-69), so the code does not mean to let Errors reach callers on bad input. No javadoc says so in words: the expectation is implied by the throws clause and the conversion.
  • The input is valid. (a), ((a)) and 50 nested groups parse to the same query as a; the surround docs and grammar allow groups at any depth, and nothing documents a limit. The surround tests treat ParseException as the parser's failure channel (Test01Exceptions.java:32-62, "No ParseException for:").
  • The same module already converts errors. StandardSyntaxParser.parse catches Error and throws its declared QueryNodeParseException (StandardSyntaxParser.java:88-101, catch at :95).
  • The project fixed the same shape elsewhere. #16423 (merged 2026-07-27, approved by rmuir and msokolov) added a nesting limit, default 1,024, to the expressions parser, which "could throw StackOverflowError on deeply nested expressions" and "no longer throws virtual machine errors" (lucene/CHANGES.txt:551-555); it keeps a StackOverflowError catch as a fallback (JavascriptCompiler.java:266-271).
  • No way around it for callers except bounding the text themselves: there is no nesting option, and BasicQueryFactory's 1,024-basic-query limit counts the term queries made when the query is rewritten against an index for search (BasicQueryFactory.java:25, :68-71; SimpleTermRewriteQuery.java:36-53), long after the parser has overflowed.
  • History. The catch and the unbounded recursion are both in the parser's first commit (2005); no commit, test or discussion mentions nesting depth.
The devil's-advocate check182 words

The strongest case for "intended" (rated moderate by the cross-model reviewer, OpenAI GPT-5.6, and by the intended-behaviour devil's advocate): a Java throws clause never covers VirtualMachineErrors; ParseException is about parse errors and deep nesting is not one; SECURITY.md:38 names "deeply nested queries" among inputs whose limits are "the responsibility of the downstream application"; on #7218 (2015, RegExp) rmuir called a stack overflow the embedder's stack-size problem, and on #752 (2022) rmuir and msokolov rejected catching StackOverflowError; #16423 and #11510 were filed as enhancements.

Why it fails: the input is small (1.5 to 4 KB) and valid, and the caller has no API to bound nesting; SECURITY.md:46 says such out-of-scope issues "are still valid bug reports and hardening suggestions"; msokolov's #752 objection was to the technique ("If we could somehow detect the problem before it gets to that, then throwing IAE would make sense"), which is what a nesting counter does; and the same maintainers approved exactly that fix, with a fallback catch, for the expressions parser in #16423. What remains is a classification risk: maintainers may label it an improvement.

Suggested fix

Recommended A: a nesting counter in QueryParser.jj, plus a last-resort StackOverflowError catch in parse2

Count nesting in the grammar (QueryParser.jj): each ( of a group or a prefix operator's argument list increments a counter, and past 1,024 the parser throws ParseException, as #16423 did for the expressions parser. A StackOverflowError catch in parse2 stays as a fallback for small stacks. Validated: the repro and the whole queryparser suite pass.

  ( <LPAREN> {enterGroup();} q = FieldsQuery() <RPAREN> {nestingDepth--;}
  | q = PrefixOperatorQuery()
  ...
  private void enterGroup() throws ParseException {
    if (++nestingDepth > MAX_NESTING_DEPTH) {
      throw new ParseException(
          "Nesting level too deep: more than " + MAX_NESTING_DEPTH + " nested groups");
    }
  }
All options and trade-offs4 options · 803 words

Fix A was implemented in the grammar, regenerated with JavaCC through gradle and run (diff below). B was run earlier as a shadow of the generated file only. C and D were not run.

A: a nesting counter in QueryParser.jj, plus a last-resort StackOverflowError catch in parse2 (validated by the checks) (recommended)

enterGroup() increments nestingDepth after each <LPAREN> in PrimaryQuery and FieldsQueryList and throws ParseException past MAX_NESTING_DEPTH (1,024); the matching <RPAREN> decrements it. parse2 resets the counter and converts a StackOverflowError that still happens into ParseException("Nesting level too deep") with the error as cause. Regenerated with ./gradlew :lucene:queryparser:regenerateSurroundQueryParser (+25/-3 in QueryParser.jj, +26 in the generated QueryParser.java, the checksum file).

  ( <LPAREN> {enterGroup();} q = FieldsQuery() <RPAREN> {nestingDepth--;}
  | q = PrefixOperatorQuery()
  ...
  private void enterGroup() throws ParseException {
    if (++nestingDepth > MAX_NESTING_DEPTH) {
      throw new ParseException(
          "Nesting level too deep: more than " + MAX_NESTING_DEPTH + " nested groups");
    }
  }

Trade-offs

  • When the stack holds 1,024 levels (the 2 MB default did, in these runs), the counter rejects deeper input deterministically; on a smaller stack the overflow can come first, and the fallback turns it into ParseException (tested configurations only). It covers every recursion path: the grammar re-enters FieldsQuery only through PrimaryQuery's ( and FieldsQueryList; operator chains and field prefixes are loops. It bounds nesting, not input size. One int increment per group, nothing on the hot search path.
  • 1,024 matches #16423 and the module's 1,024-basic-query limit (a binary OR chain within that limit nests at most 1,023 deep). But a 1 MB stack can run out first: after 3,000 shallow parses, 900 groups overflow there, so the fallback catch, not the counter, turns them into ParseException (executed), and whether a 900-group query parses still depends on the stack. A lower limit avoids that but rejects deep machine-built queries that parse today.
  • Adds a public constant (MAX_NESTING_DEPTH); maintainers may prefer a configurable limit, as JavascriptCompiler.compile(..., maxNestingDepth) has.
  • The fallback is a catch of StackOverflowError, which rmuir and msokolov objected to on #752 as the only fix; #16423 keeps the same fallback behind its counter.

What the checks validated

Executed 2026-10-31 in a scratch worktree of 645d4fe14a20 with OpenJDK 25.0.3, gradle --no-daemon, seed DEADBEEF:

  • Stock: the repro fails with assertions on and off (expected a query or ParseException, got StackOverflowError).
  • Regenerated with ./gradlew :lucene:queryparser:regenerateSurroundQueryParser (JavaCC through gradle): the generated QueryParser.java changes only by the counter, enterGroup() and the catch; the regenerate checksum file is updated.
  • With fix A: the whole :lucene:queryparser:test passes, 538 tests including the repro and 4 validation-only methods, 1 skipped (assertions on); the repro and the validation methods also pass with assertions off; regenerateSurroundQueryParserChecksumCheck, checkGoogleJavaFormat, forbiddenApisMain and ecjLintMain pass.
  • Validation-only methods (not the repro): 1,024 nested groups parse to the same query as a; 1,025 give ParseException: Nesting level too deep: more than 1024 nested groups; 1,025 nested OR(b, ...) prefix lists are rejected; a parser instance parses again after a rejection.
  • Probe outside the test framework (assertions off; 3,000 shallow parses first; two JVMs per row): at -Xss1m stock overflows from 900 groups, fix A returns ParseException: Nesting level too deep with a StackOverflowError cause from 900 to 100,000 groups (the fallback); on the 2 MB default stack stock overflows at 2,000, fix A parses up to 1,024 and rejects 1,025 and more through the counter. With one parse per fresh JVM, fix A rejects 1,025 and more through the counter on both stacks.

Not run: other modules (nothing else in the repository calls the surround parser), x86_64, and a benchmark (the change adds one int increment per group to parsing only).

B: only catch StackOverflowError in parse2 and throw ParseException

The finder's sketch: next to the TokenMgrError catch, catch (StackOverflowError soe) throws ParseException("Nesting level too deep") with the cause.

Trade-offs

Smallest change, and it made the repro and the independent reproducer's tests pass (run as a shadow of the generated QueryParser.java, which a real patch must instead change through QueryParser.jj). But which inputs fail still depends on stack size and JIT state, the parser first recurses until the stack is full, and catch-only is the technique maintainers rejected on #752.

C: the counter without the fallback

Fix A without the catch in parse2.

Trade-offs

Never catches an Error, but with a 1,024 limit it leaves a StackOverflowError window on 1 MB stacks (as few as 736 groups after warm-up); closing it needs a much lower limit, which rejects valid queries that parse today. Not run.

D: deprecate or remove the surround parser

Open issue #16820 proposes deprecating the surround parser for Lucene 11.

Trade-offs

Does not change any 10.x release, which stays affected; users of the parser would still need A or B there. Not a fix on its own.

Downstream note

Downstream servers that accept surround syntax from users (Solr, for one, ships a surround query parser plugin) get this Error on the request thread unless they bound query length or nesting themselves, which SECURITY.md leaves to the application.

How it was found

A hunting unit looking for siblings of the expressions-parser fix #16423 ran a static check over the real bytecode for recursive call cycles, reachable from public parse methods, with no depth guard and no Error conversion. It flagged the surround grammar; a per-stack-size depth sweep confirmed it, and the case was reduced to a public-API JUnit.

The method in full158 words

Method: semi-formal. The lead was the hunt plan's hypothesis that parsers of user query text are siblings of #16423; tools then witnessed it.

  • Static model. GuardScan builds the call graph of the real queryparser bytecode, finds the recursive strongly connected components reachable from 25 public parse entry points over edges with no depth guard and no Error conversion, and predicted that surround parse and parse2 can leak StackOverflowError through a nine-method grammar cycle. On a jar with the fix sketch it reports no leak.
  • Depth sweep. DepthSweep ran one JVM per -Xss: the smallest overflowing depth was 121, 326 and 736 at 256 KB, 512 KB and 1 MB.
  • Reduction. A public-API JUnit on the stock jar (100,000 nested groups), later cut to the one-test repro shown here.
  • Checked by an independent reproducer, a cross-model reviewer, an intended-behaviour devil's advocate, an adjudication, a history and a prior-report follow-up, a credibility audit and this brief's gradle runs (below).

Prior reports, history and checks

Prior reports

No prior report of the surround parser overflowing: 106 queries over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream repositories and the web, plus the changed files of all 413 open PRs (2026-10-28). The same bug class is known in other parsers (#11687, #11510, #16423).

Searches5 searches

Verdict new: no report, issue, PR or list post by anyone says the surround parser overflows on nested groups. Keyword search cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Solr, Elasticsearch, OpenSearch, Lucene.NET)2026-10-28a scripted prior-report search (74 queries) and 19 manual searches: StackOverflowError, stack overflow, nesting depth, deeply nested, surround parser; every item created since 2026-10-25no surround overflow report
Open PRs2026-10-28changed files of all 413 open apache/lucene PRsnone touches surround/parser/; #13008 touches only surround/query/
JIRA archive (LUCENE, SOLR)2026-10-288 JQL queriesSOLR-8539 (an OutOfMemoryError with a surround query) and LUCENE-5205 (regex recursion in a proposed parser): not this bug
Mailing lists2026-10-2820 queries over dev@, java-user@ and the Solr listsno thread about the surround parser overflowing
Related items (read)2026-10-28bodies and commentssame class, other fix sites: #11687 (SimpleQueryParser, open; mikemccand asked in general whether other parsers have the problem), #11510 / #752 / #7218 / #12462 (RegExp), #16423 (expressions, fixed), elastic/elasticsearch#152385 (downstream catch for the classic and simple parsers); #16820 proposes deprecating the surround parser

History

In the parser's first commit, 9d70229506f8 (2005-07-05, LUCENE-368 / #1446): not a regression. Every release with the surround parser, 1.9 (2006) to 10.6.0, is affected; 21 releases (10.6.0 as its release-tag commit) and main were run.

Versions tested5 rows

Introduced by 9d70229506f8 ("#34331 - Add Paul Elschot's Surround query language parser", 2005-07-05, svn r209183; contribution LUCENE-368 = #1446, no PR). Both halves, the TokenMgrError-only catch and the unbounded PrimaryQuery recursion, are there in their current form (git blame -w -M -C; git log -L on PrimaryQuery and parse2 lists only this commit). A textual check of all 147 release tags that contain the parser finds the same markers: one TokenMgrError catch, one parenthesised-group production, two FieldsQueryList calls, no depth or nesting check. 1.4.3 and earlier have no surround parser.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (and a9779ef3c193) affectedthe one-test repro and the finder's JUnit fail with assertions on and off
10.6.0 (4ae946e5a9b9), 10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe finder's JUnit fails (2 of 2); 10.6.0 as jars built from its release-tag commit with assertions off, 9.1.0 with assertions on, the others both
1.9.1, 2.0.0, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0 affecteda release-portable probe: 100,000 nested groups and 100,000 (a OR groups overflow, assertions on and off; shallow controls parse
1.4.3 no parserthe surround parser did not exist yet
fix A on 645d4fe14a20 not affectedthe repro passes; too-deep input gives ParseException

Independent checks

A finder, an independent reproducer, a cross-model reviewer, an intended-behaviour devil's advocate, an adjudication, a credibility audit, a registry re-run and fact checks: every executed run reproduces it. The adjudication confirmed it with medium confidence, about acceptance, not reality.

Checks and verdicts379 words

Every verifier ran on main a9779ef3c193 (same queryparser code as 645d4fe14a20); the minimal repro and this brief re-ran on 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateJUnit fails 2 of 2 on main (assertions on and off), 10.5.2, 9.12.3 and 10.6.0; passes with the catch sketch; static model and depth sweep.
Independent reproducer realOwn test (shallow control passes; 2,000, 100,000 and 20,000 (a OR levels fail) on main, 10.5.2 and 9.12.3; measured the thresholds (736/801/736 first at 1 MB).
Cross-model reviewer (OpenAI GPT-5.6, refute lens) not real (procedural)Could not write or run anything, so its "not real" is procedural; agrees with the root cause and that the input is in contract; rates the intended-behaviour case moderate. A separate run re-ran the JUnit: 2 of 2 fail.
Intended-behaviour devil's advocate realNo intended, documented, misuse or unreachability argument survives; 800-term OR chains overflow at 1 MB.
Adjudication confirmed, mediumRe-ran the independent reproduction's and the finder's tests on a clean checkout; corrected the claims (as few as about 740 levels; not an x86_64 figure; expectation implied, not documented; fix belongs in the .jj).
Credibility audit keepRe-ran the JUnit (2 of 2 fail, both assertion modes); own ascending probe (one JVM per shape and stack): 1,000 groups and, after shorter chains, a 740-term chain overflow at 1 MB.
This brief real; fix A validatedgradle in a scratch worktree of 645d4fe14a20: stock fails with assertions on and off; fix A regenerated and passes (see Suggested fix).
Independent fact check real; 5 wording fixes, appliedRe-ran the repro standalone and through gradle in a clean worktree (fails with assertions on and off), regenerated fix A from the .jj byte for byte (the repro, the 534-test module suite and the checksum check pass) and ran its own threshold probe.
Cross-model fact check (OpenAI GPT-5.6) real; 4 errors, 6 overclaims, appliedReading only, plus git apply --check of fix A; it ran no Java. Agrees the bug is real and the counter covers both recursion paths; rates the intended-behaviour case moderate.
Diagram check passStills of the PNG checked against the code at 645d4fe14a20 and the probe runs; no wrong fact.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,504 words

Finding record

LFV-919, snapshot of 2026-11-06

Registry title
Surround QueryParser.parse throws StackOverflowError instead of its declared ParseException on deeply nested groups (as few as about 740 levels, about 1.5 KB of query text, on a 1 MB thread stack)
Area
queryparser / surround (generated JavaCC parser, QueryParser.jj)
Priority
Low, rank 821: The niche surround parser also lets StackOverflowError escape on deeply nested groups, and it is the least used of the four parsers, so it closes the parser StackOverflowError group above the parser-unreachable hang LFV-030.
Impact class
wrong error
Impact note
Hits applications that pass query text, without a nesting or length limit, to the opt-in, niche surround QueryParser (lucene/queryparser) and rely on its declared ParseException: a valid query with deeply nested groups makes parse throw java.lang.StackOverflowError, with -ea and -da, in every release since 1.9; the depth needed depends on stack size and JIT state (as few as about 740 levels at -Xss1m and about 1,550 on the 2 MB aarch64 default stack on the first deep parse, about 10x more once the recursion is JIT-compiled, and 100,000 levels always), and nothing else goes wrong (no wrong query, no index effect, the JVM and later parses continue).
Summary
You call the surround parser's documented entry point QueryParser.parse(String), which declares only ParseException and already turns the lexer's TokenMgrError (an Error) into a ParseException, on a valid query with many nested groups, for example "(".repeat(d) + "a" + ")".repeat(d) or a long chain of "(a OR " groups, and expect a query or a ParseException. Instead the generated recursive-descent grammar, which spends seven frames per "(" (FieldsQuery -> OrQuery -> AndQuery -> NotQuery -> NQuery -> WQuery -> PrimaryQuery -> FieldsQuery) and has no depth bound, runs out of stack and java.lang.StackOverflowError escapes parse, so a caller that handles ParseException gets an undeclared Error. On a 1 MB thread stack as few as about 740 nested groups (1,473 chars) are enough on the first deep parse, and an 800-term chain of parenthesised binary ORs (7,884 chars, under the module's 1024-basic-query limit) fails even after JIT warm-up.
Introduced
9d70229506f88d3259ba0d24a824c0dbadc52c8e (trunk, '#34331 - Add Paul Elschot's Surround query language parser', svn r209183, the surround parser's first commit; contribution LUCENE-368 = #1446, no pull request): both halves are already there in their current form, parse2 catching only TokenMgrError and the FieldsQuery ... PrimaryQuery('(' FieldsQuery ')') cycle (plus PrefixOperatorQuery -> FieldsQueryList -> FieldsQuery) with no depth bound [read: git blame -w -M -C of QueryParser.jj (parse/parse2 and 138 of the 151 grammar lines involved; the other 13 are afad8123d2a, 2010, generics only), git log -L on PrimaryQuery and parse2, git log -S on the TokenMgrError catch]; not a regression: the bug is as old as the parser (2005-07-05 (trunk 9d70229506f); first release 1.9 (2006-02-27))
First affected version
1.9 [read: tag releases/lucene/1.9, 2006-02-27; 1.9.0 is not on Maven Central]; 1.9.1 [exec] is the first executed release; 1.4.3 and earlier have no surround parser [read]
Affected versions
every release that ships the surround parser, 1.9 to 10.5.2, plus 10.6.0 (branch_10_6 4ae946e5a9b9, the RC1 revision) and main a9779ef3c193 (unreleased 11.0) [exec: 20 releases (1.9.1, 2.0.0, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2) and main, each with -ea and -da: a release-portable main() probe affected in 40 of 40 runs (100,000 nested groups and 100,000 '(a OR ' groups) and the candidate JUnit affected on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; branch_10_6 with -da]; the other releases by code identity [read: all 147 release tags that contain the parser have the same parse2 catch and the same recursion, and no occurrence of StackOverflow, depth or nesting in the grammar or the generated parser]. Not fixed upstream: upstream main c5ab3e57b9ba (2026-10-28) has no change under lucene/queryparser after a9779ef3c193, and the surround parser directory was last changed on main on 2026-02-01 (whitespace) [read]. Executed on Linux aarch64 with JDK 25 only; x86_64 was not run.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record verdict new): scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, the LUCENE and SOLR JIRA archive and the web (74 queries in the follow-up's run, plus the finder's two runs and the intended-behaviour check's 40-query intent run), 32 manual GitHub, JIRA, list and web search entries (106 follow-up queries in all), with bodies and comments of #7218, #5467, #11687, #11510, #16820, #16918, #752, #16423 and elastic/elasticsearch#152385 read, the changed files of all 413 open apache/lucene PRs (only #13008 touches surround code, surround/query only), and every issue and PR created since 2026-10-25 (#16905..#16931, none related; re-check 2026-10-28: 0 issues or PRs created since 2026-10-26 for surround, StackOverflowError, 'query parser' or nesting, and the 18 PRs created since then are unrelated). Nearest items, none the same bug: #11687 (SimpleQueryParser stack overflow on nested queries, open; mikemccand asks in general 'I wonder if our other query parsers have this problem too?' without naming or testing the surround parser), #11510, #752, #7218 and #12462 (RegExp; the maintainers' stance against catching StackOverflowError), #16423 (expressions nesting guard plus StackOverflowError fallback, merged 2026-07-27, the fix precedent), elastic/elasticsearch#152385 (a downstream catch around the classic and simple parsers, no surround), #6269 (LUCENE-5205, regex recursion in a proposed SpanQueryParser), SOLR-8539 (Solr swallowing OutOfMemoryError), open #16820 (proposes deprecating the surround parser for Lucene 11). Not fixed upstream (upstream main c5ab3e57b9ba). Gap: token-based searches can miss a report that names none of the searched terms.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through gradle (in a scratch worktree, and again in a clean worktree by an independent fact check) and through a standalone runner; with the recommended fix (nesting counter in QueryParser.jj, regenerated with JavaCC through gradle) it passes, together with the whole queryparser test suite and the module's regenerate-checksum, format, forbidden-API and lint checks. The hunt ran on main a9779ef3c193, which has the same lucene/queryparser code: 20 releases from 1.9.1 to 10.5.2 executed from Maven Central jars (SHA-1 checked), all but 9.1.0 with assertions on and off (9.1.0 on only), and 10.6.0 as jars built from its release-tag commit 4ae946e5a9b9 (assertions off). Upstream main 2f424fd428da (2026-10-30) has no change under lucene/queryparser after 645d4fe14a20 [read].

Lucene and upstream sources

Finding record
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.java:21-22 (at a9779ef3c193) (class javadoc: 'The only method that clients should need to call is parse()'), :53-56 (parse(String) declares only 'throws ParseException' and builds a new parser per call), :62-69 (parse2: 'catch (TokenMgrError tme) { throw new ParseException(tme.getMessage()); }', nothing else caught), :345-352 (PrimaryQuery LPAREN branch calls FieldsQuery again)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.jj:88-91 (at a9779ef3c193) (parse), :97-104 (parse2 and its TokenMgrError catch), :379 ('<LPAREN> q = FieldsQuery() <RPAREN>' in PrimaryQuery); no depth counter anywhere in the grammar
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.java:88-100 (at a9779ef3c193) (the same module's flexible syntax parser catches Error and throws its declared QueryNodeParseException)
  • lucene/CHANGES.txt:540-544 (at a9779ef3c193) (GITHUB#16423: the expressions parser 'could throw StackOverflowError on deeply nested expressions' and now has a nesting limit, default 1024, and 'no longer throws virtual machine errors')
  • SECURITY.md:28 (downstream application sanitizes untrusted input), :38 ('deeply nested queries'; bounding them is the downstream application's job), :46 (out-of-scope issues 'are still valid bug reports and hardening suggestions'), :50 (in-scope bar)
  • Only candidates from two other hunt units name surround/parser (other fix sites); the sibling findings (classic, simple, flexible, RegExp) fix other grammars.
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.java:21-22, :53-56, :62-69, :151-155, :186-190, :218-222, :250-254, :282-286, :313-317, :344-351, :162-167, :577-590, :700-704
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.jj:24, :97-104, :251-257, :376-385, :388-420
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/ParseException.java:5-7; TokenMgrError.java:7
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/query/BasicQueryFactory.java:25, :39-41, :68-81; SimpleTermRewriteQuery.java:36-53; SpanNearClauseFactory.java:110
  • a Lucene main checkout (645d4fe14a20): grep -rniE 'depth|nesting|StackOverflow' lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/ (no hit)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.java:53-56; QueryParser.jj:24
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.java:53, :62-69
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/test/org/apache/lucene/queryparser/surround/query/Test01Exceptions.java:32-62
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.java:88-101
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:551-555; lucene/expressions/src/java/org/apache/lucene/expressions/js/JavascriptCompiler.java:104-105, :138, :266-271
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/query/BasicQueryFactory.java:25, :68-71; SimpleTermRewriteQuery.java:36-53
  • SECURITY.md:38, :46
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/js/JavascriptCompiler.java:138, :195-197, :266-271
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/build.gradle:136-160 (regenerateSurroundQueryParser)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed threshold measurements. Its fix strip was drawn before fix A was built and run; the validated fix is the one described under the suggested fix.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.