From c49f5840aee1c3cfb360643b231547ea9a8f1a0e Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sun, 23 Aug 2026 00:14:05 +0100 Subject: [PATCH] add extractDirectClientIP for the address a client cannot choose Motivation: `extractClientIP` prefers the X-Forwarded-For and X-Real-IP headers over the remote address attribute. Those headers are set by whoever sent the request, so unless a trusted proxy in front of the server overwrites them the client picks the address the application sees. That is the intended behaviour behind a proxy, but there is no equally convenient directive for the cases where the address must not be under the client's control, and the scaladoc and javadoc of `extractClientIP` do not mention the difference at all (only the docs page does). Modification: Add `extractDirectClientIP` to both DSLs, extracting the `AttributeKeys.remoteAddress` attribute alone, and point at it from the scaladoc and javadoc of `extractClientIP`. Document the new directive with its own page and examples, and cross-link it from the warning on the `extractClientIP` page. Result: Applications that need the address of the actual peer, for example for access control or rate limiting, can ask for it directly, and the distinction is visible where the directives are used rather than only on the documentation site. `extractClientIP` is unchanged. Tests: - sbt "http-tests/testOnly org.apache.pekko.http.scaladsl.server.directives.MiscDirectivesSpec" - pass, 4 new tests including one that asserts the forwarding headers are ignored - sbt http-tests/test - pass - sbt docs/test - pass, with new Scala and Java examples - sbt docs/paradox - pass - sbt +http/mimaReportBinaryIssues - pass - sbt http/scalafmt http-tests/Test/scalafmt docs/Test/scalafmt docs/Test/javafmt - clean References: None - makes the address that a client cannot spoof directly available --- .../misc-directives/extractClientIP.md | 3 +- .../misc-directives/extractDirectClientIP.md | 23 ++++++++++++ .../directives/misc-directives/index.md | 1 + .../MiscDirectivesExamplesTest.java | 35 +++++++++++++++++++ .../MiscDirectivesExamplesSpec.scala | 19 ++++++++++ .../directives/MiscDirectivesSpec.scala | 24 +++++++++++++ .../server/directives/MiscDirectives.scala | 19 ++++++++++ .../server/directives/MiscDirectives.scala | 27 ++++++++++++-- 8 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 docs/src/main/paradox/routing-dsl/directives/misc-directives/extractDirectClientIP.md diff --git a/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractClientIP.md b/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractClientIP.md index 2832a417a5..c3f3b56e9b 100644 --- a/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractClientIP.md +++ b/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractClientIP.md @@ -17,7 +17,8 @@ If no valid IP address is encountered, this extractor will return RemoteAddress. @@@ warning Clients can send any values in these headers. If the client is not a trusted upstream, the IP address can be malicious. -For sensitive operations use the @apidoc[AttributeKeys.remoteAddress](AttributeKeys$) @ref[attribute](../../../common/http-model.md#attributes), +For sensitive operations use @ref[extractDirectClientIP](extractDirectClientIP.md), which reads the +@apidoc[AttributeKeys.remoteAddress](AttributeKeys$) @ref[attribute](../../../common/http-model.md#attributes) only, or use the specific headers which are known to be set correctly by the infrastructure you do trust. @@@ diff --git a/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractDirectClientIP.md b/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractDirectClientIP.md new file mode 100644 index 0000000000..36534103c3 --- /dev/null +++ b/docs/src/main/paradox/routing-dsl/directives/misc-directives/extractDirectClientIP.md @@ -0,0 +1,23 @@ +# extractDirectClientIP + +@@@ div { .group-scala } + +## Signature + +@@signature [MiscDirectives.scala](/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectives.scala) { #extractDirectClientIP } + +@@@ + +## Description + +Provides the value of the @apidoc[AttributeKeys.remoteAddress](AttributeKeys$) @ref[attribute](../../../common/http-model.md#attributes), which is the address of the peer of the connection the request arrived on. It requires the `pekko.http.server.remote-address-attribute` setting to be `on` and provides `RemoteAddress.Unknown` otherwise. + +Unlike @ref[extractClientIP](extractClientIP.md), this directive ignores the `X-Forwarded-For` and `X-Real-IP` headers, so the address it provides cannot be chosen by the client. Note that behind a proxy this is the address of the proxy and not of the client that the proxy forwarded the request for. + +## Example + +Scala +: @@snip [MiscDirectivesExamplesSpec.scala](/docs/src/test/scala/docs/http/scaladsl/server/directives/MiscDirectivesExamplesSpec.scala) { #extractDirectClientIP-example } + +Java +: @@snip [MiscDirectivesExamplesTest.java](/docs/src/test/java/docs/http/javadsl/server/directives/MiscDirectivesExamplesTest.java) { #extractDirectClientIPExample } diff --git a/docs/src/main/paradox/routing-dsl/directives/misc-directives/index.md b/docs/src/main/paradox/routing-dsl/directives/misc-directives/index.md index 470710c654..7418de3f4a 100644 --- a/docs/src/main/paradox/routing-dsl/directives/misc-directives/index.md +++ b/docs/src/main/paradox/routing-dsl/directives/misc-directives/index.md @@ -5,6 +5,7 @@ @@@ index * [extractClientIP](extractClientIP.md) +* [extractDirectClientIP](extractDirectClientIP.md) * [rejectEmptyResponse](rejectEmptyResponse.md) * [requestEntityEmpty](requestEntityEmpty.md) * [requestEntityPresent](requestEntityPresent.md) diff --git a/docs/src/test/java/docs/http/javadsl/server/directives/MiscDirectivesExamplesTest.java b/docs/src/test/java/docs/http/javadsl/server/directives/MiscDirectivesExamplesTest.java index f5abce2c3f..1ca0ee022a 100644 --- a/docs/src/test/java/docs/http/javadsl/server/directives/MiscDirectivesExamplesTest.java +++ b/docs/src/test/java/docs/http/javadsl/server/directives/MiscDirectivesExamplesTest.java @@ -13,6 +13,7 @@ package docs.http.javadsl.server.directives; +import org.apache.pekko.http.javadsl.model.AttributeKeys; import org.apache.pekko.http.javadsl.model.HttpRequest; import org.apache.pekko.http.javadsl.model.StatusCodes; import org.apache.pekko.http.javadsl.model.headers.*; @@ -51,6 +52,11 @@ import static org.apache.pekko.http.javadsl.server.Directives.extractClientIP; // #extractClientIP +// #extractDirectClientIP +import static org.apache.pekko.http.javadsl.server.Directives.complete; +import static org.apache.pekko.http.javadsl.server.Directives.extractDirectClientIP; + +// #extractDirectClientIP // #requestEntity-empty-present-example import static org.apache.pekko.http.javadsl.server.Directives.complete; import static org.apache.pekko.http.javadsl.server.Directives.requestEntityEmpty; @@ -170,6 +176,35 @@ public void testExtractClientIP() throws UnknownHostException { // #extractClientIPExample } + @Test + public void testExtractDirectClientIP() throws UnknownHostException { + // #extractDirectClientIPExample + final Route route = + extractDirectClientIP( + remoteAddr -> + complete( + "Client's IP is " + + remoteAddr + .getAddress() + .map(InetAddress::getHostAddress) + .orElseGet(() -> "unknown"))); + + // tests: + final String ip = "192.168.1.2"; + final org.apache.pekko.http.javadsl.model.RemoteAddress remoteAddress = + org.apache.pekko.http.javadsl.model.RemoteAddress.create(InetAddress.getByName(ip)); + + testRoute(route) + .run(HttpRequest.GET("/").addAttribute(AttributeKeys.remoteAddress, remoteAddress)) + .assertEntity("Client's IP is " + ip); + + // a client cannot choose the address by sending a header + testRoute(route) + .run(HttpRequest.GET("/").addHeader(XForwardedFor.create(remoteAddress))) + .assertEntity("Client's IP is unknown"); + // #extractDirectClientIPExample + } + @Test public void testRequestEntityEmpty() { // #requestEntity-empty-present-example diff --git a/docs/src/test/scala/docs/http/scaladsl/server/directives/MiscDirectivesExamplesSpec.scala b/docs/src/test/scala/docs/http/scaladsl/server/directives/MiscDirectivesExamplesSpec.scala index 192e752f3a..9addf39f45 100644 --- a/docs/src/test/scala/docs/http/scaladsl/server/directives/MiscDirectivesExamplesSpec.scala +++ b/docs/src/test/scala/docs/http/scaladsl/server/directives/MiscDirectivesExamplesSpec.scala @@ -37,6 +37,25 @@ class MiscDirectivesExamplesSpec extends RoutingSpec with CompileOnlySpec { // #extractClientIP-example } + "extractDirectClientIP-example" in { + // #extractDirectClientIP-example + val route = extractDirectClientIP { ip => + complete("Client's ip is " + ip.toOption.map(_.getHostAddress).getOrElse("unknown")) + } + + // tests: + val remoteAddress = RemoteAddress(InetAddress.getByName("192.168.3.12")) + Get("/").withAttributes(Map(AttributeKeys.remoteAddress -> remoteAddress)) ~> route ~> check { + responseAs[String] shouldEqual "Client's ip is 192.168.3.12" + } + + // a client cannot choose the address by sending a header + Get("/").withHeaders(`X-Forwarded-For`(RemoteAddress(InetAddress.getByName("1.2.3.4")))) ~> route ~> check { + responseAs[String] shouldEqual "Client's ip is unknown" + } + // #extractDirectClientIP-example + } + "rejectEmptyResponse-example" in { // #rejectEmptyResponse-example val route = rejectEmptyResponse { diff --git a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectivesSpec.scala b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectivesSpec.scala index 978efc58f6..5222358ab3 100644 --- a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectivesSpec.scala +++ b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectivesSpec.scala @@ -46,6 +46,30 @@ class MiscDirectivesSpec extends RoutingSpec { extractClientIP { echoComplete } } ~> check { responseAs[String] shouldEqual "unknown" } } + "extract from the remote address attribute" in { + Get().withAttributes(Map(AttributeKeys.remoteAddress -> remoteAddress("5.6.7.8"))) ~> { + extractClientIP { echoComplete } + } ~> check { responseAs[String] shouldEqual "5.6.7.8" } + } + } + + "the extractDirectClientIP directive" should { + "extract from the remote address attribute" in { + Get().withAttributes(Map(AttributeKeys.remoteAddress -> remoteAddress("5.6.7.8"))) ~> { + extractDirectClientIP { echoComplete } + } ~> check { responseAs[String] shouldEqual "5.6.7.8" } + } + "ignore the X-Forwarded-For and X-Real-IP headers a client may have sent" in { + Get().withAttributes(Map(AttributeKeys.remoteAddress -> remoteAddress("5.6.7.8"))) ~> + addHeaders(`X-Forwarded-For`(remoteAddress("2.3.4.5")), RawHeader("x-real-ip", "1.2.3.4")) ~> { + extractDirectClientIP { echoComplete } + } ~> check { responseAs[String] shouldEqual "5.6.7.8" } + } + "extract unknown when the attribute is not set" in { + Get() ~> addHeader(`X-Forwarded-For`(remoteAddress("2.3.4.5"))) ~> { + extractDirectClientIP { echoComplete } + } ~> check { responseAs[String] shouldEqual "unknown" } + } } "the selectPreferredLanguage directive" should { diff --git a/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/MiscDirectives.scala b/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/MiscDirectives.scala index 5e4aa1797e..38fa94100f 100644 --- a/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/MiscDirectives.scala +++ b/http/src/main/scala/org/apache/pekko/http/javadsl/server/directives/MiscDirectives.scala @@ -41,11 +41,30 @@ abstract class MiscDirectives extends MethodDirectives { * Extracts the client's IP from either the X-Forwarded-For, Remote-Address, X-Real-IP header * or [[pekko.http.javadsl.model.AttributeKeys.remoteAddress]] attribute * (in that order of priority). + * + * Note that the headers are under the control of the client unless a trusted proxy in front of this server + * overwrites them. Use [[extractDirectClientIP]] where the address must not be chosen by the client, for example + * for access control or rate limiting. */ def extractClientIP(inner: JFunction[RemoteAddress, Route]): Route = RouteAdapter { D.extractClientIP { ip => inner.apply(ip).delegate } } + /** + * Extracts the client's IP from the [[pekko.http.javadsl.model.AttributeKeys.remoteAddress]] attribute alone, that + * is the address of the peer of the connection the request arrived on. Forwarding headers are ignored, so the + * address cannot be chosen by the client, but it is the address of the last proxy rather than of the client itself + * when the request was forwarded. + * + * Requires the `pekko.http.server.remote-address-attribute` setting to be `on` and extracts + * [[pekko.http.javadsl.model.RemoteAddresses.UNKNOWN]] otherwise. + * + * @since 2.0.0 + */ + def extractDirectClientIP(inner: JFunction[RemoteAddress, Route]): Route = RouteAdapter { + D.extractDirectClientIP { ip => inner.apply(ip).delegate } + } + /** * Rejects if the request entity is non-empty. */ diff --git a/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectives.scala b/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectives.scala index e127f1aad6..91a1aba9f6 100644 --- a/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectives.scala +++ b/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/MiscDirectives.scala @@ -41,10 +41,28 @@ trait MiscDirectives { * or [[pekko.http.scaladsl.model.AttributeKeys.remoteAddress]] attribute * (in that order of priority). * + * Note that the headers are under the control of the client unless a trusted proxy in front of this server + * overwrites them. Use [[extractDirectClientIP]] where the address must not be chosen by the client, for example + * for access control or rate limiting. + * * @group misc */ def extractClientIP: Directive1[RemoteAddress] = MiscDirectives._extractClientIP + /** + * Extracts the client's IP from the [[pekko.http.scaladsl.model.AttributeKeys.remoteAddress]] attribute alone, that + * is the address of the peer of the connection the request arrived on. Forwarding headers are ignored, so the + * address cannot be chosen by the client, but it is the address of the last proxy rather than of the client itself + * when the request was forwarded. + * + * Requires the `pekko.http.server.remote-address-attribute` setting to be `on` and extracts + * [[pekko.http.scaladsl.model.RemoteAddress.Unknown]] otherwise. + * + * @since 2.0.0 + * @group misc + */ + def extractDirectClientIP: Directive1[RemoteAddress] = MiscDirectives._extractDirectClientIP + /** * Rejects if the request entity is non-empty. * @@ -116,13 +134,16 @@ object MiscDirectives extends MiscDirectives { import RouteDirectives._ import RouteResult._ - private val _extractClientIP: Directive1[RemoteAddress] = - headerValuePF { case `X-Forwarded-For`(Seq(address, _*)) => address } | - headerValuePF { case `X-Real-Ip`(address) => address } | + private val _extractDirectClientIP: Directive1[RemoteAddress] = extractRequest.map { request => request.attribute(AttributeKeys.remoteAddress).getOrElse(RemoteAddress.Unknown) } + private val _extractClientIP: Directive1[RemoteAddress] = + headerValuePF { case `X-Forwarded-For`(Seq(address, _*)) => address } | + headerValuePF { case `X-Real-Ip`(address) => address } | + _extractDirectClientIP + private val _requestEntityEmpty: Directive0 = extract(_.request.entity.isKnownEmpty).flatMap(if (_) pass else reject)