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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit 00e039c4b4ab5451ec2af709fade9005763e4df9
Author: James Bognar <[email protected]>
AuthorDate: Sun Aug 16 18:48:54 2026 -0400

    READY-385: Bound remote SAML metadata fetch size (1 MiB cap)
    
    Caps the amount of data read from a remote IdP metadata URL so a
    malicious or misbehaving endpoint cannot exhaust memory via an
    unbounded response body.
---
 .../server/auth/saml/SamlMetadataResolvers.java    | 113 +++++++++++++++++-
 .../SamlMetadataResolvers_BoundedFetch_Test.java   | 127 +++++++++++++++++++++
 2 files changed, 239 insertions(+), 1 deletion(-)

diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers.java
index 40b54331a6..accdce55fa 100644
--- 
a/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers.java
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/main/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers.java
@@ -21,9 +21,12 @@ import static org.apache.juneau.commons.utils.Shorts.*;
 import java.io.*;
 import java.net.*;
 import java.net.http.*;
+import java.nio.*;
 import java.nio.file.*;
 import java.security.cert.*;
 import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
 
 import javax.xml.parsers.*;
 
@@ -140,6 +143,10 @@ public final class SamlMetadataResolvers {
         * <jv>metadataSigningCert</jv>, so a substituted or tampered metadata 
blob is rejected regardless of the
         * transport used to fetch it.  The same transport rule as {@link 
#url(String)} still applies.
         *
+        * <p>
+        * The response body is bounded to {@link 
SamlAuthFilter#DEFAULT_MAX_INFLATED_BYTES} (1 MiB); use
+        * {@link #url(String, X509Certificate, long)} to override that cap.
+        *
         * @param url The metadata URL.  Must use HTTPS or target a loopback 
host.
         * @param metadataSigningCert The certificate whose public key signed 
the metadata document.  When
         *      <jk>null</jk>, no signature validation is performed (equivalent 
to {@link #url(String)}).
@@ -148,6 +155,29 @@ public final class SamlMetadataResolvers {
         *      verify against the pinned certificate.
         */
        public static MetadataResolver url(String url, X509Certificate 
metadataSigningCert) throws IOException {
+               return url(url, metadataSigningCert, 
SamlAuthFilter.DEFAULT_MAX_INFLATED_BYTES);
+       }
+
+       /**
+        * Creates a {@link MetadataResolver} that fetches SAML 2.0 metadata 
from the given URL, verifies the
+        * metadata's XML signature against the supplied pinned certificate 
(when provided), and bounds the fetched
+        * response body to <jv>maxMetadataBytes</jv>.
+        *
+        * <p>
+        * A malicious or compromised metadata endpoint must not be able to 
exhaust heap by returning an
+        * unbounded response.  The cap is enforced both against a declared 
{@code Content-Length} header (rejected
+        * up front) and against the actual streamed byte count (aborted 
mid-fetch), so a missing or lying
+        * {@code Content-Length} cannot bypass it.
+        *
+        * @param url The metadata URL.  Must use HTTPS or target a loopback 
host.
+        * @param metadataSigningCert The certificate whose public key signed 
the metadata document.  When
+        *      <jk>null</jk>, no signature validation is performed.
+        * @param maxMetadataBytes The maximum number of response bytes to 
accept before aborting the fetch.
+        * @return An initialized {@link MetadataResolver}.
+        * @throws IOException If the URL cannot be fetched, the response 
exceeds <jv>maxMetadataBytes</jv>, the
+        *      metadata is malformed, or its signature does not verify against 
the pinned certificate.
+        */
+       public static MetadataResolver url(String url, X509Certificate 
metadataSigningCert, long maxMetadataBytes) throws IOException {
                if (url == null)
                        throw new IllegalArgumentException("url must not be 
null");
                UriUtils.assertSecureOrLoopback(URI.create(url));
@@ -162,7 +192,7 @@ public final class SamlMetadataResolvers {
                                .timeout(Duration.ofSeconds(30))
                                .GET()
                                .build();
-                       var resp = client.send(req, 
HttpResponse.BodyHandlers.ofByteArray());
+                       var resp = client.send(req, 
boundedByteArrayBodyHandler(maxMetadataBytes));
                        if (resp.statusCode() < 200 || resp.statusCode() >= 
300) // HTT: false branch (2xx success) requires live SAML metadata endpoint; 
covered by integration tests
                                throw ioex("Failed to fetch SAML metadata from 
%s (HTTP %s)", url, resp.statusCode());
 
@@ -218,4 +248,85 @@ public final class SamlMetadataResolvers {
                        throw new IllegalArgumentException("path must not be 
null");
                return file(path.toFile());
        }
+
+       /**
+        * Builds a {@link HttpResponse.BodyHandler} that rejects a declared 
{@code Content-Length} over
+        * <jv>maxBytes</jv> up front, and otherwise aborts streaming as soon 
as the actual byte count exceeds
+        * <jv>maxBytes</jv> &mdash; so a missing or understated {@code 
Content-Length} cannot bypass the cap.
+        */
+       private static HttpResponse.BodyHandler<byte[]> 
boundedByteArrayBodyHandler(long maxBytes) {
+               return responseInfo -> {
+                       var declaredLength = 
responseInfo.headers().firstValueAsLong("Content-Length");
+                       if (declaredLength.isPresent() && 
declaredLength.getAsLong() > maxBytes)
+                               return 
BoundedByteArraySubscriber.rejected(maxBytes, declaredLength.getAsLong());
+                       return new BoundedByteArraySubscriber(maxBytes);
+               };
+       }
+
+       /**
+        * A {@link HttpResponse.BodySubscriber} that accumulates the response 
body into a byte array, aborting
+        * (cancelling the upstream subscription and failing {@link 
#getBody()}) as soon as the accumulated byte
+        * count exceeds a fixed cap.
+        */
+       private static final class BoundedByteArraySubscriber implements 
HttpResponse.BodySubscriber<byte[]> {
+               private final long maxBytes;
+               private final ByteArrayOutputStream out = new 
ByteArrayOutputStream();
+               private final CompletableFuture<byte[]> future = new 
CompletableFuture<>();
+               private Flow.Subscription subscription;
+               private long total;
+
+               BoundedByteArraySubscriber(long maxBytes) {
+                       this.maxBytes = maxBytes;
+               }
+
+               static BoundedByteArraySubscriber rejected(long maxBytes, long 
declaredLength) {
+                       var subscriber = new 
BoundedByteArraySubscriber(maxBytes);
+                       subscriber.future.completeExceptionally(
+                               ioex("SAML metadata response declares 
Content-Length %s bytes, exceeding the %s-byte cap", declaredLength, maxBytes));
+                       return subscriber;
+               }
+
+               @Override /* BodySubscriber */
+               public CompletionStage<byte[]> getBody() {
+                       return future;
+               }
+
+               @Override /* Flow.Subscriber */
+               public void onSubscribe(Flow.Subscription subscription) {
+                       if (future.isDone()) { // Already rejected via a 
declared Content-Length over the cap.
+                               subscription.cancel();
+                               return;
+                       }
+                       subscription.request(Long.MAX_VALUE);
+                       this.subscription = subscription;
+               }
+
+               @Override /* Flow.Subscriber */
+               public void onNext(List<ByteBuffer> item) {
+                       if (future.isDone())
+                               return;
+                       for (var buf : item) {
+                               total += buf.remaining();
+                               if (total > maxBytes) {
+                                       subscription.cancel();
+                                       future.completeExceptionally(ioex("SAML 
metadata response exceeds the %s-byte cap", maxBytes));
+                                       return;
+                               }
+                               var bytes = new byte[buf.remaining()];
+                               buf.get(bytes);
+                               out.writeBytes(bytes);
+                       }
+               }
+
+               @Override /* Flow.Subscriber */
+               public void onError(Throwable throwable) {
+                       future.completeExceptionally(throwable);
+               }
+
+               @Override /* Flow.Subscriber */
+               public void onComplete() {
+                       if (!future.isDone())
+                               future.complete(out.toByteArray());
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers_BoundedFetch_Test.java
 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers_BoundedFetch_Test.java
new file mode 100644
index 0000000000..5e10648835
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-auth-saml/src/test/java/org/apache/juneau/rest/server/auth/saml/SamlMetadataResolvers_BoundedFetch_Test.java
@@ -0,0 +1,127 @@
+/*
+ * 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.juneau.rest.server.auth.saml;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * Tests that {@link SamlMetadataResolvers#url(String)} bounds the remote 
metadata fetch to a byte cap, both
+ * when the server declares an over-cap {@code Content-Length} up front and 
when it streams past the cap with
+ * no declared length at all.
+ *
+ * @since 10.0.0
+ */
+class SamlMetadataResolvers_BoundedFetch_Test extends TestBase {
+
+       private static final String MINIMAL_METADATA =
+               "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+               + "<EntityDescriptor 
xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\" 
entityID=\"https://idp.example.com/idp\";>"
+               + "<IDPSSODescriptor 
protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">"
+               + "<SingleSignOnService 
Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" 
Location=\"https://idp.example.com/sso\"/>"
+               + "</IDPSSODescriptor></EntityDescriptor>";
+
+       @FunctionalInterface
+       private interface UrlAction {
+               void run(String url) throws Exception;
+       }
+
+       @SuppressWarnings({
+               "resource" // HttpServer held as local fixture; stopped in 
finally block
+       })
+       private static void withServer(HttpHandler handler, UrlAction action) 
throws Exception {
+               var server = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+               server.createContext("/metadata", handler);
+               server.start();
+               try {
+                       var url = "http://127.0.0.1:"; + 
server.getAddress().getPort() + "/metadata";
+                       action.run(url);
+               } finally {
+                       server.stop(0);
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: declared Content-Length over the cap — rejected before streaming 
the body.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_declaredContentLengthOverDefaultCap_rejected() {
+               // 2 MiB of filler with a matching (accurate) Content-Length 
header, against the 1 MiB default cap.
+               var body = new byte[2 * 1024 * 1024];
+               Arrays.fill(body, (byte) 'A');
+               var ex = assertThrows(IOException.class, () -> 
withServer(exchange -> {
+                       exchange.sendResponseHeaders(200, body.length);
+                       try (var os = exchange.getResponseBody()) {
+                               os.write(body);
+                       }
+               }, url -> SamlMetadataResolvers.url(url)));
+               // Rejection happens up-front on the declared Content-Length, 
not after a failed XML parse of the filler.
+               assertTrue(ex.getMessage().contains("cap"), "Expected a 
cap-rejection message, got: " + ex.getMessage());
+       }
+
+       @Test void a02_declaredContentLengthOverCustomCap_rejected() {
+               var body = new byte[4000];
+               Arrays.fill(body, (byte) 'A');
+               var ex = assertThrows(IOException.class, () -> 
withServer(exchange -> {
+                       exchange.sendResponseHeaders(200, body.length);
+                       try (var os = exchange.getResponseBody()) {
+                               os.write(body);
+                       }
+               }, url -> SamlMetadataResolvers.url(url, null, 1024)));
+               assertTrue(ex.getMessage().contains("cap"), "Expected a 
cap-rejection message, got: " + ex.getMessage());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: no declared Content-Length (chunked) — streaming aborts once the 
actual byte count exceeds the cap.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_chunkedStreamOverDefaultCap_rejected() {
+               var chunk = new byte[64 * 1024];
+               Arrays.fill(chunk, (byte) 'A');
+               var ex = assertThrows(IOException.class, () -> 
withServer(exchange -> {
+                       exchange.sendResponseHeaders(200, 0); // 0 → chunked 
transfer, no Content-Length header
+                       try (var os = exchange.getResponseBody()) {
+                               for (var i = 0; i < 32; i++) // 32 * 64 KiB = 2 
MiB, well past the 1 MiB default cap
+                                       os.write(chunk);
+                       }
+               }, url -> SamlMetadataResolvers.url(url)));
+               assertTrue(ex.getMessage().contains("cap"), "Expected a 
cap-rejection message, got: " + ex.getMessage());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: typical small metadata document — still resolves under the cap 
(regression guard).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_smallValidMetadata_stillResolves() {
+               var bytes = MINIMAL_METADATA.getBytes(StandardCharsets.UTF_8);
+               assertDoesNotThrow(() -> withServer(exchange -> {
+                       exchange.sendResponseHeaders(200, bytes.length);
+                       try (var os = exchange.getResponseBody()) {
+                               os.write(bytes);
+                       }
+               }, url -> assertNotNull(SamlMetadataResolvers.url(url))));
+       }
+}

Reply via email to