Skip to content

Commit 629eff4

Browse files
committed
fix: use X-Real-IP instead of X-Forwarded-For to prevent rate limit bypass
X-Forwarded-For can be spoofed by clients since nginx's $proxy_add_x_forwarded_for appends to the client-supplied value. X-Real-IP is set by nginx to $remote_addr which overwrites any client-supplied header, making it immune to spoofing.
1 parent d529673 commit 629eff4

5 files changed

Lines changed: 21 additions & 25 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ out/
1818
!**/src/main/**/out/
1919
!**/src/test/**/out/
2020

21+
.kotlin
22+
2123
### Eclipse ###
2224
.apt_generated
2325
.classpath

.idea/kotlinc.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/kotlin/eu/darken/octi/kserver/common/IpHelper.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import io.ktor.server.request.*
55
fun ApplicationRequest.clientIp(): String {
66
val connectionIp = local.remoteAddress
77
return if (IpHelper.isLoopback(connectionIp)) {
8-
headers["X-Forwarded-For"]?.split(",")?.firstOrNull()?.trim() ?: connectionIp
8+
headers["X-Real-IP"]?.trim() ?: connectionIp
99
} else {
1010
connectionIp
1111
}

src/test/kotlin/eu/darken/octi/kserver/common/ClientIpTest.kt

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ class ClientIpTest {
1111

1212
private fun createRequest(
1313
remoteAddress: String,
14-
forwardedFor: String? = null,
14+
realIp: String? = null,
1515
): ApplicationRequest {
1616
val local = mockk<RequestConnectionPoint> {
1717
every { this@mockk.remoteAddress } returns remoteAddress
1818
}
1919
val headers = HeadersBuilder().apply {
20-
if (forwardedFor != null) append("X-Forwarded-For", forwardedFor)
20+
if (realIp != null) append("X-Real-IP", realIp)
2121
}.build()
2222
return mockk {
2323
every { this@mockk.local } returns local
@@ -32,31 +32,25 @@ class ClientIpTest {
3232
}
3333

3434
@Test
35-
fun `non-loopback ignores X-Forwarded-For`() {
36-
val request = createRequest(remoteAddress = "203.0.113.5", forwardedFor = "10.0.0.1")
35+
fun `non-loopback ignores X-Real-IP`() {
36+
val request = createRequest(remoteAddress = "203.0.113.5", realIp = "10.0.0.1")
3737
request.clientIp() shouldBe "203.0.113.5"
3838
}
3939

4040
@Test
41-
fun `loopback IPv4 uses X-Forwarded-For`() {
42-
val request = createRequest(remoteAddress = "127.0.0.1", forwardedFor = "198.51.100.7")
41+
fun `loopback IPv4 uses X-Real-IP`() {
42+
val request = createRequest(remoteAddress = "127.0.0.1", realIp = "198.51.100.7")
4343
request.clientIp() shouldBe "198.51.100.7"
4444
}
4545

4646
@Test
47-
fun `loopback IPv6 uses X-Forwarded-For`() {
48-
val request = createRequest(remoteAddress = "0:0:0:0:0:0:0:1", forwardedFor = "198.51.100.7")
47+
fun `loopback IPv6 uses X-Real-IP`() {
48+
val request = createRequest(remoteAddress = "0:0:0:0:0:0:0:1", realIp = "198.51.100.7")
4949
request.clientIp() shouldBe "198.51.100.7"
5050
}
5151

5252
@Test
53-
fun `loopback takes first IP from X-Forwarded-For chain`() {
54-
val request = createRequest(remoteAddress = "127.0.0.1", forwardedFor = "198.51.100.7, 10.0.0.1")
55-
request.clientIp() shouldBe "198.51.100.7"
56-
}
57-
58-
@Test
59-
fun `loopback without X-Forwarded-For falls back to connection IP`() {
53+
fun `loopback without X-Real-IP falls back to connection IP`() {
6054
val request = createRequest(remoteAddress = "127.0.0.1")
6155
request.clientIp() shouldBe "127.0.0.1"
6256
}

src/test/kotlin/eu/darken/octi/kserver/common/RateLimiterTest.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ class RateLimiterTest : TestRunner() {
5858
}
5959

6060
@Test
61-
fun `test different X-Forwarded-For from trusted proxy have separate rate limits`() = runTest2(
61+
fun `test different X-Real-IP from trusted proxy have separate rate limits`() = runTest2(
6262
appConfig = baseConfig.copy(
6363
rateLimit = RateLimitConfig(limit = 2, resetTime = Duration.ofSeconds(5))
6464
)
6565
) {
66-
// Tests connect via loopback (trusted proxy), so X-Forwarded-For is used
66+
// Tests connect via loopback (trusted proxy), so X-Real-IP is used
6767
repeat(2) {
6868
http.get("/v1/status") {
69-
header("X-Forwarded-For", "192.168.1.1")
69+
header("X-Real-IP", "192.168.1.1")
7070
}.apply {
7171
status shouldBe HttpStatusCode.OK
7272
}
@@ -75,7 +75,7 @@ class RateLimiterTest : TestRunner() {
7575

7676
// 192.168.1.1 is exhausted
7777
http.get("/v1/status") {
78-
header("X-Forwarded-For", "192.168.1.1")
78+
header("X-Real-IP", "192.168.1.1")
7979
}.apply {
8080
status shouldBe HttpStatusCode.TooManyRequests
8181
}
@@ -84,7 +84,7 @@ class RateLimiterTest : TestRunner() {
8484
// 192.168.1.2 should still have its own limit
8585
repeat(2) {
8686
http.get("/v1/status") {
87-
header("X-Forwarded-For", "192.168.1.2")
87+
header("X-Real-IP", "192.168.1.2")
8888
}.apply {
8989
status shouldBe HttpStatusCode.OK
9090
}
@@ -101,15 +101,15 @@ class RateLimiterTest : TestRunner() {
101101
// Exhaust rate limit for an IP
102102
repeat(2) {
103103
http.get("/v1/status") {
104-
header("X-Forwarded-For", "192.168.1.1")
104+
header("X-Real-IP", "192.168.1.1")
105105
}.apply {
106106
status shouldBe HttpStatusCode.OK
107107
}
108108
Thread.sleep(100)
109109
}
110110

111111
http.get("/v1/status") {
112-
header("X-Forwarded-For", "192.168.1.1")
112+
header("X-Real-IP", "192.168.1.1")
113113
}.apply {
114114
status shouldBe HttpStatusCode.TooManyRequests
115115
}
@@ -121,7 +121,7 @@ class RateLimiterTest : TestRunner() {
121121
// Should be able to make requests again since entries were cleaned up
122122
repeat(2) {
123123
http.get("/v1/status") {
124-
header("X-Forwarded-For", "192.168.1.1")
124+
header("X-Real-IP", "192.168.1.1")
125125
}.apply {
126126
status shouldBe HttpStatusCode.OK
127127
}

0 commit comments

Comments
 (0)