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> — 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)))); + } +}
