This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 620dcb1102 fix(admin): allow application ports and pin Swagger import
DNS (#7868)
620dcb1102 is described below
commit 620dcb11022849491c3121309bd99f1bd509c95c
Author: redyyang <[email protected]>
AuthorDate: Fri Oct 23 10:07:11 2026 +0800
fix(admin): allow application ports and pin Swagger import DNS (#7868)
* fix(admin): allow application ports and pin Swagger import DNS
* fix(admin): validate IPv4 ranges in mapped IPv6 DNS answers
Apply the same IPv4 address policy when a resolver preserves ::ffff:a.b.c.d
as an explicit Inet6Address. Regression cases reject internal mapped answers in
mixed DNS results and retain public addresses at range boundaries.
* test(ci): align cryptor failures and wait for usable SDK routes
---
.../admin/config/HttpUtilsConfiguration.java | 3 +
.../config/properties/SwaggerImportProperties.java | 78 +++++++
.../service/impl/SwaggerImportServiceImpl.java | 34 ++--
.../org/apache/shenyu/admin/utils/HttpUtils.java | 23 +++
.../shenyu/admin/utils/UrlSecurityUtils.java | 225 +++++++--------------
shenyu-admin/src/main/resources/application.yml | 6 +
.../properties/SwaggerImportPropertiesTest.java | 76 +++++++
.../service/impl/SwaggerImportServiceImplTest.java | 51 ++++-
.../apache/shenyu/admin/utils/HttpUtilsTest.java | 6 +-
.../shenyu/admin/utils/UrlSecurityUtilsTest.java | 120 +++++++++++
.../admin/utils/ValidatedHttpRequestTest.java | 148 ++++++++++++++
11 files changed, 596 insertions(+), 174 deletions(-)
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/HttpUtilsConfiguration.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/HttpUtilsConfiguration.java
index 6b5990eb2c..69575d7fd2 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/HttpUtilsConfiguration.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/HttpUtilsConfiguration.java
@@ -18,7 +18,9 @@
package org.apache.shenyu.admin.config;
import org.apache.shenyu.admin.utils.HttpUtils;
+import org.apache.shenyu.admin.config.properties.SwaggerImportProperties;
import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import
org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -26,6 +28,7 @@ import org.springframework.context.annotation.Configuration;
* HTTP utilities configuration.
*/
@Configuration
+@EnableConfigurationProperties(SwaggerImportProperties.class)
public class HttpUtilsConfiguration {
/**
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/SwaggerImportProperties.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/SwaggerImportProperties.java
new file mode 100644
index 0000000000..ab17bb5cf9
--- /dev/null
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/SwaggerImportProperties.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shenyu.admin.config.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Port policy for Swagger import, MCP import and connection testing.
+ * This policy never permits private or internal destination addresses.
+ */
+@ConfigurationProperties(prefix = "shenyu.swagger")
+public class SwaggerImportProperties {
+
+ private Set<Integer> allowedPorts = Collections.emptySet();
+
+ private Set<Integer> deniedPorts = Set.of(22, 23, 3389, 25, 53, 135, 139,
445,
+ 5432, 3306, 1433, 6379, 11211, 5984, 9200, 2181, 9092, 9093,
+ 2375, 2376, 25672, 5672, 15672, 4369);
+
+ /**
+ * Get allowed ports. An empty set permits any port not denied.
+ * @return allowed ports
+ */
+ public Set<Integer> getAllowedPorts() {
+ return allowedPorts;
+ }
+
+ /**
+ * Set allowed ports. Denied ports take precedence.
+ * @param allowedPorts allowed ports
+ */
+ public void setAllowedPorts(final Set<Integer> allowedPorts) {
+ this.allowedPorts = validatedPorts(allowedPorts);
+ }
+
+ /**
+ * Get denied ports.
+ * @return denied ports
+ */
+ public Set<Integer> getDeniedPorts() {
+ return deniedPorts;
+ }
+
+ /**
+ * Replace denied ports. An empty set disables only the port deny list.
+ * @param deniedPorts denied ports
+ */
+ public void setDeniedPorts(final Set<Integer> deniedPorts) {
+ this.deniedPorts = validatedPorts(deniedPorts);
+ }
+
+ private Set<Integer> validatedPorts(final Set<Integer> ports) {
+ if (Objects.isNull(ports) || ports.stream().anyMatch(port ->
Objects.isNull(port) || port < 1 || port > 65535)) {
+ throw new IllegalArgumentException("Swagger ports must be between
1 and 65535");
+ }
+ return Collections.unmodifiableSet(new HashSet<>(ports));
+ }
+}
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImpl.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImpl.java
index d1ccd07b80..69e9bde1f2 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImpl.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImpl.java
@@ -26,9 +26,11 @@ import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.parser.OpenAPIV3Parser;
import okhttp3.Response;
+import okhttp3.Dns;
import org.apache.commons.lang3.StringUtils;
import org.apache.shenyu.admin.model.bean.UpstreamInstance;
+import org.apache.shenyu.admin.config.properties.SwaggerImportProperties;
import org.apache.shenyu.admin.model.dto.SwaggerImportRequest;
import org.apache.shenyu.admin.service.SwaggerImportService;
import org.apache.shenyu.admin.service.manager.DocManager;
@@ -45,6 +47,7 @@ import
org.apache.shenyu.register.common.dto.MetaDataRegisterDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@@ -74,6 +77,8 @@ public class SwaggerImportServiceImpl implements
SwaggerImportService {
private final HttpUtils httpUtils;
+ private final SwaggerImportProperties swaggerImportProperties;
+
@Value("${shenyu.swagger.max-body-size:10485760}")
private long maxSwaggerBodySize;
@@ -81,8 +86,14 @@ public class SwaggerImportServiceImpl implements
SwaggerImportService {
private ShenyuClientRegisterMcpServiceImpl shenyuClientRegisterMcpService;
public SwaggerImportServiceImpl(final DocManager docManager, final
HttpUtils httpUtils) {
+ this(docManager, httpUtils, new SwaggerImportProperties());
+ }
+
+ @Autowired
+ public SwaggerImportServiceImpl(final DocManager docManager, final
HttpUtils httpUtils, final SwaggerImportProperties swaggerImportProperties) {
this.docManager = docManager;
this.httpUtils = httpUtils;
+ this.swaggerImportProperties = swaggerImportProperties;
}
@Override
@@ -91,10 +102,10 @@ public class SwaggerImportServiceImpl implements
SwaggerImportService {
try {
// 1. Validate URL
- validateSwaggerUrl(request.getSwaggerUrl());
+ Dns validatedDns = validateSwaggerUrl(request.getSwaggerUrl());
// 2. Get swagger document
- String swaggerJson = fetchSwaggerDoc(request.getSwaggerUrl());
+ String swaggerJson = fetchSwaggerDoc(request.getSwaggerUrl(),
validatedDns);
// 3. Validate Swagger content and version
validateSwaggerContent(swaggerJson);
@@ -125,9 +136,9 @@ public class SwaggerImportServiceImpl implements
SwaggerImportService {
LOG.info("Start importing Mcp config: {}", request);
try {
- validateSwaggerUrl(request.getSwaggerUrl());
+ Dns validatedDns = validateSwaggerUrl(request.getSwaggerUrl());
- String swaggerJson = fetchSwaggerDoc(request.getSwaggerUrl());
+ String swaggerJson = fetchSwaggerDoc(request.getSwaggerUrl(),
validatedDns);
String namespaceId =
StringUtils.defaultIfEmpty(request.getNamespaceId(), SYS_DEFAULT_NAMESPACE_ID);
List<McpToolsRegisterDTO> mcpToolsRegisterDTOList =
buildMcpToolRegisterDTO(swaggerJson, namespaceId);
@@ -253,9 +264,8 @@ public class SwaggerImportServiceImpl implements
SwaggerImportService {
@Override
public boolean testConnection(final String swaggerUrl) {
try {
- validateSwaggerUrl(swaggerUrl);
- try (Response response = httpUtils.requestForResponse(swaggerUrl,
- Collections.emptyMap(), Collections.emptyMap(),
HttpUtils.HTTPMethod.GET, false)) {
+ Dns validatedDns = validateSwaggerUrl(swaggerUrl);
+ try (Response response =
httpUtils.requestForValidatedResponse(swaggerUrl, validatedDns)) {
return response.code() == 200;
}
} catch (Exception e) {
@@ -264,14 +274,12 @@ public class SwaggerImportServiceImpl implements
SwaggerImportService {
}
}
- private void validateSwaggerUrl(final String swaggerUrl) {
- // Use UrlSecurityUtils for SSRF protection
- UrlSecurityUtils.validateUrlForSSRF(swaggerUrl);
+ private Dns validateSwaggerUrl(final String swaggerUrl) {
+ return UrlSecurityUtils.validatedDns(swaggerUrl,
swaggerImportProperties, Dns.SYSTEM);
}
- private String fetchSwaggerDoc(final String swaggerUrl) throws IOException
{
- try (Response response = httpUtils.requestForResponse(swaggerUrl,
- Collections.emptyMap(), Collections.emptyMap(),
HttpUtils.HTTPMethod.GET, false)) {
+ private String fetchSwaggerDoc(final String swaggerUrl, final Dns
validatedDns) throws IOException {
+ try (Response response =
httpUtils.requestForValidatedResponse(swaggerUrl, validatedDns)) {
if (response.code() != 200) {
throw new RuntimeException("Failed to get Swagger document,
HTTP status code: " + response.code());
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/HttpUtils.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/HttpUtils.java
index 977b7ec9a7..5bb1a8a370 100644
--- a/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/HttpUtils.java
+++ b/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/HttpUtils.java
@@ -18,6 +18,8 @@
package org.apache.shenyu.admin.utils;
import okhttp3.Cookie;
+import okhttp3.ConnectionPool;
+import okhttp3.Dns;
import okhttp3.CookieJar;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
@@ -43,6 +45,7 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
+import java.net.Proxy;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -356,6 +359,26 @@ public class HttpUtils {
.execute();
}
+ /**
+ * Fetch an external document using DNS returned by {@link
UrlSecurityUtils#validatedDns}.
+ * The URL must be the same URL that was validated. Isolate connections
from the ordinary
+ * client, bypass proxies and reject redirects so only validated
destinations are used.
+ * @param url validated URL
+ * @param validatedDns pinned DNS for the validated URL
+ * @return response which the caller must close
+ * @throws IOException if the request fails
+ */
+ public Response requestForValidatedResponse(final String url, final Dns
validatedDns) throws IOException {
+ OkHttpClient client = httpClient.newBuilder()
+ .dns(validatedDns)
+ .proxy(Proxy.NO_PROXY)
+ .connectionPool(new ConnectionPool(0, 1, TimeUnit.SECONDS))
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .build();
+ return client.newCall(new
Request.Builder().url(url).get().build()).execute();
+ }
+
/**
* download file.
*
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/UrlSecurityUtils.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/UrlSecurityUtils.java
index cbdad61455..fcfbd18441 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/UrlSecurityUtils.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/utils/UrlSecurityUtils.java
@@ -17,204 +17,121 @@
package org.apache.shenyu.admin.utils;
+import okhttp3.Dns;
import okhttp3.HttpUrl;
+import org.apache.shenyu.admin.config.properties.SwaggerImportProperties;
import java.net.InetAddress;
import java.net.UnknownHostException;
-import java.util.Arrays;
-import java.util.HashSet;
+import java.util.List;
import java.util.Objects;
-import java.util.Set;
/**
* URL security utilities for preventing SSRF and other URL-based attacks.
*/
public final class UrlSecurityUtils {
- private static final String HTTP_PROTOCOL = "http";
-
- private static final String HTTPS_PROTOCOL = "https";
-
- /**
- * Private constructor to prevent instantiation.
- */
private UrlSecurityUtils() {
}
/**
- * Validate URL to prevent SSRF attacks.
- *
- * @param url the URL to validate
+ * Validate a URL using the default Swagger import policy.
+ * @param url URL to validate
* @throws IllegalArgumentException if the URL is not safe for external
requests
*/
public static void validateUrlForSSRF(final String url) {
- if (Objects.isNull(url) || url.trim().isEmpty()) {
- throw new IllegalArgumentException("URL cannot be empty");
- }
-
- HttpUrl parsedUrl = HttpUrl.parse(url);
- if (Objects.isNull(parsedUrl)) {
- throw new IllegalArgumentException("Invalid URL format");
- }
-
- String protocol = parsedUrl.scheme();
-
- // Only allow HTTP and HTTPS protocols
- if (!HTTP_PROTOCOL.equals(protocol) &&
!HTTPS_PROTOCOL.equals(protocol)) {
- throw new IllegalArgumentException("Only HTTP and HTTPS protocols
are allowed");
- }
-
- // Validate host for SSRF protection using the same URL parser as
request execution.
- validateHostForSSRF(parsedUrl.host(), parsedUrl.port());
+ validatedDns(url, new SwaggerImportProperties(), Dns.SYSTEM);
}
/**
- * Validate host to prevent SSRF attacks.
- *
- * @param host the host to validate
- * @param port the port to validate
+ * Validate a host using the default Swagger import policy.
+ * @param host host to validate
+ * @param port port to validate, or -1 for the default HTTP port
* @throws IllegalArgumentException if the host is not allowed
*/
public static void validateHostForSSRF(final String host, final int port) {
if (Objects.isNull(host) || host.trim().isEmpty()) {
throw new IllegalArgumentException("Host cannot be empty");
}
+ validateUrlForSSRF(new
HttpUrl.Builder().scheme("http").host(host.trim())
+ .port(port == -1 ? 80 : port).build().toString());
+ }
- String normalizedHost = host.toLowerCase().trim();
-
- // Check for localhost variations
- if (isLocalhost(normalizedHost)) {
- throw new IllegalArgumentException("Access to localhost is not
allowed");
+ /**
+ * Resolve and validate every destination once, then pin that result for
request execution.
+ * The returned DNS rejects other hostnames; callers must also disable
redirects and proxies
+ * and isolate their connection pool so a previous connection cannot
bypass this resolution.
+ * @param url URL to validate, parsed using the same parser as OkHttp
requests
+ * @param properties allowed and denied port policy
+ * @param resolver resolver used for this request
+ * @return DNS restricted to an immutable copy of the validated addresses
+ * @throws IllegalArgumentException if the URL, port or any resolved
address is not allowed
+ */
+ public static Dns validatedDns(final String url, final
SwaggerImportProperties properties, final Dns resolver) {
+ if (Objects.isNull(url) || url.trim().isEmpty()) {
+ throw new IllegalArgumentException("URL cannot be empty");
}
-
- // Check for private IP addresses
- if (isPrivateOrInternalIP(normalizedHost)) {
- throw new IllegalArgumentException("Access to private or internal
IP addresses is not allowed");
+ HttpUrl parsedUrl = HttpUrl.parse(url);
+ if (Objects.isNull(parsedUrl)) {
+ throw new IllegalArgumentException("Invalid HTTP or HTTPS URL");
}
-
- // Check for sensitive ports
- if (isSensitivePort(port)) {
- throw new IllegalArgumentException("Access to sensitive ports is
not allowed");
+ int port = parsedUrl.port();
+ if (properties.getDeniedPorts().contains(port)
+ || !properties.getAllowedPorts().isEmpty() &&
!properties.getAllowedPorts().contains(port)) {
+ throw new IllegalArgumentException("Access to this port is not
allowed");
}
-
- // Additional validation for DNS resolution
+ List<InetAddress> addresses;
try {
- InetAddress[] addresses = InetAddress.getAllByName(normalizedHost);
- for (InetAddress address : addresses) {
- if (address.isLoopbackAddress() || address.isLinkLocalAddress()
- || address.isSiteLocalAddress() ||
address.isAnyLocalAddress()) {
- throw new IllegalArgumentException("Resolved IP address is
not allowed: " + address.getHostAddress());
- }
-
- // Check resolved IP against private ranges
- if (isPrivateIPAddress(address.getHostAddress())) {
- throw new IllegalArgumentException("Resolved IP address is
private: " + address.getHostAddress());
- }
- }
+ addresses = resolver.lookup(parsedUrl.host());
} catch (UnknownHostException e) {
- throw new IllegalArgumentException("Cannot resolve host: " + host);
+ throw new IllegalArgumentException("Cannot resolve host: " +
parsedUrl.host(), e);
}
+ if (Objects.isNull(addresses) || addresses.isEmpty() ||
addresses.stream().anyMatch(UrlSecurityUtils::isInternalAddress)) {
+ throw new IllegalArgumentException("Resolved IP address is private
or internal");
+ }
+ List<InetAddress> validatedAddresses = List.copyOf(addresses);
+ return hostname -> {
+ if (!parsedUrl.host().equals(hostname)) {
+ throw new UnknownHostException("Host differs from validated
request");
+ }
+ return validatedAddresses;
+ };
}
- /**
- * Check if the host is localhost or localhost variations.
- *
- * @param host the host to check
- * @return true if the host is localhost
- */
- private static boolean isLocalhost(final String host) {
- Set<String> localhostVariations = new HashSet<>(Arrays.asList(
- "localhost", "127.0.0.1", "::1", "0.0.0.0",
"0000:0000:0000:0000:0000:0000:0000:0001"
- ));
- return localhostVariations.contains(host);
- }
-
- /**
- * Check if the host is a private or internal IP address.
- *
- * @param host the host to check
- * @return true if the host is private or internal
- */
- private static boolean isPrivateOrInternalIP(final String host) {
- // Check for IPv4 private ranges
- if (host.matches("^10\\..*")
- || host.matches("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*")
- || host.matches("^192\\.168\\..*")) {
+ private static boolean isInternalAddress(final InetAddress address) {
+ if (Objects.isNull(address) || address.isLoopbackAddress() ||
address.isLinkLocalAddress()
+ || address.isSiteLocalAddress() || address.isAnyLocalAddress()
|| address.isMulticastAddress()) {
return true;
}
-
- // Check for IPv6 private ranges
- if (host.startsWith("fc") || host.startsWith("fd")
- || host.startsWith("fe80") || "::1".equals(host)) {
- return true;
+ byte[] bytes = address.getAddress();
+ int first = bytes[0] & 0xff;
+ if (bytes.length == 4) {
+ return isInternalIpv4Address(bytes, 0);
}
-
- // Check for other internal addresses
- if (host.matches("^169\\.254\\..*")
- || host.matches("^224\\..*")
- || host.matches("^255\\..*")) {
- return true;
+ if (isIpv4MappedAddress(bytes)) {
+ return isInternalIpv4Address(bytes, 12);
}
-
- return false;
+ // Java does not classify IPv6 unique-local addresses as site-local.
+ return (first & 0xfe) == 0xfc;
}
- /**
- * Check if the resolved IP address is private.
- *
- * @param ip the IP address to check
- * @return true if the IP is private
- */
- private static boolean isPrivateIPAddress(final String ip) {
- try {
- InetAddress address = InetAddress.getByName(ip);
- return address.isSiteLocalAddress() || address.isLoopbackAddress()
- || address.isLinkLocalAddress() ||
address.isAnyLocalAddress();
- } catch (UnknownHostException e) {
+ private static boolean isIpv4MappedAddress(final byte[] bytes) {
+ if (bytes.length != 16 || bytes[10] != (byte) 0xff || bytes[11] !=
(byte) 0xff) {
return false;
}
- }
-
- /**
- * Check if the port is sensitive (commonly used for internal services).
- *
- * @param port the port to check
- * @return true if the port is sensitive
- */
- private static boolean isSensitivePort(final int port) {
- if (port == -1) {
- // Default port, will be 80 or 443
- return false;
+ for (int index = 0; index < 10; index++) {
+ if (bytes[index] != 0) {
+ return false;
+ }
}
+ return true;
+ }
- // Common sensitive ports
- Set<Integer> sensitivePorts = new HashSet<>(Arrays.asList(
- /* SSH and remote access */
- 22, 23, 3389,
- /* Email services */
- 25,
- /* DNS */
- 53,
- /* Windows services */
- 135, 139, 445,
- /* Database services */
- 5432, 3306, 1433,
- /* Cache services */
- 6379, 11211,
- /* Search and storage */
- 5984, 9200,
- /* Middleware */
- 2181, 9092, 9093,
- /* Common internal web services */
- 8080, 8081, 9090, 9091,
- /* Container services */
- 2375, 2376,
- /* Message queue */
- 25672, 5672, 15672,
- /* Other services */
- 4369
- ));
-
- return sensitivePorts.contains(port);
+ private static boolean isInternalIpv4Address(final byte[] bytes, final int
offset) {
+ int first = bytes[offset] & 0xff;
+ int second = bytes[offset + 1] & 0xff;
+ return first == 0 || first == 10 || first == 127 || first >= 224
+ || first == 169 && second == 254 || first == 172 && second >=
16 && second <= 31
+ || first == 192 && second == 168 || first == 100 && second >=
64 && second <= 127;
}
-}
+}
diff --git a/shenyu-admin/src/main/resources/application.yml
b/shenyu-admin/src/main/resources/application.yml
index 015396d931..19d88e671b 100755
--- a/shenyu-admin/src/main/resources/application.yml
+++ b/shenyu-admin/src/main/resources/application.yml
@@ -215,6 +215,12 @@ shenyu:
- /alert/report
swagger:
max-body-size: 10485760
+ # Applies to Swagger import, MCP import and test-connection. Empty allows
any non-denied port.
+ allowed-ports: []
+ # Replaces the default deny list; denied ports take precedence over
allowed-ports.
+ # 8080/8081/9090/9091 are allowed by default. All DNS answers must still
be public addresses.
+ # Redirects and proxies are disabled; the request connects only to
validated DNS addresses.
+ denied-ports: [22, 23, 3389, 25, 53, 135, 139, 445, 5432, 3306, 1433,
6379, 11211, 5984, 9200, 2181, 9092, 9093, 2375, 2376, 25672, 5672, 15672, 4369]
dashboard:
core:
onlySuperAdminPermission:
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/SwaggerImportPropertiesTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/SwaggerImportPropertiesTest.java
new file mode 100644
index 0000000000..513d3674fb
--- /dev/null
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/SwaggerImportPropertiesTest.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shenyu.admin.config.properties;
+
+import org.apache.shenyu.admin.config.HttpUtilsConfiguration;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test Swagger import port policy binding.
+ */
+public class SwaggerImportPropertiesTest {
+
+ private final ApplicationContextRunner contextRunner = new
ApplicationContextRunner().withUserConfiguration(HttpUtilsConfiguration.class);
+
+ @Test
+ public void shouldBindAllowedAndDeniedPorts() {
+
contextRunner.withPropertyValues("shenyu.swagger.allowed-ports=80,443,8080",
"shenyu.swagger.denied-ports=8080,9090")
+ .run(context -> {
+ SwaggerImportProperties properties =
context.getBean(SwaggerImportProperties.class);
+ assertEquals(Set.of(80, 443, 8080),
properties.getAllowedPorts());
+ assertEquals(Set.of(8080, 9090),
properties.getDeniedPorts());
+ });
+ }
+
+ @Test
+ public void shouldAllowExplicitEmptyDenyList() {
+ contextRunner.withPropertyValues("shenyu.swagger.denied-ports=")
+ .run(context ->
assertTrue(context.getBean(SwaggerImportProperties.class).getDeniedPorts().isEmpty()));
+ }
+
+ @Test
+ public void shouldRejectInvalidBoundPorts() {
+ contextRunner.withPropertyValues("shenyu.swagger.allowed-ports=65536")
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ contextRunner.withPropertyValues("shenyu.swagger.denied-ports=0")
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ }
+
+ @Test
+ public void shouldCopyConfigurationAndRejectInvalidValues() {
+ SwaggerImportProperties properties = new SwaggerImportProperties();
+ Set<Integer> ports = new HashSet<>(Set.of(8080));
+ properties.setAllowedPorts(ports);
+ ports.clear();
+ assertEquals(Set.of(8080), properties.getAllowedPorts());
+ assertThrows(UnsupportedOperationException.class, () ->
properties.getAllowedPorts().clear());
+ assertThrows(IllegalArgumentException.class, () ->
properties.setDeniedPorts(null));
+ assertThrows(IllegalArgumentException.class, () ->
properties.setDeniedPorts(Collections.singleton(null)));
+ assertThrows(IllegalArgumentException.class, () ->
properties.setAllowedPorts(Set.of(-1)));
+ }
+}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImplTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImplTest.java
index 313a87ad2a..ba631256f2 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImplTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/SwaggerImportServiceImplTest.java
@@ -18,12 +18,14 @@
package org.apache.shenyu.admin.service.impl;
import okhttp3.MediaType;
+import okhttp3.Dns;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
+import org.apache.shenyu.admin.config.properties.SwaggerImportProperties;
import org.apache.shenyu.admin.model.bean.DocInfo;
import org.apache.shenyu.admin.model.bean.UpstreamInstance;
import org.apache.shenyu.admin.model.dto.SwaggerImportRequest;
@@ -33,6 +35,8 @@ import org.apache.shenyu.admin.utils.HttpUtils;
import org.apache.shenyu.register.common.dto.McpToolsRegisterDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
@@ -45,6 +49,9 @@ import
org.apache.shenyu.register.mcp.common.dto.ShenyuMcpTool;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
+import java.net.InetAddress;
+import java.util.Collections;
+import java.util.Set;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -53,6 +60,8 @@ import java.util.function.Consumer;
import static
org.apache.shenyu.common.constant.Constants.SYS_DEFAULT_NAMESPACE_ID;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@@ -80,6 +89,8 @@ public class SwaggerImportServiceImplTest {
private SwaggerImportServiceImpl service;
+ private SwaggerImportProperties properties;
+
@Mock
private ShenyuClientRegisterMcpServiceImpl mcpService;
@@ -87,7 +98,8 @@ public class SwaggerImportServiceImplTest {
public void setUp() {
docManager = new RecordingDocManager();
httpUtils = new StubHttpUtils();
- service = new SwaggerImportServiceImpl(docManager, httpUtils);
+ properties = new SwaggerImportProperties();
+ service = new SwaggerImportServiceImpl(docManager, httpUtils,
properties);
ReflectionTestUtils.setField(service, "maxSwaggerBodySize",
DEFAULT_MAX_SWAGGER_BODY_SIZE);
ReflectionTestUtils.setField(service,
"shenyuClientRegisterMcpService", mcpService);
}
@@ -133,6 +145,33 @@ public class SwaggerImportServiceImplTest {
captor.getValue().getMetaDataRegisterDTO().getNamespaceId());
}
+ @ParameterizedTest
+ @ValueSource(ints = {8080, 8081, 9090, 9091})
+ public void shouldUseValidatedTransportForAllSwaggerEntryPoints(final int
port) throws IOException {
+ SwaggerImportRequest request = request();
+ request.setSwaggerUrl("http://8.8.8.8:" + port + "/v3/api-docs");
+ httpUtils.setResponse(response(responseBody(SWAGGER_JSON, -1,
JSON_UTF_8)));
+ service.importSwagger(request);
+ assertEquals(SWAGGER_JSON, docManager.getDocJson());
+ httpUtils.setResponse(response(responseBody(MCP_SWAGGER_JSON, -1,
JSON_UTF_8)));
+ service.importMcpConfig(request);
+ httpUtils.setResponse(response(responseBody(SWAGGER_JSON, -1,
JSON_UTF_8)));
+ assertTrue(service.testConnection(request.getSwaggerUrl()));
+ assertEquals(3, httpUtils.requests);
+
assertEquals(Collections.singletonList(InetAddress.getByName("8.8.8.8")),
httpUtils.dns.lookup("8.8.8.8"));
+ }
+
+ @Test
+ public void shouldApplyConfiguredPolicyToAllSwaggerEntryPoints() {
+ properties.setDeniedPorts(Set.of(8080));
+ SwaggerImportRequest request = request();
+ request.setSwaggerUrl("http://8.8.8.8:8080/v3/api-docs");
+ assertThrows(IllegalArgumentException.class, () ->
service.importSwagger(request));
+ assertThrows(IllegalArgumentException.class, () ->
service.importMcpConfig(request));
+ assertFalse(service.testConnection(request.getSwaggerUrl()));
+ assertEquals(0, httpUtils.requests);
+ }
+
private SwaggerImportRequest request() {
SwaggerImportRequest request = new SwaggerImportRequest();
request.setSwaggerUrl(SWAGGER_URL);
@@ -223,14 +262,18 @@ public class SwaggerImportServiceImplTest {
private Response response;
+ private Dns dns;
+
+ private int requests;
+
private void setResponse(final Response response) {
this.response = response;
}
@Override
- public Response requestForResponse(final String url, final Map<String,
?> form,
- final Map<String, String> header,
final HTTPMethod method,
- final boolean followRedirects) {
+ public Response requestForValidatedResponse(final String url, final
Dns validatedDns) {
+ dns = validatedDns;
+ requests++;
return response;
}
}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/HttpUtilsTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/HttpUtilsTest.java
index 07f5634dde..de1bc4e27d 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/HttpUtilsTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/HttpUtilsTest.java
@@ -23,6 +23,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
+import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -127,9 +128,8 @@ public class HttpUtilsTest {
}
@Test
- public void fileUtilsToBytesByFileExistsTest() {
- String path =
HttpUtilsTest.class.getClassLoader().getResource("./").getPath();
- File file = new File(path + "application.yml");
+ public void fileUtilsToBytesByFileExistsTest() throws URISyntaxException {
+ File file = new
File(HttpUtilsTest.class.getClassLoader().getResource("application.yml").toURI());
Assertions.assertDoesNotThrow(() -> HttpUtils.FileUtils.toBytes(file));
}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/UrlSecurityUtilsTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/UrlSecurityUtilsTest.java
index 850055d47a..c963a5826b 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/UrlSecurityUtilsTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/UrlSecurityUtilsTest.java
@@ -17,8 +17,24 @@
package org.apache.shenyu.admin.utils;
+import okhttp3.Dns;
+import org.apache.shenyu.admin.config.properties.SwaggerImportProperties;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
@@ -28,9 +44,113 @@ public class UrlSecurityUtilsTest {
private static final String PARSER_CONFUSION_PAYLOAD =
"http://127.0.0.1:6666\\@1.1.1.1";
+ @ParameterizedTest
+ @ValueSource(ints = {8080, 8081, 9090, 9091})
+ public void shouldAllowPublicApplicationPorts(final int port) {
+ assertDoesNotThrow(() ->
UrlSecurityUtils.validateUrlForSSRF("http://8.8.8.8:" + port + "/v3/api-docs"));
+ }
+
@Test
public void validateUrlForSSRFShouldRejectParserConfusionPayload() {
assertThrows(IllegalArgumentException.class,
() ->
UrlSecurityUtils.validateUrlForSSRF(PARSER_CONFUSION_PAYLOAD));
}
+
+ @ParameterizedTest
+ @ValueSource(strings = {"127.0.0.1", "127.1", "2130706433", "0.0.0.0",
"10.0.0.1", "172.16.0.1", "192.168.0.1",
+ "169.254.169.254", "100.64.0.1", "224.0.0.1", "239.1.2.3",
"255.255.255.255", "[::1]", "[::]",
+ "[fe80::1]", "[fc00::1]", "[fd00::1]", "[ff02::1]",
"[::ffff:127.0.0.1]"})
+ public void shouldRejectInternalLiteralsOnApplicationPorts(final String
host) {
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validateUrlForSSRF("http://" + host + ":8080/docs"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"127.0.0.1", "10.0.0.1", "169.254.169.254",
"100.64.0.1", "239.1.2.3", "fc00::1", "fd00::1", "ff02::1"})
+ public void shouldRejectEveryInternalDnsAnswer(final String address)
throws UnknownHostException {
+ List<InetAddress> answers =
Arrays.asList(InetAddress.getByName("8.8.8.8"), InetAddress.getByName(address));
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test:8080/docs",
+ new SwaggerImportProperties(), hostname -> answers));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"0.0.0.0", "10.0.0.1", "127.0.0.1",
"169.254.169.254", "172.16.0.1", "172.31.255.255",
+ "192.168.0.1", "100.64.0.1", "100.127.255.255", "224.0.0.1",
"239.1.2.3", "240.0.0.1", "255.255.255.255"})
+ public void shouldRejectInternalIpv4MappedIpv6DnsAnswers(final String
address) throws UnknownHostException {
+ InetAddress mapped = mappedAddress(address);
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test:8080/docs",
+ new SwaggerImportProperties(), hostname ->
Arrays.asList(InetAddress.getByName("8.8.8.8"), mapped)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"8.8.8.8", "100.63.255.255", "100.128.0.1",
"172.15.255.255", "172.32.0.1", "192.169.0.1"})
+ public void shouldAllowPublicIpv4MappedIpv6DnsAnswers(final String
address) throws UnknownHostException {
+ InetAddress mapped = mappedAddress(address);
+ Dns pinned =
UrlSecurityUtils.validatedDns("http://example.test:8080/docs",
+ new SwaggerImportProperties(), hostname ->
Collections.singletonList(mapped));
+ assertEquals(Collections.singletonList(mapped),
pinned.lookup("example.test"));
+ }
+
+ private static InetAddress mappedAddress(final String address) throws
UnknownHostException {
+ byte[] bytes = new byte[16];
+ bytes[10] = (byte) 0xff;
+ bytes[11] = (byte) 0xff;
+ System.arraycopy(InetAddress.getByName(address).getAddress(), 0,
bytes, 12, 4);
+ // Keep the 16-byte form: InetAddress.getByName normally collapses
mapped literals to IPv4.
+ return Inet6Address.getByAddress(null, bytes, -1);
+ }
+
+ @Test
+ public void shouldResolveOnlyOnceAndFreezeValidatedAnswers() throws
UnknownHostException {
+ AtomicInteger resolutions = new AtomicInteger();
+ InetAddress publicAddress = InetAddress.getByName("8.8.8.8");
+ List<InetAddress> answers = new
ArrayList<>(Collections.singletonList(publicAddress));
+ Dns pinned =
UrlSecurityUtils.validatedDns("http://fc-public.example:8080/docs", new
SwaggerImportProperties(), hostname -> {
+ resolutions.incrementAndGet();
+ return answers;
+ });
+ answers.set(0, InetAddress.getByName("127.0.0.1"));
+ assertEquals(Collections.singletonList(publicAddress),
pinned.lookup("fc-public.example"));
+ assertEquals(Collections.singletonList(publicAddress),
pinned.lookup("fc-public.example"));
+ assertEquals(1, resolutions.get());
+ assertThrows(UnsupportedOperationException.class, () ->
pinned.lookup("fc-public.example").clear());
+ assertThrows(UnknownHostException.class, () ->
pinned.lookup("other.example"));
+ }
+
+ @Test
+ public void shouldRejectEmptyNullAndUnresolvableDnsAnswers() {
+ SwaggerImportProperties policy = new SwaggerImportProperties();
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test", policy, host -> null));
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test", policy, host ->
Collections.emptyList()));
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test", policy, host ->
Collections.singletonList(null)));
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test", policy, host -> {
+ throw new UnknownHostException("unavailable");
+ }));
+ }
+
+ @Test
+ public void shouldApplyAllowAndDenyPolicyBeforeResolving() {
+ SwaggerImportProperties policy = new SwaggerImportProperties();
+ policy.setAllowedPorts(Set.of(8080, 3306));
+ Dns forbiddenLookup = host -> {
+ throw new AssertionError("Rejected port must not trigger DNS");
+ };
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test:3306", policy,
forbiddenLookup));
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://example.test:8081", policy,
forbiddenLookup));
+ assertDoesNotThrow(() ->
UrlSecurityUtils.validatedDns("http://8.8.8.8:8080", policy, Dns.SYSTEM));
+ policy.setDeniedPorts(Collections.emptySet());
+ assertDoesNotThrow(() ->
UrlSecurityUtils.validatedDns("http://8.8.8.8:3306", policy, Dns.SYSTEM));
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validatedDns("http://127.0.0.1:3306", policy, Dns.SYSTEM));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {22, 25, 53, 3306, 5432, 6379, 2181, 9200})
+ public void shouldContinueDenyingInfrastructurePorts(final int port) {
+ assertThrows(IllegalArgumentException.class, () ->
UrlSecurityUtils.validateUrlForSSRF("http://8.8.8.8:" + port));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"http://8.8.8.8", "https://8.8.8.8",
"https://[2606:4700:4700::1111]:9090"})
+ public void shouldAllowPublicAddressesAndDefaultPorts(final String url) {
+ assertDoesNotThrow(() -> UrlSecurityUtils.validateUrlForSSRF(url));
+ }
}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/ValidatedHttpRequestTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/ValidatedHttpRequestTest.java
new file mode 100644
index 0000000000..7c999bf257
--- /dev/null
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/utils/ValidatedHttpRequestTest.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shenyu.admin.utils;
+
+import com.sun.net.httpserver.HttpServer;
+import okhttp3.Dns;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.apache.shenyu.admin.config.properties.SwaggerImportProperties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.net.SocketFactory;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Exercise real HTTP transport without contacting an external server.
+ */
+public class ValidatedHttpRequestTest {
+
+ private HttpServer server;
+
+ private OkHttpClient baseClient;
+
+ private HttpUtils httpUtils;
+
+ private InetAddress publicAddress;
+
+ private final List<InetAddress> connectedAddresses = new ArrayList<>();
+
+ private final AtomicInteger privateRequests = new AtomicInteger();
+
+ private final AtomicReference<String> hostHeader = new AtomicReference<>();
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ publicAddress = InetAddress.getByName("8.8.8.8");
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/docs", exchange -> {
+ hostHeader.set(exchange.getRequestHeaders().getFirst("Host"));
+ byte[] body = "swagger document".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ server.createContext("/redirect", exchange -> {
+ exchange.getResponseHeaders().add("Location", "http://127.0.0.1:"
+ server.getAddress().getPort() + "/private");
+ exchange.sendResponseHeaders(302, -1);
+ exchange.close();
+ });
+ server.createContext("/private", exchange -> {
+ privateRequests.incrementAndGet();
+ exchange.sendResponseHeaders(200, -1);
+ exchange.close();
+ });
+ server.start();
+ SocketFactory socketFactory = mock(SocketFactory.class);
+ when(socketFactory.createSocket()).thenAnswer(invocation -> new
Socket() {
+ @Override
+ public void connect(final SocketAddress endpoint, final int
timeout) throws IOException {
+ connectedAddresses.add(((InetSocketAddress)
endpoint).getAddress());
+ // Record OkHttp's actual selected destination, then route
only this test socket to the fixture.
+ super.connect(server.getAddress(), timeout);
+ }
+ });
+ baseClient = new OkHttpClient.Builder().socketFactory(socketFactory)
+ .dns(host ->
Collections.singletonList(InetAddress.getByName("127.0.0.1")))
+ .proxy(Proxy.NO_PROXY).build();
+ httpUtils = new HttpUtils();
+ httpUtils.setHttpClient(baseClient);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ baseClient.connectionPool().evictAll();
+ server.stop(0);
+ }
+
+ @Test
+ public void
shouldUsePinnedAddressesWithoutRepeatingDnsOrReusingUnvalidatedConnections()
throws IOException {
+ String url = url("/docs");
+ // Populate the ordinary client's pool with a connection to an
internal DNS answer.
+ try (Response response = baseClient.newCall(new
Request.Builder().url(url).build()).execute()) {
+ assertEquals("swagger document", response.body().string());
+ }
+ AtomicInteger resolutions = new AtomicInteger();
+ Dns changingDns = host ->
Collections.singletonList(resolutions.getAndIncrement() == 0
+ ? publicAddress : InetAddress.getByName("127.0.0.1"));
+ Dns validatedDns = UrlSecurityUtils.validatedDns(url, new
SwaggerImportProperties(), changingDns);
+ try (Response response = httpUtils.requestForValidatedResponse(url,
validatedDns)) {
+ assertEquals(200, response.code());
+ assertEquals("swagger document", response.body().string());
+ }
+ assertEquals(1, resolutions.get());
+ assertEquals(2, connectedAddresses.size());
+ assertEquals(publicAddress, connectedAddresses.get(1));
+ assertEquals("docs.example:" + server.getAddress().getPort(),
hostHeader.get());
+ }
+
+ @Test
+ public void shouldBypassInheritedProxyAndNotFollowRedirects() throws
IOException {
+ httpUtils.setHttpClient(baseClient.newBuilder()
+ .proxy(new Proxy(Proxy.Type.HTTP, new
InetSocketAddress("127.0.0.1", 1))).build());
+ String url = url("/redirect");
+ Dns validatedDns = UrlSecurityUtils.validatedDns(url, new
SwaggerImportProperties(), host -> Collections.singletonList(publicAddress));
+ try (Response response = httpUtils.requestForValidatedResponse(url,
validatedDns)) {
+ assertEquals(302, response.code());
+ }
+ assertEquals(0, privateRequests.get());
+ assertEquals(Collections.singletonList(publicAddress),
connectedAddresses);
+ }
+
+ private String url(final String path) {
+ return "http://docs.example:" + server.getAddress().getPort() + path;
+ }
+}