Skip to content

Commit 1e41aa4

Browse files
authored
fix: bound untrusted GeoJSON input to prevent parser resource exhaustion (#1733)
* fix: bound untrusted GeoJSON input to prevent parser resource exhaustion #1699 and #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 #1699 depth-200 GeometryCollection test); adds tests for each case. * test: make the deep-nesting test reproduce the overflow deterministically 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.
1 parent fe802a4 commit 1e41aa4

2 files changed

Lines changed: 168 additions & 2 deletions

File tree

data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,17 @@ import kotlinx.serialization.json.jsonObject
2525
import kotlinx.serialization.json.jsonPrimitive
2626
import java.io.InputStream
2727

28-
class GeoJsonParser {
28+
class GeoJsonParser(
29+
private val maxInputSize: Long = DEFAULT_MAX_INPUT_SIZE,
30+
private val maxStructuralDepth: Int = DEFAULT_MAX_STRUCTURAL_DEPTH,
31+
) {
2932
fun parse(inputStream: InputStream): GeoJsonObject? {
30-
val json = inputStream.bufferedReader().use { it.readText() }
33+
// Bound the untrusted input *before* materialising it. [Json.parseToJsonElement] reads the
34+
// whole document into an in-memory tree, so without these guards a hostile document can
35+
// exhaust memory (a wide document) or overflow the parser's stack (a deeply nested one)
36+
// before the [MAX_GEOMETRY_DEPTH] check — which runs on the already-built tree — can act.
37+
val json = inputStream.readTextBounded(maxInputSize)
38+
checkStructuralDepth(json, maxStructuralDepth)
3139
val jsonElement = Json.parseToJsonElement(json)
3240

3341
return when (jsonElement.jsonObject["type"]?.jsonPrimitive?.content) {
@@ -53,12 +61,84 @@ class GeoJsonParser {
5361

5462
companion object {
5563
const val MAX_GEOMETRY_DEPTH = 20
64+
65+
/**
66+
* Maximum number of characters read from an untrusted document. [Json.parseToJsonElement]
67+
* materialises the whole document in memory (with a large text-to-object amplification), so an
68+
* unbounded input is an [OutOfMemoryError] vector even with no nesting. The default is far
69+
* above any realistic layer (~60x the largest bundled sample); raise it via the constructor
70+
* for trusted large sources.
71+
*/
72+
const val DEFAULT_MAX_INPUT_SIZE = 10L * 1024 * 1024
73+
74+
/**
75+
* Maximum raw structural nesting (`{`/`[`) accepted. [Json.parseToJsonElement] parses nested
76+
* JSON with stack recursion, so a deeply nested document overflows the stack *before* the
77+
* [MAX_GEOMETRY_DEPTH] guard (which runs on the already-built tree) can act. Chosen to stay
78+
* safe on small Android thread stacks while remaining far above any legitimate GeoJSON, whose
79+
* structural depth is well under 100 even with maximally nested geometries.
80+
*/
81+
const val DEFAULT_MAX_STRUCTURAL_DEPTH = 512
82+
5683
val SUPPORTED_EXTENSIONS = setOf("json", "geojson")
5784

5885
fun canParse(header: String): Boolean = header.trimStart().startsWith("{")
5986
}
6087
}
6188

89+
/**
90+
* Reads the stream as UTF-8 text, failing fast with an [IllegalArgumentException] once more than
91+
* [maxChars] characters have been read, so an oversized untrusted document is never fully materialised.
92+
*/
93+
private fun InputStream.readTextBounded(maxChars: Long): String {
94+
val reader = bufferedReader()
95+
val builder = StringBuilder()
96+
val buffer = CharArray(8 * 1024)
97+
var total = 0L
98+
while (true) {
99+
val read = reader.read(buffer)
100+
if (read < 0) break
101+
total += read
102+
require(total <= maxChars) {
103+
"GeoJSON input exceeds the maximum allowed size of $maxChars characters"
104+
}
105+
builder.append(buffer, 0, read)
106+
}
107+
return builder.toString()
108+
}
109+
110+
/**
111+
* Rejects, with an [IllegalArgumentException], any document whose raw structural nesting (`{`/`[`)
112+
* exceeds [maxDepth]. Runs in a single pass that ignores brackets inside string literals, *before*
113+
* [Json.parseToJsonElement], so a maliciously deep document is rejected instead of overflowing the
114+
* parser's stack.
115+
*/
116+
private fun checkStructuralDepth(json: String, maxDepth: Int) {
117+
var depth = 0
118+
var inString = false
119+
var escaped = false
120+
for (c in json) {
121+
if (inString) {
122+
when {
123+
escaped -> escaped = false
124+
c == '\\' -> escaped = true
125+
c == '"' -> inString = false
126+
}
127+
continue
128+
}
129+
when (c) {
130+
'"' -> inString = true
131+
'[', '{' -> {
132+
depth++
133+
require(depth <= maxDepth) {
134+
"GeoJSON structural nesting exceeds the maximum depth of $maxDepth"
135+
}
136+
}
137+
']', '}' -> if (depth > 0) depth--
138+
}
139+
}
140+
}
141+
62142
private fun parseFeatureCollection(json: JsonElement): GeoJsonFeatureCollection {
63143
val featuresJson = json.jsonObject["features"]?.jsonArray
64144
val features = featuresJson?.map { parseFeature(it) } ?: emptyList()
@@ -118,6 +198,11 @@ private fun parseCoordinates(coordinates: List<JsonElement>): Coordinates {
118198
val lng = coordinates[0].jsonPrimitive.content.toDouble()
119199
val lat = coordinates[1].jsonPrimitive.content.toDouble()
120200
val alt = if (coordinates.size > 2) coordinates[2].jsonPrimitive.content.toDouble() else null
201+
// Reject non-finite values (e.g. "Infinity"/"NaN", which String.toDouble() accepts) so poisoned
202+
// coordinates cannot flow unchecked into LatLng/LatLngBounds and corrupt rendering downstream.
203+
require(lng.isFinite() && lat.isFinite() && (alt == null || alt.isFinite())) {
204+
"GeoJSON coordinate contains a non-finite value"
205+
}
121206
return Coordinates(lat, lng, alt)
122207
}
123208

data/src/test/java/com/google/maps/android/data/parser/geojson/GeoJsonParserTest.kt

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,87 @@ class GeoJsonParserTest {
326326
assertThat(parseGeometry(element, -1)).isNull()
327327
}
328328

329+
@Test
330+
fun testDeeplyNestedArraysAreRejectedInsteadOfOverflowingTheParserStack() {
331+
// Json.parseToJsonElement() descends into nested *arrays* on the call stack
332+
// (JsonTreeReader.readArray -> read -> readArray ...; only nested objects switch to the
333+
// stackless path), and it runs before anything inspects the parsed tree, so the
334+
// MAX_GEOMETRY_DEPTH guard never sees this document: unpatched, the parse dies with a
335+
// StackOverflowError, which is an Error and therefore not contained by the
336+
// catch (Exception) in DataLayerLoader.
337+
//
338+
// Three details keep that deterministic rather than machine-dependent:
339+
// - the nesting sits under "bbox", a member the parser never dereferences, so no
340+
// coordinate/type check can reject the document first and mask the overflow;
341+
// - the parse runs on a thread with an explicit 1 MB stack, the usual size of a worker
342+
// thread, instead of whatever (much larger) stack the test runner's thread has. 1 MB is
343+
// above every platform minimum, so the request is honoured as-is;
344+
// - 50 000 levels overflow that stack whether or not the parser is JIT-compiled (the
345+
// threshold is a few thousand levels interpreted and ~17 000 once compiled), while the
346+
// structural-depth check rejects the document at nesting level 513 without recursing
347+
// at all.
348+
// A small document is parsed first so that class loading is not charged to the bounded
349+
// stack.
350+
val point = """{"type": "Point", "coordinates": [0.0, 0.0]}"""
351+
parser.parse(ByteArrayInputStream(point.toByteArray()))
352+
353+
val deep = "[".repeat(50000) + "1" + "]".repeat(50000)
354+
val geoJson = """{"type": "FeatureCollection", "bbox": $deep, "features": []}"""
355+
assertFailsWith<IllegalArgumentException> {
356+
parseOnThreadWithStackSize(1024L * 1024) {
357+
parser.parse(ByteArrayInputStream(geoJson.toByteArray()))
358+
}
359+
}
360+
}
361+
362+
@Test
363+
fun testExcessivelyNestedGeometryCollectionIsRejected() {
364+
val nested = buildNestedGeometryCollectionJson(1000)
365+
val stream = ByteArrayInputStream(nested.toByteArray())
366+
assertFailsWith<IllegalArgumentException> { parser.parse(stream) }
367+
}
368+
369+
@Test
370+
fun testOversizedInputIsRejected() {
371+
val boundedParser = GeoJsonParser(maxInputSize = 1024L)
372+
val big =
373+
"{\"type\":\"MultiPoint\",\"coordinates\":[" +
374+
(0 until 2000).joinToString(",") { "[1,1]" } + "]}"
375+
val stream = ByteArrayInputStream(big.toByteArray())
376+
assertFailsWith<IllegalArgumentException> { boundedParser.parse(stream) }
377+
}
378+
379+
@Test
380+
fun testNonFiniteCoordinateIsRejected() {
381+
val geoJson =
382+
"""{"type": "Feature", "geometry": {"type": "Point", "coordinates": ["Infinity", "NaN"]}}"""
383+
val stream = ByteArrayInputStream(geoJson.toByteArray())
384+
assertFailsWith<IllegalArgumentException> { parser.parse(stream) }
385+
}
386+
387+
/**
388+
* Runs [block] on a thread with a fixed stack size and rethrows whatever it threw, so that a
389+
* test about stack consumption does not depend on the stack size of the thread the test
390+
* framework happens to use.
391+
*/
392+
private fun parseOnThreadWithStackSize(
393+
stackSizeBytes: Long,
394+
block: () -> Unit,
395+
) {
396+
var thrown: Throwable? = null
397+
val thread =
398+
Thread(null, {
399+
try {
400+
block()
401+
} catch (t: Throwable) {
402+
thrown = t
403+
}
404+
}, "geojson-parse", stackSizeBytes)
405+
thread.start()
406+
thread.join()
407+
thrown?.let { throw it }
408+
}
409+
329410
private fun buildNestedGeometryCollectionJson(depth: Int): String {
330411
var current = """{"type": "Point", "coordinates": [0.0, 0.0]}"""
331412
repeat(depth) {

0 commit comments

Comments
 (0)