Skip to content

Commit 7c36b51

Browse files
authored
build(deps): bump xmlutil serialization to 1.0.1 and harden data layer parsers (#1741)
* build(deps): bump xmlutil serialization to 1.0.1 and harden data layer parsers - Update io.github.pdvrieze.xmlutil:serialization in libs.versions.toml from 0.91.3 to 1.0.1. - Migrate XML {} configuration DSL in GpxParser and KmlParser by promoting isCollectingNSAttributes to the top-level XML {} builder per xmlutil 1.0.0+ DSL changes. - Update expected exception for malformed KML coordinate tests in KmlParserTest from XmlParsingException to XmlException per xmlutil 1.0.x error hierarchy. - Harden GeoJSON, KML, and GPX coordinate models (Coordinates, LatLngAlt, Wpt) by enforcing finiteness checks in init blocks and serializers to prevent NaN/Infinity poisoning from reaching LatLng/LatLngBounds. - Sanitize non-finite numeric style properties (stroke-width, fill-opacity, stroke-opacity, width, scale) in GeoJsonMapper and KmlMapper to safe visual defaults. - Add comprehensive SecurityHardeningTest suite covering adversarial coordinate poisoning and non-finite style injection across all three spatial formats. * refactor(data): add KDoc to Wpt and elevation property alias - address review feedback from @kikoso
1 parent c39ba07 commit 7c36b51

12 files changed

Lines changed: 271 additions & 19 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ package com.google.maps.android.data.parser.geojson
2626
* @property lng The longitude of the coordinate.
2727
* @property alt The altitude of the coordinate, in meters. Optional.
2828
*/
29-
data class Coordinates(val lat: Double, val lng: Double, val alt: Double? = null)
29+
data class Coordinates(val lat: Double, val lng: Double, val alt: Double? = null) {
30+
init {
31+
require(lat.isFinite() && lng.isFinite() && (alt == null || alt.isFinite())) {
32+
"GeoJSON coordinate contains a non-finite value"
33+
}
34+
}
35+
}
3036

3137
// Using a sealed interface for all GeoJSON objects
3238
sealed interface GeoJsonObject {

data/src/main/java/com/google/maps/android/data/parser/gpx/GpxModel.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ data class Metadata(
5656
val time: String? = null,
5757
)
5858

59+
/**
60+
* Represents a GPX waypoint (`<wpt>`), point of interest, or named feature on a map.
61+
*
62+
* @property lat The latitude of the waypoint.
63+
* @property lon The longitude of the waypoint.
64+
* @property ele The elevation (in meters) of the waypoint, or null if unspecified.
65+
* @property time The timestamp of the waypoint.
66+
* @property name The name of the waypoint.
67+
* @property desc A description of the waypoint.
68+
* @property sym The symbol name or icon for the waypoint.
69+
*/
5970
@Serializable
6071
@XmlSerialName("wpt", namespace = GPX_NAMESPACE, prefix = "")
6172
data class Wpt(
@@ -78,7 +89,19 @@ data class Wpt(
7889
@XmlElement(true)
7990
@XmlSerialName("sym", namespace = GPX_NAMESPACE, prefix = "")
8091
val sym: String? = null,
81-
)
92+
) {
93+
/**
94+
* Descriptive alias for [ele] (elevation in meters).
95+
*/
96+
val elevation: Double?
97+
get() = ele
98+
99+
init {
100+
require(lat.isFinite() && lon.isFinite() && (elevation?.isFinite() ?: true)) {
101+
"GPX coordinate contains a non-finite value"
102+
}
103+
}
104+
}
82105

83106
@Serializable
84107
@XmlSerialName("rte", namespace = GPX_NAMESPACE, prefix = "")

data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ class GpxParser {
3131
XML {
3232
defaultPolicy {
3333
ignoreUnknownChildren()
34-
isCollectingNSAttributes = true
3534
}
35+
isCollectingNSAttributes = true
3636
}
3737

3838
fun parse(inputStream: InputStream): Gpx {

data/src/main/java/com/google/maps/android/data/parser/kml/KmlParser.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ class KmlParser {
2929
XML {
3030
defaultPolicy {
3131
ignoreUnknownChildren()
32-
isCollectingNSAttributes = true
3332
}
33+
isCollectingNSAttributes = true
3434
}
3535

3636
fun parseAsKml(inputStream: InputStream): Kml {

data/src/main/java/com/google/maps/android/data/parser/kml/LatLngAlt.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,10 @@ data class LatLngAlt(
2222
val latitude: Double,
2323
val longitude: Double,
2424
val altitude: Double? = null,
25-
)
25+
) {
26+
init {
27+
require(latitude.isFinite() && longitude.isFinite() && (altitude == null || altitude.isFinite())) {
28+
"KML coordinate contains a non-finite value"
29+
}
30+
}
31+
}

data/src/main/java/com/google/maps/android/data/parser/kml/LatLngAltSerializer.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,16 @@ internal object LatLngAltSerializer : KSerializer<LatLngAlt> {
3737

3838
internal fun parse(string: String): LatLngAlt {
3939
val parts = string.split(",").map { it.trim().toDouble() }
40+
val lng = parts[0]
41+
val lat = parts[1]
42+
val alt = parts.getOrNull(2)
43+
require(lng.isFinite() && lat.isFinite() && (alt == null || alt.isFinite())) {
44+
"KML coordinate contains a non-finite value"
45+
}
4046
return LatLngAlt(
41-
longitude = parts[0],
42-
latitude = parts[1],
43-
altitude = parts.getOrNull(2),
47+
longitude = lng,
48+
latitude = lat,
49+
altitude = alt,
4450
)
4551
}
4652
}

data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ object GeoJsonMapper {
9595
geometry is LineString || (geometry is MultiGeometry && !geometry.isPolygonal()) -> {
9696
// MultiGeometry could contain lines
9797
val strokeColor = props["stroke"]?.let { parseColor(it) }
98-
val strokeWidth = props["stroke-width"]?.toFloatOrNull()
98+
val strokeWidth = props["stroke-width"]?.toFloatOrNull()?.takeIf { it.isFinite() && it >= 0f }
9999
if (strokeColor != null || strokeWidth != null) {
100100
LineStyle(
101101
color = strokeColor ?: 0xFF000000.toInt(),
@@ -105,10 +105,10 @@ object GeoJsonMapper {
105105
}
106106
geometry is ModelPolygon || (geometry is MultiGeometry && geometry.isPolygonal()) -> {
107107
val strokeColor = props["stroke"]?.let { parseColor(it) }
108-
val strokeWidth = props["stroke-width"]?.toFloatOrNull()
108+
val strokeWidth = props["stroke-width"]?.toFloatOrNull()?.takeIf { it.isFinite() && it >= 0f }
109109
val fillColor = props["fill"]?.let { parseColor(it) }
110-
val fillOpacity = props["fill-opacity"]?.toFloatOrNull()
111-
val strokeOpacity = props["stroke-opacity"]?.toFloatOrNull()
110+
val fillOpacity = props["fill-opacity"]?.toFloatOrNull()?.takeIf { it.isFinite() && it in 0f..1f }
111+
val strokeOpacity = props["stroke-opacity"]?.toFloatOrNull()?.takeIf { it.isFinite() && it in 0f..1f }
112112

113113
val finalFillColor = if (fillColor != null && fillOpacity != null) {
114114
applyOpacity(fillColor, fillOpacity)

data/src/main/java/com/google/maps/android/data/renderer/mapper/KmlMapper.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ private fun KmlStyle.toRendererStyle(geometry: Geometry): Style? =
251251
is PointGeometry -> {
252252
iconStyle?.let {
253253
PointStyle(
254-
scale = it.scale,
254+
scale = it.scale.takeIf { s -> s.isFinite() && s >= 0f } ?: 1.0f,
255255
iconUrl = it.icon?.href,
256256
// TODO: Map other properties like heading, hotSpot if needed
257257
)
@@ -262,7 +262,7 @@ private fun KmlStyle.toRendererStyle(geometry: Geometry): Style? =
262262
lineStyle?.let {
263263
LineStyle(
264264
color = convertKmlColor(it.color ?: 0xFF000000.toInt()),
265-
width = it.width ?: 1.0f,
265+
width = it.width?.takeIf { w -> w.isFinite() && w >= 0f } ?: 1.0f,
266266
)
267267
}
268268
}
@@ -272,7 +272,7 @@ private fun KmlStyle.toRendererStyle(geometry: Geometry): Style? =
272272
PolygonStyle(
273273
fillColor = if (it.fill) convertKmlColor(it.color ?: 0x00000000) else 0x00000000,
274274
strokeColor = convertKmlColor(lineStyle?.color ?: 0xFF000000.toInt()),
275-
strokeWidth = lineStyle?.width ?: 1.0f,
275+
strokeWidth = lineStyle?.width?.takeIf { w -> w.isFinite() && w >= 0f } ?: 1.0f,
276276
// TODO: Handle outline property
277277
)
278278
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.maps.android.data.parser
17+
18+
import com.google.common.truth.Truth.assertThat
19+
import com.google.maps.android.data.parser.geojson.GeoJsonParser
20+
import com.google.maps.android.data.parser.gpx.GpxParser
21+
import com.google.maps.android.data.parser.kml.KmlParser
22+
import com.google.maps.android.data.renderer.mapper.GeoJsonMapper
23+
import com.google.maps.android.data.renderer.mapper.toLayer
24+
import com.google.maps.android.data.renderer.model.LineStyle
25+
import com.google.maps.android.data.renderer.model.PolygonStyle
26+
import org.junit.Test
27+
import org.junit.runner.RunWith
28+
import org.robolectric.RobolectricTestRunner
29+
import kotlin.test.assertFailsWith
30+
31+
@RunWith(RobolectricTestRunner::class)
32+
class SecurityHardeningTest {
33+
private val kmlParser = KmlParser()
34+
private val gpxParser = GpxParser()
35+
private val geoJsonParser = GeoJsonParser()
36+
37+
@Test
38+
fun testKmlCoordinateNanPoisoning_throwsException() {
39+
val kml = """
40+
<kml xmlns="http://www.opengis.net/kml/2.2">
41+
<Document>
42+
<Placemark>
43+
<Point>
44+
<coordinates>NaN,NaN,0</coordinates>
45+
</Point>
46+
</Placemark>
47+
</Document>
48+
</kml>
49+
""".trimIndent()
50+
51+
assertFailsWith<Exception> {
52+
val parsed = kmlParser.parseAsKml(kml.byteInputStream())
53+
parsed.toLayer()
54+
}
55+
}
56+
57+
@Test
58+
fun testKmlCoordinateInfinityPoisoning_throwsException() {
59+
val kml = """
60+
<kml xmlns="http://www.opengis.net/kml/2.2">
61+
<Document>
62+
<Placemark>
63+
<Point>
64+
<coordinates>10.0,Infinity,0</coordinates>
65+
</Point>
66+
</Placemark>
67+
</Document>
68+
</kml>
69+
""".trimIndent()
70+
71+
assertFailsWith<Exception> {
72+
val parsed = kmlParser.parseAsKml(kml.byteInputStream())
73+
parsed.toLayer()
74+
}
75+
}
76+
77+
@Test
78+
fun testGpxCoordinateNanPoisoning_throwsException() {
79+
val gpx = """
80+
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1">
81+
<wpt lat="NaN" lon="10.0">
82+
<name>Poisoned Waypoint</name>
83+
</wpt>
84+
</gpx>
85+
""".trimIndent()
86+
87+
assertFailsWith<Exception> {
88+
val parsed = gpxParser.parse(gpx.byteInputStream())
89+
parsed.toLayer()
90+
}
91+
}
92+
93+
@Test
94+
fun testGpxCoordinateInfinityPoisoning_throwsException() {
95+
val gpx = """
96+
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1">
97+
<wpt lat="10.0" lon="-Infinity">
98+
<name>Poisoned Waypoint</name>
99+
</wpt>
100+
</gpx>
101+
""".trimIndent()
102+
103+
assertFailsWith<Exception> {
104+
val parsed = gpxParser.parse(gpx.byteInputStream())
105+
parsed.toLayer()
106+
}
107+
}
108+
109+
@Test
110+
fun testGeoJsonNonFiniteStyleProperties_sanitizedToSafeDefaults() {
111+
val json = """
112+
{
113+
"type": "FeatureCollection",
114+
"features": [
115+
{
116+
"type": "Feature",
117+
"geometry": {
118+
"type": "Polygon",
119+
"coordinates": [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [10.0, 0.0], [0.0, 0.0]]]
120+
},
121+
"properties": {
122+
"stroke-width": "NaN",
123+
"fill-opacity": "Infinity",
124+
"stroke-opacity": "-5.0",
125+
"stroke": "#FF0000",
126+
"fill": "#00FF00"
127+
}
128+
}
129+
]
130+
}
131+
""".trimIndent()
132+
133+
val layer = geoJsonParser.parse(json.byteInputStream())!!.toLayer()
134+
val feature = layer.features.first()
135+
val style = feature.style as PolygonStyle
136+
137+
// Non-finite width must fall back to safe default 1.0f rather than Float.NaN
138+
assertThat(style.strokeWidth).isEqualTo(1.0f)
139+
assertThat(style.strokeWidth.isFinite()).isTrue()
140+
}
141+
142+
@Test
143+
fun testGeoJsonLineStringNonFiniteStrokeWidth_sanitizedToSafeDefault() {
144+
val json = """
145+
{
146+
"type": "FeatureCollection",
147+
"features": [
148+
{
149+
"type": "Feature",
150+
"geometry": {
151+
"type": "LineString",
152+
"coordinates": [[0.0, 0.0], [10.0, 10.0]]
153+
},
154+
"properties": {
155+
"stroke-width": "Infinity",
156+
"stroke": "#0000FF"
157+
}
158+
}
159+
]
160+
}
161+
""".trimIndent()
162+
163+
val layer = geoJsonParser.parse(json.byteInputStream())!!.toLayer()
164+
val feature = layer.features.first()
165+
val style = feature.style as LineStyle
166+
167+
// Infinity width must fall back to safe default 1.0f rather than Float.POSITIVE_INFINITY
168+
assertThat(style.width).isEqualTo(1.0f)
169+
assertThat(style.width.isFinite()).isTrue()
170+
}
171+
172+
@Test
173+
fun testKmlNonFiniteStyleProperties_sanitizedToSafeDefaults() {
174+
val kml = """
175+
<kml xmlns="http://www.opengis.net/kml/2.2">
176+
<Document>
177+
<Style id="poisonedStyle">
178+
<LineStyle>
179+
<width>NaN</width>
180+
</LineStyle>
181+
<IconStyle>
182+
<scale>Infinity</scale>
183+
</IconStyle>
184+
</Style>
185+
<Placemark>
186+
<styleUrl>#poisonedStyle</styleUrl>
187+
<LineString>
188+
<coordinates>0,0,0 10,10,0</coordinates>
189+
</LineString>
190+
</Placemark>
191+
</Document>
192+
</kml>
193+
""".trimIndent()
194+
195+
// Should parse safely without crashing and sanitize non-finite width/scale to safe defaults
196+
val layer = kmlParser.parseAsKml(kml.byteInputStream()).toLayer()
197+
val feature = layer.features.first()
198+
val style = feature.style as LineStyle
199+
assertThat(style.width.isFinite()).isTrue()
200+
assertThat(style.width).isAtLeast(0.0f)
201+
}
202+
}

data/src/test/java/com/google/maps/android/data/parser/gpx/GpxParserTest.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,11 @@ class GpxParserTest {
108108
assertTrue(trkFeature.geometry is LineString)
109109
assertEquals("Trk1", trkFeature.properties["name"])
110110
}
111+
112+
@Test
113+
fun `test Wpt elevation property alias`() {
114+
val wpt = Wpt(lat = 1.0, lon = 2.0, ele = 123.45)
115+
assertEquals(123.45, wpt.ele)
116+
assertEquals(123.45, wpt.elevation)
117+
}
111118
}

0 commit comments

Comments
 (0)