This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/main by this push:
     new 6d8605a71 add extractDirectClientIP for the address a client cannot 
choose (#1219)
6d8605a71 is described below

commit 6d8605a71fe11c803ade411ad71b0b7a4c5ac882
Author: PJ Fanning <[email protected]>
AuthorDate: Thu Aug 27 12:40:23 2026 +0100

    add extractDirectClientIP for the address a client cannot choose (#1219)
    
    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
---
 .../directives/misc-directives/extractClientIP.md  |  3 +-
 .../misc-directives/extractDirectClientIP.md       | 23 ++++++++++++++
 .../directives/misc-directives/index.md            |  1 +
 .../directives/MiscDirectivesExamplesTest.java     | 35 ++++++++++++++++++++++
 .../directives/MiscDirectivesExamplesSpec.scala    | 19 ++++++++++++
 .../server/directives/MiscDirectivesSpec.scala     | 24 +++++++++++++++
 .../javadsl/server/directives/MiscDirectives.scala | 19 ++++++++++++
 .../server/directives/MiscDirectives.scala         | 27 +++++++++++++++--
 8 files changed, 147 insertions(+), 4 deletions(-)

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 2832a417a..c3f3b56e9 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 000000000..36534103c
--- /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 470710c65..7418de3f4 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 f5abce2c3..1ca0ee022 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.complete;
 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 class MiscDirectivesExamplesTest extends 
JUnitJupiterRouteTest {
     // #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 192e752f3..9addf39f4 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 978efc58f..5222358ab 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 5e4aa1797..38fa94100 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 e127f1aad..91a1aba9f 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)
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to