Skip to content

Commit 30ac3cb

Browse files
Feature/123 support x forwarded for header for accurate client ip identification behind proxies (#131)
* Add ForwardedHeaderFilter Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add ForwardedHeaderFilterTest, verify request is passed when header is blank, verify IP is set when containing X-Forwarded-For header Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Verify request is passed when header is blank Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Refactor test class Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add java docs Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Changes by mattiashagstrom * Verify that other request fields are properly forwarded Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add trusted proxies to config loader, refactor ForwardedHeaderFilter too look for trusted proxies in config loader Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Edit filter to handle trusted proxies, update tests to match, add documentation Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> --------- Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com>
1 parent 93fd9aa commit 30ac3cb

5 files changed

Lines changed: 284 additions & 24 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package org.juv25d.filter;
2+
3+
import org.juv25d.filter.annotation.Global;
4+
import org.juv25d.http.HttpRequest;
5+
import org.juv25d.http.HttpResponse;
6+
import org.juv25d.util.ConfigLoader;
7+
8+
import java.io.IOException;
9+
import java.util.List;
10+
11+
/**
12+
* Resolves the real client IP when requests pass through reverse proxies.
13+
*
14+
* <p>Reads the <b>X-Forwarded-For</b> header and replaces the request's
15+
* remote IP with the first non-trusted address. Trusted proxy IPs are
16+
* configured via {@link ConfigLoader}.</p>
17+
*
18+
* <p>This prevents IP spoofing and ensures downstream filters (e.g. rate
19+
* limiting) see the correct client address.</p>
20+
*
21+
* <p>If the header is missing, the original remote IP is used.</p>
22+
*/
23+
24+
// Order is set to default / 0 until the filter order is finalized.
25+
@Global(order = 0)
26+
public class ForwardedHeaderFilter implements Filter {
27+
28+
private final ConfigLoader configLoader;
29+
30+
public ForwardedHeaderFilter() {
31+
this(ConfigLoader.getInstance());
32+
}
33+
34+
ForwardedHeaderFilter(ConfigLoader configLoader) {
35+
this.configLoader = configLoader;
36+
}
37+
38+
@Override
39+
public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException {
40+
List<String> trustedProxies = configLoader.getTrustedProxies();
41+
42+
String forwardedFor = req.headers().get("X-Forwarded-For");
43+
44+
if (forwardedFor == null || forwardedFor.isBlank()) {
45+
chain.doFilter(req, res);
46+
return;
47+
}
48+
49+
String resolvedIp = resolveFromHeader(forwardedFor, trustedProxies);
50+
51+
HttpRequest newReq = new HttpRequest(
52+
req.method(),
53+
req.path(),
54+
req.queryString(),
55+
req.httpVersion(),
56+
req.headers(),
57+
req.body(),
58+
resolvedIp,
59+
req.creationTimeNanos()
60+
);
61+
chain.doFilter(newReq, res);
62+
}
63+
64+
private String resolveFromHeader(String header, List<String> trustedProxies) {
65+
String[] parts = header.split(",");
66+
67+
if (trustedProxies == null || trustedProxies.isEmpty()) {
68+
return parts[0].trim();
69+
}
70+
71+
int i = parts.length - 1;
72+
73+
while (i >= 0 && trustedProxies.contains(parts[i].trim())) {
74+
i--;
75+
}
76+
77+
if (i >= 0) {
78+
return parts[i].trim();
79+
}
80+
return parts[0].trim();
81+
}
82+
}

src/main/java/org/juv25d/util/ConfigLoader.java

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44
import org.yaml.snakeyaml.Yaml;
55

66
import java.io.InputStream;
7-
import java.util.ArrayList;
8-
import java.util.Collections;
9-
import java.util.List;
10-
import java.util.Map;
7+
import java.util.*;
118

129
public class ConfigLoader {
1310
private static ConfigLoader instance;
@@ -17,15 +14,18 @@ public class ConfigLoader {
1714
private long requestsPerMinute;
1815
private long burstCapacity;
1916
private boolean rateLimitingEnabled;
17+
private List<String> trustedProxies;
2018
private List<ProxyRoute> proxyRoutes = new ArrayList<>();
2119

2220
private ConfigLoader() {
2321
loadConfiguration(getClass().getClassLoader()
24-
.getResourceAsStream("application-properties.yml")); }
22+
.getResourceAsStream("application-properties.yml"));
23+
}
2524

2625
// new constructor for testing
2726
ConfigLoader(InputStream input) {
28-
loadConfiguration(input); }
27+
loadConfiguration(input);
28+
}
2929

