All findings · Lucene finding · LFV-953 · geojson-coordinates-null-value-npe

Polygon.fromGeoJSON throws NullPointerException instead of ParseException when coordinates is null or an object

Polygon.fromGeoJSON rejects a non-array coordinates with its own ParseException, but when the value is JSON null or an object {}, that check throws NullPointerException while building its error message. The bad input is rejected either way; only the exception type is wrong, in every release since 6.2.0.

Summary

Priority
Low, rank 1074An NPE instead of ParseException for malformed GeoJSON coordinates is a loud wrong-error on input that is rejected either way.
Impact
wrong error
Since
6.2.0 (LUCENE-7380, 2016-07-15: main-line commit 343f374b530, 6.x backport 573aaf75f52)introduced 2016-07-15 · over 10 years
Affected
6.2.0-10.6.0 and main
Status
documented
Found by
non-formal: a randomized differential test, then code reading

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: GeoJSON polygons in Lucene

  1. GeoJSON writes a polygon as {"type":"Polygon","coordinates":[[[100.0,0.0],[101.0,0.0],[101.0,1.0],[100.0,1.0],[100.0,0.0]]]}: coordinates is an array of rings, each ring an array of [lon, lat] points. A MultiPolygon's coordinates is an array of such polygons.
  2. Polygon.fromGeoJSON(String) in Lucene core turns such text into Polygon objects, instead of building them from double[] coordinates, and declares throws ParseException for text it cannot use.
Diagram of the bug: Polygon.fromGeoJSON parses coordinates values. A string such as "x" reaches the check 'coordinates should be an array' and gives ParseException. JSON null and an object {} are both stored as Java null; the same check then evaluates o.getClass() on null and throws NullPointerException: Cannot invoke "Object.getClass()" because "o" is null, so a catch (ParseException) handler misses it. The fix uses a null-safe type name in the message.
A JSON null or {} becomes Java null inside the parser; the not-an-array check then calls getClass() on it and throws NullPointerException before its ParseException exists. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Parse {"type":"Polygon","coordinates":"x"} with Polygon.fromGeoJSON: coordinates is a string, not an array. The parser's check rejects it: ParseException: coordinates should be an array, but got: class java.lang.String at character offset 32.
  2. Now pass {"type":"Polygon","coordinates":null} (or "coordinates":{}). You expect the same ParseException, the only exception the method declares.
  3. Instead you get NullPointerException: Cannot invoke "Object.getClass()" because "o" is null. The parser stores JSON null and nested objects as Java null, and the check builds its message with + o.getClass() (line 208).
  4. A MultiPolygon element fails the same way: {"type":"MultiPolygon","coordinates":[{}]} throws at line 90. A catch (ParseException e) misses both, while string, number and boolean values get ParseException.

Who is affected

Applications that call Polygon.fromGeoJSON on GeoJSON they did not produce and catch only ParseException, when coordinates is null or an object such as {}: directly, in a Feature geometry, or as a MultiPolygon element, for example from a serializer that writes null for an unset field. Rare; the input is rejected either way, and nothing wrong is returned or indexed.

Mechanism, terms and numbers618 words

Polygon.fromGeoJSON(String) declares throws ParseException and is backed by the package-private SimpleGeoJSONPolygonParser. The parser checks that "coordinates" is an array, and for a MultiPolygon that each element of it is an array, and otherwise throws ParseException("coordinates should be an array, but got: " + o.getClass()) or "elements of coordinates array should be an array, but got: " + o.getClass(). But it stores a JSON null value, and every nested object it skips, as Java null. So for {"type":"Polygon","coordinates":null}, "coordinates":{} or {"type":"MultiPolygon","coordinates":[{}]}, building the message dereferences null and java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "o" is null escapes instead of the ParseException; a catch (ParseException e) does not see it. A string, number or boolean in the same place gets the intended ParseException, and every other message in the class that reports a parsed value concatenates the value itself (+ o, + point, ...), which is null-safe. (An object whose own members trip one of the parser's checks, such as {"type":null}, gets that check's ParseException first.) The input is malformed and is rejected either way: there is no wrong polygon, hang or index effect, only the wrong exception. The two messages came with the parser in LUCENE-7380 (6.2.0, 2016) and are in every release since, including 10.6.0 and main. A null-safe type name in the two messages fixes it.

