From 6c33c8e571ed57a4583e37fec0b412043ddd513b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 16 Apr 2026 14:09:37 +0200 Subject: [PATCH 1/3] fix: KMZ zip bomb mitigation by adding entry and size limits --- .../maps/android/data/kml/KmlLayer.java | 64 ++++++++++++- .../maps/android/data/kml/KmlZipBombTest.java | 91 +++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.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..a09da6885 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 @@ -46,6 +46,9 @@ */ public class KmlLayer extends Layer { + private static final int MAX_KMZ_ENTRY_COUNT = 200; + private static final long MAX_KMZ_UNCOMPRESSED_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB + /** * Creates a new KmlLayer object - addLayerToMap() must be called to trigger rendering onto a map. * @@ -145,16 +148,22 @@ public KmlLayer(GoogleMap map, BufferedInputStream bis = new BufferedInputStream(stream); bis.mark(1024); ZipInputStream zip = new ZipInputStream(bis); + CountingInputStream countingStream = new CountingInputStream(zip, MAX_KMZ_UNCOMPRESSED_TOTAL_SIZE); try { KmlParser parser = null; ZipEntry entry = zip.getNextEntry(); if (entry != null) { // is a KMZ zip file + int entryCount = 0; HashMap images = new HashMap<>(); while (entry != null) { + entryCount++; + if (entryCount > MAX_KMZ_ENTRY_COUNT) { + throw new IOException("Zip bomb detected! Max number of entries exceeded: " + MAX_KMZ_ENTRY_COUNT); + } if (parser == null && entry.getName().toLowerCase().endsWith(".kml")) { - parser = parseKml(zip); + parser = parseKml(countingStream); } else { - Bitmap bitmap = BitmapFactory.decodeStream(zip); + Bitmap bitmap = BitmapFactory.decodeStream(countingStream); if (bitmap != null) { images.put(entry.getName(), bitmap); } else { @@ -182,6 +191,57 @@ public KmlLayer(GoogleMap map, } } + /** + * Wrapper for an InputStream that counts the number of bytes read and throws an IOException + * if the limit is exceeded. + */ + private static class CountingInputStream extends InputStream { + private final InputStream mIn; + private long mTotalBytes = 0; + private final long mMaxBytes; + + public CountingInputStream(InputStream in, long maxBytes) { + mIn = in; + mMaxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + int b = mIn.read(); + if (b != -1) { + mTotalBytes++; + checkLimit(); + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = mIn.read(b, off, len); + if (n != -1) { + mTotalBytes += n; + checkLimit(); + } + return n; + } + + @Override + public long skip(long n) throws IOException { + long skipped = mIn.skip(n); + if (skipped > 0) { + mTotalBytes += skipped; + checkLimit(); + } + return skipped; + } + + private void checkLimit() throws IOException { + if (mTotalBytes > mMaxBytes) { + throw new IOException("Zip bomb detected! Uncompressed size exceeds limit of " + mMaxBytes + " bytes."); + } + } + } + private static KmlParser parseKml(InputStream stream) throws XmlPullParserException, IOException { XmlPullParser xmlPullParser = createXmlParser(stream); KmlParser parser = new KmlParser(xmlPullParser); diff --git a/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java b/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java new file mode 100644 index 000000000..72cfe6e23 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java @@ -0,0 +1,91 @@ +/* + * 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; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +@RunWith(RobolectricTestRunner.class) +public class KmlZipBombTest { + + @Test + public void testValidKmz() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos); + zos.putNextEntry(new ZipEntry("doc.kml")); + zos.write("".getBytes()); + zos.closeEntry(); + zos.close(); + + Context context = ApplicationProvider.getApplicationContext(); + KmlLayer layer = new KmlLayer(null, new ByteArrayInputStream(baos.toByteArray()), context); + assertNotNull(layer); + } + + @Test + public void testMaxEntriesLimit() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos); + for (int i = 0; i < 202; i++) { + zos.putNextEntry(new ZipEntry("entry" + i + ".txt")); + zos.write("data".getBytes()); + zos.closeEntry(); + } + zos.close(); + + Context context = ApplicationProvider.getApplicationContext(); + try { + new KmlLayer(null, new ByteArrayInputStream(baos.toByteArray()), context); + fail("Should have thrown IOException due to too many entries"); + } catch (IOException e) { + // Expected + } + } + + @Test + public void testMaxSizeLimit() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos); + zos.putNextEntry(new ZipEntry("large_entry.kml")); + // 50MB + 1 byte + byte[] largeData = new byte[1024 * 1024]; + for (int i = 0; i < 51; i++) { + zos.write(largeData); + } + zos.closeEntry(); + zos.close(); + + Context context = ApplicationProvider.getApplicationContext(); + try { + new KmlLayer(null, new ByteArrayInputStream(baos.toByteArray()), context); + fail("Should have thrown IOException due to size limit"); + } catch (IOException e) { + // Expected + } + } +} From 06c246734d41df250fbb62050cefd01fb3c99b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 16 Apr 2026 14:13:07 +0200 Subject: [PATCH 2/3] docs: license --- .../google/maps/android/data/kml/KmlZipBombTest.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java b/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java index 72cfe6e23..afc1c6d69 100644 --- a/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java +++ b/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java @@ -1,18 +1,19 @@ /* - * 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. * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * + * + * 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; import android.content.Context; From 52524f613a5e49da4aa2b72560ec0ed20cb5d11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Wed, 22 Apr 2026 14:43:58 +0200 Subject: [PATCH 3/3] feat: make KMZ zip bomb limits configurable --- .../maps/android/data/kml/KmlLayer.java | 122 ++++++++++++++++-- 1 file changed, 114 insertions(+), 8 deletions(-) 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 a09da6885..da9e19c85 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 @@ -46,12 +46,12 @@ */ public class KmlLayer extends Layer { - private static final int MAX_KMZ_ENTRY_COUNT = 200; - private static final long MAX_KMZ_UNCOMPRESSED_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB + private static final int DEFAULT_MAX_KMZ_ENTRY_COUNT = 200; + private static final long DEFAULT_MAX_KMZ_UNCOMPRESSED_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB /** * 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. * * @param map GoogleMap object @@ -67,7 +67,25 @@ public KmlLayer(GoogleMap map, int resourceId, Context context) /** * 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. * + * @param map GoogleMap object + * @param resourceId Raw resource KML or KMZ file + * @param context The Context + * @param maxKmzEntryCount The maximum number of entries a KMZ file can contain. + * @param maxKmzUncompressedTotalSize The maximum size of the uncompressed KMZ file in bytes. + * @throws XmlPullParserException if file cannot be parsed + * @throws IOException if I/O error + */ + public KmlLayer(GoogleMap map, int resourceId, Context context, int maxKmzEntryCount, long maxKmzUncompressedTotalSize) + throws XmlPullParserException, IOException { + this(map, context.getResources().openRawResource(resourceId), context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, maxKmzEntryCount, maxKmzUncompressedTotalSize); + } + + /** + * 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. * * @param map GoogleMap object @@ -83,9 +101,27 @@ public KmlLayer(GoogleMap map, InputStream stream, Context context) /** * 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. * + * @param map GoogleMap object + * @param stream InputStream containing KML or KMZ file + * @param context The Context + * @param maxKmzEntryCount The maximum number of entries a KMZ file can contain. + * @param maxKmzUncompressedTotalSize The maximum size of the uncompressed KMZ file in bytes. + * @throws XmlPullParserException if file cannot be parsed + * @throws IOException if I/O error + */ + public KmlLayer(GoogleMap map, InputStream stream, Context context, int maxKmzEntryCount, long maxKmzUncompressedTotalSize) + throws XmlPullParserException, IOException { + this(map, stream, context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, maxKmzEntryCount, maxKmzUncompressedTotalSize); + } + + /** + * 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. * @@ -114,9 +150,44 @@ public KmlLayer(GoogleMap map, /** * 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 resourceId Raw resource 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 + * @param maxKmzEntryCount The maximum number of entries a KMZ file can contain. + * @param maxKmzUncompressedTotalSize The maximum size of the uncompressed KMZ file in bytes. + * @throws XmlPullParserException if file cannot be parsed + * @throws IOException if I/O error + */ + public KmlLayer(GoogleMap map, + @RawRes int resourceId, + Context context, + MarkerManager markerManager, + PolygonManager polygonManager, + PolylineManager polylineManager, + GroundOverlayManager groundOverlayManager, + Renderer.ImagesCache cache, + int maxKmzEntryCount, + long maxKmzUncompressedTotalSize) + throws XmlPullParserException, IOException { + this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, maxKmzEntryCount, maxKmzUncompressedTotalSize); + } + + /** + * 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. * @@ -140,6 +211,41 @@ public KmlLayer(GoogleMap map, GroundOverlayManager groundOverlayManager, Renderer.ImagesCache cache) throws XmlPullParserException, IOException { + this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, DEFAULT_MAX_KMZ_ENTRY_COUNT, DEFAULT_MAX_KMZ_UNCOMPRESSED_TOTAL_SIZE); + } + + /** + * 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 + * @param maxKmzEntryCount The maximum number of entries a KMZ file can contain. + * @param maxKmzUncompressedTotalSize The maximum size of the uncompressed KMZ file in bytes. + * @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, + int maxKmzEntryCount, + long maxKmzUncompressedTotalSize) + throws XmlPullParserException, IOException { if (stream == null) { throw new IllegalArgumentException("KML InputStream cannot be null"); } @@ -148,7 +254,7 @@ public KmlLayer(GoogleMap map, BufferedInputStream bis = new BufferedInputStream(stream); bis.mark(1024); ZipInputStream zip = new ZipInputStream(bis); - CountingInputStream countingStream = new CountingInputStream(zip, MAX_KMZ_UNCOMPRESSED_TOTAL_SIZE); + CountingInputStream countingStream = new CountingInputStream(zip, maxKmzUncompressedTotalSize); try { KmlParser parser = null; ZipEntry entry = zip.getNextEntry(); @@ -157,8 +263,8 @@ public KmlLayer(GoogleMap map, HashMap images = new HashMap<>(); while (entry != null) { entryCount++; - if (entryCount > MAX_KMZ_ENTRY_COUNT) { - throw new IOException("Zip bomb detected! Max number of entries exceeded: " + MAX_KMZ_ENTRY_COUNT); + if (entryCount > maxKmzEntryCount) { + throw new IOException("Zip bomb detected! Max number of entries exceeded: " + maxKmzEntryCount); } if (parser == null && entry.getName().toLowerCase().endsWith(".kml")) { parser = parseKml(countingStream);