diff --git a/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt index 6a6c6228b..df490bc09 100644 --- a/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt +++ b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt @@ -25,9 +25,17 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.io.InputStream -class GeoJsonParser { +class GeoJsonParser( + private val maxInputSize: Long = DEFAULT_MAX_INPUT_SIZE, + private val maxStructuralDepth: Int = DEFAULT_MAX_STRUCTURAL_DEPTH, +) { fun parse(inputStream: InputStream): GeoJsonObject? { - val json = inputStream.bufferedReader().use { it.readText() } + // Bound the untrusted input *before* materialising it. [Json.parseToJsonElement] reads the + // whole document into an in-memory tree, so without these guards a hostile document can + // exhaust memory (a wide document) or overflow the parser's stack (a deeply nested one) + // before the [MAX_GEOMETRY_DEPTH] check — which runs on the already-built tree — can act. + val json = inputStream.readTextBounded(maxInputSize) + checkStructuralDepth(json, maxStructuralDepth) val jsonElement = Json.parseToJsonElement(json) return when (jsonElement.jsonObject["type"]?.jsonPrimitive?.content) { @@ -53,12 +61,84 @@ class GeoJsonParser { companion object { const val MAX_GEOMETRY_DEPTH = 20 + + /** + * Maximum number of characters read from an untrusted document. [Json.parseToJsonElement] + * materialises the whole document in memory (with a large text-to-object amplification), so an + * unbounded input is an [OutOfMemoryError] vector even with no nesting. The default is far + * above any realistic layer (~60x the largest bundled sample); raise it via the constructor + * for trusted large sources. + */ + const val DEFAULT_MAX_INPUT_SIZE = 10L * 1024 * 1024 + + /** + * Maximum raw structural nesting (`{`/`[`) accepted. [Json.parseToJsonElement] parses nested + * JSON with stack recursion, so a deeply nested document overflows the stack *before* the + * [MAX_GEOMETRY_DEPTH] guard (which runs on the already-built tree) can act. Chosen to stay + * safe on small Android thread stacks while remaining far above any legitimate GeoJSON, whose + * structural depth is well under 100 even with maximally nested geometries. + */ + const val DEFAULT_MAX_STRUCTURAL_DEPTH = 512 + val SUPPORTED_EXTENSIONS = setOf("json", "geojson") fun canParse(header: String): Boolean = header.trimStart().startsWith("{") } } +/** + * Reads the stream as UTF-8 text, failing fast with an [IllegalArgumentException] once more than + * [maxChars] characters have been read, so an oversized untrusted document is never fully materialised. + */ +private fun InputStream.readTextBounded(maxChars: Long): String { + val reader = bufferedReader() + val builder = StringBuilder() + val buffer = CharArray(8 * 1024) + var total = 0L + while (true) { + val read = reader.read(buffer) + if (read < 0) break + total += read + require(total <= maxChars) { + "GeoJSON input exceeds the maximum allowed size of $maxChars characters" + } + builder.append(buffer, 0, read) + } + return builder.toString() +} + +/** + * Rejects, with an [IllegalArgumentException], any document whose raw structural nesting (`{`/`[`) + * exceeds [maxDepth]. Runs in a single pass that ignores brackets inside string literals, *before* + * [Json.parseToJsonElement], so a maliciously deep document is rejected instead of overflowing the + * parser's stack. + */ +private fun checkStructuralDepth(json: String, maxDepth: Int) { + var depth = 0 + var inString = false + var escaped = false + for (c in json) { + if (inString) { + when { + escaped -> escaped = false + c == '\\' -> escaped = true + c == '"' -> inString = false + } + continue + } + when (c) { + '"' -> inString = true + '[', '{' -> { + depth++ + require(depth <= maxDepth) { + "GeoJSON structural nesting exceeds the maximum depth of $maxDepth" + } + } + ']', '}' -> if (depth > 0) depth-- + } + } +} + private fun parseFeatureCollection(json: JsonElement): GeoJsonFeatureCollection { val featuresJson = json.jsonObject["features"]?.jsonArray val features = featuresJson?.map { parseFeature(it) } ?: emptyList() @@ -118,6 +198,11 @@ private fun parseCoordinates(coordinates: List): Coordinates { val lng = coordinates[0].jsonPrimitive.content.toDouble() val lat = coordinates[1].jsonPrimitive.content.toDouble() val alt = if (coordinates.size > 2) coordinates[2].jsonPrimitive.content.toDouble() else null + // Reject non-finite values (e.g. "Infinity"/"NaN", which String.toDouble() accepts) so poisoned + // coordinates cannot flow unchecked into LatLng/LatLngBounds and corrupt rendering downstream. + require(lng.isFinite() && lat.isFinite() && (alt == null || alt.isFinite())) { + "GeoJSON coordinate contains a non-finite value" + } return Coordinates(lat, lng, alt) } diff --git a/data/src/test/java/com/google/maps/android/data/parser/geojson/GeoJsonParserTest.kt b/data/src/test/java/com/google/maps/android/data/parser/geojson/GeoJsonParserTest.kt index 5559f5599..5302b67c4 100644 --- a/data/src/test/java/com/google/maps/android/data/parser/geojson/GeoJsonParserTest.kt +++ b/data/src/test/java/com/google/maps/android/data/parser/geojson/GeoJsonParserTest.kt @@ -326,6 +326,43 @@ class GeoJsonParserTest { assertThat(parseGeometry(element, -1)).isNull() } + @Test + fun testDeeplyNestedArraysAreRejectedWithoutStackOverflow() { + // A ~6 KB document of deeply nested arrays overflows Json.parseToJsonElement's stack before + // the MAX_GEOMETRY_DEPTH guard (which runs on the already-built tree) can act. The + // structural-depth check must reject it up-front instead of crashing. + val deep = + "{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",\"coordinates\":" + + "[".repeat(3000) + "1" + "]".repeat(3000) + "}}" + val stream = ByteArrayInputStream(deep.toByteArray()) + assertFailsWith { parser.parse(stream) } + } + + @Test + fun testExcessivelyNestedGeometryCollectionIsRejected() { + val nested = buildNestedGeometryCollectionJson(1000) + val stream = ByteArrayInputStream(nested.toByteArray()) + assertFailsWith { parser.parse(stream) } + } + + @Test + fun testOversizedInputIsRejected() { + val boundedParser = GeoJsonParser(maxInputSize = 1024L) + val big = + "{\"type\":\"MultiPoint\",\"coordinates\":[" + + (0 until 2000).joinToString(",") { "[1,1]" } + "]}" + val stream = ByteArrayInputStream(big.toByteArray()) + assertFailsWith { boundedParser.parse(stream) } + } + + @Test + fun testNonFiniteCoordinateIsRejected() { + val geoJson = + """{"type": "Feature", "geometry": {"type": "Point", "coordinates": ["Infinity", "NaN"]}}""" + val stream = ByteArrayInputStream(geoJson.toByteArray()) + assertFailsWith { parser.parse(stream) } + } + private fun buildNestedGeometryCollectionJson(depth: Int): String { var current = """{"type": "Point", "coordinates": [0.0, 0.0]}""" repeat(depth) {