Terms

  • SimpleGeoJSONPolygonParser: the package-private parser behind Polygon.fromGeoJSON (Polygon.java:255-257). It "does minimal parsing of a GeoJSON object, to extract either Polygon or MultiPolygon" (SimpleGeoJSONPolygonParser.java:47-51).
  • o: the Java value the parser builds for each JSON member value: a List for an array, String, Double, Boolean, or null.

The bug

  1. In parseObject, a nested object is parsed recursively and then, if that parse throws nothing, dropped as o = null (:146-154); the literal null also gives o = null (:163-165). In parseArray, an object element is added to the list as null (:310-313).
  2. The coordinates check (:205-209) is if (o instanceof List == false) { ... throw newParseException("coordinates should be an array, but got: " + o.getClass()); }. null instanceof List is false, so a null o enters the branch, and o.getClass() (:208) throws before the ParseException is created.
  3. The MultiPolygon element check in parse() (:86-91) does the same with "elements of coordinates array should be an array, but got: " + o.getClass() (:90).
  4. Every other message that reports a parsed value concatenates the value itself, which prints null safely: + o at :177, :182, :189, :231-232 and :239-240; + point at :256; + pointList and + pointList.get(0|1) at :262, :267 and :272.

The numbers

Main 645d4fe14a20, Polygon.fromGeoJSON on four inputs (a small probe against the build's classes):

{"type":"Polygon","coordinates":"x"} -> ParseException: coordinates should be an array, but got: class java.lang.String at character offset 32; fragment leading to this:
{"type":"Polygon","coordinates":null} -> java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "o" is null
{"type":"Polygon","coordinates":{}} -> java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "o" is null
{"type":"MultiPolygon","coordinates":[{}]} -> java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "o" is null

The same through a Feature geometry with "coordinates":null and a FeatureCollection feature whose MultiPolygon coordinates is {} (an independent reproducer and a devil's-advocate survey). Not affected: type null or {}, Polygon coordinates [{}], [[{}]] or [null] (a literal null inside an array is rejected earlier by parseArray), MultiPolygon [[{}]], geometry null: all give ParseException. So does an object whose members fail the parser's own checks: "coordinates":{"type":null} gives ParseException: type should be a string, but got: null from the nested parse, while {"a":1} gives the NullPointerException.

Exposure and likelihood201 words
  • Who is hit. Callers of Polygon.fromGeoJSON (core; Polygon is @lucene.experimental) that handle bad input with catch (ParseException e), the only exception the method declares. Nothing in Lucene calls the parser on its own.
  • What it takes. Malformed GeoJSON (RFC 7946 requires coordinates to be an array) whose coordinates member is null or an object the parser skips without error (such as {}). No report to the Lucene project since 6.2.0 (2016); likelihood "rare".
  • What happens. The unchecked NullPointerException bypasses the ParseException handler, and its message says nothing about the input; on JDKs without helpful NullPointerException messages (JEP 358, on by default since JDK 15) it has no message at all. The recorded runs with assertions on and off behave the same.
  • What does not happen. No polygon is returned for the bad input, so nothing wrong is indexed or queried; no hang, no resource use, no state left behind (one parser object per call). Valid GeoJSON is not affected.
  • Related, separate fix sites (not part of this bug). Empty Polygon coordinates [] give IndexOutOfBoundsException in parsePolygon (:228, no check there); a bad \u escape gives NumberFormatException (part of LFV-569); deep nesting gives StackOverflowError (a separate finding).

Minimal repro

One JUnit test on the public API with two one-line inputs: coordinates set to null, and a MultiPolygon whose only element is {}. Each must throw ParseException. Deterministic, single thread, no randomness; about a second including JVM start.

TestGeoJSONNullCoordinates.java (JUnit, one test)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.geo;

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

public class TestGeoJSONNullCoordinates extends LuceneTestCase {

  public void testNonArrayCoordinates() {
    // expected ParseException "...should be an array", actual NPE from o.getClass() on null
    expectThrows(
        ParseException.class,
        () -> Polygon.fromGeoJSON("{\"type\":\"Polygon\",\"coordinates\":null}"));
    expectThrows(
        ParseException.class,
        () -> Polygon.fromGeoJSON("{\"type\":\"MultiPolygon\",\"coordinates\":[{}]}"));
  }
}
Command
cp TestGeoJSONNullCoordinates.java $LUCENE/lucene/core/src/test/org/apache/lucene/geo/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.geo.TestGeoJSONNullCoordinates -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
ParseException: coordinates should be an array, but got: ..., as for a string value.
Observed
NullPointerException: Cannot invoke "Object.getClass()" because "o" is null at SimpleGeoJSONPolygonParser.java:208; the same with assertions off.

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

More files, environment, full output209 words

The first line covers the coordinates message (:208), the second the MultiPolygon element message (:90). On a stock build the first expectThrows already fails, so the second is not reached; a scratch copy holding only the second line fails the same way at :90, and with the fix both pass.

As the regression test: the same test, for example as a new method in TestPolygon next to the testIllegalGeoJSON* tests.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on the 10.6.0 release; the same inputs fail on all 11 releases run from 6.2.0 to 10.6.0 (the rest inferred from unchanged code)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestGeoJSONNullCoordinates.java $LUCENE/lucene/core/src/test/org/apache/lucene/geo/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.geo.TestGeoJSONNullCoordinates -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestGeoJSONNullCoordinates > testNonArrayCoordinates FAILED
    junit.framework.AssertionFailedError: Unexpected exception type, expected ParseException but got java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "o" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B0DEAA79794FCE0A]:0)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1437)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at app//org.apache.lucene.geo.TestGeoJSONNullCoordinates.testNonArrayCoordinates(TestGeoJSONNullCoordinates.java:26)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "o" is null
            at org.apache.lucene.geo.SimpleGeoJSONPolygonParser.parseObject(SimpleGeoJSONPolygonParser.java:208)
            at org.apache.lucene.geo.SimpleGeoJSONPolygonParser.parse(SimpleGeoJSONPolygonParser.java:65)
            at org.apache.lucene.geo.Polygon.fromGeoJSON(Polygon.java:256)
            at org.apache.lucene.geo.TestGeoJSONNullCoordinates.lambda$testNonArrayCoordinates$0(TestGeoJSONNullCoordinates.java:28)

