All findings · Lucene finding · LFV-949 · geojson-parser-unbounded-nesting-recursion

Polygon.fromGeoJSON throws StackOverflowError instead of ParseException on deeply nested JSON

Polygon.fromGeoJSON reads every [ or { with one more recursive call and never counts the depth, so 10,000 nested brackets (20 KB, even inside a valid Feature's properties) throw StackOverflowError instead of returning the polygon or the declared ParseException. Every release since 6.2.0 is affected.

Summary

Priority
Low, rank 822Polygon.fromGeoJSON lets a StackOverflowError escape on adversarial deep nesting, the same wrong-error class as the query-parser StackOverflowError of LFV-919, and GeoJSON parsing is used less than the query parsers, so it follows LFV-919.
Impact
wrong error
Since
6.2.0 (LUCENE-7380, 2016-07-15)introduced 2016-07-15 · over 10 years
Affected
every release 6.2.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: a bytecode recursion-guard scan and a fresh-JVM 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 parser reads nesting

  1. A GeoJSON Feature is a JSON object with a geometry (the polygon's coordinates) and properties, which may hold any JSON values. Polygon.fromGeoJSON keeps the geometry and skips the properties.
  2. The parser is recursive: on [ it calls parseArray, on { it calls parseObject, and a bracket nested inside calls them again. Each call keeps its stack frame until its closing bracket.
  3. A Java thread has a fixed-size stack (2 MiB by default on the test machine, often 1 MiB on Linux x86-64). When the frames fill it, the JVM throws StackOverflowError, which is an Error, not an Exception.
Diagram of the bug: Polygon.fromGeoJSON parses a GeoJSON Feature whose property p is an array nested 10,000 deep. Each bracket adds one parseArray call to the thread stack; at 5,000 levels the stack holds and the polygon is returned, at 10,000 it overflows and StackOverflowError escapes past catch (ParseException). With a depth limit of 1,000 the parse ends in ParseException instead.
The parser calls itself once per bracket. 5,000 nested brackets fit on the default 2 MiB stack; 10,000 (20 KB) do not, and StackOverflowError escapes instead of a polygon or ParseException. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You parse a valid GeoJSON Feature with Polygon.fromGeoJSON: a normal 5-point polygon, plus a property "p": [[[...]]], an empty array inside 10,000 brackets. The whole text is 20,146 chars.
  2. You expect the polygon back, since the parser skips properties, or at worst the method's declared ParseException. With 5,000 brackets (10,146 chars) you do get the polygon.
  3. Instead java.lang.StackOverflowError escapes (fresh JVM, default 2 MiB thread stack; with a 1 MiB stack 5,000 brackets already fail). It is an Error, so catch (ParseException) and catch (Exception) do not stop it.
  4. Why: on [ the parser calls parseArray, on { it calls parseObject, and they call each other for every nested bracket with no depth limit. Each level holds a stack frame until the stack runs out.
  5. Malformed text behaves the same: unterminated [[[[... gets the proper ParseException at 5,000 levels and StackOverflowError at 10,000. Lucene offers no setting to bound it.

Who is affected

Applications that pass caller-supplied GeoJSON (a query polygon or a field value) to Polygon.fromGeoJSON and treat ParseException as bad input. 20 KB of brackets is enough, and Lucene gives the caller no depth or size setting; a length cap would also reject large real polygons. Real GeoJSON nests a few levels, so only hostile or broken input triggers it.

Mechanism, terms and numbers626 words

Polygon.fromGeoJSON(String) declares only ParseException, but its parser, SimpleGeoJSONPolygonParser, reads each nested JSON array or object with one more recursive call (parseArray and parseObject call each other) and has no depth limit and no Error handler. In a fresh JVM on the default 2040 KiB thread stack (Linux aarch64, OpenJDK 25.0.3), a valid Feature whose properties hold an array nested 10,000 deep (20,146 chars) throws java.lang.StackOverflowError; 5,000 levels return the polygon, and with a 1 MiB stack 5,000 levels fail. Nested object properties and malformed unterminated arrays fail the same way. No wrong polygon is returned and no index is touched; the defect is the undeclared Error, which passes catch (ParseException) and catch (Exception), from an API that gives the caller no limit to set. It has been in every release since 6.2.0, when the parser was added. A depth limit that throws ParseException, as #16423 added to the expressions parser, fixes it.

What happens (code at 645d4fe14a20)

  1. Polygon.fromGeoJSON returns new SimpleGeoJSONPolygonParser(geojson).parse() (Polygon.java:255-257), and parse() starts with parseObject("") (SimpleGeoJSONPolygonParser.java:63-65).
  2. parseObject reads each member; a value starting with [ goes to parseArray(newPath) (:138-145), one starting with { to parseObject(newPath) (:146-154). That includes members under properties, which the parser reads only to skip them (it acts only on type, coordinates and crs, :125-128, :174-215).
  3. parseArray sends a nested [ to parseArray(path + ".[]") (:308-309) and a nested { to parseObject(path + ".[]") (:310-313).
  4. Neither method has a depth parameter, field or check, and the class's only catch is for NumberFormatException in parseNumber (:343-348). Each level holds a frame until its closing bracket; when the stack is full the JVM throws StackOverflowError, and it leaves fromGeoJSON, which declares only ParseException (Polygon.java:255).
  5. A smaller second path: for nested arrays under coordinates, the parse can finish and parsePoints then builds an error message with the nested list (:254-263), whose toString() recurses the same depth (seen at 2,000 levels on a 512 KiB stack only). A depth limit bounds this too (by reasoning; the fix was not run on this shape).

A static scan of the stock bytecode (JDK ClassFile API) finds exactly one recursive cycle in the class, {parseArray, parseObject}, with no depth counter and no Error handler.

The numbers

Fresh JVM per line, -da, main thread, stock 645d4fe14a20 core jar, OpenJDK 25.0.3 on Linux aarch64; the input is the repro's Feature with properties.p nested depth arrays deep:

depthcharsthread stackoutcome
1,0002,146default (2040 KiB)1 polygon
5,00010,146default1 polygon
10,00020,146defaultStackOverflowError
50,000100,146defaultStackOverflowError
2,0004,1461 MiB (-Xss1m)1 polygon
5,00010,1461 MiBStackOverflowError

These are sample depths, not the overflow point. An independent fact check (3 fresh JVMs per depth, same Feature, main thread) puts it between 6,500 levels (13,146 chars, polygon 3 of 3) and 7,000 (14,146 chars, StackOverflowError 3 of 3, also with -ea) on the default stack, and between 3,500 and 4,000 levels (8,146 chars) on 1 MiB.

Other shapes on the default stack (independent reproducer, -da; the hunt's 420-point sweep, -ea and -da): a nested object property {"a":{"a":...}} is fine at 5,000 and fails at 10,000 (60,141 chars); unterminated {"type":"Polygon","coordinates":[[[[... gives ParseException at 5,000 and the Error at 10,000. 1,000 levels never failed. The exact depth varies with stack size, the thread, JIT state and version: the diagram's runs, parsing on a new thread with the same default stack, saw 10,000 levels overflow in 9 of 10 runs, and 6.2.0 needs 20,000 levels on the default stack for the valid shapes; x86 was not run.

Exposure and likelihood289 words
  • Who. Code that turns request-supplied GeoJSON into Polygons through fromGeoJSON, which the Polygon class javadoc recommends for GeoJSON text (Polygon.java:23-26). Nothing in Lucene calls it outside tests. An independent verifier read one open-source search server that calls it on document field values and catches only ParseException (read, not run).
  • Default vs opt-in. No setting is involved; it is the only behaviour of a public static API. Polygon is @lucene.experimental (Polygon.java:41).
  • Scale. On the main thread of a fresh JVM the array property overflows from about 7,000 levels (14 KB) on the default 2040 KiB aarch64 stack and from about 4,000 (8 KB) on a 1 MiB stack (3 of 3 JVMs at each depth); 10,000 and 5,000 are the round depths first measured. The exact depth varies with the stack, the thread, JIT state and version.
  • No way to bound it through Lucene. fromGeoJSON has no depth or size parameter. A length cap is a poor substitute: at about 22 chars per vertex ([100.123456,0.123456],) a 1,000-vertex polygon is already about 22 KB, more than the 14 KB that already overflowed. Workarounds include pre-parsing with another JSON parser or counting the bracket depth before the call, a thread with a larger stack, or catching StackOverflowError, which maintainers discourage (Robert Muir and Mike Sokolov on #752).
  • Effect. An Error in the calling thread only: no wrong polygon, no index change, no shared state (the parser is a new object per call). What follows depends on how the caller handles Error.
  • Likelihood: contrived. A MultiPolygon's coordinates are 4 arrays deep, inside a Feature inside a FeatureCollection; nobody writes thousands of levels. The documented searches found no report to Lucene in ten years.

Minimal repro

One JUnit class, one test method, core only, public API: Polygon.fromGeoJSON on a valid Feature whose property p is an empty array nested 50,000 deep (100,146 chars), on the test thread's default stack. It accepts the polygon or a ParseException. Deterministic, single thread, no index.

TestGeoJSONDeepNesting.java (one test class)40 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.geo;

import java.text.ParseException;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGeoJSONDeepNesting extends LuceneTestCase {

  public void testFeatureWithDeeplyNestedArrayProperty() {
    String geojson =
        "{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":"
            + "[[[100.0,0.0],[101.0,0.0],[101.0,1.0],[100.0,1.0],[100.0,0.0]]]},"
            + "\"properties\":{\"p\":"
            + "[".repeat(50_000)
            + "]".repeat(50_000)
            + "}}";
    try {
      assertEquals(1, Polygon.fromGeoJSON(geojson).length);
    } catch (ParseException expected) {
      // the declared failure mode, acceptable for too-deep input
    } catch (StackOverflowError e) {
      throw new AssertionError("expected the polygon or ParseException, got StackOverflowError", e);
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestGeoJSONDeepNesting -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
fromGeoJSON returns the 1-element Polygon[], or throws ParseException for too-deep input.
Observed
AssertionError: expected the polygon or ParseException, got StackOverflowError (from parseArray), with assertions on and off.

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

More files, environment, full output272 words

LuceneTestCase is only the base class; the input is fixed (the randomized runner only picks environment settings). The package is org.apache.lucene.geo only so that the file sits next to TestPolygon; Polygon.fromGeoJSON is public. 50,000 levels is well above the fresh-JVM overflow point (about 7,000 levels on the default 2040 KiB stack) and also overflows after JIT warm-up (10 of 10 in one JVM, with -ea and -da). The test runs on the forked test JVM's default thread stack (Lucene's build sets no -Xss), so a test JVM configured with a much larger stack could let the unfixed parser pass. It is not deeper on purpose: on a JVM with a larger stack, the parser's per-level path strings would make heap use grow with the square of the depth reached.

Regression test: this same method, in TestPolygon or as its own class; with the recommended fix the parse ends in ParseException and the test passes.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.5.2 and 9.12.3: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestGeoJSONDeepNesting.java $LUCENE/lucene/core/src/test/org/apache/lucene/geo/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestGeoJSONDeepNesting -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false (stack trimmed; -Ptests.asserts=true fails the same way)
java.lang.AssertionError: expected the polygon or ParseException, got StackOverflowError
	at __randomizedtesting.SeedInfo.seed([DEADBEEF:DDD1C89C8A6B3F85]:0)
	at org.apache.lucene.geo.TestGeoJSONDeepNesting.testFeatureWithDeeplyNestedArrayProperty(TestGeoJSONDeepNesting.java:37)
Caused by: java.lang.StackOverflowError
	at org.apache.lucene.geo.SimpleGeoJSONPolygonParser.parseArray(SimpleGeoJSONPolygonParser.java:285)
	at org.apache.lucene.geo.SimpleGeoJSONPolygonParser.parseArray(SimpleGeoJSONPolygonParser.java:309)
	at org.apache.lucene.geo.SimpleGeoJSONPolygonParser.parseArray(SimpleGeoJSONPolygonParser.java:309)
	... (1,023 recorded parseArray:309 frames)

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

# fresh-JVM probe on the stock jar, default 2040 KiB stack, -da
depth=5000 chars=10146 OK polygons=1
depth=10000 chars=20146 StackOverflowError

Why it looks unintended

  • fromGeoJSON declares only ParseException, and its class comment and TestPolygon show Features with nested properties; only the depth differs.
  • Unterminated [[[[... gets the proper ParseException at 5,000 levels and StackOverflowError at 10,000: depth alone flips the outcome.
  • #16423 (10.6.0) gave the expressions parser a nesting limit so that it "no longer throws virtual machine errors".
The full argument178 words
  • Declared outcomes. Polygon.java:248-257: "Parses a standard GeoJSON polygon string. The type of the incoming GeoJSON object must be a Polygon or MultiPolygon, optionally embedded under a "type: Feature"", and the method declares only ParseException.
  • Nested properties are accepted on purpose. The class comment shows a Feature with "prop1": {"this": "that"} in properties (SimpleGeoJSONPolygonParser.java:23-45); TestPolygon.testGeoJSONPolygonFeature parses that Feature, and testPolygonPropertiesCanBeStringArrays (LUCENE-8964, #10007) an array property. RFC 7946 section 3.2 lets properties be any JSON object.
  • Depth alone decides. The same unterminated text gives the parser's own "hit EOF" ParseException at 5,000 levels and the Error at 10,000.
  • Precedent. lucene/CHANGES.txt:551-555 (10.6.0): GITHUB#16423 (Uwe Schindler, Seth Kraft) added a nesting limit (default 1024) to the expressions parser, which "could throw StackOverflowError on deeply nested expressions" and now "no longer throws virtual machine errors". RFC 8259 section 9 allows a parser to limit nesting depth; a limit that throws the declared ParseException is all this asks.
  • Nothing makes the Error an outcome. No test covers nesting depth (lucene/core/src/test/org/apache/lucene/geo), and the introducing issue #8433 never discusses it.
The devil's-advocate check170 words

The strongest case for "intended": SECURITY.md line 38 makes limits on "deeply nested queries" and similar untrusted input "the responsibility of the downstream application"; the parser "Does minimal parsing" (SimpleGeoJSONPolygonParser.java:47-51) and already rejects some valid JSON (true, false or null inside an array, :318-321); Polygon is @lucene.experimental; a throws clause never rules out a VirtualMachineError; LUCENE-6156 (#7218), a RegExp StackOverflowError, was resolved "Not A Problem"; and #16423 was labelled an enhancement.

Why it fails: line 38 rests on "Lucene provides mechanisms to bound this", and fromGeoJSON has none; line 46 adds that such issues "are still valid bug reports and hardening suggestions". LUCENE-6156 was about a stack a downstream product had shrunk (as Robert Muir noted there); this fails on the default stack. The request is a ParseException at a depth limit, not that any depth must parse, so "minimal parser" does not explain the Error. Verdict: a real, low-priority robustness bug, of the kind #16423 fixed as an enhancement.

Suggested fix

Recommended Pass the nesting depth down and throw ParseException past 1,000

Pass a nesting depth into parseObject and parseArray and throw ParseException ("maximum nesting depth of 1000 exceeded") past 1,000 levels, as #16423 did for expressions. One file, +19/-7; real GeoJSON needs fewer than 10 levels. The repro and all org.apache.lucene.geo tests pass with it.

  /** Maximum nesting depth of arrays and objects; deeper input throws {@code ParseException}. */
  static final int MAX_NESTING_DEPTH = 1000;

  /** Throws {@code ParseException} instead of recursing deeper than {@link #MAX_NESTING_DEPTH}. */
  private void checkDepth(int depth) throws ParseException {
    if (depth > MAX_NESTING_DEPTH) {
      throw newParseException("maximum nesting depth of " + MAX_NESTING_DEPTH + " exceeded");
    }
  }

  // parseObject(String path, int depth) / parseArray(String path, int depth) start with checkDepth(depth)
  // and recurse with depth + 1; parse() starts with parseObject("", 1).
All options and trade-offs5 options · 638 words

Option A was written and validated in a scratch worktree of 645d4fe14a20; option B is the hunt's sketch, validated earlier with the parser shadowed. The limit value is the maintainers' choice.

Pass the nesting depth down and throw ParseException past 1,000 (validated by the checks) (recommended)

SimpleGeoJSONPolygonParser.java only. parse() calls parseObject("", 1); parseObject and parseArray take an int depth, call checkDepth(depth) first and pass depth + 1 at their four recursive calls. The depth counts every array and object from the outer object, and the check runs before the bracket is consumed, so the exception offset points at the first bracket past the limit.

  /** Maximum nesting depth of arrays and objects; deeper input throws {@code ParseException}. */
  static final int MAX_NESTING_DEPTH = 1000;

  /** Throws {@code ParseException} instead of recursing deeper than {@link #MAX_NESTING_DEPTH}. */
  private void checkDepth(int depth) throws ParseException {
    if (depth > MAX_NESTING_DEPTH) {
      throw newParseException("maximum nesting depth of " + MAX_NESTING_DEPTH + " exceeded");
    }
  }

  // parseObject(String path, int depth) / parseArray(String path, int depth) start with checkDepth(depth)
  // and recurse with depth + 1; parse() starts with parseObject("", 1).

Trade-offs

  • Smallest change with the same frames per level as today; the cost is one int compare per array or object.
  • 1,000 is far above any ordinary GeoJSON structure; it does deliberately reject exceptionally deep but valid JSON, which RFC 8259 section 9 allows. The value is a judgement call: #16423 uses 1024 (configurable), and jackson-core's default limit is 1000.
  • It does not address the separate heap growth from long keys above about 900 levels (LFV-952), which needs its own fix.

What the checks validated

Gradle --no-daemon in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3 (2026-10-31). Stock: the repro fails with assertions on and off. With the fix:

  • the repro passes with assertions on and off;
  • org.apache.lucene.geo.* (18 classes, including the repro, TestPolygon 16, TestTessellator 77 with its GeoJSON test polygons, TestSimpleWKTShapeParsing 14): 194 tests, 0 failures, 7 skipped, with seeds DEADBEEF and CAFEBABE;
  • checkGoogleJavaFormat, forbiddenApisMain and ecjLintMain of lucene/core pass;
  • a fresh-JVM probe on the compiled fix: 998 arrays inside properties.p (1,000 levels in all) return the polygon; 999, 10,000 and 50,000 give ParseException: maximum nesting depth of 1000 exceeded at character offset 1142 (the bracket that opens level 1,001); the same on a 512 KiB stack and with -ea.

The whole lucene/core test suite was not run.

A depth field with enter/exit wrappers (the hunt's sketch) (validated by the checks)

A depth field, an enter() that throws past MAX_NESTING_DEPTH = 1000, and parseObject / parseArray each split into a wrapper with try { ... } finally { depth--; } and a body. Same behaviour as the recommended option.

Trade-offs

Two methods per level (a wrapper frame each) and a larger diff for the same effect; the finally is not needed, because any ParseException ends the whole parse.

What the checks validated

With the parser shadowed in a standalone runner on main: the hunt's 3-shape JUnit passes 3 of 3, upstream TestPolygon 16 of 16, and the minimal repro passes with -ea and -da on 645d4fe14a20.

Make the limit configurable (not run)

An overload such as Polygon.fromGeoJSON(String, int maxNestingDepth), as #16423 made its limit configurable.

Trade-offs

Adds public API to an experimental class for a limit ordinary GeoJSON never reaches.

Rewrite the parser iteratively (not run)

An explicit stack instead of recursion.

Trade-offs

Removes the stack limit but rewrites the parser; deep input then costs only heap (quadratic with today's per-level path strings). No benefit for real GeoJSON.

Catch StackOverflowError and rethrow ParseException (not recommended)

Wrap the parse in catch (StackOverflowError e).

Trade-offs

Maintainers argued against catching the Error for RegExp on #752 and preferred detecting the problem before it happens; it also unwinds a full stack on every bad input.

Downstream note

Search servers that build polygons from request-supplied GeoJSON through Polygon.fromGeoJSON and catch only ParseException (one open-source server does, read only) get an Error that escapes that handler; until Lucene has a limit they need their own depth check.

How it was found

Planned as a sibling of the expressions-parser nesting fix #16423. A static scan of the stock bytecode found one recursive cycle, parseArray/parseObject, with no depth counter and no Error handler; a 420-point fresh-JVM sweep over depth, stack size and input shape mapped where it fails; a public-API JUnit followed.

The method in full126 words

Method: semi-formal.

  • Lead. A probe for text parsers that might throw virtual machine errors on deep nesting, the class #16423 fixed in lucene/expressions, ran the stock jars and saw StackOverflowError at 5,000-20,000 GeoJSON levels.
  • Static check. A recursion-guard scan of the stock bytecode with the JDK ClassFile API: one recursive cycle {parseArray, parseObject}, no depth counter, no Error handler, prediction "unguarded recursion".
  • Model-based sweep. 420 points, one fresh JVM each: 10 input families (6 GeoJSON, 4 WKT; the WKT polygon-parentheses control never mismatched), depths 1,000 to 100,000, 512 KiB, 1 MiB and default stacks, -ea and -da, each compared with a depth-independent expected outcome (a result for valid input, ParseException for malformed input).
  • Reduced to a public-API JUnit, then to the one-test minimal repro.

Prior reports, history and checks

Prior reports

No report to Lucene found (GitHub issues and PRs incl. the JIRA archive, commits, the lists, the web, all 414 open PRs; 2026-10-28, refreshed 2026-10-31). An outside Rust port's PR, huyz0/lucene-rust#33 (merged 2026-10-28), notes in its parity table that Java's parser throws StackOverflowError on deep nesting and caps its own port at 256.

Searches4 searches

Nothing reported to Lucene discusses unbounded recursion, a missing nesting limit or a StackOverflowError in fromGeoJSON. The prior-report search for the sibling WKT finding LFV-954 (2026-10-28) found the Rust-port PR above; this doc re-read it on 2026-10-31. Its parity table for geo/SimpleGeoJSONPolygonParser says "arrays/objects nesting past MAX_NESTING (256) is a ParseException (Java: StackOverflowError)". It is not a report to Lucene and proposes no Lucene fix. Not fixed upstream: the newest commit on the parser is 448c7d8e2414 (2026-02-11).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the JIRA archive; GitHub-wide; Elasticsearch, OpenSearch, Solr)2026-10-28three runs of a scripted prior-report search (51 queries each), 11 manual apache/lucene searches with every GeoJSON item (19) triaged, GitHub-wide searches, and the changed files of all 414 open PRsbackground only: #16423 (precedent), #10007, #8433, #11687, #752 / #12462 / #7218, #12295 (open, a cast refactor)
JIRA, mailing lists, web, a Go port2026-10-28all 13 LUCENE tickets mentioning GeoJSON; GeoJSON mails on dev@, java-user@ and issues@; 11 web queries; an outside Go port's sourcenothing on nesting or StackOverflowError
Outside Lucene ports2026-10-28 (found), 2026-10-31 (re-read)the PR found by the search for the sibling WKT finding LFV-954, read through the GitHub API (body, files, parity table)huyz0/lucene-rust#33: the parity note above; no apache/lucene issue or PR by its author
apache/lucene refresh2026-10-31searches for GeoJSON, fromGeoJSON, SimpleGeoJSONPolygonParser, nesting depth geo, StackOverflowError polygon; files of PRs #16929-#16956; newest issuesnothing new; no recent PR touches a geo file

History

Introduced with the parser itself in 6.2.0 by LUCENE-7380 (#8433; 343f374b530, backport 573aaf75f52, 2016-07-15). All 89 releases 6.2.0 to 10.5.2, the 10.6.0 release revision and main were run and fail; the recursive calls never changed. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-7380 "add Polygon.fromGeoJSON" (#8433; JIRA patch era, no PR; by mikemccand with a +1 from rmuir), committed 2016-07-15 as 343f374b530 on main and 573aaf75f52 on branch_6x. git blame -w attributes the four recursive call lines to 343f374b530; later commits on the file (LUCENE-8964, a reformat, refactors) do not touch them, and no commit ever added or removed a depth guard. The version driver uses only Polygon.fromGeoJSON (50,000 levels on a 1 MiB thread, three shapes, shallow controls).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (gradle)
main a9779ef3c193; 10.6.0 release revision (branch_10_6 4ae946e5a9b9) affected3 of 3 shapes fail; controls pass
6.2.0 to 10.5.2 (89 releases) affected3 of 3 shapes fail on each (SHA-1-checked jars); the JUnit also fails on 9.12.3 and 10.5.2
6.2.0, default stack deeperat 10,000 levels only the unterminated shape fails; all three fail at 20,000
6.1.0 and older no APIno Polygon.fromGeoJSON (compile error)
main 645d4fe14a20 with the fix not affectedrepro passes; deep input ends in ParseException

Independent checks

The finder, an independent reproducer, a devil's advocate and an adjudication found it real and re-ran it on main, 10.5.2 and 9.12.3; OpenAI GPT-5.6 reviews, reading-only, agreed with the root cause. This doc's gradle runs reproduced it on 645d4fe14a20 and validated the fix.

Checks and verdicts361 words

Both cross-model verification reviews could only read the code and could not execute Java; the later cross-model fact check of this doc ran fresh-JVM probes only. No cross-model verification review has executed the bug's tests.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (geo text parsers unit) candidate3-shape public-API JUnit fails 3 of 3 on main (-ea, -da), branch_10_6, 10.5.2 and 9.12.3; bytecode scan; 420-point sweep.
Refute lens (OpenAI GPT-5.6, reading-only) not real, low (procedural)Could not run Java; agreed with the root cause and that the inputs are in contract; a separate re-run failed 3 of 3.
Independent reproducer realOwn JUnit (100,000 levels, 2 MiB thread, depth-50 control) fails 3 of 4 on main, 10.5.2 and 9.12.3; measured the default- and small-stack thresholds.
Works-as-designed lens (OpenAI GPT-5.6, reading-only) real, mediumIntended-behaviour argument moderate and rejected; separate re-runs fail 3 of 3.
Intended-behaviour devil's advocate realBuilt the strongest intended case and rebutted it; re-ran on main and 10.5.2.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit (-ea, -da) and the finder's (-da); found no duplicate.
Minimal repro verifiedThe one-test repro fails on 645d4fe14a20 with -ea and -da (10 of 10 warm) and passes with the hunt's fix.
This doc real; fix validatedGradle in a scratch worktree of 645d4fe14a20: stock fails with assertions on and off; the recommended fix passes the repro and the geo tests; fresh-JVM sample depths re-measured; re-read the Rust-port PR that the search for the sibling WKT finding LFV-954 had found.
Independent fact check holds; two numbers correctedRe-ran the minimal test on main (-ea, -da), 10.5.2 and 9.12.3 (-da) and the gradle command in its own scratch worktree: fails on stock, passes with the fix (geo tests and checks pass); narrowed the overflow point to about 7,000 levels on the default stack.
Cross-model fact check (OpenAI GPT-5.6) real; wording correctionsRan fresh-JVM probes that match the sample depths in the table; its wording corrections (declared outcomes, the limit's trade-off, run modes) are applied.

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,522 words

Finding record

LFV-949, snapshot of 2026-11-06

Registry title
Polygon.fromGeoJSON throws StackOverflowError instead of returning the polygon or its declared ParseException when JSON arrays or objects nest thousands of levels deep, even inside a valid Feature's properties (10,000 levels, about 20 KB of text, on the default thread stack of the test machine; every release since 6.2.0)
Area
core / geo GeoJSON parsing (Polygon.fromGeoJSON, SimpleGeoJSONPolygonParser)
Priority
Low, rank 822: Polygon.fromGeoJSON lets a StackOverflowError escape on adversarial deep nesting, the same wrong-error class as the query-parser StackOverflowError of LFV-919, and GeoJSON parsing is used less than the query parsers, so it follows LFV-919.
Impact class
wrong error
Impact note
Hits applications that pass GeoJSON text without a depth limit to Polygon.fromGeoJSON (core; Polygon is @lucene.experimental; no production caller in the Lucene tree) and rely on its declared ParseException: nesting of about 10,000 levels (about 20 KB for an array property, 60 KB for an object property) on the 2040 KiB default thread stack of the aarch64 JDK 25 test machine, or 5,000 levels on a 1 MiB stack, inside a valid Feature's properties, under coordinates, or in unterminated arrays or objects, makes the call throw java.lang.StackOverflowError, which passes catch (ParseException) and catch (Exception) handlers; 1,000 levels never failed, the thresholds vary with stack size, JIT state and version, and x86 was not measured; no setting is involved, no wrong polygon is returned, no index is touched, with -ea and -da, in every release from 6.2.0 to 10.6.0 and on main.
Summary
You call Polygon.fromGeoJSON on a valid GeoJSON Feature whose properties hold an array nested 10,000 levels deep (about 20 KB of text), or on malformed text with thousands of unterminated '[', and expect the polygon back, or the method's declared ParseException for bad or too-deep input. Instead java.lang.StackOverflowError escapes, because SimpleGeoJSONPolygonParser.parseObject and parseArray call each other once per nesting level with no depth limit and nothing turns the Error into a ParseException. The caller has no limit it can set, and a plain length cap does not help, because 20 KB already fails while real polygons are often longer.
Introduced
343f374b530fa71dc6102d74725b536f5f1367f3 (main line, 2016-07-15, 'LUCENE-7380: add Polygon.fromGeoJSON', committed by mikemccand after a +1 from rmuir; LUCENE-7380 is apache/lucene#8433; JIRA patch era, no pull request; first shipped in 7.0.0) and its branch_6x backport 573aaf75f52f446c8b7ab915eefd420013c544a1 (same message, shipped in 6.2.0). The unbounded recursion was born with the parser and is not a regression [read: git blame -w attributes the four recursive call lines (SimpleGeoJSONPolygonParser.java:145, :153, :309, :312 on main), both method bodies and fromGeoJSON to 343f374b530; git log -L over parseArray and parseObject shows no later change to the recursive calls; git log --all -G '[Dd]epth|[Nn]esting|StackOverflow' on the file returns no commit; exec: 6.1.0 has no fromGeoJSON (compile error), 6.2.0 fails] (2016-07-15 (author and commit date of 343f374b530 and 573aaf75f52, 10:51:08 -0400; JIRA resolved the same day))
First affected version
6.2.0 [exec] (the first release with Polygon.fromGeoJSON and SimpleGeoJSONPolygonParser; 6.1.0 has Polygon but no fromGeoJSON, and 5.5.3-6.0.1 have no org.apache.lucene.geo.Polygon [exec: compile error]); first 7.x release 7.0.0 [exec], from the main-line commit
Affected versions
Every release from 6.2.0 to 10.6.0 (89 releases to 10.5.2, and 10.6.0 run as its release revision, branch_10_6 head 4ae946e5a9b9) and main a9779ef3c193. [exec] the version-portable public-API driver of the version-history runs fails 3 of 3 deep cases (valid Feature with an array property, valid Feature with an object property, unterminated arrays; 50,000 levels on a 1 MiB thread stack, -da, seed 383DEE9) with every shallow control passing on all 89 releases, branch_10_6 and main, and with -ea on 6.2.0, 10.5.2 and main; with the depth-guard fix shadowed into main it passes 3 of 3. The finder's JUnit fails 3 of 3 on main (-ea and -da), branch_10_6, 10.5.2 and 9.12.3 (-da), and the history step re-ran it with -ea on 10.5.2 and 9.12.3 (3 of 3); the independent reproduction's own JUnit fails 3 of 4 (control passes) on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3 (-da); the intended-behaviour check re-ran the finder's JUnit on main (-ea and -da) and 10.5.2 (-da); the adjudication on main (-ea and -da); the registration check on main (-da, seed 5EED0383, 3 of 3). On the 2040 KiB default stack, 10,000 levels fail 3 of 3 on 10.5.2 and main, while 6.2.0 fails only the unterminated case at 10,000 and all three at 20,000 (cause of the version difference not investigated). [read] the recursion is unchanged since 343f374b530; 8.8.2-8.11.4 are untagged in the history clone and were covered by execution. Not fixed upstream: upstream main 645d4fe14a20 (2026-10-28) has no commit on SimpleGeoJSONPolygonParser.java after 448c7d8e241 (2026-02-11, a toArray refactor). All runs on Linux aarch64 with JDK 25.0.3; x86 thresholds not measured.
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 low-confidence “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
no report to Lucene found (the prior-report record, verdict new before the Rust-port PR below was found, searched 2026-10-28, read-only): three scripted prior-report searches (51 queries each: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and web; 186, 214 and 41 distinct hits) plus the finder's run (51 queries, 70 hits, 0 high), manual GitHub searches (every apache/lucene issue or PR mentioning GeoJSON (19) triaged; fromGeoJSON, SimpleGeoJSONPolygonParser, nesting depth/limit, deeply nested and StackOverflowError phrasings; GitHub-wide and in Elasticsearch, OpenSearch and Solr), every LUCENE JIRA ticket mentioning GeoJSON (13), dev@, java-user@ and issues@ GeoJSON mails, 11 web queries, an outside Go port of the parser, and the changed files of all 414 open apache/lucene PRs: nothing reported to Lucene discusses unbounded recursion, a missing nesting limit or a StackOverflowError in Polygon.fromGeoJSON or SimpleGeoJSONPolygonParser. Closest items, not reports of this bug: #16423 (merged 2026-07-27, 10.6.0), the same defect class fixed with a nesting limit in lucene/expressions only, never mentioning geo; #10007 (LUCENE-8964, 8.3.0), another fix on the same properties-skipping path (string arrays), no depth discussion; #8433 (LUCENE-7380), the introducing issue, no depth discussion; #11687 (SimpleQueryParser stack overflow, open) and #752/#12462 (RegExp), the same symptom in other classes; #12295 (open since 2023-05), the only open PR touching the parser, a cast refactor. Not fixed: no commit on the parser upstream after 448c7d8e241 (2026-02-11). Refresh 2026-10-28 (GitHub search and REST API, read-only): the newest apache/lucene PRs through #16936 (created 2026-10-28) touch no geo file, 'GeoJSON' PR and issue searches return only older tessellator and field items, 'SimpleGeoJSONPolygonParser' PR search returns nothing, 'fromGeoJSON' returns only #8433, and upstream main 645d4fe14a20 has no newer commit on the file. Found afterwards (2026-10-28, by the prior-report search for the sibling WKT finding LFV-954; re-read 2026-10-31): the merged PR huyz0/lucene-rust#33 of an outside Rust port of Lucene 10.5.0 says in its parity table (row geo/SimpleGeoJSONPolygonParser) that nesting past its own limit of 256 is a ParseException where Java throws StackOverflowError; it bounds only its own port and is not a report to Lucene.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree, and a fresh-JVM probe on the stock jar gives the thresholds above; with the recommended fix the test passes, as do all org.apache.lucene.geo tests with two seeds and the format, forbidden-API and ecj-lint checks. Earlier runs: main a9779ef3c193 (identical parser), the 10.6.0 release revision (branch_10_6 4ae946e5a9b9) and all 89 releases 6.2.0 to 10.5.2 (SHA-1-checked jars). Upstream main checked on 2026-10-31: the newest commit on the parser is still 448c7d8e2414 (2026-02-11).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:47-53 (at a9779ef3c193) ('Does minimal parsing'), :100-160 (parseObject; recursion at :145 parseArray(newPath) and :153 parseObject(newPath)), :255-265 (parsePoints error message with the nested list, the toString overflow in the nested-coordinates shape), :283-322 (parseArray; recursion at :309 parseArray(path + ".[]") and :312 parseObject(path + ".[]"))
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:41 (at a9779ef3c193) (@lucene.experimental), :246-257 (fromGeoJSON javadoc: a Polygon or MultiPolygon 'optionally embedded under a "type: Feature"'; throws ParseException)
  • lucene/CHANGES.txt:541-545 (at a9779ef3c193) (GITHUB#16423, expressions parser nesting limit, 'no longer throws virtual machine errors'); SECURITY.md lines 28, 38, 46, 50
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:23-26, :41, :248-257
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:23-53, :63-65, :100-154, :174-219, :249-263, :283-327, :343-348
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:23-26, :41, :255
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:41, :248-257
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:23-53, :318-321
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/geo/TestPolygon.java:176-200, :348-363
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:551-555 (GITHUB#16423)
  • SECURITY.md:38, :46, :50
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:551-555 (GITHUB#16423, default limit 1024)

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 thresholds.

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.