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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Filter> requestLoggerFilter(AppLoggingConfig appLoggingConfig) {
@SneakyThrows
public ClientIpResolver clientIpResolver(SepServerConfig sepServerConfig) {
return new ClientIpResolver(TrustedProxies.parse(sepServerConfig.getTrustedProxies()));
}

@Bean
public FilterRegistrationBean<Filter> requestLoggerFilter(
AppLoggingConfig appLoggingConfig, ClientIpResolver clientIpResolver) {
FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new RequestLoggerFilter(appLoggingConfig));
registrationBean.setFilter(new RequestLoggerFilter(appLoggingConfig, clientIpResolver));
registrationBean.addUrlPatterns("/");
registrationBean.addUrlPatterns("/*");
return registrationBean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> trustedProxies = new ArrayList<>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<String> trustedProxyEntries(ConfigMap config) {
String key = "sep_server.trusted_proxies";
List<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> forwardedFor(HttpServletRequest request) {
List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,7 +70,7 @@ public void doFilterWithLogging(

Log.infoF(
" {} - {} {} {}",
getClientIpAddress(request),
clientIpAddress,
request.getMethod(),
request.getRequestURI(),
response.getStatus());
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Range> ranges;

private TrustedProxies(List<Range> ranges) {
this.ranges = ranges;
}

public static TrustedProxies parse(List<String> entries) throws InvalidConfigException {
List<Range> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading