diff --git a/docker-compose.yml b/docker-compose.yml
index 44f17fa..43d8e91 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -44,6 +44,12 @@ services:
FRONTEND_BASEURL: ${FRONTEND_BASEURL}
MUSALLAHBOARD_BASEURL: ${MUSALLAHBOARD_BASEURL}
SPRINGDOC_API_DOCS_ENABLED: ${SPRINGDOC_API_DOCS_ENABLED:-false}
+ # Only nginx can reach this container (it is not port-published), so the compose bridge
+ # network is the trusted-proxy range. Without this the app ignores X-Forwarded-For and
+ # rate-limits every caller under nginx's single address. See lensbridge.client-ip.* in
+ # application.properties.example.
+ LENSBRIDGE_CLIENTIP_TRUSTEDPROXIES: ${CLIENT_IP_TRUSTED_PROXIES:-172.16.0.0/12}
+ LENSBRIDGE_CLIENTIP_TRUSTEDPROXYCOUNT: ${CLIENT_IP_TRUSTED_PROXY_COUNT:-1}
volumes:
- thumbnails:/app/thumbnails
networks:
diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf
index dff1b4c..a520009 100644
--- a/docker/nginx/nginx.conf
+++ b/docker/nginx/nginx.conf
@@ -93,7 +93,10 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # Overwrite rather than append ($proxy_add_x_forwarded_for): the client's own
+ # X-Forwarded-For must never survive to the app, or it can pre-seed the header
+ # and forge its own identity for per-IP rate limiting and audit records.
+ proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
diff --git a/src/main/java/com/ibrasoft/lensbridge/config/ClientIpProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/ClientIpProperties.java
new file mode 100644
index 0000000..846c56e
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/config/ClientIpProperties.java
@@ -0,0 +1,39 @@
+package com.ibrasoft.lensbridge.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Controls how {@link com.ibrasoft.lensbridge.security.ClientIpResolver} derives the originating
+ * client IP from an inbound request.
+ *
+ *
Defaults are deliberately fail-safe: with {@link #trustedProxies} empty, no
+ * {@code X-Forwarded-For} header is honoured at all and every caller is identified by its real TCP
+ * peer address. A deployment that actually sits behind a reverse proxy has to opt in by listing the
+ * proxy's address range, which means a forgotten configuration degrades to over-counting (one shared
+ * bucket) rather than to a trivially spoofable rate-limit key.
+ */
+@Component
+@ConfigurationProperties(prefix = "lensbridge.client-ip")
+@Data
+public class ClientIpProperties {
+
+ /**
+ * CIDR blocks (or bare IP literals) of reverse proxies that are allowed to supply
+ * {@code X-Forwarded-For}. Requests arriving from any other peer have the header ignored.
+ * Empty (the default) means no proxy is trusted.
+ */
+ private List trustedProxies = new ArrayList<>();
+
+ /**
+ * Number of reverse proxies we operate between the public internet and this application.
+ * The client IP is read that many entries from the right-hand end of {@code X-Forwarded-For},
+ * so hops a client prepends itself are skipped. {@code 1} matches the single nginx in
+ * {@code docker/nginx/nginx.conf}. {@code 0} disables header parsing entirely.
+ */
+ private int trustedProxyCount = 1;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java b/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java
index c96d756..5c6ff4c 100644
--- a/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java
+++ b/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java
@@ -3,10 +3,12 @@
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.ibrasoft.lensbridge.model.auth.Role;
+import com.ibrasoft.lensbridge.security.ClientIpResolver;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
@@ -27,8 +29,11 @@
@Slf4j
@Component
+@RequiredArgsConstructor
public class RateLimitingFilter extends OncePerRequestFilter {
+ private final ClientIpResolver clientIpResolver;
+
@Value("${ratelimit.requests:10}")
private int maxRequests;
@@ -53,7 +58,7 @@ protected void doFilterInternal(HttpServletRequest request,
filterChain.doFilter(request, response);
return;
}
- String ip = getClientIp(request);
+ String ip = clientIpResolver.resolveClientIp(request);
Bucket bucket = bucketCache.get(ip, k -> newBucket());
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
@@ -74,14 +79,6 @@ private Bucket newBucket() {
.build();
}
- private String getClientIp(HttpServletRequest request) {
- String xfHeader = request.getHeader("X-Forwarded-For");
- if (xfHeader != null && !xfHeader.isEmpty()) {
- return xfHeader.split(",")[0].trim();
- }
- return request.getRemoteAddr();
- }
-
private boolean isUserExemptFromRateLimit() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java
index f51708f..bacecf8 100644
--- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java
+++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java
@@ -4,6 +4,7 @@
import com.ibrasoft.lensbridge.dto.board.response.AgentEnrollResponse;
import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse;
import com.ibrasoft.lensbridge.exception.ApiResponseException;
+import com.ibrasoft.lensbridge.security.ClientIpResolver;
import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService;
import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome;
import io.swagger.v3.oas.annotations.Operation;
@@ -36,6 +37,7 @@
public class AgentEnrollmentController {
private final DeviceEnrollmentService enrollmentService;
+ private final ClientIpResolver clientIpResolver;
@Value("${musallahboard.agent.websocketUrl}")
private String websocketUrl;
@@ -54,7 +56,7 @@ public class AgentEnrollmentController {
@PostMapping("/enroll")
public ResponseEntity enroll(@Valid @RequestBody AgentEnrollRequest request,
HttpServletRequest httpRequest) {
- String remoteIp = resolveClientIp(httpRequest);
+ String remoteIp = clientIpResolver.resolveClientIp(httpRequest);
Outcome outcome = enrollmentService.enroll(
request.getToken(),
@@ -76,13 +78,4 @@ public ResponseEntity enroll(@Valid @RequestBody AgentEnrol
new MessageResponse("Enrollment token is invalid, expired, or already used"));
};
}
-
- private static String resolveClientIp(HttpServletRequest request) {
- String forwarded = request.getHeader("X-Forwarded-For");
- if (forwarded != null && !forwarded.isBlank()) {
- int comma = forwarded.indexOf(',');
- return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
- }
- return request.getRemoteAddr();
- }
}
diff --git a/src/main/java/com/ibrasoft/lensbridge/security/ClientIpResolver.java b/src/main/java/com/ibrasoft/lensbridge/security/ClientIpResolver.java
new file mode 100644
index 0000000..1da8d41
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/security/ClientIpResolver.java
@@ -0,0 +1,179 @@
+package com.ibrasoft.lensbridge.security;
+
+import com.ibrasoft.lensbridge.config.ClientIpProperties;
+import jakarta.annotation.PostConstruct;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.web.util.matcher.IpAddressMatcher;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Single source of truth for "which IP is this request really from".
+ *
+ * {@code X-Forwarded-For} is a client-supplied header. nginx's {@code $proxy_add_x_forwarded_for}
+ * appends the peer address to whatever the client already sent, so the left-hand entries are
+ * entirely attacker-controlled and only the right-hand end is trustworthy. Reading
+ * {@code split(",")[0]} therefore lets any caller mint a fresh identity per request, which defeats
+ * every per-IP control built on top of it (rate limiting, lockout counters, audit trails).
+ *
+ *
This resolver instead:
+ *
+ * - ignores the header outright unless the request's real TCP peer is a configured trusted
+ * proxy ({@code lensbridge.client-ip.trusted-proxies}), and
+ * - counts from the right, skipping {@code lensbridge.client-ip.trusted-proxy-count}
+ * hops, so entries a client prepended are never selected, and
+ * - falls back to {@link HttpServletRequest#getRemoteAddr()} whenever the header is absent,
+ * shorter than expected, or does not hold a syntactically valid IP literal.
+ *
+ *
+ * Injected wherever a caller identity is needed; do not re-implement header parsing locally.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ClientIpResolver {
+
+ static final String FORWARDED_FOR_HEADER = "X-Forwarded-For";
+
+ /** Used only when the container cannot report a peer address at all. */
+ static final String UNKNOWN_IP = "unknown";
+
+ private static final Pattern IPV4 = Pattern.compile(
+ "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$");
+
+ /** Longest possible textual IPv6 address (IPv4-mapped form), used as a cheap sanity bound. */
+ private static final int MAX_IP_LITERAL_LENGTH = 45;
+
+ private final ClientIpProperties properties;
+
+ private List trustedProxyMatchers = List.of();
+
+ @PostConstruct
+ void initialiseTrustedProxies() {
+ List matchers = new ArrayList<>();
+ for (String cidr : properties.getTrustedProxies()) {
+ if (cidr == null || cidr.isBlank()) {
+ continue;
+ }
+ try {
+ matchers.add(new IpAddressMatcher(cidr.trim()));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(
+ "lensbridge.client-ip.trusted-proxies contains an invalid CIDR/IP: '" + cidr + "'", e);
+ }
+ }
+ this.trustedProxyMatchers = List.copyOf(matchers);
+
+ if (trustedProxyMatchers.isEmpty()) {
+ log.info("Client IP resolution: no trusted proxies configured; {} will be ignored and the "
+ + "TCP peer address used instead.", FORWARDED_FOR_HEADER);
+ } else {
+ log.info("Client IP resolution: trusting {} for requests from {}, reading hop {} from the right.",
+ FORWARDED_FOR_HEADER, properties.getTrustedProxies(), properties.getTrustedProxyCount());
+ }
+ }
+
+ /**
+ * Resolves the originating client IP for {@code request}.
+ *
+ * @return a syntactically valid IP literal, or {@value #UNKNOWN_IP} if the container reported no
+ * peer address. Never {@code null}, so it is safe to use directly as a cache/lookup key.
+ */
+ public String resolveClientIp(HttpServletRequest request) {
+ String remoteAddr = request.getRemoteAddr();
+ if (remoteAddr == null || remoteAddr.isBlank()) {
+ return UNKNOWN_IP;
+ }
+
+ int hopsToSkip = properties.getTrustedProxyCount();
+ if (hopsToSkip <= 0 || trustedProxyMatchers.isEmpty() || !isTrustedProxy(remoteAddr)) {
+ // Either header parsing is disabled, or this request did not come through a proxy we
+ // operate, so nothing it claims about its own origin can be believed.
+ return remoteAddr;
+ }
+
+ String header = request.getHeader(FORWARDED_FOR_HEADER);
+ if (header == null || header.isBlank()) {
+ return remoteAddr;
+ }
+
+ List hops = new ArrayList<>();
+ for (String hop : header.split(",")) {
+ String trimmed = hop.trim();
+ if (!trimmed.isEmpty()) {
+ hops.add(trimmed);
+ }
+ }
+
+ int index = hops.size() - hopsToSkip;
+ if (index < 0) {
+ log.debug("{} has {} usable entries but {} trusted hops are configured; falling back to peer address.",
+ FORWARDED_FOR_HEADER, hops.size(), hopsToSkip);
+ return remoteAddr;
+ }
+
+ String candidate = stripPort(hops.get(index));
+ if (!isIpLiteral(candidate)) {
+ log.debug("{} hop {} is not a valid IP literal; falling back to peer address.",
+ FORWARDED_FOR_HEADER, index);
+ return remoteAddr;
+ }
+ return candidate;
+ }
+
+ private boolean isTrustedProxy(String address) {
+ for (IpAddressMatcher matcher : trustedProxyMatchers) {
+ try {
+ if (matcher.matches(address)) {
+ return true;
+ }
+ } catch (IllegalArgumentException e) {
+ // Address family mismatch or an unparseable peer address: simply not a match.
+ log.trace("Trusted-proxy matcher rejected peer address '{}'", address);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Removes a {@code :port} suffix and IPv6 brackets. Proxies occasionally emit {@code 1.2.3.4:5678}
+ * or {@code [2001:db8::1]:443}; a bare IPv6 address (which is full of colons) is left untouched.
+ */
+ private static String stripPort(String value) {
+ if (value.startsWith("[")) {
+ int close = value.indexOf(']');
+ return close > 0 ? value.substring(1, close) : value;
+ }
+ int colon = value.indexOf(':');
+ if (colon > 0 && value.indexOf(':', colon + 1) < 0) {
+ return value.substring(0, colon);
+ }
+ return value;
+ }
+
+ /**
+ * Lexical-only IP check. Deliberately avoids {@code InetAddress.getByName}, which would perform a
+ * DNS lookup for anything that is not an IP literal — an attacker-triggered outbound request.
+ */
+ private static boolean isIpLiteral(String value) {
+ if (value.isEmpty() || value.length() > MAX_IP_LITERAL_LENGTH) {
+ return false;
+ }
+ if (value.indexOf(':') < 0) {
+ return IPV4.matcher(value).matches();
+ }
+ // IPv6: hex groups separated by colons, optionally ending in a dotted-quad tail.
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (Character.digit(c, 16) < 0 && c != ':' && c != '.') {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example
index 165d227..b9f6784 100644
--- a/src/main/resources/application.properties.example
+++ b/src/main/resources/application.properties.example
@@ -76,6 +76,19 @@ ratelimit.requests=100
ratelimit.duration.minutes=1
ratelimit.exempt-roles=ROLE_ROOT
+# Client IP resolution (used for rate-limit buckets, enrollment lastSeenIp, audit records).
+#
+# X-Forwarded-For is client-supplied. It is honoured ONLY when the request's real TCP peer matches
+# one of the CIDRs below, and even then the client IP is read `trusted-proxy-count` entries from the
+# RIGHT-hand end so hops the client prepended are skipped. Leave trusted-proxies empty (the default)
+# when the app is exposed directly -- the header is then ignored entirely and the peer address wins.
+#
+# Behind the docker-compose nginx, set this to the compose bridge network, e.g.:
+# lensbridge.client-ip.trusted-proxies=172.16.0.0/12
+# The app container is not port-published, so only nginx can reach it on that network.
+lensbridge.client-ip.trusted-proxies=
+lensbridge.client-ip.trusted-proxy-count=1
+
# Spring multipart cap
spring.servlet.multipart.max-file-size=250MB
spring.servlet.multipart.max-request-size=5000MB
diff --git a/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java b/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java
index 14026b3..d26aac7 100644
--- a/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java
+++ b/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java
@@ -1,6 +1,7 @@
package com.ibrasoft.lensbridge.config;
import com.ibrasoft.lensbridge.model.auth.Role;
+import com.ibrasoft.lensbridge.security.ClientIpResolver;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -32,8 +33,11 @@
* The {@code @Value} default for {@code ratelimit.requests} is 10, not 100.
* Tests set the limit explicitly via reflection so they don't depend on the default.
* Exempt-role detection reads from the {@link SecurityContextHolder}, not the request.
- * Per-client isolation keys on {@code X-Forwarded-For} (first hop) falling back to
- * {@code remoteAddr}.
+ * Per-client isolation keys on the client IP as resolved by {@link ClientIpResolver}:
+ * the forwarded header is honoured only for peers inside the configured trusted-proxy
+ * range, and otherwise ignored in favour of {@code remoteAddr}. These tests configure
+ * {@code 10.0.0.0/8} as the trusted range, so {@code 10.x} peers are treated as our
+ * proxy and everything else as a direct caller.
* On a successful pass-through an {@code X-Rate-Limit-Remaining} header is added; on a
* block the status is set to 429 and a plain-text body is written (chain not invoked).
*
@@ -45,7 +49,13 @@ class RateLimitingFilterTest {
@BeforeEach
void setUp() {
- filter = new RateLimitingFilter();
+ ClientIpProperties clientIpProperties = new ClientIpProperties();
+ clientIpProperties.setTrustedProxies(List.of("10.0.0.0/8"));
+ clientIpProperties.setTrustedProxyCount(1);
+ ClientIpResolver clientIpResolver = new ClientIpResolver(clientIpProperties);
+ ReflectionTestUtils.invokeMethod(clientIpResolver, "initialiseTrustedProxies");
+
+ filter = new RateLimitingFilter(clientIpResolver);
ReflectionTestUtils.setField(filter, "maxRequests", 3);
ReflectionTestUtils.setField(filter, "durationMinutes", 1);
ReflectionTestUtils.setField(filter, "exemptRoles", List.of(Role.ROOT));
@@ -165,25 +175,60 @@ void bucketsAreIsolatedPerClientIp() throws Exception {
}
@Test
- void clientIpIsTakenFromXForwardedForFirstHop() throws Exception {
+ void forwardedHeaderFromATrustedProxyIdentifiesTheClient() throws Exception {
FilterChain chain = mock(FilterChain.class);
- // Two requests with different remoteAddr but the same X-Forwarded-For client
- // share a bucket, proving the forwarded header is the bucket key.
+ // 10.0.0.99 is inside the configured trusted range, so it is our proxy and the entry it
+ // appended -- the last one -- is the real client. Two requests arriving through different
+ // proxy instances for the same client must therefore share a bucket.
for (int i = 0; i < 3; i++) {
MockHttpServletRequest request = requestFromIp("10.0.0.99");
- request.addHeader("X-Forwarded-For", "203.0.113.7, 10.0.0.99");
+ request.addHeader("X-Forwarded-For", "203.0.113.7");
filter.doFilter(request, new MockHttpServletResponse(), chain);
}
MockHttpServletRequest blockedReq = requestFromIp("10.0.0.100");
- blockedReq.addHeader("X-Forwarded-For", "203.0.113.7, 10.0.0.100");
+ blockedReq.addHeader("X-Forwarded-For", "203.0.113.7");
+ MockHttpServletResponse blocked = new MockHttpServletResponse();
+ filter.doFilter(blockedReq, blocked, chain);
+
+ assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value());
+ }
+
+ @Test
+ void spoofedForwardedHeaderFromAnUntrustedPeerCannotEvadeTheBucket() throws Exception {
+ FilterChain chain = mock(FilterChain.class);
+
+ // The whole point of the fix. 198.51.100.5 is a direct caller, not a proxy we operate, so
+ // nothing it claims about its own origin is believed. Rotating a fresh forged header on
+ // every request used to mint a fresh bucket each time and made the limiter a no-op.
+ for (int i = 0; i < 3; i++) {
+ MockHttpServletRequest request = requestFromIp("198.51.100.5");
+ request.addHeader("X-Forwarded-For", "203.0.113." + i);
+ filter.doFilter(request, new MockHttpServletResponse(), chain);
+ }
+
+ MockHttpServletRequest blockedReq = requestFromIp("198.51.100.5");
+ blockedReq.addHeader("X-Forwarded-For", "203.0.113.250");
MockHttpServletResponse blocked = new MockHttpServletResponse();
filter.doFilter(blockedReq, blocked, chain);
assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value());
}
+ @Test
+ void trustedProxyWithoutAForwardedHeaderFallsBackToThePeerAddress() throws Exception {
+ FilterChain chain = mock(FilterChain.class);
+
+ for (int i = 0; i < 3; i++) {
+ filter.doFilter(requestFromIp("10.1.2.3"), new MockHttpServletResponse(), chain);
+ }
+ MockHttpServletResponse blocked = new MockHttpServletResponse();
+ filter.doFilter(requestFromIp("10.1.2.3"), blocked, chain);
+
+ assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value());
+ }
+
@Test
void unauthenticatedRequestIsNotExempt() throws Exception {
// No authentication in the security context.
diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java b/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java
index 2d34cbc..00f0c82 100644
--- a/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java
+++ b/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java
@@ -1,5 +1,7 @@
package com.ibrasoft.lensbridge.controller;
+import com.ibrasoft.lensbridge.config.ClientIpProperties;
+import com.ibrasoft.lensbridge.security.ClientIpResolver;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
@@ -18,11 +20,26 @@
* enforced (yielding 403 for wrong roles), and a filter chain that requires authentication for all
* requests (yielding 401 for anonymous, matching the production entry-point behaviour). CSRF is
* disabled, mirroring production.
+ *
+ * It also supplies {@link ClientIpResolver}. {@code @WebMvcTest} picks up
+ * {@link com.ibrasoft.lensbridge.config.RateLimitingFilter} because it is a servlet filter,
+ * but not the resolver it depends on, which lives outside the slice. Without this bean every
+ * importer of this config fails to start its context. No trusted proxies are configured, so
+ * the resolver ignores {@code X-Forwarded-For} entirely and keys on the peer address --
+ * the safe default, and the right one for a slice test with no proxy in front of it.
*/
@TestConfiguration
@EnableMethodSecurity
class MethodSecurityTestConfig {
+ @Bean
+ ClientIpResolver testClientIpResolver() {
+ // @PostConstruct on the resolver is honoured by the container for @Bean instances,
+ // so the trusted-proxy matchers are initialised without calling the (package-private)
+ // initialiser from here.
+ return new ClientIpResolver(new ClientIpProperties());
+ }
+
@Bean
SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())