Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -118,6 +198,11 @@ private fun parseCoordinates(coordinates: List<JsonElement>): 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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IllegalArgumentException> { parser.parse(stream) }
}

@Test
fun testExcessivelyNestedGeometryCollectionIsRejected() {
val nested = buildNestedGeometryCollectionJson(1000)
val stream = ByteArrayInputStream(nested.toByteArray())
assertFailsWith<IllegalArgumentException> { 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<IllegalArgumentException> { boundedParser.parse(stream) }
}

@Test
fun testNonFiniteCoordinateIsRejected() {
val geoJson =
"""{"type": "Feature", "geometry": {"type": "Point", "coordinates": ["Infinity", "NaN"]}}"""
val stream = ByteArrayInputStream(geoJson.toByteArray())
assertFailsWith<IllegalArgumentException> { parser.parse(stream) }
}

private fun buildNestedGeometryCollectionJson(depth: Int): String {
var current = """{"type": "Point", "coordinates": [0.0, 0.0]}"""
repeat(depth) {
Expand Down