# release jars 10.6.0 (assertions on and off): the same failure at SimpleGeoJSONPolygonParser.java:208

Why it looks unintended

  • The check exists for exactly this input (coordinates is not an array, so throw ParseException). It works for strings, numbers and booleans and fails only while building its message for null and {}.
  • Every other message that reports a parsed value concatenates it (null-safe), and TestPolygon expects ParseException for malformed GeoJSON.
  • The introducing issue records "improved the ParseException throwing".
The full argument167 words
  • The branch's only job is the ParseException. SimpleGeoJSONPolygonParser.java:205-209 and :86-91 test "value is not a List" and do nothing but throw ParseException. null and objects are two of the five non-array JSON value kinds; for the other three the branch works.
  • Sibling messages. :177, :182, :189, :231-232, :239-240, :256, :262, :267 and :272 concatenate the value (o, point or pointList), which is null-safe, and those inputs get ParseException.
  • Contract and tests. Polygon.java:255 declares only throws ParseException. TestPolygon.testIllegalGeoJSONExtraCrapAtEnd (:268-281), testIllegalGeoJSONLinkedCRS (:283-302) and testIllegalGeoJSONMultipleFeatures (:305-346) expect ParseException; no test expects a NullPointerException, and none asserts the two messages.
  • Intent. LUCENE-7380 (#8433): the parser "throws an exception otherwise" for inputs it cannot handle, and the author wrote before committing "I added class level javadocs, and improved the ParseException throwing". git blame attributes both messages and the o = null branches to that commit (apart from a reformat).
The devil's-advocate check157 words

The strongest case for "intended" (an intended-behaviour checker, and a read-only reviewer from another model family, OpenAI GPT-5.6, which said "not real"): the inputs are not standard GeoJSON; the javadoc ("Parses a standard GeoJSON polygon string") has no @throws and promises no exception type for malformed input; Polygon is @lucene.experimental and the parser calls itself minimal; a throws clause never excludes unchecked exceptions; and the parser has other unchecked exits on malformed input, so "always ParseException" was never achieved.

Why it fails: the claim does not rest on an outside contract but on the parser's own check, which detects exactly this input and tries to throw ParseException; a slip in the message expression stops it. The refuting reviewer conceded that null "reaches a NullPointerException instead of the intended local ParseException branch" and agreed with the root cause. The other unchecked exits are places with no check at all. The argument limits severity (low), not validity.

Suggested fix

Recommended A. A null-safe type name in both messages

Build the two messages with a null-safe type name: a 3-line helper typeName(o) returns "an object or null" for null, else o.getClass(), so string, number and boolean messages are unchanged. With it the repro passes with assertions on and off, and the 18 org.apache.lucene.geo test classes and :lucene:core:check pass.

-              "elements of coordinates array should be an array, but got: " + o.getClass());
+              "elements of coordinates array should be an array, but got: " + typeName(o));
 ...
