- A GeoJSON Feature is a JSON object with a
geometry(the polygon's coordinates) andproperties, which may hold any JSON values.Polygon.fromGeoJSONkeeps the geometry and skips the properties. - The parser is recursive: on
[it callsparseArray, on{it callsparseObject, and a bracket nested inside calls them again. Each call keeps its stack frame until its closing bracket. - 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 anError, not anException.
StackOverflowError escapes instead of a polygon or ParseException. Select the still to open it at full size. Generated with AI, checked against the code.- 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. - You expect the polygon back, since the parser skips
properties, or at worst the method's declaredParseException. With 5,000 brackets (10,146 chars) you do get the polygon. - Instead
java.lang.StackOverflowErrorescapes (fresh JVM, default 2 MiB thread stack; with a 1 MiB stack 5,000 brackets already fail). It is anError, socatch (ParseException)andcatch (Exception)do not stop it. - Why: on
[the parser callsparseArray, on{it callsparseObject, and they call each other for every nested bracket with no depth limit. Each level holds a stack frame until the stack runs out. - Malformed text behaves the same: unterminated
[[[[...gets the properParseExceptionat 5,000 levels andStackOverflowErrorat 10,000. Lucene offers no setting to bound it.
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.
Polygon.fromGeoJSONreturnsnew SimpleGeoJSONPolygonParser(geojson).parse()(Polygon.java:255-257), andparse()starts withparseObject("")(SimpleGeoJSONPolygonParser.java:63-65).parseObjectreads each member; a value starting with[goes toparseArray(newPath)(:138-145), one starting with{toparseObject(newPath)(:146-154). That includes members underproperties, which the parser reads only to skip them (it acts only ontype,coordinatesandcrs,:125-128,:174-215).parseArraysends a nested[toparseArray(path + ".[]")(:308-309) and a nested{toparseObject(path + ".[]")(:310-313).- Neither method has a depth parameter, field or check, and the class's only
catchis forNumberFormatExceptioninparseNumber(:343-348). Each level holds a frame until its closing bracket; when the stack is full the JVM throwsStackOverflowError, and it leavesfromGeoJSON, which declares onlyParseException(Polygon.java:255). - A smaller second path: for nested arrays under
coordinates, the parse can finish andparsePointsthen builds an error message with the nested list (:254-263), whosetoString()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.
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:
| depth | chars | thread stack | outcome |
|---|---|---|---|
| 1,000 | 2,146 | default (2040 KiB) | 1 polygon |
| 5,000 | 10,146 | default | 1 polygon |
| 10,000 | 20,146 | default | StackOverflowError |
| 50,000 | 100,146 | default | StackOverflowError |
| 2,000 | 4,146 | 1 MiB (-Xss1m) | 1 polygon |
| 5,000 | 10,146 | 1 MiB | StackOverflowError |
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 throughfromGeoJSON, which thePolygonclass 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 onlyParseException(read, not run). - Default vs opt-in. No setting is involved; it is the only behaviour of a public static API.
Polygonis@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.
fromGeoJSONhas 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 catchingStackOverflowError, which maintainers discourage (Robert Muir and Mike Sokolov on #752). - Effect. An
Errorin 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 handlesError. - 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.
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.
/*
* 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);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestGeoJSONDeepNesting -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
fromGeoJSONreturns the 1-elementPolygon[], or throwsParseExceptionfor too-deep input.- Observed
AssertionError: expected the polygon or ParseException, got StackOverflowError(fromparseArray), with assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.5.2 and 9.12.3: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run) |
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
# 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 StackOverflowErrorfromGeoJSONdeclares onlyParseException, and its class comment andTestPolygonshow Features with nested properties; only the depth differs.- Unterminated
[[[[...gets the properParseExceptionat 5,000 levels andStackOverflowErrorat 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 onlyParseException. - Nested properties are accepted on purpose. The class comment shows a Feature with
"prop1": {"this": "that"}inproperties(SimpleGeoJSONPolygonParser.java:23-45);TestPolygon.testGeoJSONPolygonFeatureparses that Feature, andtestPolygonPropertiesCanBeStringArrays(LUCENE-8964, #10007) an array property. RFC 7946 section 3.2 letspropertiesbe any JSON object. - Depth alone decides. The same unterminated text gives the parser's own "hit EOF"
ParseExceptionat 5,000 levels and theErrorat 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 declaredParseExceptionis 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.
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,TestPolygon16,TestTessellator77 with its GeoJSON test polygons,TestSimpleWKTShapeParsing14): 194 tests, 0 failures, 7 skipped, with seeds DEADBEEF and CAFEBABE;checkGoogleJavaFormat,forbiddenApisMainandecjLintMainoflucene/corepass;- 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 giveParseException: 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, 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.
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.
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.
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.
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.
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
StackOverflowErrorat 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, noErrorhandler, 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,
-eaand-da, each compared with a depth-independent expected outcome (a result for valid input,ParseExceptionfor malformed input). - Reduced to a public-API JUnit, then to the one-test minimal repro.
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the JIRA archive; GitHub-wide; Elasticsearch, OpenSearch, Solr) | 2026-10-28 | three 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 PRs | background only: #16423 (precedent), #10007, #8433, #11687, #752 / #12462 / #7218, #12295 (open, a cast refactor) |
| JIRA, mailing lists, web, a Go port | 2026-10-28 | all 13 LUCENE tickets mentioning GeoJSON; GeoJSON mails on dev@, java-user@ and issues@; 11 web queries; an outside Go port's source | nothing on nesting or StackOverflowError |
| Outside Lucene ports | 2026-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 refresh | 2026-10-31 | searches for GeoJSON, fromGeoJSON, SimpleGeoJSONPolygonParser, nesting depth geo, StackOverflowError polygon; files of PRs #16929-#16956; newest issues | nothing new; no recent PR touches a geo file |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (gradle) |
main a9779ef3c193; 10.6.0 release revision (branch_10_6 4ae946e5a9b9) | affected | 3 of 3 shapes fail; controls pass |
| 6.2.0 to 10.5.2 (89 releases) | affected | 3 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 | deeper | at 10,000 levels only the unterminated shape fails; all three fail at 20,000 |
| 6.1.0 and older | no API | no Polygon.fromGeoJSON (compile error) |
main 645d4fe14a20 with the fix | not affected | repro passes; deep input ends in ParseException |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (geo text parsers unit) | candidate | 3-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 | real | Own 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, medium | Intended-behaviour argument moderate and rejected; separate re-runs fail 3 of 3. |
| Intended-behaviour devil's advocate | real | Built the strongest intended case and rebutted it; re-ran on main and 10.5.2. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's JUnit (-ea, -da) and the finder's (-da); found no duplicate. |
| Minimal repro | verified | The 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 validated | Gradle 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 corrected | Re-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 corrections | Ran 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. |
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.
- PNG 557 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,522 words
- 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 allorg.apache.lucene.geotests with two seeds and the format, forbidden-API and ecj-lint checks. Earlier runs: maina9779ef3c193(identical parser), the 10.6.0 release revision (branch_10_64ae946e5a9b9) 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 still448c7d8e2414(2026-02-11).
- 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-257a 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-257a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:23-53, :318-321a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/geo/TestPolygon.java:176-200, :348-363a 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)
- TestGeoJSONDeepNesting.java (one test class):
repro/LFV-949/src/org/apache/lucene/geo/TestGeoJSONDeepNesting.java(40 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed thresholds.
assets/LFV-949/bug.png(557 KB)
