From 6bec545f9b44fffc5704d400c6a3dc273e397cf0 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 18 Aug 2026 08:45:46 +0700 Subject: [PATCH 1/5] [FIX] Include more ip ranges in WebPush target validation (#3112) * [FIX] Include more ip ranges in WebPush target validation * fixup! [FIX] Include more ip ranges in WebPush target validation * fixup! fixup! [FIX] Include more ip ranges in WebPush target validation --- .../modules/ROOT/pages/operate/security.adoc | 4 +- .../jmap/pushsubscription/SSRFValidator.scala | 202 +++++++++++++++++ .../jmap/pushsubscription/WebPushClient.scala | 44 ++-- .../DefaultWebPushClientSSRFTest.scala | 88 ++++++++ .../pushsubscription/SSRFValidatorTest.scala | 213 ++++++++++++++++++ .../SafeWebPushClientContract.scala | 17 +- .../WebPushClientContract.scala | 18 ++ 7 files changed, 559 insertions(+), 27 deletions(-) create mode 100644 server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/SSRFValidator.scala create mode 100644 server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/DefaultWebPushClientSSRFTest.scala create mode 100644 server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/SSRFValidatorTest.scala diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/security.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/security.adoc index dd48755e338..0bd84229cc3 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/security.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/security.adoc @@ -64,7 +64,7 @@ or xref:configure/jmx.adoc[disabling JMX]. JMX is needed to use the existing CLI 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:configure/jmap.adoc[configured] using the `push.prevent.server.side.request.forgery=true` property, +xref:configure/jmap.adoc[configured] using the `webpush.prevent.server.side.request.forgery=true` property, forbidding push to private addresses. === Best practice: Should @@ -206,4 +206,4 @@ This issue can be mitigated by: - Possibly running James in a container - Disabling JMX all-together (Guice only) -Read more link:http://james.apache.org//james/update/2017/10/20/james-3.0.1.html[here]. \ No newline at end of file +Read more link:http://james.apache.org//james/update/2017/10/20/james-3.0.1.html[here]. 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 00000000000..96b61d8ec39 --- /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("")}. " + + 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 f413381e0be..e3f2959605f 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(detailError: String) extends W 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 00000000000..d37c5e8e443 --- /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 00000000000..40e05d39ec8 --- /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 71b536e79e9..c1802d255b3 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 URL(s"http://$ip")), 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 a41b292d5d2..b032758718e 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 @@ -110,6 +110,24 @@ trait WebPushClientContract { VerificationTimes.atLeast(1)) } + @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 From f41c65cbb7d8f8a2c1efe3f79cdfc81a679e5c35 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 18 Aug 2026 09:16:07 +0700 Subject: [PATCH 2/5] fixup! [FIX] Include more ip ranges in WebPush target validation (#3112) --- .../james/jmap/pushsubscription/SafeWebPushClientContract.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c1802d255b3..98ff630903f 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 @@ -19,7 +19,7 @@ package org.apache.james.jmap.pushsubscription -import java.net.URL +import java.net.{URI, URL} import java.nio.charset.StandardCharsets import org.apache.james.jmap.api.model.PushSubscriptionServerURL From 2dd9c245245e0970e8d2d65835fdf0a5b3459d6f Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Wed, 19 Aug 2026 16:54:07 +0700 Subject: [PATCH 3/5] Disable unstable test --- .../james/blob/objectstorage/aws/S3BlobStoreDAOTest.java | 6 ++++++ .../test/java/org/apache/james/smtpserver/SMTPSaslTest.java | 2 ++ .../apache/james/queue/rabbitmq/RabbitMQMailQueueTest.java | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAOTest.java b/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAOTest.java index 43b7cf78181..f46dd861ebc 100644 --- a/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAOTest.java +++ b/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAOTest.java @@ -33,6 +33,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -108,4 +109,9 @@ void readShouldNotLeakHttpConnexionsForUnclosedStreams() { } })).doesNotThrowAnyException(); } + + @Override + @Disabled("Unstable") + public void saveByteSourceShouldBeIdempotent(String description, byte[] bytes) { + } } diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java index 0a15082d31a..e007e661a3c 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java @@ -82,6 +82,7 @@ import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockserver.integration.ClientAndServer; import org.mockserver.model.HttpRequest; @@ -344,6 +345,7 @@ void ehloShouldAdvertiseOAUTHBEARERWhenConfigIsProvided() throws Exception { } @Test + @Disabled("Unstable") void ehloShouldAdvertiseXOAUTH2WhenConfigIsProvided() throws Exception { SMTPSClient client = initSMTPSClient(); diff --git a/server/queue/queue-rabbitmq/src/test/java/org/apache/james/queue/rabbitmq/RabbitMQMailQueueTest.java b/server/queue/queue-rabbitmq/src/test/java/org/apache/james/queue/rabbitmq/RabbitMQMailQueueTest.java index 55fa07907d9..42b73d5ec3f 100644 --- a/server/queue/queue-rabbitmq/src/test/java/org/apache/james/queue/rabbitmq/RabbitMQMailQueueTest.java +++ b/server/queue/queue-rabbitmq/src/test/java/org/apache/james/queue/rabbitmq/RabbitMQMailQueueTest.java @@ -174,6 +174,11 @@ public ManageableMailQueue getManageableMailQueue() { return mailQueue; } + @Override + @Disabled("Unstable") + public void dequeueShouldNotTouchEnqueueMetric(MailQueueMetricExtension.MailQueueMetricTestSystem testSystem) { + } + @Test void browseShouldReturnCurrentlyEnqueuedMailFromAllSlices() throws Exception { ManageableMailQueue mailQueue = getManageableMailQueue(); From 425a60699cafe0f2a07adf50e5da6d470ba0a8af Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 21 Aug 2026 15:05:02 +0700 Subject: [PATCH 4/5] [BUILD] Cap scalac heap to avoid the CI agent being OOM killed --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 91ecaef797e..3f4b786b5a6 100644 --- a/pom.xml +++ b/pom.xml @@ -3065,8 +3065,8 @@ -Xss128m - -Xms4g - -Xmx4g + -Xms512m + -Xmx2g -XX:MaxMetaspaceSize=2g From 2817f30ee1c7543c259b4dae92e5e0b806e97272 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Sun, 23 Aug 2026 10:36:58 +0700 Subject: [PATCH 5/5] Disable faulty git maven plugin --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3f4b786b5a6..fafda837361 100644 --- a/pom.xml +++ b/pom.xml @@ -3645,7 +3645,7 @@ git.commit.message.short git.dirty - false + true true false