From 952227fc310ce16ca4f1dcde21eac8ebd41b470a Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 25 Aug 2026 21:33:15 -0400 Subject: [PATCH] Resolve client IP through a trusted-proxy allowlist RateLimitingFilter.getClientIp took X-Forwarded-For.split(",")[0] -- the first entry, which is fully attacker-controlled -- while nginx used $proxy_add_x_forwarded_for, which APPENDS the real peer to whatever the client already sent. So the value being trusted was the one the caller supplied. Rotating a random X-Forwarded-For per request minted a fresh Caffeine bucket every time and made the 100 req/min limiter a no-op, including in front of the unauthenticated /api/agent/enroll endpoint where it is the only abuse guard. AgentEnrollmentController.resolveClientIp duplicated the same helper, so enrollment lastSeenIp and audit records could be poisoned with arbitrary attacker-chosen addresses. New ClientIpResolver honours the header only when the peer is inside a configured trusted-proxy CIDR set, then reads hop (size - trustedProxyCount) -- the entry our own proxy contributed. With no trusted proxies configured it ignores the header entirely and uses the peer address, so the default is safe and an unconfigured deployment fails closed. Both call sites now share it. nginx overwrites rather than appends (proxy_set_header X-Forwarded-For $remote_addr) so a client's own header can never survive to the app, and compose sets the bridge network as the trusted range. The existing clientIpIsTakenFromXForwardedForFirstHop test asserted the vulnerable behaviour and is replaced by three tests covering a trusted proxy, a spoofing attempt from an untrusted peer, and the no-header fallback. MethodSecurityTestConfig gains the resolver bean, which @WebMvcTest slices need since they pick up the filter but not its dependency. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm --- docker-compose.yml | 6 + docker/nginx/nginx.conf | 5 +- .../lensbridge/config/ClientIpProperties.java | 39 ++++ .../lensbridge/config/RateLimitingFilter.java | 15 +- .../controller/AgentEnrollmentController.java | 13 +- .../lensbridge/security/ClientIpResolver.java | 179 ++++++++++++++++++ .../resources/application.properties.example | 13 ++ .../config/RateLimitingFilterTest.java | 61 +++++- .../controller/MethodSecurityTestConfig.java | 17 ++ 9 files changed, 320 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/ClientIpProperties.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/security/ClientIpResolver.java 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: + *

    + *
  1. ignores the header outright unless the request's real TCP peer is a configured trusted + * proxy ({@code lensbridge.client-ip.trusted-proxies}), and
  2. + *
  3. counts from the right, skipping {@code lensbridge.client-ip.trusted-proxy-count} + * hops, so entries a client prepended are never selected, and
  4. + *
  5. falls back to {@link HttpServletRequest#getRemoteAddr()} whenever the header is absent, + * shorter than expected, or does not hold a syntactically valid IP literal.
  6. + *
+ * + *

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())