Skip to content

Commit 2b9cd44

Browse files
fix: Perform additional security checks on overrideServerUrl API (#2408)
1 parent 6b7597f commit 2b9cd44

4 files changed

Lines changed: 134 additions & 3 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package io.appium.java_client.internal;
16+
17+
import org.openqa.selenium.SessionNotCreatedException;
18+
19+
import java.net.InetAddress;
20+
import java.net.URL;
21+
import java.net.UnknownHostException;
22+
23+
/**
24+
* Validates URLs used with {@code overrideServerUrl} (for example after a {@code directConnect}
25+
* response). Refuses the override when any resolved address is loopback, link-local (including
26+
* typical cloud metadata IPv4 link-local space), unspecified, or multicast.
27+
*
28+
* <p>This is not a full &quot;public internet only&quot; policy: RFC 1918 private space, shared
29+
* address space ({@code 100.64.0.0/10}), IPv6 unique-local ({@code fc00::/7}), and other addresses
30+
* outside the checks above are still accepted. Stricter control belongs at the application or
31+
* network layer (allowlists, egress rules, etc.).
32+
*/
33+
public final class DirectConnectUrlSafety {
34+
35+
private DirectConnectUrlSafety() {
36+
}
37+
38+
/**
39+
* Ensures the given URL's host does not resolve to loopback, link-local, unspecified, or
40+
* multicast addresses (see class documentation for what is still allowed).
41+
*
42+
* @param url candidate server URL
43+
* @throws SessionNotCreatedException if the host is missing, cannot be resolved, or resolves
44+
* to any address in the disallowed categories
45+
*/
46+
public static void requireSafeOverrideTarget(URL url) throws SessionNotCreatedException {
47+
String host = url.getHost();
48+
if (host == null || host.isEmpty()) {
49+
throw new SessionNotCreatedException(
50+
"Refusing to override the server URL: the URL must include a non-empty host.");
51+
}
52+
53+
final InetAddress[] addresses;
54+
try {
55+
addresses = InetAddress.getAllByName(host);
56+
} catch (UnknownHostException e) {
57+
throw new SessionNotCreatedException(
58+
"Refusing to override the server URL: cannot resolve host '" + host + "'.", e);
59+
}
60+
61+
for (InetAddress address : addresses) {
62+
if (isDisallowed(address)) {
63+
throw new SessionNotCreatedException(String.format(
64+
"Refusing to override the server URL: host '%s' resolves to %s, which is not "
65+
+ "allowed (loopback, link-local, unspecified, or multicast address).",
66+
host,
67+
address.getHostAddress()));
68+
}
69+
}
70+
}
71+
72+
private static boolean isDisallowed(InetAddress address) {
73+
return address.isLoopbackAddress()
74+
|| address.isLinkLocalAddress()
75+
|| address.isAnyLocalAddress()
76+
|| address.isMulticastAddress();
77+
}
78+
}

src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import com.google.common.base.Throwables;
2020
import io.appium.java_client.AppiumClientConfig;
21+
import io.appium.java_client.internal.DirectConnectUrlSafety;
2122
import io.appium.java_client.internal.ReflectionHelpers;
2223
import lombok.Getter;
2324
import org.jspecify.annotations.NullMarked;
@@ -155,9 +156,16 @@ protected HttpClient getClient() {
155156
* Override the http client in the HttpCommandExecutor class with a new http client instance with the given URL.
156157
* It uses the same http client factory and client config for the new http client instance
157158
* if the constructor got them.
158-
* @param serverUrl A url to override.
159+
*
160+
* @param serverUrl URL to use for subsequent HTTP requests. Before switching clients, the host is
161+
* resolved and the override is refused if any resolved address is loopback,
162+
* link-local (including IPv4 link-local such as metadata-service ranges),
163+
* unspecified ({@code 0.0.0.0} / {@code ::}), or multicast. Private (RFC 1918),
164+
* carrier-grade NAT ({@code 100.64.0.0/10}), IPv6 unique-local ({@code fc00::/7}),
165+
* and other non-special addresses are not rejected by this check.
159166
*/
160167
protected void overrideServerUrl(URL serverUrl) {
168+
DirectConnectUrlSafety.requireSafeOverrideTarget(serverUrl);
161169
HttpClient newClient = getHttpClientFactory().createClient(appiumClientConfig.baseUrl(serverUrl));
162170
setPrivateFieldValue(HttpCommandExecutor.class, "client", newClient);
163171
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.appium.java_client.internal;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.params.ParameterizedTest;
5+
import org.junit.jupiter.params.provider.ValueSource;
6+
import org.openqa.selenium.SessionNotCreatedException;
7+
8+
import java.net.MalformedURLException;
9+
import java.net.URL;
10+
11+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
12+
import static org.junit.jupiter.api.Assertions.assertThrows;
13+
14+
class DirectConnectUrlSafetyTest {
15+
16+
@Test
17+
void allowsDocumentationNetIpLiteral() throws MalformedURLException {
18+
// RFC 5737 TEST-NET-3; parsed locally without DNS
19+
assertDoesNotThrow(() -> DirectConnectUrlSafety.requireSafeOverrideTarget(
20+
new URL("https://203.0.113.1/wd/hub")));
21+
}
22+
23+
@ParameterizedTest(name = "[{index}] {0}")
24+
@ValueSource(strings = {
25+
"https://127.0.0.1:4443/wd/hub",
26+
"https://localhost:4443/wd/hub",
27+
"https://169.254.169.254/wd/hub",
28+
"https://[::1]:4443/wd/hub",
29+
"https://0.0.0.0:4443/wd/hub",
30+
"https://[::]:4443/wd/hub",
31+
"https://224.0.0.1:4443/wd/hub",
32+
})
33+
void rejectsDisallowedOverrideTargets(String urlSpec) throws MalformedURLException {
34+
assertThrows(SessionNotCreatedException.class,
35+
() -> DirectConnectUrlSafety.requireSafeOverrideTarget(new URL(urlSpec)));
36+
}
37+
}

src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import io.appium.java_client.AppiumClientConfig;
44
import io.appium.java_client.MobileCommand;
55
import org.junit.jupiter.api.Test;
6+
import org.openqa.selenium.SessionNotCreatedException;
67

78
import java.net.MalformedURLException;
89
import java.net.URL;
910

1011
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
1112
import static org.junit.jupiter.api.Assertions.assertNotNull;
13+
import static org.junit.jupiter.api.Assertions.assertThrows;
1214

1315
class AppiumCommandExecutorTest {
1416
private static final String APPIUM_URL = "https://appium.example.com";
@@ -35,7 +37,13 @@ void getHttpClientFactory() {
3537
}
3638

3739
@Test
38-
void overrideServerUrl() {
39-
assertDoesNotThrow(() -> createExecutor().overrideServerUrl(new URL("https://direct.example.com")));
40+
void overrideServerUrl() throws MalformedURLException {
41+
assertDoesNotThrow(() -> createExecutor().overrideServerUrl(new URL("https://203.0.113.1/wd/hub")));
42+
}
43+
44+
@Test
45+
void overrideServerUrlRejectsLoopbackTarget() throws MalformedURLException {
46+
assertThrows(SessionNotCreatedException.class,
47+
() -> createExecutor().overrideServerUrl(new URL("https://127.0.0.1:4443/wd/hub")));
4048
}
4149
}

0 commit comments

Comments
 (0)