Skip to content

fix: bound untrusted GeoJSON input to prevent parser resource exhaustion - #1733

Merged
dkhawk merged 2 commits into
googlemaps:mainfrom
agilira:fix/geojson-parser-resource-exhaustion
Aug 4, 2026
Merged

dkhawk merged 2 commits into
googlemaps:mainfrom
agilira:fix/geojson-parser-resource-exhaustion

Conversation

@agilira

@agilira agilira commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Harden GeoJsonParser against resource exhaustion when parsing untrusted GeoJSON, by bounding the input before it is materialised. This closes the remaining gap left by the nesting-depth guards added in #1699 and #1710.

Problem

#1699 (MAX_GEOMETRY_DEPTH) and #1710 both guard against deeply nested input, but the GeoJSON guard runs during a second pass over the tree that Json.parseToJsonElement() has already fully materialised:

fun parse(inputStream: InputStream): GeoJsonObject? {
    val json = inputStream.bufferedReader().use { it.readText() }
    val jsonElement = Json.parseToJsonElement(json)   // whole document materialised here
    ...                                                // MAX_GEOMETRY_DEPTH is only checked after this

So for GeoJSON the guard acts too late. With an untrusted document a caller can still hit:

  • Deep nesting → StackOverflowError. kotlinx-serialization's JSON reader recurses on nested arrays, so a few thousand nested arrays overflow the stack inside parseToJsonElement, before MAX_GEOMETRY_DEPTH is ever reached. (KmlParser avoids this by bounding depth during streaming with DepthLimitingReader; GeoJSON has no equivalent.)
  • Wide, shallow document → OutOfMemoryError. A large flat array (e.g. a MultiPoint with millions of positions) materialises to far more heap than the source text, with no nesting for a depth guard to catch.
  • Non-finite coordinates. "Infinity"/"NaN" are accepted by String.toDouble() and currently flow straight into LatLng/LatLngBounds.

The first two throw java.lang.Error subclasses, so the catch (e: Exception) in DataLayerLoader does not contain them and the failure propagates to the host app.

Fix

Bound the input up front, before parseToJsonElement():

  • readTextBounded() — caps the number of characters read (configurable, default 10 MiB), so an oversized document is never fully materialised.
  • checkStructuralDepth() — rejects raw {/[ nesting beyond a limit, in a single string-aware pass (configurable, default 512 — safe on small Android thread stacks, and far above any legitimate GeoJSON, whose structural depth stays well under 100 even with maximally nested geometries).
  • parseCoordinates() — rejects non-finite values.

Both limits are constructor parameters with safe defaults, mirroring KmzParser's configurable zip-bomb limits (#1677). Rejected input throws IllegalArgumentException (an Exception), so DataLayerLoader degrades to a null layer instead of crashing.

Testing

Adds unit tests for each case (deep-nested arrays, excessively nested GeometryCollection, oversized input, non-finite coordinate). Existing behaviour is preserved, including the #1699 testDeeplyNestedGeometryCollectionDoesNotThrowStackOverflow case (depth 200 stays well within the structural limit and still parses).

Compatibility

GeoJsonParser's new parameters have defaults, so GeoJsonParser() continues to work unchanged for all existing (Kotlin) call sites (DataLayerLoader, GeoJsonLayer). No @JvmOverloads is added, matching the sibling KmzParser.

@google-cla

google-cla Bot commented Jul 25, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

googlemaps#1699 and googlemaps#1710 added nesting-depth guards, but they run on the tree that
Json.parseToJsonElement() has already fully materialised, so they act too late:
GeoJsonParser.parse() reads and materialises the whole untrusted document
before any check. A hostile layer can therefore still

  - overflow the parser stack with deep nesting (StackOverflowError) before the
    MAX_GEOMETRY_DEPTH=20 guard runs (contrast KmlParser, which bounds depth
    *during* streaming via DepthLimitingReader);
  - exhaust the heap with a wide, shallow document (OutOfMemoryError), which no
    depth guard addresses;
  - forward non-finite coordinates ("Infinity"/"NaN", accepted by
    String.toDouble()) straight into LatLng/LatLngBounds.

The first two throw java.lang.Error subclasses, so the catch (Exception) in
DataLayerLoader does not contain them and the host app crashes on load.

Bound the input up front, before parseToJsonElement:

  - readTextBounded() caps the characters read (configurable, default 10 MiB);
  - checkStructuralDepth() rejects raw '{'/'[' nesting beyond a limit in a
    single string-aware pass (configurable, default 512 - safe on small Android
    stacks, far above any legitimate GeoJSON);
  - parseCoordinates() rejects non-finite values.

Limits are constructor parameters with safe defaults, mirroring KmzParser.
Existing behaviour is preserved (incl. the googlemaps#1699 depth-200 GeometryCollection
test); adds tests for each case.
@agilira
agilira force-pushed the fix/geojson-parser-resource-exhaustion branch from 0e1e861 to 7a73969 Compare July 25, 2026 15:36
@kikoso

kikoso commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

Hello @agilira

Thanks for the PR. Running testDeeplyNestedArraysAreRejectedWithoutStackOverflow against main passes, with no StackOverflowError and no error of any kind.

So, I think you could either significantly increase the depth (or use a shape that doesn't trip the coordinate-type check first) so it actually reproduces a stack overflow on unpatched code and demonstrates the guard catching it. Or we could drop the "prevents stack overflow" framing from this test's name/comment and just fold it into the existing structural-depth coverage, since testExcessivelyNestedGeometryCollectionIsRejected already proves checkStructuralDepth works.

Would you be able to update the PR? Thanks!

…ally

The previous version of this test could pass on unpatched code for an
unrelated reason: with the nesting inside "coordinates", a parse that
survives the stack reaches parseCoordinates(), which calls jsonPrimitive
on a JsonArray and throws IllegalArgumentException - the very type the
test asserts.

Move the nesting under "bbox", a member the parser never dereferences,
so nothing can reject the document first, and run the parse on a thread
with an explicit 1 MB stack so the outcome does not depend on the stack
size of the thread the test framework happens to use. 50 000 levels
overflow that stack whether or not the parser is JIT-compiled, while the
structural-depth check rejects the document at nesting level 513 without
recursing at all. A small document is parsed first so that class loading
is not charged to the bounded stack.

On main this test now fails with StackOverflowError instead of passing.
@agilira

agilira commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — you're right, and the reason it passes on main is worse than "not deep enough": the test could go green for an unrelated reason.

With the nesting inside coordinates, if the stack survives, parseToJsonElement completes and parseCoordinates() then does coordinates[0].jsonPrimitive on a JsonArray, which throws IllegalArgumentException ("Element ... is not a JsonPrimitive"). The test asserts exactly that type, so on unpatched code it passes with no stack overflow at all — the coordinate-type check you suspected.

Whether the overflow happens also depends on the stack of the thread running the test, and on whether the parser is still interpreted. Measured on 5.0.0 with kotlinx-serialization 1.9.0, first nesting depth that overflows, once JIT-compiled:

thread stack first depth that overflows
256 KB ~360
1 MB ~16,800
8 MB ~148,000

Cold, the thresholds are several times lower (at 1 MB a first-run parse already overflows somewhere between 2,000 and 5,000). Also worth noting: only nested objects switch to the stackless path (readDeepRecursive, at depth 200); nested arrays recurse JsonTreeReader.readArray -> read -> readArray, which is where the overflow lands.

I took your first option and made it deterministic:

  • the nesting now sits under "bbox", a member the parser never dereferences, so nothing can reject the document for an unrelated reason and mask the result;
  • the parse runs on a thread with an explicit 1 MB stack — a normal worker-thread size, above every platform minimum, so it doesn't depend on the machine or on the runner's thread;
  • the depth is 50,000, which overflows that stack interpreted or compiled (and still overflows a 16 MB stack cold), while on this branch the structural-depth check rejects the document at nesting level 513 without recursing at all;
  • a small document is parsed first, so class loading isn't charged to the bounded stack — below ~192 KB the overflow lands in ClassLoader.defineClass1 rather than in the parser, and I'd rather the test never be sensitive to that.

Result: with only this test added on top of main it fails with

expected class java.lang.IllegalArgumentException but was class java.lang.StackOverflowError

and on this branch the whole file passes (21/21 locally; CI still needs your approval to run). Renamed to testDeeplyNestedArraysAreRejectedInsteadOfOverflowingTheParserStack.

If you'd still rather not have a stack-sensitive test in the suite, I'm happy to take your second option instead and fold it into the existing structural-depth coverage — just say the word.

@dkhawk dkhawk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @agilira for this pull request and for updating the regression test to deterministically demonstrate stack overflow prevention.

Review Summary

  • Root Cause & Solution: Correctly identifies that deeply nested GeoJSON arrays or objects can cause a StackOverflowError during Json.parseToJsonElement(), and that oversized documents can cause out-of-memory errors. The streaming single-pass structural depth check (checkStructuralDepth) and bounded input reader (readTextBounded) prevent both resource exhaustion vectors cleanly.
  • Poisoned Coordinate Protection: Checking finite coordinates (isFinite()) in parseCoordinates() ensures non-finite strings like "Infinity" or "NaN" cannot propagate downstream into SDK rendering models.
  • Verification & Testing:
    • Tested testDeeplyNestedArraysAreRejectedInsteadOfOverflowingTheParserStack on unpatched main: confirmed deterministic failure (java.lang.StackOverflowError).
    • Tested on PR #1733: confirmed 100% passing tests across the entire suite (./gradlew test).
    • Verified backward compatibility for existing call sites.

LGTM.

@dkhawk
dkhawk merged commit 1e41aa4 into googlemaps:main Aug 4, 2026
9 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants