Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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 java.net.InetAddress;
import java.net.URI;
import java.util.Locale;

/**
* Default, secure {@link KmlUrlSanitizer} used when a layer loads remote icons/overlays.
*
* <p>Icon and GroundOverlay {@code <href>} values come from the parsed KML/GeoJSON document,
* which is frequently untrusted (downloaded, user-provided, or shared). Fetching those URLs
* without validation is a Server-Side Request Forgery (SSRF) primitive: a crafted document can
* make the app issue requests to loopback, link-local (including the {@code 169.254.169.254}
* metadata endpoint), or private-network hosts reachable from the device.
*
* <p>This sanitizer allows only {@code http}/{@code https} URLs whose host does not resolve to a
* loopback, any-local, link-local, site-local (RFC 1918), or multicast address, and returns
* {@code null} (block) otherwise. Public icon/overlay URLs continue to load unchanged.
*/
public class DefaultKmlUrlSanitizer implements KmlUrlSanitizer {

@Override
public String sanitizeUrl(String url) {
if (url == null) {
return null;
}
final URI uri;
try {
uri = new URI(url);
} catch (Exception e) {
return null;
}

final String scheme = uri.getScheme();
if (scheme == null) {
return null;
}
final String lowerScheme = scheme.toLowerCase(Locale.ROOT);
if (!lowerScheme.equals("http") && !lowerScheme.equals("https")) {
return null;
}

final String host = uri.getHost();
if (host == null || host.isEmpty()) {
return null;
}

try {
// Block if ANY resolved address is internal, to defeat split-horizon / multi-A tricks.
for (InetAddress address : InetAddress.getAllByName(host)) {
if (address.isLoopbackAddress()
|| address.isAnyLocalAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return null;
}
}
} catch (Exception e) {
// Unresolvable host: fail closed.
return null;
}

return url;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package com.google.maps.android.data.renderer
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import com.google.maps.android.data.kml.DefaultKmlUrlSanitizer
import com.google.maps.android.data.kml.KmlUrlSanitizer
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
Expand All @@ -35,6 +37,11 @@ import java.util.concurrent.ConcurrentHashMap
*/
open class UrlIconProvider(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
// Icon/overlay hrefs originate from the (frequently untrusted) KML/GeoJSON document. The
// sanitizer is applied before every fetch to prevent SSRF; the secure default blocks
// non-http(s) schemes and hosts resolving to loopback/link-local/private ranges. Pass a
// custom implementation to widen/narrow the policy, or `null` to disable (not recommended).
private val urlSanitizer: KmlUrlSanitizer? = DefaultKmlUrlSanitizer(),
) : IconProvider {
private val memoryCache: LruCache<String, Bitmap>
private val inFlight = ConcurrentHashMap<String, Deferred<Bitmap?>>()
Expand Down Expand Up @@ -93,8 +100,18 @@ open class UrlIconProvider(
protected open suspend fun loadBitmapFromUrl(urlString: String): Bitmap? =
withContext(dispatcher) {
try {
val url = URL(urlString)
// Validate the (untrusted) href before issuing any request. `null` => blocked.
val safeUrl =
if (urlSanitizer != null) {
urlSanitizer.sanitizeUrl(urlString) ?: return@withContext null
} else {
urlString
}
val url = URL(safeUrl)
val connection = url.openConnection() as HttpURLConnection
// Do not auto-follow redirects: a 30x to an internal host would otherwise bypass
// the sanitizer's host check (redirect-based SSRF).
connection.instanceFollowRedirects = false
connection.doInput = true
connection.connect()
val input = connection.inputStream
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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 static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import org.junit.Test;

/**
* Unit tests for {@link DefaultKmlUrlSanitizer}. Uses IP-literal hosts so the tests are
* hermetic (no DNS/network required).
*/
public class DefaultKmlUrlSanitizerTest {

private final DefaultKmlUrlSanitizer sanitizer = new DefaultKmlUrlSanitizer();

@Test
public void blocksLoopback() {
assertNull(sanitizer.sanitizeUrl("http://127.0.0.1:8080/INTERNAL-ADMIN"));
assertNull(sanitizer.sanitizeUrl("http://[::1]/x"));
}

@Test
public void blocksLinkLocalMetadataEndpoint() {
// 169.254.169.254 is the cloud/instance metadata endpoint.
assertNull(sanitizer.sanitizeUrl("http://169.254.169.254/latest/meta-data/"));
}

@Test
public void blocksPrivateRfc1918() {
assertNull(sanitizer.sanitizeUrl("http://10.0.0.5/internal"));
assertNull(sanitizer.sanitizeUrl("http://192.168.1.1/router"));
assertNull(sanitizer.sanitizeUrl("http://172.16.0.1/x"));
}

@Test
public void blocksAnyLocalAddress() {
assertNull(sanitizer.sanitizeUrl("http://0.0.0.0/x"));
}

@Test
public void blocksNonHttpSchemes() {
assertNull(sanitizer.sanitizeUrl("file:///etc/passwd"));
assertNull(sanitizer.sanitizeUrl("ftp://8.8.8.8/x"));
assertNull(sanitizer.sanitizeUrl("gopher://8.8.8.8/x"));
}

@Test
public void blocksMalformedOrHostless() {
assertNull(sanitizer.sanitizeUrl(null));
assertNull(sanitizer.sanitizeUrl("not a url"));
assertNull(sanitizer.sanitizeUrl("http://"));
}

@Test
public void allowsPublicHttpAndHttps() {
// 8.8.8.8 is a public address (not loopback/link-local/site-local/multicast/any-local).
assertEquals("http://8.8.8.8/mapfiles/icon.png",
sanitizer.sanitizeUrl("http://8.8.8.8/mapfiles/icon.png"));
assertEquals("https://8.8.8.8/mapfiles/icon.png",
sanitizer.sanitizeUrl("https://8.8.8.8/mapfiles/icon.png"));
}
}