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

chibenwa pushed a commit to branch 3.9.x
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/3.9.x by this push:
     new b839b4cce9 [FIX] Include more ip ranges in WebPush target validation 
(#3112) (#3119)
b839b4cce9 is described below

commit b839b4cce96c2a77e2218e5ae1cc0dbffeeb4f54
Author: Benoit TELLIER <[email protected]>
AuthorDate: Wed Aug 19 16:49:26 2026 +0700

    [FIX] Include more ip ranges in WebPush target validation (#3112) (#3119)
---
 .../modules/servers/partials/operate/security.adoc |   2 +-
 .../jmap/pushsubscription/SSRFValidator.scala      | 202 +++++++++++++++++++
 .../jmap/pushsubscription/WebPushClient.scala      |  44 ++---
 .../DefaultWebPushClientSSRFTest.scala             |  88 +++++++++
 .../jmap/pushsubscription/SSRFValidatorTest.scala  | 213 +++++++++++++++++++++
 .../SafeWebPushClientContract.scala                |  17 +-
 .../pushsubscription/WebPushClientContract.scala   |  18 ++
 7 files changed, 558 insertions(+), 26 deletions(-)

diff --git a/docs/modules/servers/partials/operate/security.adoc 
b/docs/modules/servers/partials/operate/security.adoc
index 16758d5aee..e94641cfd6 100644
--- a/docs/modules/servers/partials/operate/security.adoc
+++ b/docs/modules/servers/partials/operate/security.adoc
@@ -65,7 +65,7 @@ or xref:{xref-base}/configure/jmx.adoc[disabling JMX]. JMX is 
needed to use the
 features. Set the `jmx.remote.x.mlet.allow.getMBeansFromURL` to `false` to 
disable JMX remote code execution feature.
 
  - 9. If JMAP is enabled, be sure that JMAP PUSH cannot be used for server 
side request forgery. This can be
-xref:{xref-base}/configure/jmap.adoc[configured] using the 
`push.prevent.server.side.request.forgery=true` property,
+xref:{xref-base}/configure/jmap.adoc[configured] using the 
`webpush.prevent.server.side.request.forgery=true` property,
 forbidding push to private addresses.
 
 === Best practice: Should
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/SSRFValidator.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/SSRFValidator.scala
new file mode 100644
index 0000000000..96b61d8ec3
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/SSRFValidator.scala
@@ -0,0 +1,202 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.pushsubscription
+
+import java.net.{Inet4Address, Inet6Address, InetAddress, InetSocketAddress, 
UnknownHostException}
+import java.util
+import java.util.Locale
+
+import com.google.common.net.InetAddresses
+import io.netty.resolver.{AddressResolver, AddressResolverGroup, 
InetNameResolver}
+import io.netty.util.concurrent.{EventExecutor, Promise}
+import org.apache.james.jmap.api.model.PushSubscriptionServerURL
+import org.apache.james.jmap.pushsubscription.SSRFValidator.{ALLOWED_SCHEMES, 
HostResolver, SYSTEM_HOST_RESOLVER, forbiddenReason}
+import reactor.core.publisher.Mono
+import reactor.core.scala.publisher.SMono
+import reactor.core.scheduler.Schedulers
+
+import scala.jdk.CollectionConverters._
+
+object SSRFValidator {
+  /**
+   * Resolves a host name into the addresses it points to. Extracted as a 
function so that tests can
+   * exercise multi-record and DNS rebinding scenarios without relying on an 
actual DNS server.
+   */
+  type HostResolver = String => Seq[InetAddress]
+
+  val SYSTEM_HOST_RESOLVER: HostResolver = host => 
InetAddress.getAllByName(host).toSeq
+
+  val ALLOWED_SCHEMES: Set[String] = Set("http", "https")
+
+  private val IPV4_LENGTH: Int = 4
+
+  /**
+   * Describes why the supplied address must not be reached, if it must not.
+   *
+   * The JDK predicates alone leave holes an attacker can walk through: 
`isSiteLocalAddress` only knows
+   * about the deprecated fec0::/10 for IPv6, and none of them covers the 
wildcard address. On top of
+   * them we thus reject:
+   *
+   *  - the wildcard addresses (0.0.0.0, ::), which reach local services,
+   *  - 0.0.0.0/8 ("this network") and the 255.255.255.255 broadcast address,
+   *  - IPv6 unique local addresses (fc00::/7), which notably hold the IPv6 
cloud metadata endpoints,
+   *  - multicast addresses,
+   *  - the shared address space (100.64.0.0/10, RFC 6598).
+   *
+   * IPv6 addresses that embed an IPv4 one (IPv4-mapped, IPv4-compatible, 
NAT64 well-known prefix and
+   * 6to4) are additionally validated against the IPv4 address they embed: 
traffic sent to them ends up
+   * being delivered to that IPv4 address.
+   */
+  def forbiddenReason(address: InetAddress): Option[String] =
+    directlyForbiddenReason(address)
+      .orElse(embeddedIPv4(address).flatMap(forbiddenReason))
+
+  private def directlyForbiddenReason(address: InetAddress): Option[String] = 
address match {
+    case a if a.isAnyLocalAddress => Some("a wildcard address")
+    case a if a.isLoopbackAddress => Some("a loopback address")
+    case a if a.isLinkLocalAddress => Some("a link local address")
+    case a if a.isSiteLocalAddress => Some("a site local address")
+    case a if a.isMulticastAddress => Some("a multicast address")
+    case a: Inet6Address if isUniqueLocal(a) => Some("an IPv6 unique local 
address")
+    case a: Inet4Address if isThisNetwork(a) => Some("a 'this network' 
(0.0.0.0/8) address")
+    case a: Inet4Address if isSharedAddressSpace(a) => Some("a shared address 
space (100.64.0.0/10) address")
+    case a: Inet4Address if isBroadcast(a) => Some("a broadcast address")
+    case _ => None
+  }
+
+  private def isUniqueLocal(address: Inet6Address): Boolean =
+    (address.getAddress()(0) & 0xfe) == 0xfc
+
+  private def isThisNetwork(address: Inet4Address): Boolean =
+    (address.getAddress()(0) & 0xff) == 0
+
+  private def isSharedAddressSpace(address: Inet4Address): Boolean = {
+    val bytes: Array[Byte] = address.getAddress
+    (bytes(0) & 0xff) == 100 && (bytes(1) & 0xff) >= 64 && (bytes(1) & 0xff) 
<= 127
+  }
+
+  private def isBroadcast(address: Inet4Address): Boolean =
+    address.getAddress.forall(byte => (byte & 0xff) == 0xff)
+
+  /**
+   * Guava knows about the IPv4-compatible, 6to4 and Teredo forms. ISATAP it 
deliberately leaves out of
+   * `hasEmbeddedIPv4ClientAddress` as being trivially spoofable, which does 
not matter here: we are after
+   * where the packet gets delivered, not after who claims to have sent it.
+   *
+   * The IPv4-mapped and NAT64 forms are the two it does not expose as an 
`Inet6Address` predicate.
+   */
+  private def embeddedIPv4(address: InetAddress): Option[InetAddress] = 
address match {
+    case a: Inet6Address if InetAddresses.hasEmbeddedIPv4ClientAddress(a) => 
Some(InetAddresses.getEmbeddedIPv4ClientAddress(a))
+    case a: Inet6Address if InetAddresses.isIsatapAddress(a) => 
Some(InetAddresses.getIsatapIPv4Address(a))
+    case a: Inet6Address if isIPv4Mapped(a.getAddress) || 
isNat64WellKnown(a.getAddress) => Some(ipv4At(a.getAddress, 12))
+    case _ => None
+  }
+
+  // ::ffff:0:0/96
+  private def isIPv4Mapped(bytes: Array[Byte]): Boolean =
+    isZero(bytes, 0, 10) && (bytes(10) & 0xff) == 0xff && (bytes(11) & 0xff) 
== 0xff
+
+  // 64:ff9b::/96
+  private def isNat64WellKnown(bytes: Array[Byte]): Boolean =
+    (bytes(0) & 0xff) == 0x00 && (bytes(1) & 0xff) == 0x64 &&
+      (bytes(2) & 0xff) == 0xff && (bytes(3) & 0xff) == 0x9b &&
+      isZero(bytes, 4, 12)
+
+  private def isZero(bytes: Array[Byte], from: Int, until: Int): Boolean =
+    (from until until).forall(i => bytes(i) == 0)
+
+  private def ipv4At(bytes: Array[Byte], offset: Int): InetAddress =
+    InetAddress.getByAddress(bytes.slice(offset, offset + IPV4_LENGTH))
+}
+
+/**
+ * Guards JMAP push against being used as a server side request forgery 
primitive.
+ *
+ * Two layers are applied, both relying on the same address policy:
+ *
+ *  - [[validate]] rejects the push subscription URL upfront, which yields an 
explicit error to the user,
+ *  - [[addressResolverGroup]] plugs the very same policy into the resolver 
the HTTP client connects with.
+ *
+ * The second layer is what actually holds: the URL is resolved again when the 
connection is established,
+ * so validating a resolution performed beforehand leaves a window a DNS 
rebinding attack fits into.
+ * Validating within the resolver makes the validated resolution the one that 
gets connected to.
+ */
+class SSRFValidator(hostResolver: HostResolver = SYSTEM_HOST_RESOLVER,
+                    policy: InetAddress => Option[String] = forbiddenReason) {
+
+  def validate(pushServerUrl: PushSubscriptionServerURL): 
SMono[PushSubscriptionServerURL] =
+    validateScheme(pushServerUrl)
+      .flatMap(url => SMono.fromCallable(() => 
checkedResolve(url.value.getHost, s"JMAP Push subscription $url"))
+        .subscribeOn(Schedulers.boundedElastic())
+        .`then`(SMono.just(url)))
+
+  def addressResolverGroup: AddressResolverGroup[InetSocketAddress] = new 
SSRFPreventingAddressResolverGroup(this)
+
+  /**
+   * Resolves a host and returns its addresses, failing whenever a single one 
of them is forbidden.
+   *
+   * All the addresses are validated, and not only the first one: a host 
resolving to both a public and a
+   * private address would otherwise let the connection land on the private 
one.
+   */
+  private[pushsubscription] def checkedResolve(host: String, context: String): 
Seq[InetAddress] = {
+    val addresses: Seq[InetAddress] = hostResolver(host)
+
+    if (addresses.isEmpty) {
+      throw new UnknownHostException(host)
+    }
+
+    addresses.flatMap(address => policy(address).map(reason => (address, 
reason)))
+      .headOption match {
+        case Some((address, reason)) => throw new IllegalArgumentException(
+          s"$context is targeting $reason $address. This could be an attempt 
for server-side request forgery.")
+        case None => addresses
+      }
+  }
+
+  private def validateScheme(pushServerUrl: PushSubscriptionServerURL): 
SMono[PushSubscriptionServerURL] =
+    Option(pushServerUrl.value.getProtocol).map(_.toLowerCase(Locale.US)) 
match {
+      case Some(scheme) if ALLOWED_SCHEMES.contains(scheme) => 
SMono.just(pushServerUrl)
+      case scheme => SMono.error(new IllegalArgumentException(
+        s"JMAP Push subscription $pushServerUrl is using the unsupported 
scheme ${scheme.getOrElse("<none>")}. " +
+          s"Only ${ALLOWED_SCHEMES.toSeq.sorted.mkString(" and ")} are 
allowed."))
+    }
+}
+
+private class SSRFPreventingAddressResolverGroup(validator: SSRFValidator) 
extends AddressResolverGroup[InetSocketAddress] {
+  override protected def newResolver(executor: EventExecutor): 
AddressResolver[InetSocketAddress] =
+    new SSRFPreventingNameResolver(executor, validator).asAddressResolver()
+}
+
+private class SSRFPreventingNameResolver(executor: EventExecutor, validator: 
SSRFValidator) extends InetNameResolver(executor) {
+  override protected def doResolve(inetHost: String, promise: 
Promise[InetAddress]): Unit =
+    safeResolve(inetHost).subscribe(
+      (addresses: Seq[InetAddress]) => { promise.trySuccess(addresses.head); 
() },
+      (error: Throwable) => { promise.tryFailure(error); () })
+
+  override protected def doResolveAll(inetHost: String, promise: 
Promise[util.List[InetAddress]]): Unit =
+    safeResolve(inetHost).subscribe(
+      (addresses: Seq[InetAddress]) => { promise.trySuccess(addresses.asJava); 
() },
+      (error: Throwable) => { promise.tryFailure(error); () })
+
+  // Not named `resolve`: SimpleNameResolver::resolve is final
+  private def safeResolve(inetHost: String): Mono[Seq[InetAddress]] =
+    Mono.fromCallable(() => validator.checkedResolve(inetHost, s"JMAP Push 
subscription resolution of $inetHost"))
+      .subscribeOn(Schedulers.boundedElastic())
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
index 37b306aa4e..01d2ebb843 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
@@ -19,7 +19,6 @@
 
 package org.apache.james.jmap.pushsubscription
 
-import java.net.InetAddress
 import java.nio.charset.StandardCharsets
 import java.time.Duration
 import java.time.temporal.ChronoUnit
@@ -33,13 +32,10 @@ import 
org.apache.james.jmap.pushsubscription.WebPushClientHeader.{CONTENT_ENCOD
 import org.reactivestreams.Publisher
 import reactor.core.publisher.Mono
 import reactor.core.scala.publisher.SMono
-import reactor.core.scheduler.Schedulers
 import reactor.netty.ByteBufMono
 import reactor.netty.http.client.{HttpClient, HttpClientResponse}
 import reactor.netty.resources.ConnectionProvider
 
-import scala.util.{Failure, Success, Try}
-
 trait WebPushClient {
   def push(pushServerUrl: PushSubscriptionServerURL, request: PushRequest): 
Publisher[Unit]
 }
@@ -72,7 +68,7 @@ case class WebPushTemporarilyUnavailableException(httpCode: 
Int, detailError: St
 object DefaultWebPushClient {
   val PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH: Int = 1024
 
-  private def buildHttpClient(configuration: PushClientConfiguration): 
HttpClient = {
+  private def buildHttpClient(configuration: PushClientConfiguration, 
ssrfValidator: SSRFValidator): HttpClient = {
     val connectionProviderBuilder: ConnectionProvider.Builder = 
ConnectionProvider.builder(DefaultWebPushClient.getClass.getName)
     configuration.maxConnections.foreach(configValue => 
connectionProviderBuilder.maxConnections(configValue))
 
@@ -80,18 +76,32 @@ object DefaultWebPushClient {
       .map(configValue => Duration.of(configValue, ChronoUnit.SECONDS))
       .getOrElse(DEFAULT_TIMEOUT)
 
-    HttpClient.create(connectionProviderBuilder.build())
+    val httpClient: HttpClient = 
HttpClient.create(connectionProviderBuilder.build())
       .disableRetry(true)
+      // Redirects are not followed (which is the default) as their target 
would otherwise be reached
+      // without the user supplied URL being the one we validated.
+      .followRedirect(false)
       .responseTimeout(responseTimeout)
       .headers(builder => {
         builder.add("Content-Type", "application/json charset=utf-8")
       })
+
+    if (configuration.preventServerSideRequestForgery) {
+      // The push URL is resolved again when the connection is established: 
unless the very resolution
+      // the connection relies on is validated, a DNS rebinding attack slips 
through.
+      httpClient.resolver(ssrfValidator.addressResolverGroup)
+    } else {
+      httpClient
+    }
   }
 }
 
-class DefaultWebPushClient @Inject()(configuration: PushClientConfiguration) 
extends WebPushClient {
+class DefaultWebPushClient(configuration: PushClientConfiguration, 
ssrfValidator: SSRFValidator) extends WebPushClient {
 
-  val httpClient: HttpClient = buildHttpClient(configuration)
+  @Inject
+  def this(configuration: PushClientConfiguration) = this(configuration, new 
SSRFValidator())
+
+  val httpClient: HttpClient = buildHttpClient(configuration, ssrfValidator)
 
   override def push(pushServerUrl: PushSubscriptionServerURL, request: 
PushRequest): Publisher[Unit] =
     validate(pushServerUrl)
@@ -110,21 +120,11 @@ class DefaultWebPushClient @Inject()(configuration: 
PushClientConfiguration) ext
 
   private def validate(pushServerUrl: PushSubscriptionServerURL): 
SMono[PushSubscriptionServerURL] =
     if (configuration.preventServerSideRequestForgery) {
-      SMono.just(pushServerUrl.value.getHost)
-        .flatMap(host => SMono.fromCallable(() => 
InetAddress.getByName(host)).subscribeOn(Schedulers.boundedElastic()))
-        .handle[InetAddress]((inetAddress, sink) => validate(pushServerUrl, 
inetAddress).fold(sink.error, sink.next))
-        .`then`(SMono.just(pushServerUrl))
+      ssrfValidator.validate(pushServerUrl)
     } else {
       SMono.just(pushServerUrl)
     }
 
-  private def validate(pushServerUrl: PushSubscriptionServerURL, inetAddress: 
InetAddress): Try[InetAddress] = inetAddress match {
-    case address if address.isSiteLocalAddress => Failure(new 
IllegalArgumentException(s"JMAP Push subscription $pushServerUrl is targeting a 
site local address $inetAddress. This could be an attempt for server-side 
request forgery."))
-    case address if address.isLoopbackAddress => Failure(new 
IllegalArgumentException(s"JMAP Push subscription $pushServerUrl is targeting a 
loopback address $inetAddress. This could be an attempt for server-side request 
forgery."))
-    case address if address.isLinkLocalAddress => Failure(new 
IllegalArgumentException(s"JMAP Push subscription $pushServerUrl is targeting a 
link local address $inetAddress. This could be an attempt for server-side 
request forgery."))
-    case _ => Success(inetAddress)
-  }
-
   private def afterHTTPResponseHandler(httpResponse: HttpClientResponse, 
dataBuf: ByteBufMono): Mono[Void] =
     Mono.just(httpResponse.status())
       .flatMap {
@@ -138,9 +138,5 @@ class DefaultWebPushClient @Inject()(configuration: 
PushClientConfiguration) ext
   private def preProcessingData(dataBuf: ByteBufMono): Mono[String] =
     dataBuf.asString(StandardCharsets.UTF_8)
       .switchIfEmpty(Mono.just(""))
-      .map(content => if (content.length > 
PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH) {
-        content.substring(PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH)
-      } else {
-        content
-      })
+      .map(content => content.take(PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH))
 }
diff --git 
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/DefaultWebPushClientSSRFTest.scala
 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/DefaultWebPushClientSSRFTest.scala
new file mode 100644
index 0000000000..d37c5e8e44
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/DefaultWebPushClientSSRFTest.scala
@@ -0,0 +1,88 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.pushsubscription
+
+import java.net.InetAddress
+import java.util.concurrent.atomic.AtomicInteger
+
+import org.apache.james.jmap.api.model.PushSubscriptionServerURL
+import org.apache.james.jmap.pushsubscription.SSRFValidator.HostResolver
+import 
org.apache.james.jmap.pushsubscription.WebPushClientTestFixture.PUSH_REQUEST_SAMPLE
+import org.assertj.core.api.Assertions.{assertThatCode, assertThatThrownBy}
+import org.junit.jupiter.api.{AfterEach, BeforeEach, Test}
+import org.mockserver.configuration.ConfigurationProperties
+import org.mockserver.integration.ClientAndServer
+import org.mockserver.integration.ClientAndServer.startClientAndServer
+import org.mockserver.model.HttpRequest.request
+import org.mockserver.verify.VerificationTimes
+import reactor.core.scala.publisher.SMono
+
+class DefaultWebPushClientSSRFTest {
+  private val CONFIGURATION: PushClientConfiguration = PushClientConfiguration(
+    maxTimeoutSeconds = Some(10),
+    maxConnections = Some(10),
+    preventServerSideRequestForgery = true)
+
+  var mockServer: ClientAndServer = _
+
+  @BeforeEach
+  def setUp(): Unit = {
+    mockServer = startClientAndServer(0)
+    ConfigurationProperties.logLevel("WARN")
+    MockPushServer.appendSpec(mockServer)
+  }
+
+  @AfterEach
+  def tearDown(): Unit = mockServer.close()
+
+  @Test
+  def pushShouldNotReachAHostReboundToAForbiddenAddress(): Unit = {
+    // Resolves to a public address the first time, to the push server the 
connection would land on afterwards
+    val counter: AtomicInteger = new AtomicInteger(0)
+    val rebinding: HostResolver = _ => if (counter.getAndIncrement() == 0) {
+      Seq(InetAddress.getByName("93.184.216.34"))
+    } else {
+      Seq(InetAddress.getByName("127.0.0.1"))
+    }
+    val testee: DefaultWebPushClient = new DefaultWebPushClient(CONFIGURATION, 
new SSRFValidator(rebinding))
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.push(
+      
PushSubscriptionServerURL.from(s"http://push.example.com:${mockServer.getLocalPort}/push";).get,
+      PUSH_REQUEST_SAMPLE)).block())
+      .hasStackTraceContaining("server-side request forgery")
+
+    mockServer.verify(request().withPath("/push"), 
VerificationTimes.exactly(0))
+  }
+
+  @Test
+  def pushShouldSucceedThroughTheValidatingResolver(): Unit = {
+    // Server side request forgery prevention is on: only the address policy 
is relaxed, so that the
+    // loopback bound push server can be reached and the resolver the client 
connects with is exercised
+    val testee: DefaultWebPushClient = new DefaultWebPushClient(CONFIGURATION,
+      new SSRFValidator(policy = (_: InetAddress) => None))
+
+    assertThatCode(() => SMono.fromPublisher(testee.push(
+      
PushSubscriptionServerURL.from(s"http://127.0.0.1:${mockServer.getLocalPort}/push";).get,
+      PUSH_REQUEST_SAMPLE)).block())
+      .doesNotThrowAnyException()
+
+    mockServer.verify(request().withPath("/push"), 
VerificationTimes.atLeast(1))
+  }
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SSRFValidatorTest.scala
 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SSRFValidatorTest.scala
new file mode 100644
index 0000000000..40e05d39ec
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SSRFValidatorTest.scala
@@ -0,0 +1,213 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.pushsubscription
+
+import java.net.{Inet6Address, InetAddress, InetSocketAddress, 
UnknownHostException}
+import java.util.concurrent.atomic.AtomicInteger
+
+import io.netty.resolver.AddressResolver
+import io.netty.util.concurrent.ImmediateEventExecutor
+import org.apache.james.jmap.api.model.PushSubscriptionServerURL
+import org.apache.james.jmap.pushsubscription.SSRFValidator.HostResolver
+import org.assertj.core.api.Assertions.{assertThat, assertThatCode, 
assertThatThrownBy}
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+object SSRFValidatorTest {
+  val PUBLIC_ADDRESS: InetAddress = InetAddress.getByName("93.184.216.34")
+  val LOOPBACK_ADDRESS: InetAddress = InetAddress.getByName("127.0.0.1")
+
+  def resolvingTo(addresses: InetAddress*): HostResolver = _ => addresses.toSeq
+}
+
+class SSRFValidatorTest {
+  import SSRFValidatorTest._
+
+  @ParameterizedTest
+  @ValueSource(strings = Array(
+    // Wildcard: reaches local services, and is not covered by any of the JDK 
predicates
+    "0.0.0.0",
+    "::",
+    // 0.0.0.0/8
+    "0.1.2.3",
+    // Loopback
+    "127.0.0.1",
+    "127.0.0.9",
+    "127.255.255.254",
+    "::1",
+    // Site local
+    "10.9.0.3",
+    "172.16.0.1",
+    "172.31.255.255",
+    "192.168.102.35",
+    "fec0::1",
+    // Link local, including the IPv4 cloud metadata endpoint
+    "169.254.169.254",
+    "fe80::1",
+    // IPv6 unique local (fc00::/7): isSiteLocalAddress only knows about the 
deprecated fec0::/10
+    "fc00::1",
+    "fd00::1",
+    // The IPv6 cloud metadata endpoint
+    "fd00:ec2::254",
+    // Multicast
+    "224.0.0.1",
+    "239.255.255.255",
+    "ff02::1",
+    // Shared address space (RFC 6598)
+    "100.64.0.1",
+    "100.127.255.255",
+    // Broadcast
+    "255.255.255.255",
+    // IPv6 addresses embedding a forbidden IPv4 one
+    "::127.0.0.1",
+    "64:ff9b::7f00:1",
+    "2002:7f00:1::1",
+    "2002:c0a8:1::1"))
+  def forbiddenReasonShouldRejectAddressesReachingTheLocalNetwork(ip: String): 
Unit =
+    
assertThat(SSRFValidator.forbiddenReason(InetAddress.getByName(ip)).isDefined)
+      .describedAs(s"$ip is expected to be rejected")
+      .isTrue
+
+  @ParameterizedTest
+  @ValueSource(strings = Array(
+    "8.8.8.8",
+    "1.1.1.1",
+    "93.184.216.34",
+    // Just outside of the site local and shared address space ranges
+    "172.15.255.255",
+    "172.32.0.1",
+    "100.63.255.255",
+    "100.128.0.0",
+    "2001:4860:4860::8888",
+    "2606:4700:4700::1111",
+    // Embedding a public IPv4 address
+    "::ffff:8.8.8.8",
+    "2002:808:808::1",
+    "64:ff9b::808:808"))
+  def forbiddenReasonShouldAcceptPublicAddresses(ip: String): Unit =
+    
assertThat(SSRFValidator.forbiddenReason(InetAddress.getByName(ip)).isEmpty)
+      .describedAs(s"$ip is expected to be accepted")
+      .isTrue
+
+  @Test
+  def forbiddenReasonShouldRejectIPv4MappedLoopbackHeldAsAnIPv6Address(): Unit 
= {
+    // InetAddress.getByName folds the IPv4-mapped form back into an 
Inet4Address, Inet6Address::getByAddress does not
+    val mappedLoopback: Inet6Address = Inet6Address.getByAddress(null,
+      Array[Byte](0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff.toByte, 0xff.toByte, 127, 
0, 0, 1), 0)
+
+    assertThat(SSRFValidator.forbiddenReason(mappedLoopback).isDefined).isTrue
+  }
+
+  @Test
+  def validateShouldRejectAHostResolvingToASingleForbiddenAddress(): Unit = {
+    val validator = new SSRFValidator(resolvingTo(LOOPBACK_ADDRESS))
+
+    assertThatThrownBy(() => 
validator.validate(url("http://push.example.com";)).block())
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("server-side request forgery")
+  }
+
+  @Test
+  def validateShouldRejectAHostResolvingToBothAPublicAndAForbiddenAddress(): 
Unit = {
+    // Validating the first address only would let the connection land on the 
second one
+    val validator = new SSRFValidator(resolvingTo(PUBLIC_ADDRESS, 
LOOPBACK_ADDRESS))
+
+    assertThatThrownBy(() => 
validator.validate(url("http://push.example.com";)).block())
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("server-side request forgery")
+  }
+
+  @Test
+  def validateShouldAcceptAHostResolvingToPublicAddressesOnly(): Unit = {
+    val validator = new SSRFValidator(resolvingTo(PUBLIC_ADDRESS, 
InetAddress.getByName("8.8.8.8")))
+
+    assertThatCode(() => 
validator.validate(url("http://push.example.com";)).block())
+      .doesNotThrowAnyException()
+  }
+
+  @Test
+  def validateShouldRejectUnsupportedSchemes(): Unit = {
+    val validator = new SSRFValidator(resolvingTo(PUBLIC_ADDRESS))
+
+    assertThatThrownBy(() => 
validator.validate(url("file:///etc/passwd")).block())
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("unsupported scheme")
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = Array("http://push.example.com";, 
"https://push.example.com";))
+  def validateShouldAcceptHttpAndHttps(supportedUrl: String): Unit = {
+    val validator = new SSRFValidator(resolvingTo(PUBLIC_ADDRESS))
+
+    assertThatCode(() => validator.validate(url(supportedUrl)).block())
+      .doesNotThrowAnyException()
+  }
+
+  @Test
+  def validateShouldPropagateResolutionFailures(): Unit = {
+    val validator = new SSRFValidator(_ => throw new 
UnknownHostException("push.example.com"))
+
+    assertThatThrownBy(() => 
validator.validate(url("http://push.example.com";)).block())
+      .hasRootCauseInstanceOf(classOf[UnknownHostException])
+  }
+
+  @Test
+  def addressResolverGroupShouldRejectForbiddenAddresses(): Unit = {
+    assertThatThrownBy(() => resolveAll(new 
SSRFValidator(resolvingTo(LOOPBACK_ADDRESS))))
+      .hasStackTraceContaining("server-side request forgery")
+  }
+
+  @Test
+  def 
addressResolverGroupShouldRejectAHostResolvingToBothAPublicAndAForbiddenAddress():
 Unit = {
+    assertThatThrownBy(() => resolveAll(new 
SSRFValidator(resolvingTo(PUBLIC_ADDRESS, LOOPBACK_ADDRESS))))
+      .hasStackTraceContaining("server-side request forgery")
+  }
+
+  @Test
+  def addressResolverGroupShouldResolvePublicAddresses(): Unit = {
+    assertThat(resolveAll(new SSRFValidator(resolvingTo(PUBLIC_ADDRESS))))
+      .containsExactly(new InetSocketAddress(PUBLIC_ADDRESS, 443))
+  }
+
+  @Test
+  def addressResolverGroupShouldRejectARebindingHost(): Unit = {
+    // A host that passes validation once, then resolves to a forbidden address
+    val counter = new AtomicInteger(0)
+    val validator = new SSRFValidator(_ => if (counter.getAndIncrement() == 0) 
Seq(PUBLIC_ADDRESS) else Seq(LOOPBACK_ADDRESS))
+
+    assertThatCode(() => 
validator.validate(url("http://push.example.com";)).block())
+      .doesNotThrowAnyException()
+    assertThatThrownBy(() => resolveAll(validator))
+      .hasStackTraceContaining("server-side request forgery")
+  }
+
+  private def resolveAll(validator: SSRFValidator): 
java.util.List[InetSocketAddress] = {
+    val group = validator.addressResolverGroup
+    try {
+      val resolver: AddressResolver[InetSocketAddress] = 
group.getResolver(ImmediateEventExecutor.INSTANCE)
+      
resolver.resolveAll(InetSocketAddress.createUnresolved("push.example.com", 
443)).sync().get()
+    } finally {
+      group.close()
+    }
+  }
+
+  private def url(value: String): PushSubscriptionServerURL = 
PushSubscriptionServerURL.from(value).get
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SafeWebPushClientContract.scala
 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SafeWebPushClientContract.scala
index 465f6d6dcb..7d969a3c90 100644
--- 
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SafeWebPushClientContract.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SafeWebPushClientContract.scala
@@ -25,6 +25,7 @@ import java.nio.charset.StandardCharsets
 import org.apache.james.jmap.api.model.PushSubscriptionServerURL
 import 
org.apache.james.jmap.pushsubscription.WebPushClientTestFixture.PUSH_REQUEST_SAMPLE
 import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.jupiter.api.Test
 import org.junit.jupiter.params.ParameterizedTest
 import org.junit.jupiter.params.provider.ValueSource
 import reactor.core.publisher.Mono
@@ -46,11 +47,25 @@ trait SafeWebPushClientContract {
   def testee: WebPushClient
 
   @ParameterizedTest
-  @ValueSource(strings = Array("127.0.0.1", "127.0.0.9", "10.9.0.3", 
"192.168.102.35"))
+  @ValueSource(strings = Array(
+    "127.0.0.1", "127.0.0.9", "10.9.0.3", "192.168.102.35",
+    // The wildcard address reaches local services and is covered by none of 
the JDK predicates
+    "0.0.0.0", "[::]",
+    "[::1]", "169.254.169.254", "224.0.0.1", "255.255.255.255", "100.64.0.1",
+    // IPv6 unique local addresses, which hold the IPv6 cloud metadata endpoint
+    "[fc00::1]", "[fd00::1]", "[fd00:ec2::254]",
+    // IPv6 addresses embedding a forbidden IPv4 one
+    "[::127.0.0.1]", "[64:ff9b::7f00:1]", "[2002:7f00:1::1]"))
   def serverSideRequestForgeryAttemptsShouldBeRejected(ip: String): Unit = {
     assertThatThrownBy(() => 
Mono.from(testee.push(PushSubscriptionServerURL(new URI(s"http://$ip";).toURL), 
PUSH_REQUEST_SAMPLE)).block)
       .isInstanceOf(classOf[IllegalArgumentException])
   }
+
+  @Test
+  def pushShouldRejectNonHttpSchemes(): Unit = {
+    assertThatThrownBy(() => 
Mono.from(testee.push(PushSubscriptionServerURL(new 
URI("file:///etc/passwd").toURL), PUSH_REQUEST_SAMPLE)).block)
+      .isInstanceOf(classOf[IllegalArgumentException])
+  }
 }
 
 
diff --git 
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/WebPushClientContract.scala
 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/WebPushClientContract.scala
index a43ef50681..22c9891574 100644
--- 
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/WebPushClientContract.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/WebPushClientContract.scala
@@ -143,6 +143,24 @@ trait WebPushClientContract {
       VerificationTimes.once)
   }
 
+  @Test
+  def pushRequestShouldTruncateLongErrorResponsesFromPushServer(pushServer: 
ClientAndServer): Unit = {
+    val head: String = 
"a".repeat(DefaultWebPushClient.PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH)
+    pushServer
+      .when(request
+        .withPath("/invalid"))
+      .respond(response
+        .withStatusCode(500)
+        .withBody(head + "beyond-the-truncation-limit"))
+
+    assertThatThrownBy(() => SMono.fromPublisher(
+      
testee.push(PushSubscriptionServerURL.from(s"${pushServerBaseUrl.toString}/invalid").get,
+        PUSH_REQUEST_SAMPLE))
+      .block())
+      .hasMessageContaining(head)
+      .hasMessageNotContaining("beyond-the-truncation-limit")
+  }
+
   @Test
   def pushRequestShouldParserErrorResponseFromPushServerWhenFail(pushServer: 
ClientAndServer): Unit = {
     pushServer


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

Reply via email to