Skip to content

Commit 43855e9

Browse files
authored
feat: KML URL Sanitizer API (#1678)
* feat: introduce KmlUrlSanitizer API for KML resource loading * docs: license * fix: restore backward compatibility for KmlLayer constructor
1 parent 4516be3 commit 43855e9

4 files changed

Lines changed: 103 additions & 21 deletions

File tree

library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import android.graphics.BitmapFactory;
2121
import android.util.Log;
2222

23+
import androidx.annotation.Nullable;
2324
import androidx.annotation.RawRes;
2425

2526
import com.google.android.gms.maps.GoogleMap;
@@ -59,7 +60,7 @@ public class KmlLayer extends Layer {
5960
*/
6061
public KmlLayer(GoogleMap map, int resourceId, Context context)
6162
throws XmlPullParserException, IOException {
62-
this(map, context.getResources().openRawResource(resourceId), context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null);
63+
this(map, context.getResources().openRawResource(resourceId), context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, null);
6364
}
6465

6566
/**
@@ -75,7 +76,7 @@ public KmlLayer(GoogleMap map, int resourceId, Context context)
7576
*/
7677
public KmlLayer(GoogleMap map, InputStream stream, Context context)
7778
throws XmlPullParserException, IOException {
78-
this(map, stream, context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null);
79+
this(map, stream, context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, null);
7980
}
8081

8182
/**
@@ -106,7 +107,7 @@ public KmlLayer(GoogleMap map,
106107
GroundOverlayManager groundOverlayManager,
107108
Renderer.ImagesCache cache)
108109
throws XmlPullParserException, IOException {
109-
this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache);
110+
this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null);
110111
}
111112

112113
/**
@@ -137,10 +138,43 @@ public KmlLayer(GoogleMap map,
137138
GroundOverlayManager groundOverlayManager,
138139
Renderer.ImagesCache cache)
139140
throws XmlPullParserException, IOException {
141+
this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null);
142+
}
143+
144+
/**
145+
* Creates a new KmlLayer object - addLayerToMap() must be called to trigger rendering onto a map.
146+
*
147+
* Constructor may be called on a background thread, as I/O and parsing may be long-running.
148+
*
149+
* Use this constructor with shared object managers in order to handle multiple layers with
150+
* their own event handlers on the map.
151+
*
152+
* @param map GoogleMap object
153+
* @param stream InputStream containing KML or KMZ file
154+
* @param context The Context
155+
* @param markerManager marker manager to create marker collection from
156+
* @param polygonManager polygon manager to create polygon collection from
157+
* @param polylineManager polyline manager to create polyline collection from
158+
* @param groundOverlayManager ground overlay manager to create ground overlay collection from
159+
* @param cache cache to be used for fetched images
160+
* @param urlSanitizer sanitizer to be used for external URLs
161+
* @throws XmlPullParserException if file cannot be parsed
162+
* @throws IOException if I/O error
163+
*/
164+
public KmlLayer(GoogleMap map,
165+
InputStream stream,
166+
Context context,
167+
MarkerManager markerManager,
168+
PolygonManager polygonManager,
169+
PolylineManager polylineManager,
170+
GroundOverlayManager groundOverlayManager,
171+
Renderer.ImagesCache cache,
172+
@Nullable KmlUrlSanitizer urlSanitizer)
173+
throws XmlPullParserException, IOException {
140174
if (stream == null) {
141175
throw new IllegalArgumentException("KML InputStream cannot be null");
142176
}
143-
KmlRenderer renderer = new KmlRenderer(map, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache);
177+
KmlRenderer renderer = new KmlRenderer(map, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, urlSanitizer);
144178

145179
BufferedInputStream bis = new BufferedInputStream(stream);
146180
bis.mark(1024);

library/src/main/java/com/google/maps/android/data/kml/KmlRenderer.java

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,36 @@ public class KmlRenderer extends Renderer {
6767

6868
private ArrayList<KmlContainer> mContainers;
6969

70+
private final KmlUrlSanitizer mUrlSanitizer;
71+
7072
/* package */ KmlRenderer(GoogleMap map,
7173
Context context,
7274
MarkerManager markerManager,
7375
PolygonManager polygonManager,
7476
PolylineManager polylineManager,
7577
GroundOverlayManager groundOverlayManager,
76-
@Nullable ImagesCache imagesCache) {
78+
@Nullable ImagesCache imagesCache,
79+
@Nullable KmlUrlSanitizer urlSanitizer) {
7780
super(map, context, markerManager, polygonManager, polylineManager, groundOverlayManager, imagesCache);
7881
mGroundOverlayUrls = new HashSet<>();
7982
mMarkerIconsDownloaded = false;
8083
mGroundOverlayImagesDownloaded = false;
84+
mUrlSanitizer = (urlSanitizer == null) ? new DefaultKmlUrlSanitizer() : urlSanitizer;
85+
}
86+
87+
private static class DefaultKmlUrlSanitizer implements KmlUrlSanitizer {
88+
@Override
89+
public String sanitizeUrl(String url) {
90+
try {
91+
URL parsedUrl = new URL(url);
92+
if (parsedUrl.getProtocol().equalsIgnoreCase("http") || parsedUrl.getProtocol().equalsIgnoreCase("https")) {
93+
return url;
94+
}
95+
} catch (MalformedURLException e) {
96+
// Return null to block invalid URLs
97+
}
98+
return null;
99+
}
81100
}
82101

83102
/**
@@ -617,10 +636,11 @@ protected void onPostExecute(Bitmap bitmap) {
617636
* @return the bitmap of that image, scaled according to screen density.
618637
*/
619638
private Bitmap getBitmapFromUrl(String url) throws IOException {
620-
URL parsedUrl = new URL(url);
621-
if (!parsedUrl.getProtocol().equalsIgnoreCase("http") && !parsedUrl.getProtocol().equalsIgnoreCase("https")) {
622-
throw new MalformedURLException("Unsupported scheme: " + parsedUrl.getProtocol());
639+
String sanitizedUrl = mUrlSanitizer.sanitizeUrl(url);
640+
if (sanitizedUrl == null) {
641+
throw new MalformedURLException("URL blocked by sanitizer: " + url);
623642
}
643+
URL parsedUrl = new URL(sanitizedUrl);
624644
return BitmapFactory.decodeStream(openConnectionCheckRedirects(parsedUrl.openConnection()));
625645
}
626646

@@ -651,11 +671,9 @@ private InputStream openConnectionCheckRedirects(URLConnection c) throws IOExcep
651671
target = new URL(base, loc);
652672
}
653673
http.disconnect();
654-
// Redirection should be allowed only for HTTP and HTTPS
655-
// and should be limited to 5 redirections at most.
656-
if (target == null || !(target.getProtocol().equals("http")
657-
|| target.getProtocol().equals("https"))
658-
|| redirects >= 5) {
674+
675+
// Validate redirect URL using the sanitizer
676+
if (target == null || mUrlSanitizer.sanitizeUrl(target.toString()) == null || redirects >= 5) {
659677
throw new SecurityException("illegal URL redirect");
660678
}
661679
redir = true;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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.kml;
17+
18+
/**
19+
* Interface for sanitizing URLs in KML documents.
20+
* Developers can implement this to control which external resources (images, etc.) are loaded.
21+
*/
22+
public interface KmlUrlSanitizer {
23+
/**
24+
* Sanitizes a URL before it is used to fetch a resource.
25+
*
26+
* @param url The raw URL from the KML.
27+
* @return A safe, validated URL string, or null to block this resource.
28+
*/
29+
String sanitizeUrl(String url);
30+
}

library/src/test/java/com/google/maps/android/data/kml/KmlRendererTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public void setUp() throws Exception {
4141
mParser = new KmlParser(parser);
4242
mParser.parseKml();
4343

44-
mRenderer = new KmlRenderer(mMap1, null, null, null, null, null, null);
44+
mRenderer = new KmlRenderer(mMap1, null, null, null, null, null, null, null);
4545
mRenderer.storeKmlData(mParser.getStyles(), mParser.getStyleMaps(), mParser.getPlacemarks(),
4646
mParser.getContainers(), mParser.getGroundOverlays());
4747
}
@@ -65,7 +65,7 @@ public void testAssignStyleMap() {
6565
KmlStyle redStyle = new KmlStyle();
6666
styles.put("BlueValue", blueStyle);
6767
styles.put("RedValue", redStyle);
68-
KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null);
68+
KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null, null);
6969
renderer.assignStyleMap(styleMap, styles);
7070
assertNotNull(styles.get("BlueKey"));
7171
assertEquals(styles.get("BlueKey"), styles.get("BlueValue"));
@@ -80,7 +80,7 @@ public void testAssignStyleMap() {
8080

8181
@Test
8282
public void testBitmapUrlSchemeValidation() throws Exception {
83-
KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null);
83+
KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null, null);
8484
java.lang.reflect.Method method = KmlRenderer.class.getDeclaredMethod("getBitmapFromUrl", String.class);
8585
method.setAccessible(true);
8686

@@ -90,7 +90,7 @@ public void testBitmapUrlSchemeValidation() throws Exception {
9090
org.junit.Assert.fail("Should have thrown InvocationTargetException containing MalformedURLException");
9191
} catch (java.lang.reflect.InvocationTargetException e) {
9292
assertTrue(e.getCause() instanceof java.net.MalformedURLException);
93-
assertEquals("Unsupported scheme: file", e.getCause().getMessage());
93+
assertTrue(e.getCause().getMessage().contains("URL blocked by sanitizer"));
9494
}
9595

9696
// Should throw MalformedURLException for ftp:// scheme
@@ -99,20 +99,20 @@ public void testBitmapUrlSchemeValidation() throws Exception {
9999
org.junit.Assert.fail("Should have thrown InvocationTargetException containing MalformedURLException");
100100
} catch (java.lang.reflect.InvocationTargetException e) {
101101
assertTrue(e.getCause() instanceof java.net.MalformedURLException);
102-
assertEquals("Unsupported scheme: ftp", e.getCause().getMessage());
102+
assertTrue(e.getCause().getMessage().contains("URL blocked by sanitizer"));
103103
}
104104

105-
// For http/https, it should not throw MalformedURLException with "Unsupported scheme"
105+
// For http/https, it should not throw MalformedURLException with "URL blocked by sanitizer"
106106
try {
107107
method.invoke(renderer, "http://example.com/image.png");
108108
} catch (java.lang.reflect.InvocationTargetException e) {
109-
org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().startsWith("Unsupported scheme"));
109+
org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().contains("URL blocked by sanitizer"));
110110
}
111111

112112
try {
113113
method.invoke(renderer, "https://example.com/image.png");
114114
} catch (java.lang.reflect.InvocationTargetException e) {
115-
org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().startsWith("Unsupported scheme"));
115+
org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().contains("URL blocked by sanitizer"));
116116
}
117117
}
118118
}

0 commit comments

Comments
 (0)