This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch juneau-9.2.1-branch
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/juneau-9.2.1-branch by this
push:
new 81677c11fb Security fixes for the 9.2.1 release.
81677c11fb is described below
commit 81677c11fb4b35157ea176a97af0ace82126d25e
Author: James Bognar <[email protected]>
AuthorDate: Mon Aug 10 17:30:21 2026 -0400
Security fixes for the 9.2.1 release.
---
.../apache/juneau/msgpack/MsgPackInputStream.java | 5 +-
.../apache/juneau/parser/ParserInputStream.java | 44 ++++++++
.../org/apache/juneau/parser/ParserSession.java | 6 +
.../java/org/apache/juneau/swaps/ClassSwap.java | 7 +-
.../main/java/org/apache/juneau/xml/XmlReader.java | 11 ++
.../rest/client/RedirectCredentialGuard.java | 68 ++++++++++++
.../juneau/rest/client/RedirectSecurity.java | 123 +++++++++++++++++++++
.../org/apache/juneau/rest/client/RestClient.java | 3 +
.../org/apache/juneau/http/RedactedHeaders.java | 96 ++++++++++++++++
.../java/org/apache/juneau/rest/RestContext.java | 83 +++++++++++++-
.../java/org/apache/juneau/rest/RestRequest.java | 18 ++-
.../java/org/apache/juneau/rest/RestResponse.java | 76 ++++++++++++-
.../java/org/apache/juneau/rest/RestSession.java | 9 ++
.../juneau/rest/httppart/RequestContent.java | 23 +++-
.../juneau/rest/httppart/RequestFormParams.java | 47 ++++++++
.../org/apache/juneau/rest/logger/CallLogger.java | 44 +++++++-
.../apache/juneau/http/RedactedHeaders_Test.java | 55 +++++++++
.../msgpack/MsgPackParser_MaxLength_Test.java | 59 ++++++++++
.../juneau/rest/RestContext_ErrorPosture_Test.java | 64 +++++++++++
.../juneau/rest/RestRequest_ContentParam_Test.java | 46 ++++++++
.../rest/RestResponse_SafeRedirect_Test.java | 54 +++++++++
.../rest/Rest_PredefinedStatusCodes_Test.java | 14 +--
.../juneau/rest/client/RedirectSecurity_Test.java | 68 ++++++++++++
.../httppart/RequestContent_BufferSize_Test.java | 51 +++++++++
.../httppart/RequestFormParams_MaxInput_Test.java | 80 ++++++++++++++
.../juneau/swaps/ClassSwap_NoInitialize_Test.java | 50 +++++++++
.../apache/juneau/xml/XmlValidatingDtd_Test.java | 65 +++++++++++
27 files changed, 1248 insertions(+), 21 deletions(-)
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/msgpack/MsgPackInputStream.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/msgpack/MsgPackInputStream.java
index 60d9d2b24b..6f6a2d528b 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/msgpack/MsgPackInputStream.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/msgpack/MsgPackInputStream.java
@@ -115,7 +115,10 @@ public class MsgPackInputStream extends ParserInputStream {
* Read a binary field from the stream.
*/
byte[] readBinary() throws IOException {
- var b = new byte[(int)length];
+ // checkLength bounds the declared length against the
configured maximum (always <= Integer.MAX_VALUE),
+ // so a 32-bit MessagePack length in [2^31, 2^32-1] is rejected
here rather than truncating to a
+ // negative int and allocating the wrong size.
+ var b = new byte[checkLength(length, "binary")];
read(b);
return b;
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserInputStream.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserInputStream.java
index f6ce540a59..e2bab20871 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserInputStream.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserInputStream.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.parser;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
import java.io.*;
/**
@@ -36,6 +38,8 @@ public class ParserInputStream extends InputStream implements
Positionable {
private final InputStream is;
int pos = 0;
+ // Default cap (16 MiB) for wire-declared lengths; overridable via
setMaxLength.
+ private int maxLength = 16 * 1024 * 1024;
/**
* Constructor.
@@ -48,6 +52,46 @@ public class ParserInputStream extends InputStream
implements Positionable {
pipe.setPositionable(this);
}
+ /**
+ * Sets the maximum length allowed for a single wire-declared
length/count.
+ *
+ * @param value The maximum length in bytes. Values ≤ 0 disable the
cap (only the negative-length check remains).
+ */
+ public void setMaxLength(int value) {
+ maxLength = value <= 0 ? Integer.MAX_VALUE : value;
+ }
+
+ /**
+ * Bounds a wire-declared length/count against the configured maximum
before it is used to size an allocation.
+ *
+ * @param len The declared length/count read off the wire.
+ * @param what A short description of the field being read (for the
error message).
+ * @return The length as an <c>int</c>, guaranteed to be non-negative
and within the configured maximum.
+ * @throws IOException If the length is negative (or beyond int range)
or exceeds the configured maximum.
+ */
+ public int checkLength(long len, String what) throws IOException {
+ return checkLength(len, maxLength, what);
+ }
+
+ /**
+ * Bounds a wire-declared length/count against the specified maximum
before it is used to size an allocation.
+ *
+ * @param len The declared length/count read off the wire.
+ * @param maxLength The maximum allowed length. Values ≤ 0 are
treated as {@link Integer#MAX_VALUE}
+ * (only the negative-length check applies).
+ * @param what A short description of the field being read (for the
error message).
+ * @return The length as an <c>int</c>, guaranteed to be non-negative
and within the specified maximum.
+ * @throws IOException If the length is negative (or beyond int range)
or exceeds the configured maximum.
+ */
+ public static int checkLength(long len, long maxLength, String what)
throws IOException {
+ var max = maxLength <= 0 ? Integer.MAX_VALUE : maxLength;
+ if (len < 0)
+ throw ioex("Invalid {0} length (negative): {1}", what,
len);
+ if (len > max)
+ throw ioex("{0} length {1} exceeds maximum allowed
{2}", what, len, max);
+ return (int)len;
+ }
+
@Override /* Overridden from Positionable */
public Position getPosition() { return new Position(pos); }
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserSession.java
index 6033b1aa6b..90155083dd 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserSession.java
@@ -620,6 +620,8 @@ public class ParserSession extends BeanSession {
throw e;
} catch (@SuppressWarnings("unused") StackOverflowError e) {
throw new ParseException(this, "Depth too deep. Stack
overflow occurred.");
+ } catch (@SuppressWarnings("unused") OutOfMemoryError e) {
+ throw new ParseException(this, "Out of memory occurred.
Input too large to parse.");
} catch (IOException e) {
throw new ParseException(this, e, "I/O exception
occurred. exception={0}, message={1}.", cns(e), lm(e));
} catch (Exception e) {
@@ -655,6 +657,8 @@ public class ParserSession extends BeanSession {
throw e;
} catch (@SuppressWarnings("unused") StackOverflowError e) {
throw new ParseException(this, "Depth too deep. Stack
overflow occurred.");
+ } catch (@SuppressWarnings("unused") OutOfMemoryError e) {
+ throw new ParseException(this, "Out of memory occurred.
Input too large to parse.");
} catch (IOException e) {
throw new ParseException(this, e, "I/O exception
occurred. exception={0}, message={1}.", cns(e), lm(e));
} catch (Exception e) {
@@ -723,6 +727,8 @@ public class ParserSession extends BeanSession {
throw e;
} catch (@SuppressWarnings("unused") StackOverflowError e) {
throw new ParseException(this, "Depth too deep. Stack
overflow occurred.");
+ } catch (@SuppressWarnings("unused") OutOfMemoryError e) {
+ throw new ParseException(this, "Out of memory occurred.
Input too large to parse.");
} catch (Exception e) {
throw new ParseException(this, e, "Exception occurred.
exception={0}, message={1}.", cns(e), lm(e));
} finally {
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassSwap.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassSwap.java
index 316df905bf..57aa143433 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassSwap.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/swaps/ClassSwap.java
@@ -35,6 +35,11 @@ public class ClassSwap extends StringSwap<Class<?>> {
@Override /* Overridden from ObjectSwap */
public Class<?> unswap(BeanSession session, String o, ClassMeta<?>
hint) throws Exception {
- return o == null ? null : Class.forName(o);
+ if (o == null)
+ return null;
+ // Resolve without initializing — a parsed class name must
never trigger the named class's static
+ // initializer as a side effect. The class loader matches
Class.forName(String)'s implicit caller
+ // loader, so resolvable names continue to resolve exactly as
before.
+ return Class.forName(o, false,
ClassSwap.class.getClassLoader());
}
}
\ No newline at end of file
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlReader.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlReader.java
index 18808d555a..ce1faf25c2 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlReader.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlReader.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Utils.*;
import java.io.*;
+import javax.xml.*;
import javax.xml.namespace.*;
import javax.xml.stream.*;
import javax.xml.stream.util.*;
@@ -64,6 +65,16 @@ public class XmlReader implements XMLStreamReader,
Positionable {
factory.setProperty(XMLInputFactory.IS_COALESCING,
true);
factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ // Disable DTD processing unconditionally. DTD
constructs are not needed for data binding, and
+ // keeping them off — regardless of the validating flag
— bounds document/entity handling so a
+ // declared internal DTD cannot drive runaway entity
expansion.
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+ // Belt-and-suspenders: forbid resolving any external
DTD/schema on factories that honor these
+ // properties, in case a StAX implementation handles
external-entity support inconsistently.
+ if
(factory.isPropertySupported(XMLConstants.ACCESS_EXTERNAL_DTD))
+
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ if
(factory.isPropertySupported(XMLConstants.ACCESS_EXTERNAL_SCHEMA))
+
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
if
(factory.isPropertySupported(XMLInputFactory.REPORTER) && nn(reporter))
factory.setProperty(XMLInputFactory.REPORTER,
reporter);
if
(factory.isPropertySupported(XMLInputFactory.RESOLVER) && nn(resolver))
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RedirectCredentialGuard.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RedirectCredentialGuard.java
new file mode 100644
index 0000000000..b3ea250f2c
--- /dev/null
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RedirectCredentialGuard.java
@@ -0,0 +1,68 @@
+/*
+ * 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.client;
+
+import java.net.*;
+
+import org.apache.http.*;
+import org.apache.http.protocol.*;
+
+/**
+ * Request interceptor that removes caller-set credential headers before a
request is replayed against a
+ * different origin as the result of a {@code 3xx} redirect.
+ *
+ * <p>
+ * The redirect executor copies the original request's headers —
including {@code Authorization} and
+ * {@code Cookie} — onto the follow-up request aimed at the {@code
Location} target. When that target is
+ * a different scheme, host, or port (or an {@code https}→{@code http}
downgrade), forwarding those
+ * headers would disclose credentials to an unrelated origin. This
interceptor runs on every outgoing request
+ * in an exchange: it records the origin of the first request and, on any
later request in the same exchange
+ * whose origin differs, strips the credential headers before the request
leaves the client.
+ *
+ * <p>
+ * The forward/strip decision and the header set are delegated to {@link
RedirectSecurity}.
+ */
+final class RedirectCredentialGuard implements HttpRequestInterceptor {
+
+ private static final String ATTR_ORIGIN =
RedirectCredentialGuard.class.getName() + ".origin";
+
+ @Override /* HttpRequestInterceptor */
+ public void process(HttpRequest request, HttpContext context) {
+ var target = HttpCoreContext.adapt(context).getTargetHost();
+ if (target == null)
+ return;
+ var current = originOf(target);
+ if (current == null)
+ return;
+ var origin = (URI)context.getAttribute(ATTR_ORIGIN);
+ if (origin == null) {
+ context.setAttribute(ATTR_ORIGIN, current);
+ return;
+ }
+ if (RedirectSecurity.shouldStripCredentials(origin, current))
+ for (var name : RedirectSecurity.stripOnCrossOrigin())
+ request.removeHeaders(name);
+ }
+
+ private static URI originOf(HttpHost host) {
+ try {
+ return URI.create(host.toURI());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RedirectSecurity.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RedirectSecurity.java
new file mode 100644
index 0000000000..f3bc0c736b
--- /dev/null
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RedirectSecurity.java
@@ -0,0 +1,123 @@
+/*
+ * 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.client;
+
+import java.net.*;
+import java.util.*;
+
+import org.apache.juneau.http.*;
+
+/**
+ * Pure decision logic for whether caller-set credential headers (such as
{@code Authorization}
+ * or {@code Cookie}) should be forwarded or stripped when a REST call is
transparently replayed
+ * against a redirect target (an HTTP {@code 3xx} response's {@code Location}).
+ *
+ * <p>
+ * This class contains only the <b>decision</b> — it does not itself
intercept, follow, or
+ * rewrite any request. The transport binding is responsible for calling into
this class from its
+ * own redirect hook and acting on the result.
+ *
+ * <p>
+ * The policy is intentionally simple and conservative:
+ * <ul>
+ * <li>Credentials are forwarded only when the redirect target is the
<b>exact same origin</b>
+ * as the original request — same scheme, same host
(case-insensitive), and same port
+ * (after applying the standard default port for {@code
http}/{@code https}).
+ * <li>Credentials are stripped on any other origin change (different
scheme, host, or port).
+ * <li>Credentials are stripped on an {@code https} → {@code http}
scheme downgrade.
+ * </ul>
+ *
+ * <p>
+ * Both {@code from} and {@code to} must be absolute URIs (non-{@code null}
scheme and host);
+ * callers are responsible for resolving a relative {@code Location} header
against the original
+ * request URI before calling into this class.
+ */
+public final class RedirectSecurity {
+
+ private RedirectSecurity() {}
+
+ /**
+ * Returns {@code true} if {@code from} and {@code to} share the same
origin — scheme,
+ * host (case-insensitive), and port (after default-port normalization
for {@code http}/{@code https}).
+ *
+ * @param from The original request URI. Must be absolute (non-{@code
null} scheme and host).
+ * @param to The redirect target URI. Must be absolute (non-{@code
null} scheme and host).
+ * @return {@code true} if both URIs resolve to the same origin.
+ * @throws IllegalArgumentException If either argument is {@code null}
or not an absolute URI.
+ */
+ public static boolean sameOrigin(URI from, URI to) {
+ requireAbsolute(from, "from");
+ requireAbsolute(to, "to");
+ return from.getScheme().equalsIgnoreCase(to.getScheme())
+ && from.getHost().equalsIgnoreCase(to.getHost())
+ && normalizedPort(from) == normalizedPort(to);
+ }
+
+ /**
+ * Returns {@code true} if the redirect represents an {@code https}
→ {@code http} scheme downgrade.
+ *
+ * @param from The original request URI. Must be absolute (non-{@code
null} scheme and host).
+ * @param to The redirect target URI. Must be absolute (non-{@code
null} scheme and host).
+ * @return {@code true} if {@code from} is {@code https} and {@code to}
is {@code http}.
+ * @throws IllegalArgumentException If either argument is {@code null}
or not an absolute URI.
+ */
+ public static boolean isDowngrade(URI from, URI to) {
+ requireAbsolute(from, "from");
+ requireAbsolute(to, "to");
+ return "https".equalsIgnoreCase(from.getScheme()) &&
"http".equalsIgnoreCase(to.getScheme());
+ }
+
+ /**
+ * Convenience boolean form of the forward/strip decision for a single
redirect hop.
+ *
+ * @param from The original request URI. Must be absolute (non-{@code
null} scheme and host).
+ * @param to The redirect target URI. Must be absolute (non-{@code
null} scheme and host).
+ * @return {@code true} if credentials must be stripped before
replaying the request against {@code to}.
+ * @throws IllegalArgumentException If either argument is {@code null}
or not an absolute URI.
+ */
+ public static boolean shouldStripCredentials(URI from, URI to) {
+ return ! sameOrigin(from, to) || isDowngrade(from, to);
+ }
+
+ /**
+ * Returns the header names that must be stripped from a request before
it is replayed cross-origin.
+ * Reuses the canonical credential-header set from {@link
RedactedHeaders#DEFAULT}.
+ *
+ * @return The set of header names to strip on a cross-origin (or
downgraded) redirect.
+ */
+ public static Set<String> stripOnCrossOrigin() {
+ return RedactedHeaders.DEFAULT;
+ }
+
+ private static int normalizedPort(URI uri) {
+ var port = uri.getPort();
+ if (port != -1)
+ return port;
+ return switch (uri.getScheme().toLowerCase(Locale.ROOT)) {
+ case "https" -> 443;
+ case "http" -> 80;
+ default -> -1;
+ };
+ }
+
+ private static void requireAbsolute(URI uri, String argName) {
+ if (uri == null)
+ throw new IllegalArgumentException("Argument '" +
argName + "' must not be null");
+ if (uri.getScheme() == null || uri.getHost() == null)
+ throw new IllegalArgumentException("Argument '" +
argName + "' must be an absolute URI with a scheme and host: " + uri);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
index 065d3837a8..cb353d05fa 100644
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
@@ -6034,6 +6034,9 @@ public class RestClient extends BeanContextable
implements HttpClient, Closeable
if (connectionManager == null)
connectionManager = createConnectionManager();
httpClientBuilder().setConnectionManager(connectionManager);
+ // Prevent the default redirect executor from replaying
caller-set credential headers (Authorization,
+ // Cookie, etc.) to a redirect target on a different
origin or a downgraded scheme.
+ httpClientBuilder().addInterceptorLast(new
RedirectCredentialGuard());
return httpClientBuilder().build();
}
diff --git
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/RedactedHeaders.java
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/RedactedHeaders.java
new file mode 100644
index 0000000000..059e53204b
--- /dev/null
+++
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/RedactedHeaders.java
@@ -0,0 +1,96 @@
+/*
+ * 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.http;
+
+import java.util.*;
+
+/**
+ * Utility for masking the values of header names that commonly carry account
credentials
+ * (bearer tokens, session cookies, API keys, and the like) before they are
written somewhere
+ * observable, such as a log line or an echoed-back response body.
+ *
+ * <p>
+ * Matching is always case-insensitive, mirroring HTTP header-name semantics.
+ *
+ * <p>
+ * Callers that need a different or extended header set (for example, an
additional
+ * internal-trace header) can pass their own name collection to the
two-argument overloads
+ * rather than being limited to {@link #DEFAULT}.
+ */
+public final class RedactedHeaders {
+
+ /** Sentinel value substituted for a masked header value. */
+ public static final String REDACTED = "[REDACTED]";
+
+ /** Canonical set of header names commonly used to carry credentials,
matched case-insensitively. */
+ public static final Set<String> DEFAULT = Set.of(
+ "Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization",
"X-API-Key");
+
+ private RedactedHeaders() {}
+
+ /**
+ * Returns {@code true} if the given header name is in {@link #DEFAULT}
(case-insensitive).
+ *
+ * @param name The header name to test. Can be {@code null} (returns
{@code false}).
+ * @return {@code true} if the name matches one of {@link #DEFAULT},
case-insensitively.
+ */
+ public static boolean isSensitive(String name) {
+ return isSensitive(name, DEFAULT);
+ }
+
+ /**
+ * Returns {@code true} if the given header name matches one of {@code
names}, case-insensitively.
+ *
+ * @param name The header name to test. Can be {@code null} (returns
{@code false}).
+ * @param names The candidate header names. Can be {@code null} or
empty (returns {@code false}).
+ * {@code null} elements are ignored.
+ * @return {@code true} if {@code name} matches one of {@code names},
case-insensitively.
+ */
+ public static boolean isSensitive(String name, Collection<String>
names) {
+ if (name == null || names == null)
+ return false;
+ for (var n : names)
+ if (n != null && name.equalsIgnoreCase(n))
+ return true;
+ return false;
+ }
+
+ /**
+ * Masks {@code value} with {@link #REDACTED} if {@code name} is in
{@link #DEFAULT}
+ * (case-insensitive); otherwise returns {@code value} unchanged.
+ *
+ * @param name The header name. Can be {@code null} (never matches;
{@code value} is returned as-is).
+ * @param value The header value to mask. Can be {@code null}.
+ * @return {@link #REDACTED} if {@code name} is sensitive, else {@code
value}.
+ */
+ public static String redact(String name, String value) {
+ return isSensitive(name) ? REDACTED : value;
+ }
+
+ /**
+ * Masks {@code value} with {@link #REDACTED} if {@code name} matches
one of {@code names}
+ * (case-insensitive); otherwise returns {@code value} unchanged.
+ *
+ * @param name The header name. Can be {@code null} (never matches;
{@code value} is returned as-is).
+ * @param value The header value to mask. Can be {@code null}.
+ * @param names The candidate header names. Can be {@code null} or
empty (nothing is masked).
+ * @return {@link #REDACTED} if {@code name} matches one of {@code
names}, else {@code value}.
+ */
+ public static String redact(String name, String value,
Collection<String> names) {
+ return isSensitive(name, names) ? REDACTED : value;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 436b9dade7..c045d78b81 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -251,7 +251,7 @@ public class RestContext extends Context {
private List<MediaType> produces;
private List<Object> children = list();
private Logger logger;
- private long maxInput =
env("RestContext.maxInput").map(StringUtils::parseLongWithSuffix).orElse(100_000_000l);
+ private long maxInput =
env("RestContext.maxInput").map(StringUtils::parseLongWithSuffix).orElse(1_000_000l);
private MethodExecStore.Builder methodExecStore;
private MethodList destroyMethods;
private MethodList endCallMethods;
@@ -6010,16 +6010,16 @@ public class RestContext extends Context {
if (r.value().length > 0)
code = r.value()[0];
+ var appThrown = e instanceof BasicHttpException;
var e2 = (e instanceof BasicHttpException e3 ? e3 : new
BasicHttpException(code, e));
var req = session.getRequest();
var res = session.getResponse();
- Throwable t = e2.getRootCause();
- if (nn(t)) {
- Thrown t2 = thrown(t);
+ var rpcDispatch = isRpcDispatch(session);
+ var t2 = resolveThrownHeader(e2, isRenderResponseStackTraces(),
rpcDispatch);
+ if (nn(t2))
res.setHeader(t2.getName(), t2.getValue());
- }
try {
res.setContentType("text/plain");
@@ -6040,8 +6040,13 @@ public class RestContext extends Context {
w2.append("HTTP
").append(String.valueOf(statusCode)).append(":
").append(httpMessage).append("\n\n");
if (isRenderResponseStackTraces())
e.printStackTrace(w2);
- else
+ else if (rpcDispatch)
+ // RRPC is an internal transport whose
client proxy relies on the full detail to reconstruct
+ // the server-side exception; it is not
a general-purpose response surface, so it keeps the
+ // detailed body rather than the
suppressed message used for ordinary responses.
w2.append(e2.getFullStackMessage(true));
+ else
+
w2.append(suppressedErrorBodyMessage(e2, appThrown));
}
} catch (Exception e1) {
@@ -6049,6 +6054,72 @@ public class RestContext extends Context {
}
}
+ /**
+ * Determines whether a {@code Thrown} header carrying root-cause
detail should be attached to an error response.
+ *
+ * <p>
+ * The header echoes internal exception detail, so it is only emitted
when stack-trace rendering is explicitly
+ * enabled, or for RRPC dispatch targets where the client proxy relies
on it to reconstruct the thrown exception.
+ *
+ * <p>
+ * Package-private so it can be unit-tested directly.
+ *
+ * @param e2 The (possibly wrapped) exception being reported. Must not
be <jk>null</jk>.
+ * @param renderResponseStackTraces Whether the resource is configured
to render stack traces in responses.
+ * @param rpcDispatch Whether the matched operation is an RRPC dispatch
target.
+ * @return The header to set, or <jk>null</jk> if none should be set.
+ */
+ static Thrown resolveThrownHeader(BasicHttpException e2, boolean
renderResponseStackTraces, boolean rpcDispatch) {
+ if (!renderResponseStackTraces && !rpcDispatch)
+ return null;
+ var t = e2.getRootCause();
+ return nn(t) ? thrown(t) : null;
+ }
+
+ /**
+ * Returns <jk>true</jk> if the operation matched for the given session
is an RRPC dispatch target.
+ *
+ * <p>
+ * Used by {@link #handleError(RestSession, Throwable)} to decide
whether the {@code Thrown} header should be
+ * included regardless of {@link #isRenderResponseStackTraces()}.
+ *
+ * @param session The rest call. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if the matched operation (if any) is an RRPC
dispatch target.
+ */
+ static boolean isRpcDispatch(RestSession session) {
+ var opSession = session.getOpSessionOrNull();
+ return nn(opSession) && opSession.getContext() instanceof
RrpcRestOpContext;
+ }
+
+ /**
+ * Determines the message written to the plain-text error response body
when stack-trace rendering is off.
+ *
+ * <p>
+ * Only application-authored exceptions (those thrown as a {@link
BasicHttpException} with a client-facing
+ * message in mind) have their message echoed; anything else is treated
as internal detail and suppressed.
+ *
+ * <p>
+ * Package-private so it can be unit-tested directly.
+ *
+ * @param e2 The (possibly wrapped) exception being reported. Must not
be <jk>null</jk>.
+ * @param appThrown <jk>true</jk> if the original, unwrapped exception
was itself a {@link BasicHttpException}.
+ * @return The scrubbed message to write, or an empty string if nothing
should be written.
+ */
+ static String suppressedErrorBodyMessage(BasicHttpException e2, boolean
appThrown) {
+ return appThrown ? scrubForXss(e2.getMessage()) : "";
+ }
+
+ /**
+ * Replaces {@code <}, {@code >}, and {@code &} in a message about to
be written into a {@code text/plain} error
+ * response body, as a simple defense against the body being reflected
into an HTML context downstream.
+ *
+ * @param msg The message to scrub. Can be <jk>null</jk>.
+ * @return The scrubbed message, or an empty string if {@code msg} was
<jk>null</jk>.
+ */
+ private static String scrubForXss(String msg) {
+ return msg == null ? "" : msg.replace('<', ' ').replace('>', '
').replace('&', ' ');
+ }
+
/**
* Handle the case where a matching method was not found.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
index c81104fc11..013f435d16 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestRequest.java
@@ -208,6 +208,22 @@ public class RestRequest extends HttpServletRequestWrapper
{
private Charset charset;
+ /**
+ * Returns <jk>true</jk> if <c>method</c> is one of the methods the
<c>content</c> URL-parameter body
+ * override (see {@link
org.apache.juneau.rest.annotation.Rest#disableContentParam()}) is documented to
apply to.
+ *
+ * <p>
+ * Restricting the override to <c>PUT</c>/<c>POST</c> prevents a plain
cross-origin <c>GET</c> (which triggers
+ * no preflight and carries no attacker-controlled header) from
smuggling an attacker-chosen request body into
+ * an endpoint that would otherwise require a body-carrying method.
+ *
+ * @param method The request's HTTP method. Can be <jk>null</jk>
(returns <jk>false</jk>).
+ * @return <jk>true</jk> if <c>method</c> is <c>PUT</c> or <c>POST</c>,
case-insensitively.
+ */
+ static boolean isContentParamMethod(String method) {
+ return "PUT".equalsIgnoreCase(method) ||
"POST".equalsIgnoreCase(method);
+ }
+
/**
* Constructor.
*/
@@ -227,7 +243,7 @@ public class RestRequest extends HttpServletRequestWrapper {
content = new RequestContent(this);
- if (context.isAllowContentParam()) {
+ if (context.isAllowContentParam() &&
isContentParamMethod(inner.getMethod())) {
var b =
queryParams.get("content").asString().orElse(null);
if (nn(b)) {
headers.set("Content-Type",
UonSerializer.DEFAULT.getResponseContentType());
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
index 98e59f85fe..3c27a53f3f 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
@@ -122,7 +122,7 @@ public class RestResponse extends
HttpServletResponseWrapper {
private Optional<HttpPartSchema> contentSchema;
private Serializer serializer;
private Optional<SerializerMatch> serializerMatch;
- private boolean safeHeaders;
+ private boolean safeHeaders = true;
private int maxHeaderLength = 8096;
/**
@@ -530,6 +530,12 @@ public class RestResponse extends
HttpServletResponseWrapper {
* Relative URIs are always interpreted as relative to the context root.
* This is similar to how WAS handles redirect requests, and is
different from how Tomcat handles redirect requests.
*
+ * <p>
+ * This method forwards <c>uri</c> to the container as-is. If the
target is built from request input (e.g.
+ * a query parameter or header value), a caller can steer users to an
arbitrary external site; use
+ * {@link #sendSafeRedirect(String,String...)
sendSafeRedirect(String,String...)} instead in that case, which
+ * validates the target is either relative or points at an allow-listed
host.
+ *
* @param uri The redirection URL.
* @throws IOException If an input or output exception occurs
*/
@@ -541,6 +547,55 @@ public class RestResponse extends
HttpServletResponseWrapper {
inner.sendRedirect(uri);
}
+ /**
+ * Redirects to the specified URI, but only if it's a relative path
(interpreted per {@link #sendRedirect(String)
+ * sendRedirect(String)}) or an absolute/protocol-relative URI whose
host matches one of <c>allowedHosts</c>.
+ *
+ * <p>
+ * Intended for use when the redirect target is derived from request
input rather than a fixed, trusted
+ * literal, so that input can't be used to redirect a user to an
arbitrary external site.
+ *
+ * @param uri The redirection URL.
+ * @param allowedHosts The hosts (case-insensitive) an absolute or
protocol-relative <c>uri</c> is allowed to target. Ignored for relative URIs.
+ * @throws IOException If an input or output exception occurs.
+ * @throws BadRequest If <c>uri</c> is absolute or protocol-relative
and its host isn't in <c>allowedHosts</c>.
+ */
+ public void sendSafeRedirect(String uri, String...allowedHosts) throws
IOException {
+ if (! isSafeRedirectUri(uri, allowedHosts))
+ throw new BadRequest("Redirect target ''{0}'' is not a
relative path or an allowed host.", uri);
+ sendRedirect(uri);
+ }
+
+ /**
+ * Returns <jk>true</jk> if <c>uri</c> is safe to pass to {@link
#sendRedirect(String) sendRedirect(String)}
+ * without risk of redirecting to an unintended external site: either a
relative path, or an absolute/
+ * protocol-relative URI whose host is in <c>allowedHosts</c>.
+ *
+ * @param uri The candidate redirection URL.
+ * @param allowedHosts The hosts (case-insensitive) an absolute or
protocol-relative <c>uri</c> is allowed to target.
+ * @return <jk>true</jk> if the URI is safe to redirect to.
+ */
+ static boolean isSafeRedirectUri(String uri, String...allowedHosts) {
+ if (uri == null || uri.isEmpty())
+ return true;
+ if (uri.charAt(0) == '/' && ! uri.startsWith("//"))
+ return true;
+ if (uri.indexOf("://") == -1 && ! uri.startsWith("//"))
+ return true;
+ String host;
+ try {
+ host = new java.net.URI(uri.startsWith("//") ? "http:"
+ uri : uri).getHost();
+ } catch (java.net.URISyntaxException e) {
+ return false;
+ }
+ if (host == null || allowedHosts == null)
+ return false;
+ for (var h : allowedHosts)
+ if (host.equalsIgnoreCase(h))
+ return true;
+ return false;
+ }
+
/**
* Shortcut for calling <c>getRequest().setAttribute(String,Object)</c>.
*
@@ -798,6 +853,10 @@ public class RestResponse extends
HttpServletResponseWrapper {
* When enabled, invalid characters such as CTRL characters will be
stripped from header values
* before they get set.
*
+ * <p>
+ * This is now the default behavior; this method is retained so callers
that enabled it explicitly continue
+ * to compile. Use {@link #setUnsafeHeaders()} to opt back out in a
fully-trusted setting.
+ *
* @return This object.
*/
public RestResponse setSafeHeaders() {
@@ -805,6 +864,21 @@ public class RestResponse extends
HttpServletResponseWrapper {
return this;
}
+ /**
+ * Disables safe-header mode.
+ *
+ * <p>
+ * Safe-header mode (stripping invalid characters such as CTRL
characters from header values before they are
+ * set) is enabled by default. This method opts back out, restoring
verbatim header pass-through for the rare
+ * case where a caller needs it in a fully-trusted setting.
+ *
+ * @return This object.
+ */
+ public RestResponse setUnsafeHeaders() {
+ this.safeHeaders = false;
+ return this;
+ }
+
private Object getRawOutput() { return content == null ? null :
content.orElse(null); }
private FinishablePrintWriter getWriter(boolean raw, boolean autoflush)
throws NotAcceptable, IOException {
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
index f17ca79836..a0491fdc26 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestSession.java
@@ -362,6 +362,15 @@ public class RestSession extends ContextSession {
return opSession;
}
+ /**
+ * Returns the operation session for this request, or <jk>null</jk> if
one has not been created yet.
+ *
+ * @return The operation session, or <jk>null</jk> if not yet created.
+ */
+ public RestOpSession getOpSessionOrNull() {
+ return opSession;
+ }
+
/**
* Shortcut for calling <c>getRequest().getPathInfo()</c>.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestContent.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestContent.java
index 193b65bede..5406d28d27 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestContent.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestContent.java
@@ -409,8 +409,27 @@ public class RequestContent {
if (r instanceof BufferedReader r2)
return r2;
int len = req.getHttpServletRequest().getContentLength();
- int buffSize = len <= 0 ? 8192 : Math.max(len, 8192);
- return new BufferedReader(r, buffSize);
+ return new BufferedReader(r, computeReaderBufferSize(len,
maxInput));
+ }
+
+ /**
+ * Computes the initial char-buffer size for {@link #getReader()}.
+ *
+ * <p>
+ * The buffer size is derived from the client-supplied content length,
so it is clamped to the effective
+ * maximum-input ceiling to keep a spoofed length from driving an
oversized pre-allocation regardless of the
+ * actual body size. Package-private so the sizing decision can be
exercised directly in unit tests.
+ *
+ * @param declaredContentLength The request's declared content length.
May be {@code <= 0} if absent/unparsable.
+ * @param maxInput The effective maximum-input ceiling in bytes.
Values {@code <= 0} are treated as
+ * "no additional ceiling beyond {@link Integer#MAX_VALUE}".
+ * @return The buffer size to use, at least the historical {@code
8192}-byte default and never past
+ * {@code min(maxInput, Integer.MAX_VALUE)}.
+ */
+ static int computeReaderBufferSize(int declaredContentLength, long
maxInput) {
+ long ceiling = maxInput > 0 ? maxInput : Integer.MAX_VALUE;
+ long buffSize = declaredContentLength <= 0 ? 8192 :
Math.min(Math.max(declaredContentLength, 8192), ceiling);
+ return (int)Math.min(buffSize, Integer.MAX_VALUE);
}
/**
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestFormParams.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestFormParams.java
index c472a17f8c..f85f01f9f7 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestFormParams.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/httppart/RequestFormParams.java
@@ -34,6 +34,7 @@ import org.apache.juneau.commons.utils.*;
import org.apache.juneau.http.*;
import org.apache.juneau.http.header.*;
import org.apache.juneau.http.part.*;
+import org.apache.juneau.http.response.*;
import org.apache.juneau.httppart.*;
import org.apache.juneau.rest.*;
import org.apache.juneau.rest.util.*;
@@ -143,7 +144,13 @@ public class RequestFormParams extends
ArrayList<RequestFormParam> {
if (content.isLoaded() || !
req.getHeader(ContentType.class).orElse(ContentType.NULL).equalsIgnoreCase("multipart/form-data"))
m = RestUtils.parseQuery(content.getReader());
else {
+ // Enforce the effective maxInput ceiling against
multipart bodies, which otherwise route around the
+ // bounded input stream used for non-multipart content.
Reject early on the declared content length,
+ // then again on the cumulative parsed-part size as a
backstop when the length was absent or wrong.
+ var maxInput = req.getOpContext().getMaxInput();
+ checkMultipartLength(maxInput,
req.getHttpServletRequest().getContentLengthLong());
c = req.getHttpServletRequest().getParts();
+ checkMultipartLength(maxInput, c);
if (c == null || c.isEmpty())
m =
req.getHttpServletRequest().getParameterMap();
}
@@ -173,6 +180,46 @@ public class RequestFormParams extends
ArrayList<RequestFormParam> {
}
}
+ /**
+ * Rejects a {@code multipart/form-data} request outright when its
declared {@code Content-Length} already
+ * exceeds {@code maxInput}, before the servlet container is asked to
parse (and potentially buffer to disk)
+ * the body.
+ *
+ * <p>
+ * Package-private so it can be unit-tested directly.
+ *
+ * @param maxInput The effective maximum input ceiling in bytes.
Values {@code <= 0} disable the check.
+ * @param declaredContentLength The request's declared content length.
Values {@code < 0} (absent/unparsable)
+ * are ignored; the post-parse {@link #checkMultipartLength(long,
Collection)} check still applies.
+ * @throws PayloadTooLarge If {@code declaredContentLength} exceeds
{@code maxInput}.
+ */
+ static void checkMultipartLength(long maxInput, long
declaredContentLength) {
+ if (maxInput > 0 && declaredContentLength > maxInput)
+ throw new PayloadTooLarge("Multipart request content
exceeds the maximum allowed input size of {0} bytes.", maxInput);
+ }
+
+ /**
+ * Rejects a {@code multipart/form-data} request once the cumulative
size of its parsed parts exceeds
+ * {@code maxInput}, as a backstop for the case where the declared
{@code Content-Length} was absent or wrong.
+ *
+ * <p>
+ * Package-private so it can be unit-tested directly.
+ *
+ * @param maxInput The effective maximum input ceiling in bytes.
Values {@code <= 0} disable the check.
+ * @param parts The parsed request parts. Can be {@code null} or empty
(nothing is checked).
+ * @throws PayloadTooLarge If the cumulative {@link Part#getSize()}
across {@code parts} exceeds {@code maxInput}.
+ */
+ static void checkMultipartLength(long maxInput, Collection<Part> parts)
{
+ if (maxInput <= 0 || parts == null || parts.isEmpty())
+ return;
+ var total = 0L;
+ for (var p : parts) {
+ total += p.getSize();
+ if (total > maxInput)
+ throw new PayloadTooLarge("Multipart request
content exceeds the maximum allowed input size of {0} bytes.", maxInput);
+ }
+ }
+
/**
* Copy constructor.
*/
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/logger/CallLogger.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/logger/CallLogger.java
index 61cb36b3bd..8c53a4d418 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/logger/CallLogger.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/logger/CallLogger.java
@@ -33,6 +33,7 @@ import org.apache.juneau.*;
import org.apache.juneau.commons.collections.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.cp.*;
+import org.apache.juneau.http.RedactedHeaders;
import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.stats.*;
import org.apache.juneau.rest.util.*;
@@ -107,6 +108,7 @@ public class CallLogger {
Predicate<HttpServletRequest> enabledTest;
CallLoggingDetail requestDetail, responseDetail;
Level level;
+ Set<String> redactedHeaders = new
LinkedHashSet<>(RedactedHeaders.DEFAULT);
/**
* Constructor.
@@ -402,6 +404,41 @@ public class CallLogger {
thrownStore = value;
return this;
}
+
+ /**
+ * Replaces the redacted-header set with the supplied values.
+ *
+ * <p>
+ * Applies to request/response header values written at {@link
CallLoggingDetail#HEADER HEADER}/
+ * {@link CallLoggingDetail#ENTITY ENTITY} detail, regardless
of matched rule. Header names are
+ * matched case-insensitively. Pass an empty array to disable
redaction and restore the old
+ * unredacted behavior (not recommended outside of a trusted
environment or integration tests).
+ *
+ * @param values The header names to redact. Can be
<jk>null</jk> (equivalent to an empty array
+ * — disables redaction); <jk>null</jk> or blank
elements are skipped.
+ * @return This object.
+ */
+ public Builder redactedHeaders(String...values) {
+ redactedHeaders.clear();
+ if (values != null)
+ for (var v : values)
+ if (v != null && ! v.isBlank())
+ redactedHeaders.add(v);
+ return this;
+ }
+
+ /**
+ * Adds an additional header name to the redacted-header set.
+ *
+ * @param value The header name to redact (case-insensitive).
Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder redactHeader(String value) {
+ if (isBlank(value))
+ throw new IllegalArgumentException("Argument
'value' must not be null or blank");
+ redactedHeaders.add(value);
+ return this;
+ }
}
/** Represents no logger */
@@ -497,6 +534,7 @@ public class CallLogger {
private final Predicate<HttpServletRequest> enabledTest;
private final Level level;
private final CallLoggingDetail requestDetail, responseDetail;
+ private final Set<String> redactedHeaders;
/**
* Constructor.
@@ -516,6 +554,7 @@ public class CallLogger {
this.requestDetail = builder.requestDetail;
this.responseDetail = builder.responseDetail;
this.level = builder.level;
+ this.redactedHeaders = Set.copyOf(builder.redactedHeaders);
}
/**
@@ -533,6 +572,7 @@ public class CallLogger {
this.requestDetail = builder.requestDetail;
this.responseDetail = builder.responseDetail;
this.level = builder.level;
+ this.redactedHeaders = Set.copyOf(builder.redactedHeaders);
}
/**
@@ -611,7 +651,7 @@ public class CallLogger {
sb.append("\n---Request Headers---");
while (hh.hasMoreElements()) {
var h = hh.nextElement();
-
sb.append("\n\t").append(h).append(": ").append(req.getHeader(h));
+
sb.append("\n\t").append(h).append(": ").append(RedactedHeaders.redact(h,
req.getHeader(h), redactedHeaders));
}
}
}
@@ -621,7 +661,7 @@ public class CallLogger {
if (hh.size() > 0) {
sb.append("\n---Response Headers---");
for (var h : hh) {
-
sb.append("\n\t").append(h).append(": ").append(res.getHeader(h));
+
sb.append("\n\t").append(h).append(": ").append(RedactedHeaders.redact(h,
res.getHeader(h), redactedHeaders));
}
}
}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/http/RedactedHeaders_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/http/RedactedHeaders_Test.java
new file mode 100644
index 0000000000..507d0c9d2c
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/http/RedactedHeaders_Test.java
@@ -0,0 +1,55 @@
+/*
+ * 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.http;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that header values commonly carrying credentials are masked before
being written somewhere
+ * observable, while non-sensitive headers pass through unchanged.
+ */
+class RedactedHeaders_Test extends TestBase {
+
+ @Test void a01_sensitiveNamesDetectedCaseInsensitively() {
+ assertTrue(RedactedHeaders.isSensitive("Authorization"));
+ assertTrue(RedactedHeaders.isSensitive("authorization"));
+ assertTrue(RedactedHeaders.isSensitive("Cookie"));
+ assertTrue(RedactedHeaders.isSensitive("Set-Cookie"));
+ }
+
+ @Test void a02_nonSensitiveNamesPassThrough() {
+ assertFalse(RedactedHeaders.isSensitive("Accept"));
+ assertFalse(RedactedHeaders.isSensitive("Content-Type"));
+ assertFalse(RedactedHeaders.isSensitive(null));
+ }
+
+ @Test void a03_redactMasksSensitiveValues() {
+ assertEquals(RedactedHeaders.REDACTED,
RedactedHeaders.redact("Authorization", "Bearer secret"));
+ assertEquals("text/plain",
RedactedHeaders.redact("Content-Type", "text/plain"));
+ }
+
+ @Test void a04_customNameSet() {
+ var names = Set.of("X-Trace-Token");
+ assertEquals(RedactedHeaders.REDACTED,
RedactedHeaders.redact("X-Trace-Token", "abc", names));
+ assertEquals("Bearer secret",
RedactedHeaders.redact("Authorization", "Bearer secret", names));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/msgpack/MsgPackParser_MaxLength_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/msgpack/MsgPackParser_MaxLength_Test.java
new file mode 100644
index 0000000000..527c6e20d2
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/msgpack/MsgPackParser_MaxLength_Test.java
@@ -0,0 +1,59 @@
+/*
+ * 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.msgpack;
+
+import static org.apache.juneau.TestUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Bounds-check tests for the wire-length maximum enforced by {@link
MsgPackInputStream}.
+ *
+ * <p>
+ * A binary/string header carries a length prefix that is used to size a
{@code byte[]} before any payload
+ * byte is read. These tests confirm that an oversized declared length is
rejected up front rather than
+ * driving a large allocation.
+ */
+class MsgPackParser_MaxLength_Test extends TestBase {
+
+ private static InputStream is(String spacedHex) {
+ return new ByteArrayInputStream(fromSpacedHex(spacedHex));
+ }
+
+ @Test void a01_binaryOversizedDeclaredLengthRejected() {
+ // bin32 header (0xC6) declaring 16 MiB + 1 bytes (0x01000001);
a ~5-byte input must not size a giant buffer.
+ assertThrowsWithMessage(Exception.class, "exceeds maximum",
+ () -> MsgPackParser.DEFAULT.parse(is("C6 01 00 00 01"),
Object.class));
+ }
+
+ @Test void a02_stringOversizedDeclaredLengthRejected() {
+ // str32 header (0xDB) declaring 16 MiB + 1 bytes.
+ assertThrowsWithMessage(Exception.class, "exceeds maximum",
+ () -> MsgPackParser.DEFAULT.parse(is("DB 01 00 00 01"),
Object.class));
+ }
+
+ @Test void a03_binaryWithinCapAccepted() throws Exception {
+ // bin8 header (0xC4) declaring 2 bytes, followed by the 2
payload bytes; parses normally within the cap.
+ var r = MsgPackParser.DEFAULT.parse(is("C4 02 61 62"),
Object.class);
+ assertNotNull(r);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/RestContext_ErrorPosture_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestContext_ErrorPosture_Test.java
new file mode 100644
index 0000000000..06d21b4f56
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestContext_ErrorPosture_Test.java
@@ -0,0 +1,64 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.response.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the error-response posture: internal exception detail is only
echoed back to the client when it is
+ * explicitly enabled (stack-trace rendering) or required by an RRPC dispatch
target, and the plain-text error
+ * body only carries a message for application-authored exceptions.
+ */
+class RestContext_ErrorPosture_Test extends TestBase {
+
+ private static BasicHttpException ex() {
+ return new BadRequest(new
RuntimeException("root-cause-detail"));
+ }
+
+ @Test void a01_thrownHeaderSuppressedByDefault() {
+ assertNull(RestContext.resolveThrownHeader(ex(), false, false));
+ }
+
+ @Test void a02_thrownHeaderWhenStackTracesRendered() {
+ assertNotNull(RestContext.resolveThrownHeader(ex(), true,
false));
+ }
+
+ @Test void a03_thrownHeaderForRpcDispatch() {
+ assertNotNull(RestContext.resolveThrownHeader(ex(), false,
true));
+ }
+
+ @Test void a04_appThrownMessageEchoed() {
+ var e = new BadRequest("visible message");
+ assertEquals("visible message",
RestContext.suppressedErrorBodyMessage(e, true));
+ }
+
+ @Test void a05_nonAppThrownMessageSuppressed() {
+ assertEquals("", RestContext.suppressedErrorBodyMessage(ex(),
false));
+ }
+
+ @Test void a06_appThrownMessageScrubbed() {
+ var e = new BadRequest("<b>x</b>&y");
+ var r = RestContext.suppressedErrorBodyMessage(e, true);
+ assertFalse(r.contains("<"));
+ assertFalse(r.contains(">"));
+ assertFalse(r.contains("&"));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/RestRequest_ContentParam_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestRequest_ContentParam_Test.java
new file mode 100644
index 0000000000..b11b57d9e2
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestRequest_ContentParam_Test.java
@@ -0,0 +1,46 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that the {@code &content=} query-parameter override is only
honored for the request methods that
+ * carry a body ({@code PUT}/{@code POST}), so a body can't be injected
through the URL on a bodyless method.
+ */
+class RestRequest_ContentParam_Test extends TestBase {
+
+ @Test void a01_bodyMethodsAllowed() {
+ assertTrue(RestRequest.isContentParamMethod("PUT"));
+ assertTrue(RestRequest.isContentParamMethod("POST"));
+ }
+
+ @Test void a02_caseInsensitive() {
+ assertTrue(RestRequest.isContentParamMethod("put"));
+ assertTrue(RestRequest.isContentParamMethod("Post"));
+ }
+
+ @Test void a03_bodylessMethodsRejected() {
+ assertFalse(RestRequest.isContentParamMethod("GET"));
+ assertFalse(RestRequest.isContentParamMethod("DELETE"));
+ assertFalse(RestRequest.isContentParamMethod("HEAD"));
+ assertFalse(RestRequest.isContentParamMethod("OPTIONS"));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_SafeRedirect_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_SafeRedirect_Test.java
new file mode 100644
index 0000000000..21c0ccb479
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_SafeRedirect_Test.java
@@ -0,0 +1,54 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the guard used by the safe-redirect helper: only relative paths
and allow-listed hosts are treated
+ * as safe redirect targets, so a redirect target derived from request input
can't send a user to an arbitrary
+ * external site.
+ */
+class RestResponse_SafeRedirect_Test extends TestBase {
+
+ @Test void a01_relativePathsAreSafe() {
+ assertTrue(RestResponse.isSafeRedirectUri("/app/page"));
+ assertTrue(RestResponse.isSafeRedirectUri("page"));
+ assertTrue(RestResponse.isSafeRedirectUri(""));
+ assertTrue(RestResponse.isSafeRedirectUri(null));
+ }
+
+ @Test void a02_protocolRelativeIsNotSafeWithoutAllowedHost() {
+ assertFalse(RestResponse.isSafeRedirectUri("//evil.example"));
+ }
+
+ @Test void a03_absoluteCrossOriginRejected() {
+
assertFalse(RestResponse.isSafeRedirectUri("https://evil.example/steal"));
+ }
+
+ @Test void a04_absoluteAllowedHostAccepted() {
+
assertTrue(RestResponse.isSafeRedirectUri("https://good.example/next",
"good.example"));
+
assertTrue(RestResponse.isSafeRedirectUri("https://GOOD.example/next",
"good.example"));
+ }
+
+ @Test void a05_absoluteNonAllowedHostRejected() {
+
assertFalse(RestResponse.isSafeRedirectUri("https://evil.example/steal",
"good.example"));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_PredefinedStatusCodes_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_PredefinedStatusCodes_Test.java
index 3e1503376b..8ff9b96977 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_PredefinedStatusCodes_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/Rest_PredefinedStatusCodes_Test.java
@@ -116,43 +116,43 @@ class Rest_PredefinedStatusCodes_Test extends TestBase {
.run()
.assertStatus(400)
.assertContent().isContains(
- "Unknown property 'f2' encountered while trying
to parse into class"
+ "Could not convert request content content to
class type"
);
b.put("/a?noTrace=true", "{f1:'foo', f2:'foo'}",
APPLICATION_JSON)
.run()
.assertStatus(400)
.assertContent().isContains(
- "Unknown property 'f2' encountered while trying
to parse into class"
+ "Could not convert request content content to
class type"
);
b.put("/b?noTrace=true", "{f1:'foo'}", APPLICATION_JSON)
.run()
.assertStatus(400)
.assertContent().isContains(
- "NumberFormatException"
+ "Could not convert request content content to
class type"
);
b.put("/c?noTrace=true", "{f1:1}", APPLICATION_JSON)
.run()
.assertStatus(400)
.assertContent().isContains(
- "could not be instantiated"
+ "Could not convert request content content to
class type"
);
b.put("/d?noTrace=true", "{f1:1}", APPLICATION_JSON)
.run()
.assertStatus(400)
.assertContent().isContains(
- "could not be instantiated"
+ "Could not convert request content content to
class type"
);
b.put("/e?noTrace=true", "{f1:1}", APPLICATION_JSON)
.run()
.assertStatus(400)
.assertContent().isContains(
- "Class is not public"
+ "Could not convert request content content to
class type"
);
b.put("/f?noTrace=true", "'foo'", APPLICATION_JSON)
.run()
.assertStatus(400)
.assertContent().isContains(
- "Test error"
+ "Could not convert request content content to
class type"
);
b.put("/g/123?noTrace=true&p1=foo", "'foo'", APPLICATION_JSON)
.run()
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/client/RedirectSecurity_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/client/RedirectSecurity_Test.java
new file mode 100644
index 0000000000..418b59c94a
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/client/RedirectSecurity_Test.java
@@ -0,0 +1,68 @@
+/*
+ * 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.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the credential-forwarding decision used when a client
transparently follows a redirect: credentials
+ * are forwarded only to the exact same origin, and stripped on any origin
change or scheme downgrade.
+ */
+class RedirectSecurity_Test extends TestBase {
+
+ private static URI u(String s) {
+ return URI.create(s);
+ }
+
+ @Test void a01_sameOriginForwards() {
+
assertTrue(RedirectSecurity.sameOrigin(u("https://good.example/a"),
u("https://good.example/b")));
+
assertFalse(RedirectSecurity.shouldStripCredentials(u("https://good.example/a"),
u("https://good.example/b")));
+ }
+
+ @Test void a02_sameOriginDefaultPortNormalized() {
+
assertTrue(RedirectSecurity.sameOrigin(u("https://good.example/a"),
u("https://good.example:443/b")));
+ }
+
+ @Test void a03_differentHostStrips() {
+
assertTrue(RedirectSecurity.shouldStripCredentials(u("https://good.example/a"),
u("https://evil.example/b")));
+ }
+
+ @Test void a04_differentPortStrips() {
+
assertTrue(RedirectSecurity.shouldStripCredentials(u("https://good.example/a"),
u("https://good.example:8443/b")));
+ }
+
+ @Test void a05_schemeDowngradeStrips() {
+
assertTrue(RedirectSecurity.isDowngrade(u("https://good.example/a"),
u("http://good.example/b")));
+
assertTrue(RedirectSecurity.shouldStripCredentials(u("https://good.example/a"),
u("http://good.example/b")));
+ }
+
+ @Test void a06_stripSetIncludesCommonCredentialHeaders() {
+ var s = RedirectSecurity.stripOnCrossOrigin();
+
assertTrue(s.stream().anyMatch("Authorization"::equalsIgnoreCase));
+ assertTrue(s.stream().anyMatch("Cookie"::equalsIgnoreCase));
+ }
+
+ @Test void a07_nonAbsoluteRejected() {
+ assertThrows(IllegalArgumentException.class, () ->
RedirectSecurity.sameOrigin(u("/relative"), u("https://good.example/b")));
+ assertThrows(IllegalArgumentException.class, () ->
RedirectSecurity.sameOrigin(null, u("https://good.example/b")));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/httppart/RequestContent_BufferSize_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/httppart/RequestContent_BufferSize_Test.java
new file mode 100644
index 0000000000..4db21e8f9a
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/httppart/RequestContent_BufferSize_Test.java
@@ -0,0 +1,51 @@
+/*
+ * 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.httppart;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that the reader buffer size derived from the client-supplied
content length is clamped to the
+ * effective maximum-input ceiling, so a spoofed content length can't drive an
oversized pre-allocation.
+ */
+class RequestContent_BufferSize_Test extends TestBase {
+
+ @Test void a01_absentLengthUsesDefault() {
+ assertEquals(8192, RequestContent.computeReaderBufferSize(0,
1_000_000));
+ assertEquals(8192, RequestContent.computeReaderBufferSize(-1,
1_000_000));
+ }
+
+ @Test void a02_smallLengthRaisedToDefault() {
+ assertEquals(8192, RequestContent.computeReaderBufferSize(100,
1_000_000));
+ }
+
+ @Test void a03_lengthUsedWhenWithinCeiling() {
+ assertEquals(50_000,
RequestContent.computeReaderBufferSize(50_000, 1_000_000));
+ }
+
+ @Test void a04_lengthClampedToCeiling() {
+ // A spoofed near-max content length must not size the buffer
past the ceiling.
+ assertEquals(1_000_000,
RequestContent.computeReaderBufferSize(Integer.MAX_VALUE, 1_000_000));
+ }
+
+ @Test void a05_noCeilingHonorsLength() {
+ assertEquals(Integer.MAX_VALUE,
RequestContent.computeReaderBufferSize(Integer.MAX_VALUE, 0));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/httppart/RequestFormParams_MaxInput_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/httppart/RequestFormParams_MaxInput_Test.java
new file mode 100644
index 0000000000..1dc227488f
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/httppart/RequestFormParams_MaxInput_Test.java
@@ -0,0 +1,80 @@
+/*
+ * 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.httppart;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.response.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Verifies that a multipart request body is bounded by the effective
maximum-input ceiling: it is rejected on
+ * the declared content length up front, and again on the cumulative
parsed-part size as a backstop.
+ */
+class RequestFormParams_MaxInput_Test extends TestBase {
+
+ @Test void a01_declaredLengthWithinCeilingAccepted() {
+ RequestFormParams.checkMultipartLength(1_000_000, 500_000L);
+ }
+
+ @Test void a02_declaredLengthOverCeilingRejected() {
+ assertThrows(PayloadTooLarge.class, () ->
RequestFormParams.checkMultipartLength(1_000_000, 2_000_000L));
+ }
+
+ @Test void a03_absentDeclaredLengthIgnored() {
+ RequestFormParams.checkMultipartLength(1_000_000, -1L);
+ }
+
+ @Test void a04_disabledCeilingSkipsDeclaredLengthCheck() {
+ RequestFormParams.checkMultipartLength(0, Long.MAX_VALUE);
+ }
+
+ @Test void a05_cumulativePartsWithinCeilingAccepted() {
+ RequestFormParams.checkMultipartLength(1_000_000,
List.of(part(400_000), part(400_000)));
+ }
+
+ @Test void a06_cumulativePartsOverCeilingRejected() {
+ assertThrows(PayloadTooLarge.class,
+ () -> RequestFormParams.checkMultipartLength(1_000_000,
List.of(part(600_000), part(600_000))));
+ }
+
+ @Test void a07_emptyOrNullPartsIgnored() {
+ RequestFormParams.checkMultipartLength(1_000_000,
(Collection<Part>)null);
+ RequestFormParams.checkMultipartLength(1_000_000, List.of());
+ }
+
+ private static Part part(long size) {
+ return new Part() {
+ @Override public InputStream getInputStream() { throw
new UnsupportedOperationException(); }
+ @Override public String getContentType() { return null;
}
+ @Override public String getName() { return "f"; }
+ @Override public String getSubmittedFileName() { return
null; }
+ @Override public long getSize() { return size; }
+ @Override public void write(String fileName) { throw
new UnsupportedOperationException(); }
+ @Override public void delete() { throw new
UnsupportedOperationException(); }
+ @Override public String getHeader(String name) { return
null; }
+ @Override public Collection<String> getHeaders(String
name) { return List.of(); }
+ @Override public Collection<String> getHeaderNames() {
return List.of(); }
+ };
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/swaps/ClassSwap_NoInitialize_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/swaps/ClassSwap_NoInitialize_Test.java
new file mode 100644
index 0000000000..111ed9c312
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/swaps/ClassSwap_NoInitialize_Test.java
@@ -0,0 +1,50 @@
+/*
+ * 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.swaps;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that resolving a {@link Class} value from parsed input does not
trigger the named class's static
+ * initializer as a side effect.
+ */
+class ClassSwap_NoInitialize_Test extends TestBase {
+
+ /** Records whether its static initializer has run. Referencing {@code
.class} does not initialize it. */
+ static class SideEffect {
+ static volatile boolean initialized = false;
+ static { initialized = true; }
+ }
+
+ @Test void a01_unswapDoesNotInitializeResolvedClass() throws Exception {
+ SideEffect.initialized = false;
+ var swap = new ClassSwap();
+
+ var resolved = swap.unswap(null, SideEffect.class.getName(),
null);
+
+ assertEquals(SideEffect.class, resolved);
+ assertFalse(SideEffect.initialized, "Resolving the class name
must not run its static initializer");
+ }
+
+ @Test void a02_unswapResolvesCommonClass() throws Exception {
+ var swap = new ClassSwap();
+ assertEquals(String.class, swap.unswap(null,
"java.lang.String", null));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/xml/XmlValidatingDtd_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/xml/XmlValidatingDtd_Test.java
new file mode 100644
index 0000000000..f80ce659e6
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/xml/XmlValidatingDtd_Test.java
@@ -0,0 +1,65 @@
+/*
+ * 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.xml;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that DTD processing stays disabled even when the {@code
validating} parser option is enabled.
+ *
+ * <p>
+ * DTD support is not needed for data binding, so it is kept off regardless of
the {@code validating} flag:
+ * a document declaring an internal DTD with nested entity references must be
rejected rather than expanding
+ * those entities, closing the input-amplification surface that would
otherwise be reachable by toggling
+ * {@code validating}.
+ */
+class XmlValidatingDtd_Test extends TestBase {
+
+ @Test void a01_internalDtdRejectedWhenValidatingEnabled() {
+ var p = XmlParser.create().validating().build();
+ var xml = "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE A [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b
\"&a;&a;&a;&a;&a;\">]>"
+ + "<A>&b;</A>";
+
+ // DTD processing is disabled, so a document declaring an
internal DTD with nested entity references is
+ // rejected rather than parsed — the input-amplification
surface is never entered.
+ assertThrows(Exception.class, () -> p.parse(xml,
JsonMap.class));
+ }
+
+ @Test void a03_internalDtdRejectedByDefaultParser() {
+ var p = XmlParser.DEFAULT;
+ var xml = "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE A [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b
\"&a;&a;&a;&a;&a;\">]>"
+ + "<A>&b;</A>";
+
+ // Same guard on the default (non-validating) parser.
+ assertThrows(Exception.class, () -> p.parse(xml,
JsonMap.class));
+ }
+
+ @Test void a02_doctypeRejectedWhenValidatingEnabled() {
+ var p = XmlParser.create().validating().build();
+ var xml = "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE A [<!ELEMENT A ANY>]>"
+ + "<A>x</A>";
+
+ assertThrows(Exception.class, () -> p.parse(xml,
JsonMap.class));
+ }
+}