From 46c62677a6863c10a28aca11569829336c1a5fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 16 Apr 2026 14:37:43 +0200 Subject: [PATCH 1/3] feat: introduce KmlUrlSanitizer API for KML resource loading --- .../maps/android/data/kml/KmlLayer.java | 13 ++++--- .../maps/android/data/kml/KmlRenderer.java | 36 ++++++++++++++----- .../android/data/kml/KmlUrlSanitizer.java | 30 ++++++++++++++++ .../android/data/kml/KmlRendererTest.java | 16 ++++----- 4 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java diff --git a/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java b/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java index a0feb7361..fb70f8f54 100644 --- a/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java +++ b/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java @@ -20,6 +20,7 @@ import android.graphics.BitmapFactory; import android.util.Log; +import androidx.annotation.Nullable; import androidx.annotation.RawRes; import com.google.android.gms.maps.GoogleMap; @@ -59,7 +60,7 @@ public class KmlLayer extends Layer { */ public KmlLayer(GoogleMap map, int resourceId, Context context) throws XmlPullParserException, IOException { - this(map, context.getResources().openRawResource(resourceId), context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null); + this(map, context.getResources().openRawResource(resourceId), context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, null); } /** @@ -75,7 +76,7 @@ public KmlLayer(GoogleMap map, int resourceId, Context context) */ public KmlLayer(GoogleMap map, InputStream stream, Context context) throws XmlPullParserException, IOException { - this(map, stream, context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null); + this(map, stream, context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, null); } /** @@ -106,7 +107,7 @@ public KmlLayer(GoogleMap map, GroundOverlayManager groundOverlayManager, Renderer.ImagesCache cache) throws XmlPullParserException, IOException { - this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache); + this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null); } /** @@ -125,6 +126,7 @@ public KmlLayer(GoogleMap map, * @param polylineManager polyline manager to create polyline collection from * @param groundOverlayManager ground overlay manager to create ground overlay collection from * @param cache cache to be used for fetched images + * @param urlSanitizer sanitizer to be used for external URLs * @throws XmlPullParserException if file cannot be parsed * @throws IOException if I/O error */ @@ -135,12 +137,13 @@ public KmlLayer(GoogleMap map, PolygonManager polygonManager, PolylineManager polylineManager, GroundOverlayManager groundOverlayManager, - Renderer.ImagesCache cache) + Renderer.ImagesCache cache, + @Nullable KmlUrlSanitizer urlSanitizer) throws XmlPullParserException, IOException { if (stream == null) { throw new IllegalArgumentException("KML InputStream cannot be null"); } - KmlRenderer renderer = new KmlRenderer(map, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache); + KmlRenderer renderer = new KmlRenderer(map, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, urlSanitizer); BufferedInputStream bis = new BufferedInputStream(stream); bis.mark(1024); diff --git a/library/src/main/java/com/google/maps/android/data/kml/KmlRenderer.java b/library/src/main/java/com/google/maps/android/data/kml/KmlRenderer.java index 28e545b8e..e63667a3c 100644 --- a/library/src/main/java/com/google/maps/android/data/kml/KmlRenderer.java +++ b/library/src/main/java/com/google/maps/android/data/kml/KmlRenderer.java @@ -67,17 +67,36 @@ public class KmlRenderer extends Renderer { private ArrayList mContainers; + private final KmlUrlSanitizer mUrlSanitizer; + /* package */ KmlRenderer(GoogleMap map, Context context, MarkerManager markerManager, PolygonManager polygonManager, PolylineManager polylineManager, GroundOverlayManager groundOverlayManager, - @Nullable ImagesCache imagesCache) { + @Nullable ImagesCache imagesCache, + @Nullable KmlUrlSanitizer urlSanitizer) { super(map, context, markerManager, polygonManager, polylineManager, groundOverlayManager, imagesCache); mGroundOverlayUrls = new HashSet<>(); mMarkerIconsDownloaded = false; mGroundOverlayImagesDownloaded = false; + mUrlSanitizer = (urlSanitizer == null) ? new DefaultKmlUrlSanitizer() : urlSanitizer; + } + + private static class DefaultKmlUrlSanitizer implements KmlUrlSanitizer { + @Override + public String sanitizeUrl(String url) { + try { + URL parsedUrl = new URL(url); + if (parsedUrl.getProtocol().equalsIgnoreCase("http") || parsedUrl.getProtocol().equalsIgnoreCase("https")) { + return url; + } + } catch (MalformedURLException e) { + // Return null to block invalid URLs + } + return null; + } } /** @@ -617,10 +636,11 @@ protected void onPostExecute(Bitmap bitmap) { * @return the bitmap of that image, scaled according to screen density. */ private Bitmap getBitmapFromUrl(String url) throws IOException { - URL parsedUrl = new URL(url); - if (!parsedUrl.getProtocol().equalsIgnoreCase("http") && !parsedUrl.getProtocol().equalsIgnoreCase("https")) { - throw new MalformedURLException("Unsupported scheme: " + parsedUrl.getProtocol()); + String sanitizedUrl = mUrlSanitizer.sanitizeUrl(url); + if (sanitizedUrl == null) { + throw new MalformedURLException("URL blocked by sanitizer: " + url); } + URL parsedUrl = new URL(sanitizedUrl); return BitmapFactory.decodeStream(openConnectionCheckRedirects(parsedUrl.openConnection())); } @@ -651,11 +671,9 @@ private InputStream openConnectionCheckRedirects(URLConnection c) throws IOExcep target = new URL(base, loc); } http.disconnect(); - // Redirection should be allowed only for HTTP and HTTPS - // and should be limited to 5 redirections at most. - if (target == null || !(target.getProtocol().equals("http") - || target.getProtocol().equals("https")) - || redirects >= 5) { + + // Validate redirect URL using the sanitizer + if (target == null || mUrlSanitizer.sanitizeUrl(target.toString()) == null || redirects >= 5) { throw new SecurityException("illegal URL redirect"); } redir = true; diff --git a/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java b/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java new file mode 100644 index 000000000..45187dcbb --- /dev/null +++ b/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.data.kml; + +/** + * Interface for sanitizing URLs in KML documents. + * Developers can implement this to control which external resources (images, etc.) are loaded. + */ +public interface KmlUrlSanitizer { + /** + * Sanitizes a URL before it is used to fetch a resource. + * + * @param url The raw URL from the KML. + * @return A safe, validated URL string, or null to block this resource. + */ + String sanitizeUrl(String url); +} diff --git a/library/src/test/java/com/google/maps/android/data/kml/KmlRendererTest.java b/library/src/test/java/com/google/maps/android/data/kml/KmlRendererTest.java index 1ace1c29c..ec61c8322 100644 --- a/library/src/test/java/com/google/maps/android/data/kml/KmlRendererTest.java +++ b/library/src/test/java/com/google/maps/android/data/kml/KmlRendererTest.java @@ -41,7 +41,7 @@ public void setUp() throws Exception { mParser = new KmlParser(parser); mParser.parseKml(); - mRenderer = new KmlRenderer(mMap1, null, null, null, null, null, null); + mRenderer = new KmlRenderer(mMap1, null, null, null, null, null, null, null); mRenderer.storeKmlData(mParser.getStyles(), mParser.getStyleMaps(), mParser.getPlacemarks(), mParser.getContainers(), mParser.getGroundOverlays()); } @@ -65,7 +65,7 @@ public void testAssignStyleMap() { KmlStyle redStyle = new KmlStyle(); styles.put("BlueValue", blueStyle); styles.put("RedValue", redStyle); - KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null); + KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null, null); renderer.assignStyleMap(styleMap, styles); assertNotNull(styles.get("BlueKey")); assertEquals(styles.get("BlueKey"), styles.get("BlueValue")); @@ -80,7 +80,7 @@ public void testAssignStyleMap() { @Test public void testBitmapUrlSchemeValidation() throws Exception { - KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null); + KmlRenderer renderer = new KmlRenderer(null, null, null, null, null, null, null, null); java.lang.reflect.Method method = KmlRenderer.class.getDeclaredMethod("getBitmapFromUrl", String.class); method.setAccessible(true); @@ -90,7 +90,7 @@ public void testBitmapUrlSchemeValidation() throws Exception { org.junit.Assert.fail("Should have thrown InvocationTargetException containing MalformedURLException"); } catch (java.lang.reflect.InvocationTargetException e) { assertTrue(e.getCause() instanceof java.net.MalformedURLException); - assertEquals("Unsupported scheme: file", e.getCause().getMessage()); + assertTrue(e.getCause().getMessage().contains("URL blocked by sanitizer")); } // Should throw MalformedURLException for ftp:// scheme @@ -99,20 +99,20 @@ public void testBitmapUrlSchemeValidation() throws Exception { org.junit.Assert.fail("Should have thrown InvocationTargetException containing MalformedURLException"); } catch (java.lang.reflect.InvocationTargetException e) { assertTrue(e.getCause() instanceof java.net.MalformedURLException); - assertEquals("Unsupported scheme: ftp", e.getCause().getMessage()); + assertTrue(e.getCause().getMessage().contains("URL blocked by sanitizer")); } - // For http/https, it should not throw MalformedURLException with "Unsupported scheme" + // For http/https, it should not throw MalformedURLException with "URL blocked by sanitizer" try { method.invoke(renderer, "http://example.com/image.png"); } catch (java.lang.reflect.InvocationTargetException e) { - org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().startsWith("Unsupported scheme")); + org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().contains("URL blocked by sanitizer")); } try { method.invoke(renderer, "https://example.com/image.png"); } catch (java.lang.reflect.InvocationTargetException e) { - org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().startsWith("Unsupported scheme")); + org.junit.Assert.assertFalse(e.getCause().getMessage() != null && e.getCause().getMessage().contains("URL blocked by sanitizer")); } } } From 3398b0860f16b08014ff75074f21afd9f5fdd89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 16 Apr 2026 14:39:47 +0200 Subject: [PATCH 2/3] docs: license --- .../java/com/google/maps/android/data/kml/KmlUrlSanitizer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java b/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java index 45187dcbb..bedb7cf96 100644 --- a/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java +++ b/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2026 Google Inc. + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From f26c0129e03246a36f99efb721b9d7c5222e2ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 16 Apr 2026 14:56:26 +0200 Subject: [PATCH 3/3] fix: restore backward compatibility for KmlLayer constructor --- .../maps/android/data/kml/KmlLayer.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java b/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java index fb70f8f54..d8b87faf8 100644 --- a/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java +++ b/library/src/main/java/com/google/maps/android/data/kml/KmlLayer.java @@ -110,6 +110,37 @@ public KmlLayer(GoogleMap map, this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null); } + /** + * Creates a new KmlLayer object - addLayerToMap() must be called to trigger rendering onto a map. + * + * Constructor may be called on a background thread, as I/O and parsing may be long-running. + * + * Use this constructor with shared object managers in order to handle multiple layers with + * their own event handlers on the map. + * + * @param map GoogleMap object + * @param stream InputStream containing KML or KMZ file + * @param context The Context + * @param markerManager marker manager to create marker collection from + * @param polygonManager polygon manager to create polygon collection from + * @param polylineManager polyline manager to create polyline collection from + * @param groundOverlayManager ground overlay manager to create ground overlay collection from + * @param cache cache to be used for fetched images + * @throws XmlPullParserException if file cannot be parsed + * @throws IOException if I/O error + */ + public KmlLayer(GoogleMap map, + InputStream stream, + Context context, + MarkerManager markerManager, + PolygonManager polygonManager, + PolylineManager polylineManager, + GroundOverlayManager groundOverlayManager, + Renderer.ImagesCache cache) + throws XmlPullParserException, IOException { + this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null); + } + /** * Creates a new KmlLayer object - addLayerToMap() must be called to trigger rendering onto a map. *