-          throw newParseException("coordinates should be an array, but got: " + o.getClass());
+          throw newParseException("coordinates should be an array, but got: " + typeName(o));
 ...
+  /** Describes a parsed value for an error message; objects and JSON null are parsed as null. */
+  private static String typeName(Object o) {
+    return o == null ? "an object or null" : o.getClass().toString();
+  }
All options and trade-offs3 options ยท 519 words

Two options were run through gradle in a scratch worktree of 645d4fe14a20; A is recommended. A fix conflicts textually with open PR #12295, which rewrites the same two checks but keeps getClass().

A. A null-safe type name in both messages (validated by the checks) (recommended)

In SimpleGeoJSONPolygonParser (:90, :208), plus a small helper.

-              "elements of coordinates array should be an array, but got: " + o.getClass());
+              "elements of coordinates array should be an array, but got: " + typeName(o));
 ...
-          throw newParseException("coordinates should be an array, but got: " + o.getClass());
+          throw newParseException("coordinates should be an array, but got: " + typeName(o));
 ...
+  /** Describes a parsed value for an error message; objects and JSON null are parsed as null. */
+  private static String typeName(Object o) {
+    return o == null ? "an object or null" : o.getClass().toString();
+  }

Trade-offs

  • Messages for string, number and boolean values stay exactly as they are (class java.lang.String, ...).
  • null and objects both read "but got: an object or null": the parser cannot tell them apart after parsing.
  • Error path only: no change to the public API signature, to successful parses or to performance; only the two messages change for null and object values.

What the checks validated

Gradle in a scratch worktree of 645d4fe14a20 (removed afterwards), seed DEADBEEF:

  • the repro fails on the stock build and passes with the fix, assertions on and off;
  • TestPolygon 16 of 16; all 18 classes of org.apache.lucene.geo incl. the repro, 194 tests, 0 failures (7 skipped in TestTessellator);
  • :lucene:core:check -x test with the repro in place: BUILD SUCCESSFUL;
  • messages: null and {} give ParseException: coordinates should be an array, but got: an object or null at character offset 32; MultiPolygon [{}] gives elements of coordinates array should be an array, but got: an object or null at character offset 42; "x" still gives ... but got: class java.lang.String ....

B. Print the value, like the sibling messages (+ o) (validated by the checks)

Replace + o.getClass() by + o at :90 and :208 (two one-token changes).

-          throw newParseException("coordinates should be an array, but got: " + o.getClass());
+          throw newParseException("coordinates should be an array, but got: " + o);

Trade-offs

The smallest diff and consistent with the rest of the class. But {} then reads "but got: null" (misleading for an object), and string, number and boolean messages change from the class name to the value (for example "but got: x"); no test asserts the old text.

What the checks validated

Same scratch worktree: the repro passes (assertions on) and TestPolygon 16 of 16. Messages: "x" gives but got: x; null, {} and MultiPolygon [{}] all give but got: null.

Represent objects by a non-null marker (not tried)

Have parseObject/parseArray keep a marker (for example an empty map) for nested objects so the messages can say "an object".

Trade-offs

Touches the value model that features arrays and parsePolygon rely on: more change than this bug needs. Not tried.

Downstream note

An application that validates client GeoJSON with Polygon.fromGeoJSON and catches only ParseException lets this NullPointerException escape its handler, so a bad request may surface as an internal error.

How it was found

Non-formal: a randomized differential test, written to compare a fix for a sibling GeoJSON parser bug with the stock parser, saw both parsers throw NullPointerException on documents with "coordinates": {}. Reading the code found the two getClass() messages, and a public-API JUnit test reproduced it.

