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 d8b87faf8..d76f45481 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 @@ -47,9 +47,12 @@ */ public class KmlLayer extends Layer { + 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 @@ -60,12 +63,30 @@ 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, null); + this(map, context.getResources().openRawResource(resourceId), context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, null, 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. * + * @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, 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 @@ -76,14 +97,32 @@ 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, null); + this(map, stream, context, new MarkerManager(map), new PolygonManager(map), new PolylineManager(map), new GroundOverlayManager(map), null, null, 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. * + * @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, 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. * @@ -107,14 +146,49 @@ public KmlLayer(GoogleMap map, GroundOverlayManager groundOverlayManager, Renderer.ImagesCache cache) throws XmlPullParserException, IOException { - this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null); + this(map, context.getResources().openRawResource(resourceId), context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null, 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 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, 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. * @@ -138,14 +212,49 @@ public KmlLayer(GoogleMap map, GroundOverlayManager groundOverlayManager, Renderer.ImagesCache cache) throws XmlPullParserException, IOException { - this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null); + this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, null, 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 { + this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, 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. * @@ -171,6 +280,43 @@ public KmlLayer(GoogleMap map, Renderer.ImagesCache cache, @Nullable KmlUrlSanitizer urlSanitizer) throws XmlPullParserException, IOException { + this(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, cache, urlSanitizer, 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 urlSanitizer sanitizer to be used for external URLs + * @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, + @Nullable KmlUrlSanitizer urlSanitizer, + int maxKmzEntryCount, + long maxKmzUncompressedTotalSize) + throws XmlPullParserException, IOException { if (stream == null) { throw new IllegalArgumentException("KML InputStream cannot be null"); } @@ -179,16 +325,22 @@ public KmlLayer(GoogleMap map, BufferedInputStream bis = new BufferedInputStream(stream); bis.mark(1024); ZipInputStream zip = new ZipInputStream(bis); + CountingInputStream countingStream = new CountingInputStream(zip, maxKmzUncompressedTotalSize); 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 > maxKmzEntryCount) { + throw new IOException("Zip bomb detected! Max number of entries exceeded: " + maxKmzEntryCount); + } 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 { @@ -216,6 +368,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..afc1c6d69 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java @@ -0,0 +1,92 @@ +/* + * 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 + * + * 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 + } + } +}