3030

3131
public static synchronized ConfigLoader getInstance() {
@@ -50,6 +50,7 @@ private void loadConfiguration(InputStream input) {
5050
this.port = 8080;
5151
this.rootDirectory = "static";
5252
this.logLevel = "INFO";
53+
this.trustedProxies = List.of();
5354

5455
// server
5556
Map<String, Object> serverConfig = asStringObjectMap(config.get("server"));
@@ -60,6 +61,16 @@ private void loadConfiguration(InputStream input) {
6061
Object root = serverConfig.get("root-dir");
6162
if (root != null) this.rootDirectory = String.valueOf(root);
6263

64+
Object trustedProxiesValue = serverConfig.get("trusted-proxies");
65+
if (trustedProxiesValue instanceof List<?> list) {
66+
this.trustedProxies = list.stream()
67+
.filter(Objects::nonNull)
68+
.map(String::valueOf)
69+
.map(String::trim)
70+
.filter(s -> !s.isEmpty())
71+
.toList();
72+
}
73+
6374
// proxy routes
6475
Map<String, Object> proxyConfig = asStringObjectMap(serverConfig.get("proxy"));
6576
if (proxyConfig != null) {
@@ -121,10 +132,15 @@ private static Map<String, Object> asStringObjectMap(Object value) {
121132
}
122133
return Collections.emptyMap();
123134
}
135+
124136
public long getRequestsPerMinute() {
125137
return requestsPerMinute;
126138
}
127139

140+
public List<String> getTrustedProxies() {
141+
return trustedProxies;
142+
}
143+
128144
public long getBurstCapacity() {
129145
return burstCapacity;
130146
}

src/main/resources/application-properties.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
server:
22
port: 8080
33
root-dir: static
4+
trusted-proxies:
5+
- 192.168.1.109
46
proxy:
57
routes:
68
- base-route: /api/test
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package org.juv25d.filter;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.api.extension.ExtendWith;
5+
import org.juv25d.http.HttpRequest;
6+
import org.juv25d.http.HttpResponse;
7+
import org.juv25d.util.ConfigLoader;
8+
import org.mockito.ArgumentCaptor;
9+
import org.mockito.Mock;
10+
import org.mockito.junit.jupiter.MockitoExtension;
11+
12+
import java.io.IOException;
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
import static org.junit.jupiter.api.Assertions.*;
17+
import static org.mockito.Mockito.*;
18+
19+
/**
20+
* Tests for the {@link ForwardedHeaderFilter} class.
21+
*/
22+
@ExtendWith(MockitoExtension.class)
23+
class ForwardedHeaderFilterTest {
24+
25+
@Mock
26+
private HttpRequest req;
27+
@Mock
28+
private HttpResponse res;
29+
@Mock
30+
private FilterChain chain;
31+
32+
private final String expectedRemoteIp = "127.0.0.1";
33+
private final String singleForwardedHeader = "127.0.0.1";
34+
private final String multipleForwardedHeader = "127.0.0.1, 123.0.0.1, 83.2.0.12";
35+
private final List<String> trustedProxy = List.of("123.0.0.1", "83.2.0.12");
36+
37+
@Test
38+
void shouldSetRemoteIp_fromForwardedHeader() throws IOException {
39+
// Arrange
40+
ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
41+
when(req.headers()).thenReturn(Map.of("X-Forwarded-For", singleForwardedHeader));
42+
when(req.method()).thenReturn("GET");
43+
when(req.path()).thenReturn("/test");
44+
when(req.queryString()).thenReturn("");
45+
when(req.httpVersion()).thenReturn("HTTP/1.1");
46+
when(req.body()).thenReturn(new byte[0]);
47+
when(req.creationTimeNanos()).thenReturn(1000L);
48+
49+
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
50+
51+
// Act
52+
filter.doFilter(req, res, chain);
53+
54+
// Assert
55+
verify(chain).doFilter(captor.capture(), eq(res));
56+
assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
57+
assertEquals("GET", captor.getValue().method());
58+
assertEquals("/test", captor.getValue().path());
59+
assertEquals("", captor.getValue().queryString());
60+
assertEquals("HTTP/1.1", captor.getValue().httpVersion());
61+
assertArrayEquals(new byte[0], captor.getValue().body());
62+
assertEquals(1000L, captor.getValue().creationTimeNanos());
63+
}
64+
65+
@Test
66+
void shouldPassOnRequest_ifHeaderNotPresent() throws IOException {
67+
// Arrange
68+
ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
69+
when(req.remoteIp()).thenReturn(expectedRemoteIp);
70+
when(req.headers()).thenReturn(Map.of());
71+
72+
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
73+
74+
// Act
75+
filter.doFilter(req, res, chain);
76+
77+
// Assert
78+
verify(chain).doFilter(captor.capture(), eq(res));
79+
assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
80+
}
81+
82+
@Test
83+
void shouldPassOnRequest_ifHeaderIsBlank() throws IOException {
84+
// Arrange
85+
ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
86+
when(req.remoteIp()).thenReturn(expectedRemoteIp);
87+
when(req.headers()).thenReturn(Map.of("X-Forwarded-For", ""));
88+
89+
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
90+
91+
// Act
92+
filter.doFilter(req, res, chain);
93+
94+
// Assert
95+
verify(chain).doFilter(captor.capture(), eq(res));
96+
assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
97+
}
98+
99+
@Test
100+
void shouldReturnIp_toTheLeftOfTrustedProxy() throws IOException {
101+
// Arrange
102+
ConfigLoader configLoader = mock(ConfigLoader.class);
103+
when(configLoader.getTrustedProxies()).thenReturn(trustedProxy);
104+
when(req.headers()).thenReturn(Map.of("X-Forwarded-For", multipleForwardedHeader));
105+
106+
ForwardedHeaderFilter filter = new ForwardedHeaderFilter(configLoader);
107+
108+
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
109+
110+
// Act
111+
filter.doFilter(req, res, chain);
112+
113+
// Assert
114+
verify(chain).doFilter(captor.capture(), eq(res));
115+
assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
116+
}
117+
118+
@Test
119+
void shouldReturnFirstIp_whenNoTrustedProxiesAreConfigured() throws IOException {
120+
// Arrange
121+
ConfigLoader configLoader = mock(ConfigLoader.class);
122+
when(configLoader.getTrustedProxies()).thenReturn(List.of());
123+
when(req.headers()).thenReturn(Map.of("X-Forwarded-For", multipleForwardedHeader));
124+
125+
ForwardedHeaderFilter filter = new ForwardedHeaderFilter(configLoader);
126+
127+
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
128+
129+
// Act
130+
filter.doFilter(req, res, chain);
131+
132+
// Assert
133+
verify(chain).doFilter(captor.capture(), eq(res));
134+
assertEquals(singleForwardedHeader, captor.getValue().remoteIp());
135+
}
136+
137+
@Test
138+
void shouldReturnFirstIp_whenAllIpsAreTrusted() throws IOException {
139+
// Arrange
140+
ConfigLoader configLoader = mock(ConfigLoader.class);
141+
when(configLoader.getTrustedProxies()).thenReturn(List.of("127.0.0.1", "123.0.0.1", "83.2.0.12"));
142+
when(req.headers()).thenReturn(Map.of("X-Forwarded-For", multipleForwardedHeader));
143+
144+
ForwardedHeaderFilter filter = new ForwardedHeaderFilter(configLoader);
145+
146+
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
147+
148+
// Act
149+
filter.doFilter(req, res, chain);
150+
151+
// Assert
152+
verify(chain).doFilter(captor.capture(), eq(res));
153+
assertEquals(expectedRemoteIp, captor.getValue().remoteIp());
154+
}
155+
}

0 commit comments

Comments
 (0)