The method in full150 words

Method: non-formal (randomized differential testing, then code reading and executed tests). On 2026-10-28 the hunt unit for the GeoJSON and WKT parsers (siblings of #16423) was checking its fix for a per-level path-string memory bug in the same parser: a differential test ran random GeoJSON-like documents through the fixed parser and a renamed copy of the stock one. Both threw NullPointerException on documents with "coordinates": {}. That was not a difference between them, but it was a lead: reading SimpleGeoJSONPolygonParser found the two + o.getClass() messages and the o = null value branches. A 4-test public-API JUnit (null, {}, MultiPolygon [{}], a string control) reproduced it on the stock jar; independent checkers then reproduced it with their own tests, and it was cut to the one-test repro. The finder labelled it semi-formal; it is classed as non-formal because no model, proof or bounded-exhaustive check gave the signal.

Prior reports, history and checks

Prior reports

Not reported to the Lucene project: no issue, PR, commit, list thread or downstream report (2026-10-28, refreshed 2026-11-01). But a public outside Rust port documents and imitates this exact NullPointerException as Java behaviour, without reporting it to the project.

Searches5 searches

The port that writes it down (read-only GitHub API, 2026-11-01). The public repository huyz0/lucene-rust, a Rust port of Lucene 10.5.0, added its port of this parser in commit 634835d7cb (2026-10-02; the API lists no PR for it). Its module comment says Java's o.getClass() on a null value (NullPointerException) escapes "as Java's runtime exceptions do"; its value type notes that JSON null and a nested object both become null; it returns the Java message Cannot invoke "Object.getClass()" because "o" is null for a null value; and a unit test asserts that {"type": "Polygon", "coordinates": null} gives that error. Its PR #33 (merged 2026-10-28, the day the hunt found the bug) shows the comment as diff context and edits the parity-table row for SimpleGeoJSONPolygonParser, which lists NullPointerException among the exceptions that "escape as Java's". The port copies the behaviour on purpose, calls it Java behaviour rather than a bug, and never reported it to Lucene. The hunt's first searches missed it (code search does not index the repository).

Other close items, none a report of this bug: #8433 (LUCENE-7380, the introducing issue; records the ParseException intent, no discussion of null or object coordinates); #12295 (open since 2023, "Use instanceof pattern-matching where possible": rewrites both checks, keeps + o.getClass(); a fix conflicts with it textually); #10007 (LUCENE-8964, an earlier fix for string arrays in the same parser, other site). An outside Go port's source comment notes only that the Java code "embeds Class.getClass()", without saying it throws; it is not a report to the project. The exact message texts give 0 issue or PR hits on apache/lucene; GitHub-wide, the full message "coordinates should be an array, but got" with GeoJSON gives 0, and the shorter phrase gives only unrelated token matches (88). Related findings in the same parser with other fix sites: LFV-562 and LFV-569. Limits: GitHub search matches tokens, not phrases; code search does not index every repository; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene and apache/lucene-solr issues and PRs (incl. the migrated JIRA issues) and commits2026-10-28two scripted prior-report searches (56 queries each, 239 and 281 distinct hits), the finder's (51) and a verifier's (37) runs, 44 manual issue/PR and 5 code searches: fromGeoJSON, SimpleGeoJSONPolygonParser, GeoJSON with NullPointerException, null, coordinates, MultiPolygon, ParseException, and the exact message texts (both on apache/lucene; GitHub-wide only the first, with and without GeoJSON)no report; the message texts 0 hits on apache/lucene; closest #8433, #12295, #10007
Open PRs2026-10-28changed files of all 414 open PRs, and every PR created 2026-10-26 to 2026-10-28 (#16907-#16936)only #12295 touches the parser, and keeps getClass()
JIRA archive, mailing lists, downstream projects, ports, web2026-10-28all 13 LUCENE JIRA issues mentioning GeoJSON; dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch and Solr; five outside ports or copies; 15 web queriesno report (the Rust port below was not among the five ports checked)
Upstream main and GitHub search2026-11-01the parser file at 2f424fd428da (2026-10-30) and its commit list; issue search fromGeoJSON, and GeoJSON items created since 2026-10-28both + o.getClass() lines still present; newest commit on the file #15681 (2026-02-11); fromGeoJSON finds only #8433; 0 new GeoJSON items
Outside Rust port huyz0/lucene-rust (public GitHub repository and its PR #33)2026-11-01read-only GitHub API: the repository, the commit list of its port of the parser, that file at its first commit 634835d7cb and at main, PR #33 and its parity docdocuments and imitates the exact NullPointerException as Java behaviour (comment, message and a unit test with "coordinates": null) since 2026-10-02; not a report to Lucene

History

Born with the API: LUCENE-7380 (343f374b530, 2016-07-15) added fromGeoJSON and both messages; first release 6.2.0. Every release from 6.2.0 to 10.6.0 and main is affected (11 releases and main executed, the rest inferred from unchanged code); 6.1.0 has no fromGeoJSON.

Versions tested5 rows

Introduced by LUCENE-7380 "Add Polygon.fromGeoJSON" (#8433, by Mike McCandless after a +1 from Robert Muir on the JIRA patch; no GitHub PR): main-line commit 343f374b530 (first shipped in 7.0.0) and branch_6x backport 573aaf75f52 (shipped in 6.2.0), both 2016-07-15. git log -S'o.getClass()' on the file shows only this commit. Later commits to the file (LUCENE-8964 string arrays in parseArray, the LUCENE-9570 reformat that moved the lines from :201/:86 to :208/:90 in 9.0.0, typo and cosmetic changes up to #15681 in 2026) change no behaviour here. Not fixed upstream as of 2f424fd428da (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and through Gradle); passes with fix A or B
10.6.0 affectedminimal repro fails with assertions on and off (release jars, checksums verified)
branch_10_6 4ae946e5a9b, 10.5.2, 9.12.3 affectedthe candidate's JUnit fails 3 of 4 (the string control passes); the probe fails 3 of 3
6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 first affected 6.2.0probe: null, {} and MultiPolygon [{}] throw NullPointerException; the string control gets ParseException
6.1.0 no fromGeoJSONthe probe does not compile: the API does not exist yet

Independent checks

Confirmed, medium confidence because the impact is small: the finder, an independent reproducer, an intended-behaviour devil's advocate, an adjudication and a credibility audit all reproduced it. A read-only reviewer from another model family called it "not real" on contract grounds but agreed with the root cause.

Checks and verdicts327 words

Everyone agrees on the root cause and that only the exception type is wrong: the input is rejected either way. Fact checks of this doc (2026-11-01): an independent one re-ran the repro, both fixes and the gradle command and found one prior-art wording error; a read-only cross-model one (OpenAI GPT-5.6) found the Rust-port overlap and some wording overstatements. All are corrected.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate4-test JUnit fails 3 of 4 on main with assertions on and off, branch_10_6, 10.5.2 and 9.12.3; the string control passes; with the fix 4 of 4 and upstream TestPolygon 16 of 16.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumRejected the works-as-designed case because the non-array checks exist only to throw ParseException. A separate re-run: 3 of 4 fail.
independent reproducer realIts own JUnit outside the geo package (3 controls, 5 claim cases incl. {"a":1.0} and a Feature geometry) fails 5 of 8 on main (assertions on and off), 10.5.2 and 9.12.3; its own fix passes 8 of 8.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, highCould not run code; argued malformed input is outside the contract and the javadoc promises no exception type. Agreed with the root cause and conceded the NPE replaces the intended ParseException branch. Re-runs: 3 of 4 fail on main and 10.5.2.
intended-behaviour devil's advocate real, lowBuilt the strongest intended case and rejected it; 11 sibling malformed inputs give ParseException; main, 10.5.2 and 9.12.3 fail 3 of 4.
adjudication confirmed, mediumRe-ran the reproducer's JUnit with assertions on and off (5 of 8 fail each) and the finder's (3 of 4). It classed it as a wrong exception type on malformed GeoJSON.
Registration check and credibility audit confirmedRe-runs on main fail 3 of 4 with assertions on and off; the one-test repro fails on main and passes with the fix.

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

Finding record

LFV-953, snapshot of 2026-11-06

Registry title
Polygon.fromGeoJSON throws NullPointerException instead of its own ParseException 'coordinates should be an array, but got: ...' when "coordinates" (or an element of a MultiPolygon's coordinates) is JSON null or an object, because the error message calls o.getClass() on the null the parser uses for those values (every release since 6.2.0)
Area
core / geo GeoJSON parsing (Polygon.fromGeoJSON, SimpleGeoJSONPolygonParser)
Priority
Low, rank 1074: An NPE instead of ParseException for malformed GeoJSON coordinates is a loud wrong-error on input that is rejected either way.
Impact class
wrong error
Impact note
Hits applications that call Polygon.fromGeoJSON (core; Polygon is @lucene.experimental; the method has no production caller in the Lucene tree) on malformed GeoJSON whose "coordinates" member is null or an object (for example {}), directly, inside a Feature's geometry, or as an element of a MultiPolygon's or a FeatureCollection feature's coordinates, and that rely on the declared ParseException to reject bad input: they get an unchecked java.lang.NullPointerException instead, which a catch (ParseException) handler does not see; the input is rejected either way (no polygon is returned), nothing hangs, no index is touched, no setting or scale is involved, 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 GeoJSON Polygon whose "coordinates" is null or an object, such as {"type":"Polygon","coordinates":null}, or on a MultiPolygon with an object element, {"type":"MultiPolygon","coordinates":[{}]}, and expect the ParseException that the parser has an explicit check for ('coordinates should be an array, but got: ...', 'elements of coordinates array should be an array, but got: ...'), as you get when coordinates is a string, a number or a boolean. Instead java.lang.NullPointerException ('Cannot invoke "Object.getClass()" because "o" is null') escapes, because SimpleGeoJSONPolygonParser stores JSON null and nested objects as Java null and both checks build their message with + o.getClass(). Every other type check in the class builds its message with + o, which is null-safe.
Introduced
343f374b530fa71dc6102d74725b536f5f1367f3 (main line, 2016-07-15, 'LUCENE-7380: add Polygon.fromGeoJSON', by mikemccand after a +1 from rmuir on the JIRA patch; 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 bug was born with the API and is not a regression [read: git blame and git log -L on main attribute both check lines, both getClass() messages and the null-producing value branches to 343f374b530, apart from the LUCENE-9570 reformat 2d6ad2fee6d (2020-12-23) that split the MultiPolygon message line; git log -S'o.getClass()' on the file shows only the introducing 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))
First affected version
6.2.0 [exec] (the first release with Polygon.fromGeoJSON and SimpleGeoJSONPolygonParser; 6.1.0 has no fromGeoJSON [exec: compile error]); first 7.x release 7.0.0 [exec], from the main-line commit
Affected versions
Every release that has Polygon.fromGeoJSON: 6.2.0-6.6.6, all 7.x, all 8.x through 8.11.4, 9.0.0-9.12.3 and 10.0.0-10.6.0 (10.6.0 run as its release revision, branch_10_6 head 4ae946e5a9b9), plus main a9779ef3c193. [exec] the history probe (three claim inputs plus a string-valued control) fails 3 of 3 with the control passing on 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main (-ea and -da); the finder's JUnit fails 3 of 4 (control passes) on main (-ea and -da), branch_10_6, 10.5.2 and 9.12.3 (-da); the independent reproduction's own JUnit fails 5 of 8 (3 controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check on main (-ea and -da), 10.5.2 and 9.12.3 (-da); the adjudication on main (-ea and -da); the registration check on main (-da, seed 5EED0384, 3 of 4). [read] the other releases are inferred affected because the check lines and the null-producing lines are unchanged in behaviour from 343f374b530 to main. Not fixed upstream: upstream main 645d4fe14a20 (author date 2026-10-28) still has '+ o.getClass()' at SimpleGeoJSONPolygonParser.java:90 and :208, and its newest commit on the file is 448c7d8e241 (author date 2026-02-11, an unrelated toArray cleanup). All runs on Linux aarch64 with JDK 25.0.3; on JDKs before 15 (unless enabled on 14) the exception type is the same but the NullPointerException has no helpful message.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by code reading
Upstream and prior reports
none found (searched 2026-10-28, read-only): two scripted prior-report searches (56 queries each over GitHub issues/PRs/commits, Elasticsearch/OpenSearch/Solr, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 239 and 281 distinct hits, 1 high = #8433, the introducing issue) plus the finder's run (51 queries, 1,382 hits, 1 high = #9568, an unrelated Geo3D NullPointerException) and the intended-behaviour check's (37 queries, 92 hits, 1 high = #8433); manual GitHub searches (fromGeoJSON, SimpleGeoJSONPolygonParser, GeoJSON plus NullPointerException/null/coordinates/MultiPolygon/ParseException, the exact message texts '"coordinates should be an array"' and '"elements of coordinates array"' (0 issue or PR hits on apache/lucene; GitHub-wide, the full first message with GeoJSON gives 0 and the shorter phrase only unrelated token matches), apache/lucene-solr, GitHub-wide and in Elasticsearch, OpenSearch and Solr), every LUCENE JIRA ticket mentioning GeoJSON (13), list searches, 15 web queries, five outside ports or copies of the parser, and the changed files of all 414 open apache/lucene PRs: nothing reports or discusses the NullPointerException or null/object coordinates. Closest items, not reports: #8433 (LUCENE-7380, the introducing issue: 'improved the ParseException throwing', no null/object discussion); #12295 (open since 2023-05, the only open PR touching the parser: rewrites both instanceof checks but keeps '+ o.getClass()' in both messages, so it does not fix this and a fix would conflict textually with it); #10007 (LUCENE-8964, 8.3.0, string arrays in properties, other site). An outside Go port's comment only notes that 'the Java reference embeds Class.getClass()' and substitutes a nil-safe name, without saying the Java code throws; a Kotlin port and a verbatim copy carry the defect without comment. Refresh 2026-10-28 (GitHub search and REST API, read-only): 'fromGeoJSON' finds only #8433; 'SimpleGeoJSONPolygonParser' finds only #9584 and #9498 (unrelated); '"coordinates should be an array"' 0; 'GeoJSON updated:>=2026-10-20' 0; none of the 20 apache/lucene PRs created since 2026-10-26 (#16907-#16936) touches a geo file; upstream main 645d4fe14a20 still has both getClass() messages and no commit on the file after 448c7d8e241 (author date 2026-02-11). Found on 2026-11-01 (read-only GitHub API): the public outside Rust port huyz0/lucene-rust documents and imitates this NullPointerException as Java behaviour (a module comment, the Java message and a unit test with "coordinates": null, since its commit 634835d7cb of 2026-10-02; shown as diff context in its PR #33, merged 2026-10-28); it is not a report to the Lucene project.
Lucene commit tested
main 645d4fe14a20 (the minimal repro with assertions on and off, standalone and through gradle; both fix options in a scratch worktree); the 10.6.0 release jars (checksums verified against Maven Central; the minimal repro with assertions on and off); earlier: main a9779ef3c193, branch_10_6 4ae946e5a9b, 10.5.2 and 9.12.3 (the candidate's and an independent JUnit) and a probe on 10 releases from 6.2.0 to 10.5.2; 6.1.0 has no fromGeoJSON. Upstream main 2f424fd428da (2026-10-30) still has both lines (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:47-51 (at a9779ef3c193) ('Does minimal parsing'), :84-91 (MultiPolygon element check, '+ o.getClass()' at :90), :146-154 (nested object value: parseObject then o = null), :163-165 (JSON null: o = null), :177, :182, :189, :232, :240 (sibling messages built with '+ o'), :205-209 (coordinates check, '+ o.getClass()' at :208), :310-313 (object element in an array: o = null)
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:41 (at a9779ef3c193) (@lucene.experimental), :248-257 (fromGeoJSON javadoc, no @throws; signature :255 declares ParseException); SECURITY.md lines 28, 42, 46, 50
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:25-51, :63-97, :132-172, :177, :182, :189, :205-209, :231-232, :239-240, :256, :310-313, :451-462 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:23-27, :41, :248-257 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:41, :255-257 (at 645d4fe14a20); grep of lucene/ for fromGeoJSON and SimpleGeoJSONPolygonParser (only Polygon.java and tests)
  • https://openjdk.org/jeps/358 (Helpful NullPointerExceptions)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/geo/SimpleGeoJSONPolygonParser.java:47-51, :86-91, :177, :182, :189, :205-209, :231-232, :239-240, :256, :262, :267, :272 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:41, :248-255 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/geo/TestPolygon.java:268-281, :283-302, :305-346 (at 645d4fe14a20)

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 repro's inputs and outputs.

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.