diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/share/RequestLoggerBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/share/RequestLoggerBeans.java index ac9ddee2e9..0d4b37f900 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/share/RequestLoggerBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/share/RequestLoggerBeans.java @@ -1,18 +1,29 @@ package org.stellar.anchor.platform.component.share; import jakarta.servlet.Filter; +import lombok.SneakyThrows; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.stellar.anchor.platform.config.AppLoggingConfig; +import org.stellar.anchor.platform.config.SepServerConfig; +import org.stellar.anchor.platform.utils.ClientIpResolver; import org.stellar.anchor.platform.utils.RequestLoggerFilter; +import org.stellar.anchor.platform.utils.TrustedProxies; @Configuration public class RequestLoggerBeans { @Bean - public FilterRegistrationBean requestLoggerFilter(AppLoggingConfig appLoggingConfig) { + @SneakyThrows + public ClientIpResolver clientIpResolver(SepServerConfig sepServerConfig) { + return new ClientIpResolver(TrustedProxies.parse(sepServerConfig.getTrustedProxies())); + } + + @Bean + public FilterRegistrationBean requestLoggerFilter( + AppLoggingConfig appLoggingConfig, ClientIpResolver clientIpResolver) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new RequestLoggerFilter(appLoggingConfig)); + registrationBean.setFilter(new RequestLoggerFilter(appLoggingConfig, clientIpResolver)); registrationBean.addUrlPatterns("/"); registrationBean.addUrlPatterns("/*"); return registrationBean; diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/share/SharedConfigBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/share/SharedConfigBeans.java index 0b758447b0..15a748de61 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/share/SharedConfigBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/share/SharedConfigBeans.java @@ -10,6 +10,7 @@ import org.stellar.anchor.platform.config.PlatformServerConfig; import org.stellar.anchor.platform.config.PropertyDataConfig; import org.stellar.anchor.platform.config.PropertySecretConfig; +import org.stellar.anchor.platform.config.SepServerConfig; @Configuration public class SharedConfigBeans { @@ -38,6 +39,12 @@ PlatformServerConfig platformServerConfig(PropertySecretConfig secretConfig) { return new PlatformServerConfig(secretConfig); } + @Bean + @ConfigurationProperties(prefix = "sep-server") + SepServerConfig sepServerConfig() { + return new SepServerConfig(); + } + @Bean @ConfigurationProperties(prefix = "app-logging") AppLoggingConfig appLoggingConfig() { diff --git a/platform/src/main/java/org/stellar/anchor/platform/config/SepServerConfig.java b/platform/src/main/java/org/stellar/anchor/platform/config/SepServerConfig.java new file mode 100644 index 0000000000..a8a152eb85 --- /dev/null +++ b/platform/src/main/java/org/stellar/anchor/platform/config/SepServerConfig.java @@ -0,0 +1,10 @@ +package org.stellar.anchor.platform.config; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +@Data +public class SepServerConfig { + List trustedProxies = new ArrayList<>(); +} diff --git a/platform/src/main/java/org/stellar/anchor/platform/configurator/SepConfigManager.java b/platform/src/main/java/org/stellar/anchor/platform/configurator/SepConfigManager.java index 8b3716a1cf..69d5c592fc 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/configurator/SepConfigManager.java +++ b/platform/src/main/java/org/stellar/anchor/platform/configurator/SepConfigManager.java @@ -2,11 +2,14 @@ import static org.stellar.anchor.util.Log.info; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import lombok.SneakyThrows; import org.jetbrains.annotations.NotNull; import org.springframework.context.ConfigurableApplicationContext; import org.stellar.anchor.api.exception.InvalidConfigException; +import org.stellar.anchor.platform.utils.TrustedProxies; public class SepConfigManager extends ConfigManager { private static final SepConfigManager sepConfigManager = new SepConfigManager(); @@ -57,6 +60,18 @@ void updateSpringEnv(ConfigMap config) throws InvalidConfigException { @Override void validate(ConfigMap config) throws InvalidConfigException { - // noop + TrustedProxies.parse(trustedProxyEntries(config)); + } + + /** The entries in either spelling: a comma-separated value or an indexed list. */ + static List trustedProxyEntries(ConfigMap config) { + String key = "sep_server.trusted_proxies"; + List entries = new ArrayList<>(); + for (String name : config.names()) { + if (name.equals(key) || name.startsWith(key + "[")) { + entries.addAll(Arrays.asList(config.getString(name).split(","))); + } + } + return entries; } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/ClientIpResolver.java b/platform/src/main/java/org/stellar/anchor/platform/utils/ClientIpResolver.java new file mode 100644 index 0000000000..ccbb94427a --- /dev/null +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/ClientIpResolver.java @@ -0,0 +1,70 @@ +package org.stellar.anchor.platform.utils; + +import com.google.common.net.InetAddresses; +import jakarta.servlet.http.HttpServletRequest; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Derives the client IP address of a request. The TCP peer address is the answer unless the peer is + * a trusted proxy; only then is {@code X-Forwarded-For} believed, and read from the right, skipping + * trusted proxies, because the entries on the right were written by infrastructure the operator + * controls and the ones on the left by the caller. + */ +public class ClientIpResolver { + private static final String FORWARDED_FOR = "X-Forwarded-For"; + + private final TrustedProxies trustedProxies; + + public ClientIpResolver(TrustedProxies trustedProxies) { + this.trustedProxies = trustedProxies; + } + + public String resolve(HttpServletRequest request) { + String peer = request.getRemoteAddr(); + String ip = trustedProxies.contains(peer) ? resolveBehindProxy(request, peer) : peer; + return canonical(ip); + } + + /** + * The canonical form of the address: IPv6 compressed, IPv4-mapped IPv6 as IPv4, IPv6 loopback as + * 127.0.0.1. + */ + private static String canonical(String ip) { + if (!InetAddresses.isInetAddress(ip)) return ip; + InetAddress address = InetAddresses.forString(ip); + if (address instanceof Inet6Address) { + return address.isLoopbackAddress() ? "127.0.0.1" : InetAddresses.toAddrString(address); + } + // Guava returns an Inet4Address for an IPv4-mapped IPv6 literal, so this also unwraps it. + return InetAddresses.toAddrString(address); + } + + private String resolveBehindProxy(HttpServletRequest request, String peer) { + List entries = forwardedFor(request); + if (entries.isEmpty()) return peer; + + for (int i = entries.size() - 1; i >= 0; i--) { + String entry = entries.get(i); + // A hostname or any other non-literal is never trusted and never resolved. + if (!InetAddresses.isInetAddress(entry)) return peer; + if (!trustedProxies.contains(entry)) return entry; + } + // Every hop is a trusted proxy: the originator is inside the trusted network. + return entries.get(0); + } + + private static List forwardedFor(HttpServletRequest request) { + List entries = new ArrayList<>(); + for (String line : Collections.list(request.getHeaders(FORWARDED_FOR))) { + if (line.isBlank() || "unknown".equalsIgnoreCase(line.trim())) continue; + for (String entry : line.split(",", -1)) { + entries.add(entry.trim()); + } + } + return entries; + } +} diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/RequestLoggerFilter.java b/platform/src/main/java/org/stellar/anchor/platform/utils/RequestLoggerFilter.java index bd6203722d..ce1a7a7088 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/RequestLoggerFilter.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/RequestLoggerFilter.java @@ -23,17 +23,18 @@ public class RequestLoggerFilter extends OncePerRequestFilter { public static final String ATTRIBUTE_CLIENT_IP_ADDRESS = "clientIpAddress"; private final AppLoggingConfig appLoggingConfig; + private final ClientIpResolver clientIpResolver; - public RequestLoggerFilter(AppLoggingConfig appLoggingConfig) { + public RequestLoggerFilter(AppLoggingConfig appLoggingConfig, ClientIpResolver clientIpResolver) { this.appLoggingConfig = appLoggingConfig; + this.clientIpResolver = clientIpResolver; } public void doFilterWithLogging( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { long startTime = System.currentTimeMillis(); - String clientIpAddress = getClientIpAddress(request); - request.setAttribute(ATTRIBUTE_CLIENT_IP_ADDRESS, clientIpAddress); + String clientIpAddress = (String) request.getAttribute(ATTRIBUTE_CLIENT_IP_ADDRESS); // ========= Log request and response payload ("body") ======== // We CANNOT simply read the request payload here, because then the InputStream would be @@ -69,7 +70,7 @@ public void doFilterWithLogging( Log.infoF( " {} - {} {} {}", - getClientIpAddress(request), + clientIpAddress, request.getMethod(), request.getRequestURI(), response.getStatus()); @@ -86,6 +87,9 @@ protected void doFilterInternal( @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException { + // The SEP controllers read the client IP from this attribute, so it is set whether or not the + // request logger is enabled. + request.setAttribute(ATTRIBUTE_CLIENT_IP_ADDRESS, clientIpResolver.resolve(request)); if (!appLoggingConfig.isRequestLoggerEnabled()) { filterChain.doFilter(request, response); @@ -116,38 +120,4 @@ private String getBody(ContentCachingResponseWrapper wrappedResponse) { return "Unsupported Encoding"; } } - - private static String getClientIpAddress(HttpServletRequest request) { - final String[] IP_HEADER_CANDIDATES = { - "X-Forwarded-For", - "X-Real-IP", - "Proxy-Client-IP", - "WL-Proxy-Client-IP", - "HTTP_X_FORWARDED_FOR", - "HTTP_X_FORWARDED", - "HTTP_X_CLUSTER_CLIENT_IP", - "HTTP_CLIENT_IP", - "HTTP_FORWARDED_FOR", - "HTTP_FORWARDED", - "HTTP_VIA", - "REMOTE_ADDR" - }; - - String ip = request.getRemoteAddr(); - - for (String headerName : IP_HEADER_CANDIDATES) { - String headerValue = request.getHeader(headerName); - if (headerValue != null - && headerValue.length() != 0 - && !"unknown".equalsIgnoreCase(headerValue)) { - ip = headerValue.split(",")[0]; - break; - } - } - - if (ip.equals("0:0:0:0:0:0:0:1")) { - ip = "127.0.0.1"; - } - return ip; - } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/TrustedProxies.java b/platform/src/main/java/org/stellar/anchor/platform/utils/TrustedProxies.java new file mode 100644 index 0000000000..5bb6b918a8 --- /dev/null +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/TrustedProxies.java @@ -0,0 +1,91 @@ +package org.stellar.anchor.platform.utils; + +import com.google.common.net.InetAddresses; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.stellar.anchor.api.exception.InvalidConfigException; + +/** + * The proxies whose forwarding headers are believed, as declared in {@code + * sep_server.trusted_proxies}. An entry is an IPv4/IPv6 address or a CIDR range. Entries and probed + * addresses are matched as literals only: a hostname is never resolved. + */ +public class TrustedProxies { + private final List ranges; + + private TrustedProxies(List ranges) { + this.ranges = ranges; + } + + public static TrustedProxies parse(List entries) throws InvalidConfigException { + List ranges = new ArrayList<>(); + for (String entry : entries) { + String trimmed = entry.trim(); + if (trimmed.isEmpty()) continue; + ranges.add(parseRange(trimmed)); + } + return new TrustedProxies(ranges); + } + + public boolean isEmpty() { + return ranges.isEmpty(); + } + + /** + * @return true if the literal IP address equals or lies inside any entry. + */ + public boolean contains(String ip) { + if (ip == null || !InetAddresses.isInetAddress(ip)) return false; + byte[] address = InetAddresses.forString(ip).getAddress(); + return ranges.stream().anyMatch(range -> range.contains(address)); + } + + private static Range parseRange(String entry) throws InvalidConfigException { + String addressPart = entry; + Integer prefixLength = null; + int slash = entry.indexOf('/'); + if (slash >= 0) { + addressPart = entry.substring(0, slash); + String prefix = entry.substring(slash + 1); + // Digits only: Integer.parseInt alone would accept "-0" (a /0 that trusts everyone) and "+8". + if (!prefix.matches("[0-9]{1,3}")) throw invalid(entry); + prefixLength = Integer.parseInt(prefix); + } + if (!InetAddresses.isInetAddress(addressPart)) throw invalid(entry); + InetAddress address = InetAddresses.forString(addressPart); + int bits = address.getAddress().length * 8; + if (prefixLength == null) prefixLength = bits; + if (prefixLength < 0 || prefixLength > bits) throw invalid(entry); + return new Range(address.getAddress(), prefixLength); + } + + private static InvalidConfigException invalid(String entry) { + return new InvalidConfigException( + String.format( + "Invalid entry [%s] in sep_server.trusted_proxies: expected an IPv4/IPv6 address or CIDR range", + entry)); + } + + private static class Range { + private final byte[] network; + private final int prefixLength; + + Range(byte[] network, int prefixLength) { + this.network = network; + this.prefixLength = prefixLength; + } + + boolean contains(byte[] address) { + // An IPv4 address is only compared against IPv4 ranges, an IPv6 one against IPv6 ranges. + if (address.length != network.length) return false; + int fullBytes = prefixLength / 8; + if (!Arrays.equals(address, 0, fullBytes, network, 0, fullBytes)) return false; + int remainingBits = prefixLength % 8; + if (remainingBits == 0) return true; + int mask = 0xFF << (8 - remainingBits); + return (address[fullBytes] & mask) == (network[fullBytes] & mask); + } + } +} diff --git a/platform/src/main/resources/config/anchor-config-default-values.yaml b/platform/src/main/resources/config/anchor-config-default-values.yaml index 82cb14b674..cea8dbdf69 100644 --- a/platform/src/main/resources/config/anchor-config-default-values.yaml +++ b/platform/src/main/resources/config/anchor-config-default-values.yaml @@ -230,6 +230,16 @@ sep_server: # The management_server_port is the port used by Spring actuator. # https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html management_server_port: 9080 + # The proxies whose `X-Forwarded-For` header the SEP server believes when it records the client IP address + # (`request_client_ip_address`) of a SEP-6, SEP-24 or SEP-31 transaction. Each item is an IPv4 or IPv6 + # address or a CIDR range, compared against the TCP peer address of the request. + # Without a trusted proxy the recorded address is the TCP peer address and every forwarding header is ignored, + # so behind a proxy or load balancer the recorded address is the proxy's address until the proxy is listed here. + # Every host inside a listed range is trusted, not only the proxy: prefer the proxy's own addresses and use a wide + # CIDR range (such as 10.0.0.0/8) only when nothing else that can reach the SEP server lives inside it. + # Ex: trusted_proxies: [10.0.0.0/8, 2001:db8::/32] + # Ex: trusted_proxies: 10.0.0.0/8,2001:db8::/32 + trusted_proxies: ###################### # SEP-1 Configuration diff --git a/platform/src/main/resources/config/anchor-config-schema-v1.yaml b/platform/src/main/resources/config/anchor-config-schema-v1.yaml index b0b654d1d2..6d4d87d77f 100644 --- a/platform/src/main/resources/config/anchor-config-schema-v1.yaml +++ b/platform/src/main/resources/config/anchor-config-schema-v1.yaml @@ -130,6 +130,7 @@ sep6.more_info_url.txn_fields: sep_server.context_path: sep_server.management_server_port: sep_server.port: +sep_server.trusted_proxies: stellar_network.horizon_url: stellar_network.network: stellar_network.rpc_auth.type: diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/component/share/RequestLoggerBeansTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/component/share/RequestLoggerBeansTest.kt new file mode 100644 index 0000000000..f31fd3c72b --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/component/share/RequestLoggerBeansTest.kt @@ -0,0 +1,66 @@ +package org.stellar.anchor.platform.component.share + +import java.util.function.Supplier +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.stellar.anchor.platform.config.AppLoggingConfig +import org.stellar.anchor.platform.config.SepServerConfig +import org.stellar.anchor.platform.utils.ClientIpResolver +import org.stellar.anchor.platform.utils.RequestLoggerFilter.ATTRIBUTE_CLIENT_IP_ADDRESS + +/** `sep_server.trusted_proxies` must reach both the resolver and the registered filter. */ +class RequestLoggerBeansTest { + private fun runner(trustedProxies: List) = + ApplicationContextRunner() + .withUserConfiguration(RequestLoggerBeans::class.java) + .withBean(AppLoggingConfig::class.java, Supplier { AppLoggingConfig() }) + .withBean( + SepServerConfig::class.java, + Supplier { SepServerConfig().apply { this.trustedProxies = trustedProxies } } + ) + + private fun behindProxy() = + MockHttpServletRequest().apply { + remoteAddr = "198.51.100.7" + addHeader("X-Forwarded-For", "6.6.6.6, 203.0.113.9, 10.0.0.1") + } + + @Test + fun `the resolver bean honors the configured trusted proxies`() { + runner(listOf("198.51.100.7", "10.0.0.0/8")).run { context -> + assertEquals( + "203.0.113.9", + context.getBean(ClientIpResolver::class.java).resolve(behindProxy()) + ) + } + } + + @Test + fun `the resolver bean trusts nobody when none are configured`() { + runner(listOf()).run { context -> + assertEquals( + "198.51.100.7", + context.getBean(ClientIpResolver::class.java).resolve(behindProxy()) + ) + } + } + + @Test + fun `the registered filter honors the configured trusted proxies`() { + runner(listOf("198.51.100.7", "10.0.0.0/8")).run { context -> + @Suppress("UNCHECKED_CAST") + val registration = + context.getBean("requestLoggerFilter") as FilterRegistrationBean + val request = behindProxy() + + registration.filter.doFilter(request, MockHttpServletResponse(), MockFilterChain()) + + assertEquals("203.0.113.9", request.getAttribute(ATTRIBUTE_CLIENT_IP_ADDRESS)) + } + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/ConfigManagerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/ConfigManagerTest.kt index cf4e428542..1285baa320 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/ConfigManagerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/ConfigManagerTest.kt @@ -47,6 +47,25 @@ class ConfigManagerTest { ) } + @Test + fun `test sep_server trusted_proxies is accepted as an indexed list`() { + val file = ClassPathResource("config/test_anchor_config_trusted_proxies_list.yaml") + every { configManager.getConfigFileAsResource(any()) } returns file + + val config = configManager.processConfigurations(null) + assertEquals("10.0.0.0/8", config.get("sep_server.trusted_proxies[0]").value) + assertEquals("2001:db8::/32", config.get("sep_server.trusted_proxies[1]").value) + } + + @Test + fun `test sep_server trusted_proxies is accepted as a comma-separated value`() { + val file = ClassPathResource("config/test_anchor_config_trusted_proxies_csv.yaml") + every { configManager.getConfigFileAsResource(any()) } returns file + + val config = configManager.processConfigurations(null) + assertEquals("10.0.0.0/8,2001:db8::/32", config.get("sep_server.trusted_proxies").value) + } + @Test fun `test reading a file missing version throws an exception`() { val testingConfigFile = ClassPathResource("config/test_anchor_config_missing_version.yaml") diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/SepServerConfigAdapterTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/SepServerConfigAdapterTest.kt new file mode 100644 index 0000000000..ec0532d9ad --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/configurator/SepServerConfigAdapterTest.kt @@ -0,0 +1,98 @@ +package org.stellar.anchor.platform.configurator + +import java.util.Properties +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.boot.context.properties.bind.Binder +import org.springframework.core.env.PropertiesPropertySource +import org.springframework.core.env.StandardEnvironment +import org.stellar.anchor.api.exception.InvalidConfigException +import org.stellar.anchor.platform.config.SepServerConfig +import org.stellar.anchor.platform.configurator.ConfigMap.ConfigSource.ENV + +class SepServerConfigAdapterTest { + private val adapter = SepServerConfigAdapter() + + private fun configOf(vararg entries: Pair) = + ConfigMap().apply { entries.forEach { (key, value) -> put(key, value, ENV) } } + + private fun bound(vararg entries: Pair): SepServerConfig { + val env = StandardEnvironment() + val props = Properties().apply { entries.forEach { (key, value) -> setProperty(key, value) } } + env.propertySources.addFirst(PropertiesPropertySource("test", props)) + return Binder.get(env).bind("sep-server", SepServerConfig::class.java).orElse(SepServerConfig()) + } + + @Test + fun `the shipped default trusts no proxy`() { + val defaults = ConfigHelper.loadDefaultConfig() + + assertEquals( + listOf(), + SepServerConfigAdapter.trustedProxyEntries(defaults).filter { it.isNotBlank() } + ) + adapter.validate(defaults) + } + + @Test + fun `the bound default is an empty list`() { + assertEquals(listOf(), bound().trustedProxies) + assertEquals( + listOf(), + bound("sep_server.trusted_proxies" to "").trustedProxies.filter { it.isNotBlank() } + ) + } + + @Test + fun `addresses and ranges are accepted as a comma-separated value`() { + adapter.validate( + configOf("sep_server.trusted_proxies" to "10.0.0.0/8, 198.51.100.7,2001:db8::/32") + ) + } + + @Test + fun `addresses and ranges are accepted as an indexed list`() { + adapter.validate( + configOf( + "sep_server.trusted_proxies[0]" to "10.0.0.0/8", + "sep_server.trusted_proxies[1]" to "2001:db8::/32", + ) + ) + } + + @Test + fun `the binder reads both spellings of the key`() { + val csv = bound("sep_server.trusted_proxies" to "10.0.0.0/8,2001:db8::/32") + assertEquals(listOf("10.0.0.0/8", "2001:db8::/32"), csv.trustedProxies) + + val indexed = + bound( + "sep_server.trusted_proxies[0]" to "10.0.0.0/8", + "sep_server.trusted_proxies[1]" to "2001:db8::/32", + ) + assertEquals(listOf("10.0.0.0/8", "2001:db8::/32"), indexed.trustedProxies) + } + + @Test + fun `an invalid entry stops startup naming the key and the entry`() { + val csv = + assertThrows { + adapter.validate(configOf("sep_server.trusted_proxies" to "10.0.0.0/8,proxy.local")) + } + assertTrue(csv.message!!.contains("sep_server.trusted_proxies"), csv.message) + assertTrue(csv.message!!.contains("[proxy.local]"), csv.message) + + val indexed = + assertThrows { + adapter.validate( + configOf( + "sep_server.trusted_proxies[0]" to "10.0.0.0/8", + "sep_server.trusted_proxies[1]" to "10.0.0.0/33", + ) + ) + } + assertTrue(indexed.message!!.contains("[10.0.0.0/33]"), indexed.message) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/ClientIpControllerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/ClientIpControllerTest.kt new file mode 100644 index 0000000000..f1d9792f06 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/ClientIpControllerTest.kt @@ -0,0 +1,93 @@ +package org.stellar.anchor.platform.controller.sep + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.stellar.anchor.api.sep.sep24.InteractiveTransactionResponse +import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest +import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionResponse +import org.stellar.anchor.auth.WebAuthJwt +import org.stellar.anchor.filter.WebAuthJwtFilter.JWT_TOKEN +import org.stellar.anchor.platform.config.AppLoggingConfig +import org.stellar.anchor.platform.utils.ClientIpResolver +import org.stellar.anchor.platform.utils.RequestLoggerFilter +import org.stellar.anchor.platform.utils.TransactionCreationRateLimiter +import org.stellar.anchor.platform.utils.TrustedProxies +import org.stellar.anchor.sep24.Sep24Service +import org.stellar.anchor.sep31.Sep31Service + +/** A value the caller writes into a request body never becomes the stored client IP. */ +class ClientIpControllerTest { + private val peer = "198.51.100.99" + private val spoofed = "203.0.113.9" + + private val rateLimiter = mockk(relaxed = true) + private val token = mockk(relaxed = true) + + /** + * Sends one request through the request filter into [controller], as the servlet container does. + */ + private fun throughFilter(controller: (HttpServletRequest) -> Unit) { + val config = mockk(relaxed = true) + every { config.isRequestLoggerEnabled } returns false + val filter = RequestLoggerFilter(config, ClientIpResolver(TrustedProxies.parse(listOf()))) + val request = + MockHttpServletRequest().apply { + remoteAddr = peer + addHeader("X-Forwarded-For", spoofed) + setAttribute(JWT_TOKEN, token) + } + filter.doFilter( + request, + MockHttpServletResponse(), + FilterChain { req, _ -> controller(req as HttpServletRequest) } + ) + } + + @Test + fun `SEP-31 ignores a requestClientIpAddress in the body`() { + val service = mockk() + val passed = slot() + every { service.postTransaction(any(), capture(passed)) } returns + mockk(relaxed = true) + val body = Sep31PostTransactionRequest().apply { requestClientIpAddress = spoofed } + + throughFilter { Sep31Controller(service, rateLimiter).postTransaction(it, body) } + + assertEquals(peer, passed.captured.requestClientIpAddress) + } + + @Test + fun `SEP-24 deposit ignores a clientIpAddress in the body`() { + val service = mockk() + val passed = slot>() + every { service.deposit(any(), capture(passed)) } returns + InteractiveTransactionResponse("interactive_customer_info_needed", "https://a.b", "id") + + throughFilter { + Sep24Controller(service, rateLimiter).deposit(it, hashMapOf("clientIpAddress" to spoofed)) + } + + assertEquals(peer, passed.captured["clientIpAddress"]) + } + + @Test + fun `SEP-24 withdraw ignores a clientIpAddress in the body`() { + val service = mockk() + val passed = slot>() + every { service.withdraw(any(), capture(passed)) } returns + InteractiveTransactionResponse("interactive_customer_info_needed", "https://a.b", "id") + + throughFilter { + Sep24Controller(service, rateLimiter).withdraw(it, hashMapOf("clientIpAddress" to spoofed)) + } + + assertEquals(peer, passed.captured["clientIpAddress"]) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/SepRequestHelperTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/SepRequestHelperTest.kt new file mode 100644 index 0000000000..e4fcf9bc40 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/controller/sep/SepRequestHelperTest.kt @@ -0,0 +1,64 @@ +package org.stellar.anchor.platform.controller.sep + +import io.mockk.every +import io.mockk.mockk +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.stellar.anchor.platform.config.AppLoggingConfig +import org.stellar.anchor.platform.utils.ClientIpResolver +import org.stellar.anchor.platform.utils.RequestLoggerFilter +import org.stellar.anchor.platform.utils.TrustedProxies + +class SepRequestHelperTest { + /** The client IP a controller sees after the request went through the filter. */ + private fun seenByController( + loggerEnabled: Boolean, + trustedProxies: List, + peer: String, + forwardedFor: String + ): String? { + val config = mockk(relaxed = true) + every { config.isRequestLoggerEnabled } returns loggerEnabled + val filter = RequestLoggerFilter(config, ClientIpResolver(TrustedProxies.parse(trustedProxies))) + val request = + MockHttpServletRequest().apply { + remoteAddr = peer + addHeader("X-Forwarded-For", forwardedFor) + } + var seen: String? = "controller was not reached" + val chain = FilterChain { req, _ -> + seen = SepRequestHelper.getClientIpAddress(req as HttpServletRequest) + } + filter.doFilter(request, MockHttpServletResponse(), chain) + return seen + } + + @Test + fun `with the request logger disabled the controller still gets the resolved address`() { + val seen = seenByController(false, listOf(), "198.51.100.99", "203.0.113.9") + assertEquals("198.51.100.99", seen) + } + + @Test + fun `with the request logger enabled the controller gets the resolved address, not the header`() { + val seen = seenByController(true, listOf(), "198.51.100.99", "203.0.113.9") + assertEquals("198.51.100.99", seen) + } + + @Test + fun `behind a trusted proxy the controller gets the client address with the logger off or on`() { + val trusted = listOf("198.51.100.7", "10.0.0.0/8") + assertEquals( + "203.0.113.9", + seenByController(false, trusted, "198.51.100.7", "6.6.6.6, 203.0.113.9, 10.0.0.1") + ) + assertEquals( + "203.0.113.9", + seenByController(true, trusted, "198.51.100.7", "6.6.6.6, 203.0.113.9, 10.0.0.1") + ) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/ClientIpResolverTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/ClientIpResolverTest.kt new file mode 100644 index 0000000000..fbb9ccba5e --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/ClientIpResolverTest.kt @@ -0,0 +1,188 @@ +package org.stellar.anchor.platform.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest + +class ClientIpResolverTest { + private val trusted = ClientIpResolver(TrustedProxies.parse(listOf("198.51.100.7", "10.0.0.0/8"))) + private val noProxies = ClientIpResolver(TrustedProxies.parse(listOf())) + + private fun request(peer: String, vararg headers: Pair) = + MockHttpServletRequest().apply { + remoteAddr = peer + headers.forEach { (name, value) -> addHeader(name, value) } + } + + @Test + fun `a peer outside the trusted list is recorded whatever the headers say`() { + val spoofed = + request( + "198.51.100.99", + "X-Forwarded-For" to "203.0.113.9", + "X-Real-IP" to "203.0.113.8", + ) + assertEquals("198.51.100.99", trusted.resolve(spoofed)) + } + + @Test + fun `with no trusted proxies the report PoC records the peer`() { + val poc = request("198.51.100.7", "X-Forwarded-For" to "203.0.113.9, 10.0.0.1") + assertEquals("198.51.100.7", noProxies.resolve(poc)) + } + + @Test + fun `a trusted peer yields the rightmost entry that is not a trusted proxy`() { + val chain = request("198.51.100.7", "X-Forwarded-For" to "203.0.113.9, 10.0.0.1") + assertEquals("203.0.113.9", trusted.resolve(chain)) + + val twoProxies = request("198.51.100.7", "X-Forwarded-For" to "203.0.113.9, 10.0.0.2, 10.0.0.1") + assertEquals("203.0.113.9", trusted.resolve(twoProxies)) + } + + @Test + fun `an entry the caller prepended does not win`() { + val prepended = request("198.51.100.7", "X-Forwarded-For" to "6.6.6.6, 203.0.113.9, 10.0.0.1") + assertEquals("203.0.113.9", trusted.resolve(prepended)) + } + + @Test + fun `when every hop is a trusted proxy the leftmost entry is recorded`() { + val internal = request("198.51.100.7", "X-Forwarded-For" to "10.0.0.5, 10.0.0.1") + assertEquals("10.0.0.5", trusted.resolve(internal)) + } + + @Test + fun `a trusted peer with no usable X-Forwarded-For is recorded`() { + assertEquals("198.51.100.7", trusted.resolve(request("198.51.100.7"))) + assertEquals( + "198.51.100.7", + trusted.resolve(request("198.51.100.7", "X-Forwarded-For" to "")), + ) + assertEquals( + "198.51.100.7", + trusted.resolve(request("198.51.100.7", "X-Forwarded-For" to "unknown")), + ) + } + + @Test + fun `an entry that is not an IP literal makes the peer the answer without a name lookup`() { + // "localhost" would resolve to 127.0.0.1 if the entry were looked up. + listOf( + "localhost, 10.0.0.1", + "203.0.113.9, proxy.example.com", + "203.0.113.9:8080, 10.0.0.1", + "203.0.113.9,,10.0.0.1", + "203.0.113.9, 10.0.0", + ) + .forEach { value -> + assertEquals( + "198.51.100.7", + trusted.resolve(request("198.51.100.7", "X-Forwarded-For" to value)), + value, + ) + } + } + + @Test + fun `X-Real-IP and the other legacy headers are ignored even from a trusted peer`() { + val legacyOnly = + request( + "198.51.100.7", + "X-Real-IP" to "203.0.113.8", + "Proxy-Client-IP" to "203.0.113.7", + "WL-Proxy-Client-IP" to "203.0.113.6", + "HTTP_X_FORWARDED_FOR" to "203.0.113.5", + "HTTP_CLIENT_IP" to "203.0.113.4", + "HTTP_VIA" to "203.0.113.3", + ) + assertEquals("198.51.100.7", trusted.resolve(legacyOnly)) + + val both = + request( + "198.51.100.7", + "X-Forwarded-For" to "203.0.113.9, 10.0.0.1", + "X-Real-IP" to "203.0.113.8", + ) + assertEquals("203.0.113.9", trusted.resolve(both)) + } + + @Test + fun `the IPv6 loopback is recorded as 127 0 0 1`() { + assertEquals("127.0.0.1", noProxies.resolve(request("0:0:0:0:0:0:0:1"))) + + val viaProxy = request("198.51.100.7", "X-Forwarded-For" to "0:0:0:0:0:0:0:1, 10.0.0.1") + assertEquals("127.0.0.1", trusted.resolve(viaProxy)) + + assertEquals("2001:db8::5", noProxies.resolve(request("2001:db8::5"))) + } + + @Test + fun `entries are trimmed before they are judged`() { + val padded = request("198.51.100.7", "X-Forwarded-For" to " 203.0.113.9 , 10.0.0.1 ") + assertEquals("203.0.113.9", trusted.resolve(padded)) + } + + @Test + fun `several X-Forwarded-For header lines are read as one list in the order received`() { + // The caller's spoof is on the first line and the trusted proxy on the last, so reading only + // the first or only the last line gives another answer. + val threeLines = + request( + "198.51.100.7", + "X-Forwarded-For" to "6.6.6.6", + "X-Forwarded-For" to "203.0.113.9", + "X-Forwarded-For" to "10.0.0.1", + ) + assertEquals("203.0.113.9", trusted.resolve(threeLines)) + } + + @Test + fun `an IPv6 entry is only compared against IPv6 ranges`() { + // 10.0.0.0/8 must not swallow an IPv6 address; the IPv6 range must. + val v6Client = request("198.51.100.7", "X-Forwarded-For" to "2001:db8::5, 10.0.0.1") + assertEquals("2001:db8::5", trusted.resolve(v6Client)) + + val v6Proxy = ClientIpResolver(TrustedProxies.parse(listOf("198.51.100.7", "2001:db8::/32"))) + val v6Chain = request("198.51.100.7", "X-Forwarded-For" to "203.0.113.9, 2001:db8::1") + assertEquals("203.0.113.9", v6Proxy.resolve(v6Chain)) + } + + @Test + fun `every IPv6 spelling is recorded in canonical form`() { + assertEquals("2001:db8::5", noProxies.resolve(request("2001:DB8::5"))) + assertEquals( + "2001:db8::5", + noProxies.resolve(request("2001:0db8:0000:0000:0000:0000:0000:0005")) + ) + val viaProxy = request("198.51.100.7", "X-Forwarded-For" to "2001:0DB8:0:0:0:0:0:5, 10.0.0.1") + assertEquals("2001:db8::5", trusted.resolve(viaProxy)) + } + + @Test + fun `every spelling of the IPv6 loopback is recorded as 127 0 0 1 and IPv4 is left as written`() { + listOf("::1", "0:0:0:0:0:0:0:1", "0000:0000:0000:0000:0000:0000:0000:0001").forEach { + assertEquals("127.0.0.1", noProxies.resolve(request(it)), it) + assertEquals( + "127.0.0.1", + trusted.resolve(request("198.51.100.7", "X-Forwarded-For" to "$it, 10.0.0.1")), + it + ) + } + assertEquals("127.0.0.1", noProxies.resolve(request("127.0.0.1"))) + assertEquals("203.0.113.9", noProxies.resolve(request("203.0.113.9"))) + } + + @Test + fun `every IPv4-mapped IPv6 spelling is recorded as the plain IPv4 address`() { + listOf("::ffff:203.0.113.9", "::ffff:cb00:7109", "0:0:0:0:0:ffff:cb00:7109").forEach { + assertEquals("203.0.113.9", noProxies.resolve(request(it)), it) + assertEquals( + "203.0.113.9", + trusted.resolve(request("198.51.100.7", "X-Forwarded-For" to "$it, 10.0.0.1")), + it + ) + } + assertEquals("127.0.0.1", noProxies.resolve(request("::ffff:127.0.0.1"))) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/RequestLoggerFilterTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/RequestLoggerFilterTest.kt index 3ebe29c2e9..c0426c8608 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/RequestLoggerFilterTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/RequestLoggerFilterTest.kt @@ -2,13 +2,22 @@ package org.stellar.anchor.platform.utils import io.mockk.* import jakarta.servlet.FilterChain +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse +import org.stellar.anchor.LockAndMockStatic +import org.stellar.anchor.LockAndMockTest import org.stellar.anchor.platform.config.AppLoggingConfig +import org.stellar.anchor.util.Log +@ExtendWith(LockAndMockTest::class) class RequestLoggerFilterTest { + private val noProxies = ClientIpResolver(TrustedProxies.parse(listOf())) private lateinit var mockConfig: AppLoggingConfig private lateinit var request: MockHttpServletRequest private lateinit var response: MockHttpServletResponse @@ -26,7 +35,7 @@ class RequestLoggerFilterTest { fun `test when disabled the doFilterWithLogging() is not called`() { // arrange every { mockConfig.isRequestLoggerEnabled } returns false - val a = spyk(RequestLoggerFilter(mockConfig)) + val a = spyk(RequestLoggerFilter(mockConfig, noProxies)) // act a.doFilterInternal(request, response, filterChain) @@ -40,7 +49,7 @@ class RequestLoggerFilterTest { fun `test when enabled the doFilterWithLogging() is called`() { // arrange every { mockConfig.isRequestLoggerEnabled } returns true - val a = spyk(RequestLoggerFilter(mockConfig)) + val a = spyk(RequestLoggerFilter(mockConfig, noProxies)) // act a.doFilterInternal(request, response, filterChain) @@ -49,4 +58,71 @@ class RequestLoggerFilterTest { verify(exactly = 1) { filterChain.doFilter(any(), any()) } verify(exactly = 1) { a.doFilterWithLogging(any(), any(), any()) } } + + private fun spoofedRequest() { + request.remoteAddr = "198.51.100.99" + request.addHeader("X-Forwarded-For", "203.0.113.9") + request.addHeader("X-Real-IP", "203.0.113.8") + } + + @Test + fun `test when disabled the client ip attribute is the resolved address`() { + // arrange + every { mockConfig.isRequestLoggerEnabled } returns false + spoofedRequest() + + // act + RequestLoggerFilter(mockConfig, noProxies).doFilterInternal(request, response, filterChain) + + // assert + assertEquals( + "198.51.100.99", + request.getAttribute(RequestLoggerFilter.ATTRIBUTE_CLIENT_IP_ADDRESS) + ) + } + + @Test + fun `test when enabled the client ip attribute is the resolved address`() { + // arrange + every { mockConfig.isRequestLoggerEnabled } returns true + spoofedRequest() + + // act + RequestLoggerFilter(mockConfig, noProxies).doFilterInternal(request, response, filterChain) + + // assert + assertEquals( + "198.51.100.99", + request.getAttribute(RequestLoggerFilter.ATTRIBUTE_CLIENT_IP_ADDRESS) + ) + } + + @Test + @LockAndMockStatic([Log::class]) + fun `test the INFO line and the TRACE message carry the resolved address`() { + // arrange + every { mockConfig.isRequestLoggerEnabled } returns true + spoofedRequest() + val infoLines = mutableListOf() + val traceMessages = mutableListOf() + every { Log.infoF(any(), *anyVararg()) } answers + { + // the varargs arrive as one array argument + infoLines.add( + args.flatMap { if (it is Array<*>) it.toList() else listOf(it) }.joinToString(" ") + ) + } + every { Log.trace(any()) } answers { traceMessages.add(firstArg()) } + + // act + RequestLoggerFilter(mockConfig, noProxies).doFilterInternal(request, response, filterChain) + + // assert + assertEquals(1, infoLines.size) + assertTrue(infoLines[0].contains("198.51.100.99"), infoLines[0]) + assertFalse(infoLines[0].contains("203.0.113"), infoLines[0]) + assertEquals(1, traceMessages.size) + assertTrue(traceMessages[0].contains("\"clientId\": \"198.51.100.99\""), traceMessages[0]) + assertFalse(traceMessages[0].contains("203.0.113"), traceMessages[0]) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/TrustedProxiesTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/TrustedProxiesTest.kt new file mode 100644 index 0000000000..4dda021d37 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/TrustedProxiesTest.kt @@ -0,0 +1,117 @@ +package org.stellar.anchor.platform.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.stellar.anchor.api.exception.InvalidConfigException + +class TrustedProxiesTest { + @Test + fun `an empty list trusts nothing`() { + val proxies = TrustedProxies.parse(listOf()) + assertTrue(proxies.isEmpty) + assertFalse(proxies.contains("10.0.0.1")) + } + + @Test + fun `a single address matches only itself`() { + val proxies = TrustedProxies.parse(listOf("198.51.100.7")) + assertTrue(proxies.contains("198.51.100.7")) + assertFalse(proxies.contains("198.51.100.6")) + assertFalse(proxies.contains("198.51.100.8")) + } + + @Test + fun `an IPv4 range includes its first and last address and excludes the neighbors`() { + val proxies = TrustedProxies.parse(listOf("10.1.2.0/24")) + assertTrue(proxies.contains("10.1.2.0")) + assertTrue(proxies.contains("10.1.2.255")) + assertFalse(proxies.contains("10.1.1.255")) + assertFalse(proxies.contains("10.1.3.0")) + } + + @Test + fun `a prefix that does not end on a byte boundary is honored`() { + val proxies = TrustedProxies.parse(listOf("172.16.0.0/12")) + assertTrue(proxies.contains("172.16.0.0")) + assertTrue(proxies.contains("172.31.255.255")) + assertFalse(proxies.contains("172.15.255.255")) + assertFalse(proxies.contains("172.32.0.0")) + } + + @Test + fun `a zero-length prefix matches every address of its family`() { + val proxies = TrustedProxies.parse(listOf("0.0.0.0/0")) + assertTrue(proxies.contains("203.0.113.9")) + assertFalse(proxies.contains("2001:db8::1")) + } + + @Test + fun `a full-length prefix matches only that address`() { + val proxies = TrustedProxies.parse(listOf("192.0.2.5/32")) + assertTrue(proxies.contains("192.0.2.5")) + assertFalse(proxies.contains("192.0.2.4")) + assertFalse(proxies.contains("192.0.2.6")) + } + + @Test + fun `an IPv6 range includes its first and last address and excludes the neighbors`() { + val proxies = TrustedProxies.parse(listOf("2001:db8::/32")) + assertTrue(proxies.contains("2001:db8::")) + assertTrue(proxies.contains("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff")) + assertFalse(proxies.contains("2001:db7:ffff:ffff:ffff:ffff:ffff:ffff")) + assertFalse(proxies.contains("2001:db9::")) + } + + @Test + fun `an IPv4 address never matches an IPv6 range and the reverse`() { + assertFalse(TrustedProxies.parse(listOf("::/0")).contains("10.0.0.1")) + assertFalse(TrustedProxies.parse(listOf("0.0.0.0/0")).contains("::1")) + } + + @Test + fun `any entry of the list can match and blanks are skipped`() { + val proxies = TrustedProxies.parse(listOf(" 10.0.0.0/8 ", "", "2001:db8::/32")) + assertTrue(proxies.contains("10.9.9.9")) + assertTrue(proxies.contains("2001:db8::7")) + assertFalse(proxies.contains("11.0.0.1")) + } + + @Test + fun `a probed value that is not a literal never matches`() { + val proxies = TrustedProxies.parse(listOf("0.0.0.0/0", "::/0")) + assertFalse(proxies.contains("localhost")) + assertFalse(proxies.contains("proxy.example.com")) + assertFalse(proxies.contains("1.2.3.4:8080")) + assertFalse(proxies.contains("")) + assertFalse(proxies.contains(null)) + } + + @Test + fun `invalid entries name the key and the entry`() { + listOf( + "proxy.local", + "10.0.0.0/33", + "2001:db8::/129", + "10.0.0.0/-1", + "10.0.0.0/-0", + "10.0.0.0/+8", + "10.0.0.0/", + "10.0.0.0/abc", + "10.0.0" + ) + .forEach { entry -> + val ex = + assertThrows { TrustedProxies.parse(listOf("10.0.0.1", entry)) } + assertTrue(ex.message!!.contains("sep_server.trusted_proxies"), ex.message) + assertTrue(ex.message!!.contains("[$entry]"), ex.message) + } + } + + @Test + fun `the longest valid prefixes are accepted`() { + assertEquals(false, TrustedProxies.parse(listOf("10.0.0.0/32", "2001:db8::/128")).isEmpty) + } +} diff --git a/platform/src/test/resources/config/test_anchor_config_trusted_proxies_csv.yaml b/platform/src/test/resources/config/test_anchor_config_trusted_proxies_csv.yaml new file mode 100644 index 0000000000..26e2de5bcb --- /dev/null +++ b/platform/src/test/resources/config/test_anchor_config_trusted_proxies_csv.yaml @@ -0,0 +1,5 @@ +# This is used by ConfigManagerTest.kt +version: 1 + +sep_server: + trusted_proxies: 10.0.0.0/8,2001:db8::/32 diff --git a/platform/src/test/resources/config/test_anchor_config_trusted_proxies_list.yaml b/platform/src/test/resources/config/test_anchor_config_trusted_proxies_list.yaml new file mode 100644 index 0000000000..36c7b3fabd --- /dev/null +++ b/platform/src/test/resources/config/test_anchor_config_trusted_proxies_list.yaml @@ -0,0 +1,7 @@ +# This is used by ConfigManagerTest.kt +version: 1 + +sep_server: + trusted_proxies: + - 10.0.0.0/8 + - 2001:db8::/32