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
The following commit(s) were added to refs/heads/master by this push:
new f6dd6649b0 TODO-370: Harden REST debug logging against secret
disclosure (secure-by-default no-dump)
f6dd6649b0 is described below
commit f6dd6649b099b1d52ae31d9b9e0094b32a40290e
Author: James Bognar <[email protected]>
AuthorDate: Sun Aug 16 08:45:44 2026 -0400
TODO-370: Harden REST debug logging against secret disclosure
(secure-by-default no-dump)
Request/response bodies are no longer dumped in FINEST debug output by
default.
Body dumping is gated behind the JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES
environment
variable (read-once, trim-then-parse truthy semantics, env-only, no
system-property
fallback). When unset, a suppression placeholder naming the env var is
emitted instead.
- New juneau-rest-common primitives: DebugTextSanitizer (CR/LF +
control-char
log-forging sanitization) and RestDebugBodyScrubber SPI for opt-in custom
scrubbing.
- BasicRestDebugFormatter: env-var master gate, no-dump formatBody,
isBodyRenderable
default predicate, all-values header redaction with widened
formatter-local set,
separator folding, addRedactedHeaders; instance statusLine with
maxUriLength bound.
- RestSession.finish() finish-path containment via RichLogger.
- Docs: rewrote 10.32.RestServerLoggingAndDebugging tier table; release
note added.
- Tests: DebugTextSanitizer_Test, RestDebugFinishContainment_Test,
gate-seam bridges,
and a Spring Boot integration raw-body proof (b06) alongside gate-OFF
assertions.
---
.../logging/RestDebugDumpGateTestSupport.java | 53 +++
...estDebugLoggingPropagation_Springboot_Test.java | 50 ++-
.../org/apache/juneau/http/DebugTextSanitizer.java | 156 +++++++
.../apache/juneau/http/RestDebugBodyScrubber.java | 52 +++
.../juneau/http/DebugTextSanitizer_Test.java | 167 ++++++++
.../juneau/rest/mock/RestDebugCapture_Test.java | 27 +-
.../rest/mock/RestDebugFinishContainment_Test.java | 106 +++++
.../logging/RestDebugDumpGateTestSupport.java | 48 +++
.../org/apache/juneau/rest/server/RestSession.java | 15 +-
.../server/logging/BasicRestDebugFormatter.java | 457 +++++++++++++++++---
.../rest/server/logging/RestDebugFormatter.java | 78 ++++
.../rest/server/logging/RestDebugPipeline.java | 5 +-
.../logging/BasicRestDebugFormatter_Test.java | 458 ++++++++++++++++++++-
13 files changed, 1585 insertions(+), 87 deletions(-)
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/logging/RestDebugDumpGateTestSupport.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/logging/RestDebugDumpGateTestSupport.java
new file mode 100644
index 0000000000..ac9f6fbfc8
--- /dev/null
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/logging/RestDebugDumpGateTestSupport.java
@@ -0,0 +1,53 @@
+/*
+ * 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.logging;
+
+/**
+ * Test-only bridge to the package-private body-dump gate seam on {@link
BasicRestDebugFormatter}.
+ *
+ * <p>
+ * Lives in the {@code org.apache.juneau.rest.server.logging} package (as a
split-package test helper in the
+ * {@code juneau-integration-tests} test sources) purely so the Spring Boot
end-to-end debug tests can force both
+ * gate states without mutating the process environment and without a
system-property fallback. It exists only in
+ * test sources, so application code cannot reach the seam through it.
+ *
+ * <p>
+ * Duplicated (rather than reused) from the equivalent {@code
juneau-rest-mock} test helper because
+ * {@code juneau-integration-tests} depends on {@code juneau-rest-mock}'s main
artifact only, not its test-jar, so
+ * that module's test sources are not on this module's test classpath.
+ *
+ * @since 10.0.0
+ */
+public final class RestDebugDumpGateTestSupport {
+
+ private RestDebugDumpGateTestSupport() {}
+
+ /** Forces the body-dump gate on. */
+ public static void forceOn() {
+
BasicRestDebugFormatter.resetAllowDumpBodiesForTest(Boolean.TRUE);
+ }
+
+ /** Forces the body-dump gate off. */
+ public static void forceOff() {
+
BasicRestDebugFormatter.resetAllowDumpBodiesForTest(Boolean.FALSE);
+ }
+
+ /** Clears the forced state so the next resolution re-reads the
environment once. */
+ public static void reset() {
+ BasicRestDebugFormatter.resetAllowDumpBodiesForTest(null);
+ }
+}
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
index 0eb12f9f6f..555f853f00 100644
---
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
@@ -16,6 +16,7 @@
*/
package org.apache.juneau.rest.springboot.logging;
+import static
org.apache.juneau.rest.server.logging.RestDebugDumpGateTestSupport.*;
import static org.junit.jupiter.api.Assertions.*;
import java.net.*;
@@ -157,7 +158,14 @@ class RestDebugLoggingPropagation_Springboot_Test extends
TestBase {
var record = handler.records().stream().filter(x ->
OP_ECHO.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
assertNotNull(record, "TRACE property should drive JUL
detail without direct Logger.setLevel(...) calls");
assertEquals(Level.INFO, record.getLevel());
- assertTrue(record.getMessage().contains("phase4-body"),
record.getMessage());
+ // Secure-by-default (TODO-370): FINEST no longer dumps
bodies without the JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES
+ // env-var master gate. The proof that the TRACE
property drove the FINEST *tier* (not just headers) is that the
+ // body SECTION is rendered at all — here as the
suppression placeholder, since the gate is unset — while the
+ // raw body never appears.
+ var msg = record.getMessage();
+ assertTrue(msg.contains("---Request Content---"), msg);
+ assertTrue(msg.contains("body suppressed") &&
msg.contains("JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES"), msg);
+ assertFalse(msg.contains("phase4-body"), msg);
} finally {
state.restore(logger);
}
@@ -240,4 +248,44 @@ class RestDebugLoggingPropagation_Springboot_Test extends
TestBase {
opTwoState.restore(opTwo);
}
}
+
+ /**
+ * Companion to {@link
#b01_tracePropertyDrivesFinestBodyDetail_withoutProgrammaticSetLevel()}: that
test proves
+ * only the gate-OFF (secure-by-default) half of the contract -- the
suppression placeholder is rendered and the
+ * raw body never appears. This test forces the body-dump gate
<b>on</b> through the test-only seam (never the
+ * process environment) and proves the complementary half -- the raw
body DOES appear at {@code FINEST} once the
+ * operator has opted in -- inside this module's forked Spring Boot
container context.
+ */
+ @Test void b06_bodyDumpGateOn_rawBodyAppearsAtFinestTier() throws
Exception {
+ var logger = Logger.getLogger(OP_ECHO);
+ var state = new LoggerState(logger);
+ var handler = new CollectingHandler();
+ try {
+ forceOn();
+ for (var h : logger.getHandlers())
+ logger.removeHandler(h);
+ logger.setUseParentHandlers(false);
+ handler.setLevel(Level.INFO);
+ logger.addHandler(handler);
+
+ try (var app = start("logging.level." + HOST +
"=TRACE")) {
+ var port = port(app);
+ var resp = post(port, "/api/echo",
"phase4-body-visible", "Content-Type", "text/plain");
+ assertEquals(200, resp.statusCode());
+
assertTrue(resp.body().contains("phase4-body-visible"), resp.body());
+ }
+
+ var record = handler.records().stream().filter(x ->
OP_ECHO.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
+ assertNotNull(record, "TRACE property should drive JUL
detail without direct Logger.setLevel(...) calls");
+ assertEquals(Level.INFO, record.getLevel());
+ var msg = record.getMessage();
+ assertTrue(msg.contains("---Request Content---"), msg);
+ assertTrue(msg.contains("phase4-body-visible"),
+ "gate forced on via the test-only seam should
render the raw body: " + msg);
+ assertFalse(msg.contains("body suppressed"), msg);
+ } finally {
+ reset();
+ state.restore(logger);
+ }
+ }
}
diff --git
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/DebugTextSanitizer.java
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/DebugTextSanitizer.java
new file mode 100644
index 0000000000..d2725ce6e8
--- /dev/null
+++
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/DebugTextSanitizer.java
@@ -0,0 +1,156 @@
+/*
+ * 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;
+
+/**
+ * Neutralizes log-forging (log-injection) in client-controlled strings before
they are written to a log line.
+ *
+ * <p>
+ * A remote client can place CR/LF (or Unicode line/paragraph separators, bidi
controls, or other control characters)
+ * into a request URI, header name/value, or body, and — if that text is
interpolated verbatim into a log record — forge
+ * additional physical log lines (e.g. a fake status banner) or corrupt a
downstream log parser/SIEM. This utility
+ * replaces every such character with a <b>visible, inert escape</b>
(<c>\r</c>, <c>\n</c>, or <c>\\uXXXX</c>) so the
+ * value's information is preserved (a reader can still see there <i>was</i> a
newline) but it can no longer terminate
+ * the current line or start a new one.
+ *
+ * <p>
+ * The transform:
+ * <ul>
+ * <li>CR (<c>\\u000D</c>) → <c>\r</c>, LF (<c>\\u000A</c>) →
<c>\n</c>.
+ * <li>All other C0 controls (<c>\\u0000</c>–<c>\\u001F</c>)
<b>except TAB</b> (<c>\\u0009</c>) → <c>\\uXXXX</c>.
+ * <li>DEL and C1 controls (<c>\\u007F</c>–<c>\\u009F</c>, which
includes NEL <c>\\u0085</c>) → <c>\\uXXXX</c>.
+ * <li>Line separator (<c>\\u2028</c>), paragraph separator
(<c>\\u2029</c>), and bidi controls
+ * (<c>\\u202A</c>–<c>\\u202E</c>,
<c>\\u2066</c>–<c>\\u2069</c>) → <c>\\uXXXX</c>.
+ * <li>TAB (<c>\\u0009</c>) is <b>preserved</b> — it cannot forge a
line.
+ * </ul>
+ *
+ * <p>
+ * When a length cap is supplied, truncation is applied <b>after</b> escaping,
counts <b>sanitized</b> (character) output,
+ * appends a <c>…[truncated]</c> marker, and never cuts an escape
sequence or a surrogate pair in half. A fast path
+ * returns the original reference unchanged when the string contains no
character requiring an escape and is within the
+ * cap, so well-behaved clients incur no allocation.
+ *
+ * <p>
+ * All methods are thread-safe (the utility is stateless).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerLoggingAndDebugging">Logging
/ Debugging</a>
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class DebugTextSanitizer {
+
+ /** Marker appended when a value is truncated by the length cap. */
+ static final String TRUNCATED_MARKER = "\u2026[truncated]";
+
+ private DebugTextSanitizer() {}
+
+ /**
+ * Escapes every log-forging control character in the given string,
with no length cap.
+ *
+ * @param value The string to sanitize. Can be <jk>null</jk> (returns
<jk>null</jk>).
+ * @return The sanitized string, or the original reference if nothing
needed escaping. <jk>null</jk> if the input was
+ * <jk>null</jk>.
+ */
+ public static String sanitize(String value) {
+ return sanitize(value, Integer.MAX_VALUE);
+ }
+
+ /**
+ * Escapes every log-forging control character in the given string,
then caps the <i>sanitized</i> output to
+ * {@code maxLen} characters.
+ *
+ * <p>
+ * The cap counts the escaped (rendered) length, appends {@code
…[truncated]} when it clips, and never splits an
+ * escape sequence or a surrogate pair.
+ *
+ * @param value The string to sanitize. Can be <jk>null</jk> (returns
<jk>null</jk>).
+ * @param maxLen The maximum number of sanitized characters to keep
(excluding the truncation marker).
+ * @return The sanitized (and possibly truncated) string, or the
original reference if nothing needed escaping and it
+ * was within the cap. <jk>null</jk> if the input was
<jk>null</jk>.
+ */
+ public static String sanitize(String value, int maxLen) {
+ if (value == null)
+ return null;
+
+ // Fast path: nothing to escape and already within the cap →
return the original reference (no allocation).
+ var clean = value.length() <= maxLen;
+ if (clean) {
+ for (var i = 0; i < value.length(); i++) {
+ if (needsEscape(value.charAt(i))) {
+ clean = false;
+ break;
+ }
+ }
+ }
+ if (clean)
+ return value;
+
+ var sb = new StringBuilder(Math.min(value.length(),
Math.max(maxLen, 0)) + 16);
+ var truncated = false;
+ var n = value.length();
+ var i = 0;
+ while (i < n) {
+ var c = value.charAt(i);
+ String token;
+ int consumed;
+ if (needsEscape(c)) {
+ token = escape(c);
+ consumed = 1;
+ } else if (Character.isHighSurrogate(c) && i + 1 < n &&
Character.isLowSurrogate(value.charAt(i + 1))) {
+ // Keep a surrogate pair together so the cap
never cuts a code point in half.
+ token = value.substring(i, i + 2);
+ consumed = 2;
+ } else {
+ token = String.valueOf(c);
+ consumed = 1;
+ }
+ if (sb.length() + token.length() > maxLen) {
+ truncated = true;
+ break;
+ }
+ sb.append(token);
+ i += consumed;
+ }
+ if (truncated)
+ sb.append(TRUNCATED_MARKER);
+ return sb.toString();
+ }
+
+ private static boolean needsEscape(char c) {
+ if (c == '\t')
+ return false;
+ if (c <= '\u001F') // C0 controls (CR/LF included).
+ return true;
+ if (c >= '\u007F' && c <= '\u009F') // DEL + C1 controls
(includes NEL \u0085).
+ return true;
+ if (c == '\u2028' || c == '\u2029') // Line / paragraph
separators.
+ return true;
+ if (c >= '\u202A' && c <= '\u202E') // Bidi embedding/override
controls.
+ return true;
+ return c >= '\u2066' && c <= '\u2069'; // Bidi isolate
controls.
+ }
+
+ private static String escape(char c) {
+ if (c == '\r')
+ return "\\r";
+ if (c == '\n')
+ return "\\n";
+ return String.format("\\u%04X", (int) c);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/RestDebugBodyScrubber.java
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/RestDebugBodyScrubber.java
new file mode 100644
index 0000000000..8ad9603b99
--- /dev/null
+++
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/RestDebugBodyScrubber.java
@@ -0,0 +1,52 @@
+/*
+ * 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;
+
+/**
+ * Optional operator-supplied transform applied to request/response body text
before it is written to a REST debug log.
+ *
+ * <p>
+ * This SPI is <b>not</b> a default protection. Body dumping is off by default
and is enabled only by the
+ * {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment-variable master
gate. A scrubber only ever runs once that gate
+ * has already permitted dumping; it merely chooses <i>scrubbed-vs-raw</i>
body text (e.g. mask fields, drop sections). A
+ * configured scrubber never causes body content to be emitted while the gate
is unset.
+ *
+ * <p>
+ * The scrubber is <b>fail-closed</b>: if {@link #scrub(String, String)}
throws or returns <jk>null</jk>, the formatter
+ * emits a suppression placeholder instead of the body — it never falls back
to the raw, unscrubbed body. A non-<jk>null</jk>
+ * result is still sanitized (control characters escaped) and length-capped
before it reaches the log line.
+ *
+ * <p>
+ * Implementations must be <b>thread-safe</b> — the formatter may invoke
{@link #scrub(String, String)} concurrently.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerLoggingAndDebugging">Logging
/ Debugging</a>
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface RestDebugBodyScrubber {
+
+ /**
+ * Transforms body text before it is logged.
+ *
+ * @param contentType The body's content type (may be <jk>null</jk> if
unknown).
+ * @param body The raw (already byte-capped) body text.
+ * @return The scrubbed body text to log, or <jk>null</jk> to fail
closed (the formatter emits a placeholder instead).
+ */
+ String scrub(String contentType, String body);
+}
diff --git
a/juneau-rest/juneau-rest-common/src/test/java/org/apache/juneau/http/DebugTextSanitizer_Test.java
b/juneau-rest/juneau-rest-common/src/test/java/org/apache/juneau/http/DebugTextSanitizer_Test.java
new file mode 100644
index 0000000000..ea71e66a31
--- /dev/null
+++
b/juneau-rest/juneau-rest-common/src/test/java/org/apache/juneau/http/DebugTextSanitizer_Test.java
@@ -0,0 +1,167 @@
+/*
+ * 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.apache.juneau.http.DebugTextSanitizer.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link DebugTextSanitizer} — control-char escaping,
cap/truncation, and the original-reference fast path.
+ *
+ * @since 10.0.0
+ */
+class DebugTextSanitizer_Test {
+
+ //
-----------------------------------------------------------------------------------------
+ // a — CR/LF escaping (the core log-forging defense)
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void a01_crlf_renderedAsSingleInertLine() {
+ var s = sanitize("a\r\nb");
+ assertEquals("a\\r\\nb", s);
+ // The escaped output can never produce a second physical log
line.
+ assertFalse(s.contains("\r"));
+ assertFalse(s.contains("\n"));
+ }
+
+ @Test void a02_forgedBanner_cannotStartNewLine() {
+ var s = sanitize("GET /x\r\n[200] HTTP GET /admin");
+ assertEquals("GET /x\\r\\n[200] HTTP GET /admin", s);
+ assertEquals(-1, s.indexOf('\n'));
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // b — control-char ranges
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void b01_c0Controls_escapedExceptTab() {
+ assertEquals("\\u0000", sanitize("\u0000"));
+ assertEquals("\\u0001", sanitize("\u0001"));
+ assertEquals("\\u001B", sanitize("\u001B")); // ANSI ESC
+ assertEquals("\\u001F", sanitize("\u001F"));
+ }
+
+ @Test void b02_tab_preserved() {
+ assertEquals("a\tb", sanitize("a\tb"));
+ }
+
+ @Test void b03_delAndC1Controls_escaped() {
+ assertEquals("\\u007F", sanitize("\u007F")); // DEL
+ assertEquals("\\u0085", sanitize("\u0085")); // NEL
+ assertEquals("\\u0080", sanitize("\u0080"));
+ assertEquals("\\u009F", sanitize("\u009F"));
+ }
+
+ @Test void b04_lineAndParagraphSeparators_escaped() {
+ assertEquals("\\u2028", sanitize("\u2028"));
+ assertEquals("\\u2029", sanitize("\u2029"));
+ }
+
+ @Test void b05_bidiControls_escaped() {
+ assertEquals("\\u202A", sanitize("\u202A"));
+ assertEquals("\\u202E", sanitize("\u202E"));
+ assertEquals("\\u2066", sanitize("\u2066"));
+ assertEquals("\\u2069", sanitize("\u2069"));
+ }
+
+ @Test void b06_ordinaryPrintableUntouched() {
+ assertEquals("Hello, world! 123", sanitize("Hello, world!
123"));
+ assertEquals("caf\u00e9", sanitize("caf\u00e9")); // é is
printable, not escaped
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // c — surrogate handling
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void c01_surrogatePair_preservedIntact() {
+ var emoji = "\uD83D\uDE00"; // U+1F600
+ assertEquals(emoji, sanitize(emoji));
+ }
+
+ @Test void c02_surrogatePair_notSplitByCap() {
+ var s = "ab\uD83D\uDE00cd";
+ // Cap of 3 characters: "ab" fits (2), the surrogate pair is 2
chars and would exceed 3 → truncate before it.
+ var out = sanitize(s, 3);
+ assertEquals("ab" + TRUNCATED_MARKER, out);
+ // Cap of 4: "ab" + pair (2) = 4 fits exactly.
+ assertEquals("ab\uD83D\uDE00" + TRUNCATED_MARKER, sanitize(s,
4));
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // d — cap / truncation
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void d01_cap_appendsMarkerAndCountsSanitizedChars() {
+ var out = sanitize("abcdef", 3);
+ assertEquals("abc" + TRUNCATED_MARKER, out);
+ }
+
+ @Test void d02_cap_neverSplitsEscapeSequence() {
+ // "\u001B" escapes to 6 chars. With cap 3 it cannot fit, so
nothing of it is emitted.
+ var out = sanitize("\u001B", 3);
+ assertEquals(TRUNCATED_MARKER, out);
+ // With cap 6 it fits exactly.
+ assertEquals("\\u001B", sanitize("\u001B", 6));
+ // With cap 5 it cannot fit (would split), so it is dropped
whole.
+ assertEquals(TRUNCATED_MARKER, sanitize("\u001B", 5));
+ }
+
+ @Test void d03_cap_crlfEscapeNotSplit() {
+ // "a" + "\r"(→2) with cap 2 → "a" fits, "\r" needs 2 more
(total 3) → truncate.
+ assertEquals("a" + TRUNCATED_MARKER, sanitize("a\r", 2));
+ // cap 3 → "a\\r" fits.
+ assertEquals("a\\r" + TRUNCATED_MARKER, sanitize("a\rb", 3));
+ }
+
+ @Test void d04_zeroCap() {
+ assertEquals("", sanitize("", 0));
+ assertEquals(TRUNCATED_MARKER, sanitize("a", 0));
+ }
+
+ @Test void d05_withinCap_noMarker() {
+ assertEquals("abc", sanitize("abc", 3));
+ assertEquals("abc", sanitize("abc", 100));
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // e — null / empty / fast path
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void e01_null() {
+ assertNull(sanitize(null));
+ assertNull(sanitize(null, 10));
+ }
+
+ @Test void e02_empty() {
+ assertEquals("", sanitize(""));
+ assertSame("", sanitize(""));
+ }
+
+ @Test void e03_fastPath_returnsOriginalReference() {
+ var s = "no controls here";
+ assertSame(s, sanitize(s));
+ assertSame(s, sanitize(s, s.length()));
+ assertSame(s, sanitize(s, 1000));
+ }
+
+ @Test void e04_fastPath_notTakenWhenOverCap() {
+ var s = "abcdef";
+ assertNotSame(s, sanitize(s, 3));
+ }
+}
diff --git
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
index 4db7be2dda..bce65db069 100644
---
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
+++
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
@@ -16,6 +16,7 @@
*/
package org.apache.juneau.rest.mock;
+import static
org.apache.juneau.rest.server.logging.RestDebugDumpGateTestSupport.*;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
@@ -38,6 +39,11 @@ import org.junit.jupiter.api.*;
})
class RestDebugCapture_Test {
+ @AfterEach void resetDumpGate() {
+ // Never leak a forced body-dump gate state into other tests;
next resolution re-reads the environment once.
+ reset();
+ }
+
@Rest(path="/api")
public static class A_Resource {
@@ -248,6 +254,8 @@ class RestDebugCapture_Test {
var prevHandlers = logger.getHandlers();
var handler = new D00_CollectingHandler();
try {
+ // Body dumping is off by default (TODO-370); force it
on so the FINEST body-visibility proof still holds.
+ forceOn();
logger.setUseParentHandlers(false);
for (var h : prevHandlers)
logger.removeHandler(h);
@@ -256,24 +264,25 @@ class RestDebugCapture_Test {
logger.setLevel(Level.FINEST);
var client =
org.apache.juneau.rest.mock.classic.MockRestClient.create(D_Resource.class).build();
- client.post("/echo",
"real-handler-secret").run().assertContent("real-handler-secret");
+ // Renderable Content-Type so the opted-in body can
actually be dumped (absent CT ⇒ non-renderable placeholder).
+ client.post("/echo",
"real-handler-secret").header("Content-Type",
"text/plain").run().assertContent("real-handler-secret");
assertEquals(1, handler.records().size(),
"effective logger FINEST with INFO handler
should publish one INFO-stamped debug record");
var finestRecord = handler.records().get(0);
assertEquals(Level.INFO, finestRecord.getLevel());
assertTrue(finestRecord.getMessage().contains("real-handler-secret"),
- "FINEST detail should still render
request/response body content");
+ "FINEST detail should still render
request/response body content when the operator has opted in");
handler.clear();
handler.setLevel(Level.WARNING);
- client.post("/echo",
"real-handler-secret").run().assertContent("real-handler-secret");
+ client.post("/echo",
"real-handler-secret").header("Content-Type",
"text/plain").run().assertContent("real-handler-secret");
assertTrue(handler.records().isEmpty(),
"INFO-stamped records must be filtered by
handlers above INFO");
handler.setLevel(Level.INFO);
logger.setLevel(Level.INFO);
- client.post("/echo",
"real-handler-secret").run().assertContent("real-handler-secret");
+ client.post("/echo",
"real-handler-secret").header("Content-Type",
"text/plain").run().assertContent("real-handler-secret");
assertEquals(1, handler.records().size(),
"logger INFO + handler INFO should preserve the
prior single visible basic-line behavior");
@@ -311,15 +320,17 @@ class RestDebugCapture_Test {
}
@Test void d02_finestTier_captureWrapperInstalled_bodyRendered() throws
Exception {
+ // Body dumping is off by default (TODO-370); force it on +
renderable Content-Type to prove the FINEST body path.
+ forceOn();
try (var c =
RichLogger.getLogger(D_Resource.class).captureEvents(Level.FINEST)) {
var client =
org.apache.juneau.rest.mock.classic.MockRestClient.create(D_Resource.class).debug().build();
- client.post("/echo",
"two-phase-secret").run().assertContent("two-phase-secret");
+ client.post("/echo",
"two-phase-secret").header("Content-Type",
"text/plain").run().assertContent("two-phase-secret");
assertFalse(c.isEmpty());
assertEquals(Level.INFO, c.last().getLevel());
assertTrue(c.last().getMessage().contains("two-phase-secret"),
- "FINEST tier must install the capture wrapper
and render the body: " + c.last().getMessage());
+ "FINEST tier must install the capture wrapper
and render the body when opted in: " + c.last().getMessage());
}
}
@@ -512,6 +523,8 @@ class RestDebugCapture_Test {
}
@Test void a06_bodyCapOverride_lowersCaptureAtCaptureTime() throws
Exception {
+ // Body dumping is off by default (TODO-370); force it on +
renderable Content-Type to preserve the bodyCap proof.
+ forceOn();
try (var c =
RichLogger.getLogger(A06_Resource.class).captureEvents(Level.FINEST)) {
var client =
org.apache.juneau.rest.mock.classic.MockRestClient
.create(A06_Resource.class)
@@ -519,7 +532,7 @@ class RestDebugCapture_Test {
.build();
// 10-byte body; formatter overrides the cap to 4, well
below the 8KB wrapper default.
- client.post("/echo",
"0123456789").run().assertContent("0123456789");
+ client.post("/echo",
"0123456789").header("Content-Type",
"text/plain").run().assertContent("0123456789");
assertFalse(c.isEmpty());
var msg = c.last().getMessage();
diff --git
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugFinishContainment_Test.java
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugFinishContainment_Test.java
new file mode 100644
index 0000000000..f034879f82
--- /dev/null
+++
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugFinishContainment_Test.java
@@ -0,0 +1,106 @@
+/*
+ * 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.mock;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.util.logging.*;
+
+import org.apache.juneau.commons.logging.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.logging.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Proves that a formatter (or scrubber) that throws while rendering the debug
record during {@link RestSession#finish()}
+ * cannot escape and fail a completed request, and that only a fixed token —
never the secret, the message, or a second
+ * formatter pass — is logged.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+ "resource" // MockRestClient instances are short-lived test fixtures.
+})
+class RestDebugFinishContainment_Test {
+
+ private static final String FINISH_LOGGER = RestSession.class.getName();
+
+ /** A resource that IS its own formatter and throws a {@code
RuntimeException} while rendering the body ({@code FINEST}). */
+ @Rest(path="/finbody")
+ public static class A_ThrowsRuntimeInBody implements RestDebugFormatter
{
+ @Override public String formatBasic(RestRequest req,
RestResponse res) { return "[basic]"; }
+ @Override public String formatBody(RestRequest req,
RestResponse res) {
+ throw new RuntimeException("secret-in-formatter-BODY");
+ }
+ @RestPost(path="/echo")
+ public String echo(RestRequest req) throws IOException {
+ return req.getContent().asString();
+ }
+ }
+
+ @Test void
a01_formatterThrowsRuntimeAtFinest_requestStillCompletes_fixedTokenOnly()
throws Exception {
+ try (var c =
RichLogger.getLogger(FINISH_LOGGER).captureEvents(Level.WARNING)) {
+ var client =
org.apache.juneau.rest.mock.classic.MockRestClient.create(A_ThrowsRuntimeInBody.class).debug().build();
+
+ // The request thread must not fail even though the
formatter throws during finish().
+ client.post("/echo",
"request-payload").run().assertStatus().asCode().is(200).assertContent("request-payload");
+
+ var rec = c.getRecords().stream()
+ .filter(r ->
FINISH_LOGGER.equals(r.getLoggerName()))
+ .reduce((a, b) -> b)
+ .orElse(null);
+ assertNotNull(rec, "a fixed diagnostic-failure token
should be logged");
+ assertEquals("debug formatter failed",
rec.getMessage());
+
assertFalse(rec.getMessage().contains("secret-in-formatter-BODY"),
rec.getMessage());
+ assertNull(rec.getThrown(), "the failure must not
attach the formatter's exception (which carries the body)");
+ }
+ }
+
+ /** A resource that IS its own formatter and throws an {@code Error}
while rendering the basic line ({@code INFO}). */
+ @Rest(path="/finbasic")
+ public static class B_ThrowsErrorInBasic implements RestDebugFormatter {
+ @Override public String formatBasic(RestRequest req,
RestResponse res) {
+ throw new AssertionError("error-secret-in-basic");
+ }
+ @RestPost(path="/echo")
+ public String echo(RestRequest req) throws IOException {
+ return req.getContent().asString();
+ }
+ }
+
+ @Test void
a02_formatterThrowsErrorAtInfo_requestStillCompletes_fixedTokenOnly() throws
Exception {
+ var target =
Logger.getLogger(B_ThrowsErrorInBasic.class.getName());
+ var prevLevel = target.getLevel();
+ target.setLevel(Level.INFO);
+ try (var c =
RichLogger.getLogger(FINISH_LOGGER).captureEvents(Level.WARNING)) {
+ var client =
org.apache.juneau.rest.mock.classic.MockRestClient.create(B_ThrowsErrorInBasic.class).build();
+
+ client.post("/echo",
"request-payload").run().assertStatus().asCode().is(200).assertContent("request-payload");
+
+ var rec = c.getRecords().stream()
+ .filter(r ->
FINISH_LOGGER.equals(r.getLoggerName()))
+ .reduce((a, b) -> b)
+ .orElse(null);
+ assertNotNull(rec);
+ assertEquals("debug formatter failed",
rec.getMessage());
+
assertFalse(rec.getMessage().contains("error-secret-in-basic"),
rec.getMessage());
+ } finally {
+ target.setLevel(prevLevel);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/server/logging/RestDebugDumpGateTestSupport.java
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/server/logging/RestDebugDumpGateTestSupport.java
new file mode 100644
index 0000000000..b4271c010a
--- /dev/null
+++
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/server/logging/RestDebugDumpGateTestSupport.java
@@ -0,0 +1,48 @@
+/*
+ * 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.logging;
+
+/**
+ * Test-only bridge to the package-private body-dump gate seam on {@link
BasicRestDebugFormatter}.
+ *
+ * <p>
+ * Lives in the {@code org.apache.juneau.rest.server.logging} package (as a
split-package test helper in the
+ * {@code juneau-rest-mock} test sources) purely so the mock-module end-to-end
debug tests can force both gate states
+ * without mutating the process environment and without a system-property
fallback. It exists only in test sources, so
+ * application code cannot reach the seam through it.
+ *
+ * @since 10.0.0
+ */
+public final class RestDebugDumpGateTestSupport {
+
+ private RestDebugDumpGateTestSupport() {}
+
+ /** Forces the body-dump gate on. */
+ public static void forceOn() {
+
BasicRestDebugFormatter.resetAllowDumpBodiesForTest(Boolean.TRUE);
+ }
+
+ /** Forces the body-dump gate off. */
+ public static void forceOff() {
+
BasicRestDebugFormatter.resetAllowDumpBodiesForTest(Boolean.FALSE);
+ }
+
+ /** Clears the forced state so the next resolution re-reads the
environment once. */
+ public static void reset() {
+ BasicRestDebugFormatter.resetAllowDumpBodiesForTest(null);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
index 926e0fa88e..d758ee3d6e 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestSession.java
@@ -23,10 +23,12 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import java.io.*;
import java.util.*;
+import java.util.logging.*;
import org.apache.juneau.commons.collections.*;
import org.apache.juneau.commons.inject.*;
import org.apache.juneau.commons.lang.*;
+import org.apache.juneau.commons.logging.*;
import org.apache.juneau.http.*;
import org.apache.juneau.http.response.*;
import org.apache.juneau.marshall.*;
@@ -50,6 +52,9 @@ import jakarta.servlet.http.*;
})
public class RestSession extends ContextSession {
+ /** Logger for the finish-path diagnostic-failure containment token
(see {@link #finish()}). */
+ private static final RichLogger LOG =
RichLogger.getLogger(RestSession.class);
+
// Property name constants
private static final String PROP_context = "context";
private static final String PROP_resource = "resource";
@@ -304,7 +309,15 @@ public class RestSession extends ContextSession {
} catch (Exception e) {
exception(e);
}
- RestDebugPipeline.emit(this);
+ // Contain diagnostic formatting/emission: a formatter (or a
scrubber that escaped the fail-closed guard) throwing
+ // a RuntimeException/Error during a completed request must not
escape and fail the request thread. Log only a
+ // fixed token — never e.getMessage(), the body, the stack, or
a second formatter pass — so a scrubber throwing
+ // new RuntimeException(body) cannot re-leak the secret the
placeholder just refused.
+ try {
+ RestDebugPipeline.emit(this);
+ } catch (Throwable t) { // NOSONAR - deliberate containment of
any diagnostic failure at request completion.
+ LOG.log(Level.WARNING, "debug formatter failed");
+ }
return this;
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter.java
index 9edb402b02..ceb11a6b2b 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter.java
@@ -17,39 +17,69 @@
package org.apache.juneau.rest.server.logging;
import static org.apache.juneau.commons.utils.IoUtils.*;
-import static org.apache.juneau.commons.utils.StringUtils.*;
import java.util.*;
import org.apache.juneau.commons.inject.*;
-import org.apache.juneau.http.RedactedHeaders;
+import org.apache.juneau.http.*;
import org.apache.juneau.rest.server.*;
import jakarta.servlet.http.*;
/**
- * Default {@link RestDebugFormatter} implementation.
+ * Default {@link RestDebugFormatter} implementation — secure by default.
*
* <p>
* Renders, cumulatively by tier:
* <ul>
- * <li><b>Basic ({@code INFO})</b> — a single status line ({@code
[status] HTTP method uri}).
- * <li><b>Headers ({@code FINE})</b> — request/response header
blocks (credential-bearing values masked), plus
- * request/response lengths and the request execution time.
- * <li><b>Body ({@code FINEST})</b> — request/response bodies as
UTF-8 + spaced-hex, reading the cached bytes,
- * with a {@code …[truncated N bytes]} marker when the body
exceeded the capture cap.
+ * <li><b>Basic ({@code INFO})</b> — a single status line ({@code
[status] HTTP method uri}), with the method and
+ * URI sanitized and the URI length-capped.
+ * <li><b>Headers ({@code FINE})</b> — request/response header
blocks (<b>every</b> value of every header,
+ * credential-bearing values masked, all names/values sanitized
and bounded), plus request/response lengths and the
+ * request execution time.
+ * <li><b>Body ({@code FINEST})</b> — request/response bodies, but
<b>only if the operator has opted in</b> (see
+ * below); otherwise a suppression placeholder + byte count.
* </ul>
*
- * <h5 class='section'>Secure-by-default</h5>
+ * <h5 class='section'>Secure-by-default body handling</h5>
* <p>
- * Header values for the well-known credential-bearing set ({@link
RedactedHeaders#DEFAULT}) are masked with
- * {@link RedactedHeaders#REDACTED}. The set is overridable via {@link
#redactedHeaders(Collection)}.
+ * Bodies are <b>never dumped by default</b>. Dumping is a deliberate operator
opt-in behind a single environment-variable
+ * master gate, {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES}:
+ * <ul>
+ * <li><b>Env-only.</b> It is an environment variable, never a system
property, and has no system-property fallback
+ * — application code cannot flip it at runtime.
+ * <li><b>Truthy semantics.</b> The raw value is trimmed, then a non-empty
value enables dumping except case-insensitive
+ * {@code false}/{@code 0}; unset/empty/all-whitespace disables.
It is resolved once and cached, making it an
+ * emergency kill switch.
+ * <li><b>Gate subordinate.</b> With the gate unset, {@link
#formatBody(RestRequest,RestResponse) formatBody} emits only
+ * the suppression placeholder + byte count — always, even
if a {@link RestDebugBodyScrubber} is configured
+ * (the scrubber is never invoked).
+ * <li><b>Renderable content only.</b> Even when opted in, only renderable
content (see
+ * {@link #isBodyRenderable(String)}, plus an identity {@code
Content-Encoding}) is dumped; binary/unknown/compressed
+ * content yields a distinct non-renderable placeholder.
+ * <li><b>Fail-closed scrubber.</b> When the gate is set and a {@link
RestDebugBodyScrubber} is configured, its output is
+ * dumped (then sanitized and capped); if it throws or returns
<jk>null</jk>, the body fails closed to a placeholder
+ * — never the raw body.
+ * </ul>
+ * No bytes appear in any placeholder in any representation.
+ *
+ * <h5 class='section'>Header redaction and sanitization</h5>
+ * <p>
+ * Values for a widened, formatter-local credential-bearing header set are
masked with {@link RedactedHeaders#REDACTED}.
+ * The set starts from {@link RedactedHeaders#DEFAULT} plus {@code
X-Auth-Token}, {@code X-Authorization},
+ * {@code WWW-Authenticate}, {@code Referer}, and {@code Location}; the shared
{@link RedactedHeaders#DEFAULT} is left
+ * unchanged. Matching is case-insensitive with separator folding ({@code
-}/{@code _} stripped), so {@code X-Auth-Token},
+ * {@code X_Auth_Token}, and {@code XAuthToken} all match. Replace the set
with {@link #redactedHeaders(Collection)} or
+ * extend it with {@link #addRedactedHeaders(Collection)}. Every emitted
string (URI/method, header names/values, body
+ * text, placeholders) is sanitized against CR/LF/control-char log forging via
{@link DebugTextSanitizer}, in
+ * <b>mask → escape → cap</b> order.
*
+ * <h5 class='section'>Bounds</h5>
* <p>
- * Bodies are captured up to {@link #bodyCap()} bytes (default
<b>8 KB</b>). This default is deliberately smaller than
- * {@code EchoMixin}'s 1 MB body cap — debug logging is intended
for low-volume operator diagnostics where an
- * 8 KB window keeps log lines readable and memory bounded, whereas
{@code EchoMixin} is an explicit round-trip
- * introspection endpoint. The two caps are independent by design; do not
"fix" them toward parity.
+ * Bodies are bounded by {@link #bodyCap()} bytes (default <b>8 KB</b>)
at capture time; headers by
+ * {@link #maxHeaders(int)}/{@link #maxHeaderScan(int)}/{@link
#maxFieldLength(int)}; the URI by {@link #maxUriLength(int)}.
+ * The 8 KB body cap is deliberately smaller than {@code EchoMixin}'s
1 MB body cap — debug logging is
+ * intended for low-volume operator diagnostics; the two caps are independent
by design.
*
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerLoggingAndDebugging">Logging
/ Debugging</a>
@@ -62,12 +92,51 @@ public class BasicRestDebugFormatter implements
RestDebugFormatter {
/** Default body capture cap, in bytes (8 KB). */
public static final int DEFAULT_BODY_CAP = 8 * 1024;
+ /** The environment variable that gates body dumping. */
+ static final String ENV_ALLOW_DUMP_BODIES =
"JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES";
+
+ /**
+ * The widened, formatter-local default set of header names whose
values are masked. Starts from a copy of
+ * {@link RedactedHeaders#DEFAULT} and adds the credential-bearing
headers the shared default omits.
+ */
+ static final Set<String> DEFAULT_REDACTED_HEADERS;
+ static {
+ var s = new LinkedHashSet<>(RedactedHeaders.DEFAULT);
+ s.add("X-Auth-Token");
+ s.add("X-Authorization");
+ s.add("WWW-Authenticate");
+ s.add("Referer");
+ s.add("Location");
+ DEFAULT_REDACTED_HEADERS = Collections.unmodifiableSet(s);
+ }
+
+ /**
+ * Cached resolution of the body-dump master gate. {@code null} means
"not yet resolved"; resolution reads the
+ * environment exactly once. Not a system property, and there is no
system-property fallback.
+ */
+ private static volatile Boolean allowDumpBodiesCache;
+
/** The set of header names whose values are masked. Overridable via
{@link #redactedHeaders(Collection)}. */
- protected Set<String> redactedHeaders = RedactedHeaders.DEFAULT;
+ protected Set<String> redactedHeaders = DEFAULT_REDACTED_HEADERS;
+
+ /** Optional body scrubber applied to body text when the master gate is
set. */
+ protected RestDebugBodyScrubber bodyScrubber;
/** The body capture cap in bytes. Overridable via {@link
#bodyCap(int)}. */
protected int bodyCap = DEFAULT_BODY_CAP;
+ /** Cap on emitted header values per block. Overridable via {@link
#maxHeaders(int)}. */
+ protected int maxHeaders = 100;
+
+ /** Cap on values scanned while computing omitted counts. Overridable
via {@link #maxHeaderScan(int)}. */
+ protected int maxHeaderScan = 1000;
+
+ /** Per header name/value length cap after masking/escaping.
Overridable via {@link #maxFieldLength(int)}. */
+ protected int maxFieldLength = 1024;
+
+ /** Rendered URI length cap. Overridable via {@link
#maxUriLength(int)}. */
+ protected int maxUriLength = DEFAULT_MAX_URI_LENGTH;
+
/**
* Constructor.
*/
@@ -84,9 +153,9 @@ public class BasicRestDebugFormatter implements
RestDebugFormatter {
public BasicRestDebugFormatter(BeanStore beanStore) {}
/**
- * Overrides the redacted-header set.
+ * Overrides the redacted-header set (replaces the built-in widened
set).
*
- * @param value The new set of header names to mask (case-insensitive).
Must not be <jk>null</jk>.
+ * @param value The new set of header names to mask (case-insensitive,
separator-folded). Must not be <jk>null</jk>.
* @return This object.
*/
public BasicRestDebugFormatter redactedHeaders(Collection<String>
value) {
@@ -94,6 +163,35 @@ public class BasicRestDebugFormatter implements
RestDebugFormatter {
return this;
}
+ /**
+ * Extends the redacted-header set additively (keeps the built-in
widened protections).
+ *
+ * @param value The header names to add to the masked set
(case-insensitive, separator-folded). Must not be
+ * <jk>null</jk>.
+ * @return This object.
+ */
+ public BasicRestDebugFormatter addRedactedHeaders(Collection<String>
value) {
+ var s = new LinkedHashSet<>(redactedHeaders);
+ s.addAll(value);
+ redactedHeaders = s;
+ return this;
+ }
+
+ /**
+ * Sets the body scrubber applied to body text when the {@code
JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} gate is set.
+ *
+ * <p>
+ * The scrubber only ever runs when the gate is set; it chooses
scrubbed-vs-raw once dumping is already permitted. It
+ * is fail-closed: a throw or a <jk>null</jk> return yields a
placeholder, never the raw body.
+ *
+ * @param value The scrubber, or <jk>null</jk> for none (the default,
which dumps raw when gated).
+ * @return This object.
+ */
+ public BasicRestDebugFormatter bodyScrubber(RestDebugBodyScrubber
value) {
+ bodyScrubber = value;
+ return this;
+ }
+
/**
* Overrides the body capture cap.
*
@@ -105,6 +203,58 @@ public class BasicRestDebugFormatter implements
RestDebugFormatter {
return this;
}
+ /**
+ * Overrides the cap on emitted header values per block.
+ *
+ * @param value The new cap. Must be ≥ 0.
+ * @return This object.
+ */
+ public BasicRestDebugFormatter maxHeaders(int value) {
+ if (value < 0)
+ throw new IllegalArgumentException("maxHeaders must be
>= 0");
+ maxHeaders = value;
+ return this;
+ }
+
+ /**
+ * Overrides the cap on values scanned while computing omitted counts.
+ *
+ * @param value The new cap. Must be ≥ 0.
+ * @return This object.
+ */
+ public BasicRestDebugFormatter maxHeaderScan(int value) {
+ if (value < 0)
+ throw new IllegalArgumentException("maxHeaderScan must
be >= 0");
+ maxHeaderScan = value;
+ return this;
+ }
+
+ /**
+ * Overrides the per header name/value length cap.
+ *
+ * @param value The new cap. Must be ≥ 0.
+ * @return This object.
+ */
+ public BasicRestDebugFormatter maxFieldLength(int value) {
+ if (value < 0)
+ throw new IllegalArgumentException("maxFieldLength must
be >= 0");
+ maxFieldLength = value;
+ return this;
+ }
+
+ /**
+ * Overrides the rendered URI length cap.
+ *
+ * @param value The new cap. Must be ≥ 0.
+ * @return This object.
+ */
+ public BasicRestDebugFormatter maxUriLength(int value) {
+ if (value < 0)
+ throw new IllegalArgumentException("maxUriLength must
be >= 0");
+ maxUriLength = value;
+ return this;
+ }
+
@Override /* Overridden from RestDebugFormatter */
public int bodyCap() {
return bodyCap;
@@ -115,6 +265,13 @@ public class BasicRestDebugFormatter implements
RestDebugFormatter {
return statusLine(req.getHttpServletRequest(),
res.getHttpServletResponse());
}
+ @Override /* Overridden from RestDebugFormatter */
+ public String statusLine(HttpServletRequest req, HttpServletResponse
res) {
+ var method = DebugTextSanitizer.sanitize(req.getMethod(),
maxUriLength);
+ var uri = DebugTextSanitizer.sanitize(req.getRequestURI(),
maxUriLength);
+ return new
StringBuilder().append('[').append(res.getStatus()).append("] HTTP
").append(method).append(' ').append(uri).toString();
+ }
+
@Override /* Overridden from RestDebugFormatter */
public String formatHeaders(RestRequest req, RestResponse res) {
var sreq = req.getHttpServletRequest();
@@ -135,65 +292,251 @@ public class BasicRestDebugFormatter implements
RestDebugFormatter {
if (execTime != null)
sb.append("\n\tExec time:
").append(execTime).append("ms");
- var reqHeaderNames = sreq.getHeaderNames();
- if (reqHeaderNames != null && reqHeaderNames.hasMoreElements())
{
- sb.append("\n---Request Headers---");
- while (reqHeaderNames.hasMoreElements()) {
- var h = reqHeaderNames.nextElement();
- sb.append("\n\t").append(h).append(":
").append(RedactedHeaders.redact(h, sreq.getHeader(h), redactedHeaders));
+ var normalizedRedacted = normalizedRedactedSet();
+ appendRequestHeaders(sb, sreq, normalizedRedacted);
+ appendResponseHeaders(sb, sres, normalizedRedacted);
+
+ return sb.toString();
+ }
+
+ private void appendRequestHeaders(StringBuilder sb, HttpServletRequest
sreq, Set<String> normalizedRedacted) {
+ var names = sreq.getHeaderNames();
+ if (names == null || ! names.hasMoreElements())
+ return;
+ sb.append("\n---Request Headers---");
+ var emitted = 0;
+ var extra = 0L;
+ var scanned = 0;
+ var scanCapped = false;
+ while (names.hasMoreElements() && ! scanCapped) {
+ var name = names.nextElement();
+ var redact = isRedacted(name, normalizedRedacted);
+ var sName = DebugTextSanitizer.sanitize(name,
maxFieldLength);
+ var values = sreq.getHeaders(name);
+ while (values != null && values.hasMoreElements()) {
+ if (scanned >= maxHeaderScan) {
+ scanCapped = true;
+ break;
+ }
+ scanned++;
+ var v = values.nextElement();
+ if (emitted < maxHeaders) {
+ appendHeaderLine(sb, sName, redact ?
RedactedHeaders.REDACTED : v);
+ emitted++;
+ } else {
+ extra++;
+ }
}
}
+ appendOmissionMarker(sb, scanCapped, extra);
+ }
- var resHeaderNames = sres.getHeaderNames();
- if (resHeaderNames != null && ! resHeaderNames.isEmpty()) {
- sb.append("\n---Response Headers---");
- for (var h : resHeaderNames)
- sb.append("\n\t").append(h).append(":
").append(RedactedHeaders.redact(h, sres.getHeader(h), redactedHeaders));
+ private void appendResponseHeaders(StringBuilder sb,
HttpServletResponse sres, Set<String> normalizedRedacted) {
+ var names = sres.getHeaderNames();
+ if (names == null || names.isEmpty())
+ return;
+ sb.append("\n---Response Headers---");
+ var emitted = 0;
+ var extra = 0L;
+ var scanned = 0;
+ var scanCapped = false;
+ for (var name : names) {
+ if (scanCapped)
+ break;
+ var redact = isRedacted(name, normalizedRedacted);
+ var sName = DebugTextSanitizer.sanitize(name,
maxFieldLength);
+ for (var v : sres.getHeaders(name)) {
+ if (scanned >= maxHeaderScan) {
+ scanCapped = true;
+ break;
+ }
+ scanned++;
+ if (emitted < maxHeaders) {
+ appendHeaderLine(sb, sName, redact ?
RedactedHeaders.REDACTED : v);
+ emitted++;
+ } else {
+ extra++;
+ }
+ }
}
+ appendOmissionMarker(sb, scanCapped, extra);
+ }
- return sb.toString();
+ private void appendHeaderLine(StringBuilder sb, String sanitizedName,
String value) {
+ // Order: mask (already applied by caller) → escape → cap.
+ sb.append("\n\t").append(sanitizedName).append(":
").append(DebugTextSanitizer.sanitize(value, maxFieldLength));
+ }
+
+ private static void appendOmissionMarker(StringBuilder sb, boolean
scanCapped, long extra) {
+ if (scanCapped)
+ sb.append("\n\t\u2026[more headers omitted]");
+ else if (extra > 0)
+ sb.append("\n\t\u2026[+").append(extra).append(" more
headers omitted]");
}
@Override /* Overridden from RestDebugFormatter */
public String formatBody(RestRequest req, RestResponse res) {
+ var sreq = req.getHttpServletRequest();
+ var sres = res.getHttpServletResponse();
var sb = new StringBuilder();
- appendBody(sb, "Request", req.getCachedContent(),
req.getCachedContentLength());
- appendBody(sb, "Response", res.getCachedContent(),
res.getCachedContentLength());
+ appendBody(sb, "Request", req.getCachedContent(),
req.getCachedContentLength(), sreq.getContentType(),
+ sreq.getHeader("Content-Encoding"));
+ appendBody(sb, "Response", res.getCachedContent(),
res.getCachedContentLength(), sres.getContentType(),
+ sres.getHeader("Content-Encoding"));
return sb.toString();
}
- private void appendBody(StringBuilder sb, String label, byte[] content,
long totalLength) {
+ private void appendBody(StringBuilder sb, String label, byte[] content,
long totalLength, String contentType, String contentEncoding) {
if (content == null || content.length == 0)
return;
- try {
- sb.append("\n---").append(label).append(" Content
UTF-8---");
- sb.append("\n").append(new String(content, UTF8));
- sb.append("\n---").append(label).append(" Content
Hex---");
- sb.append("\n").append(toSpacedHex(content));
- var omitted = totalLength - content.length;
- if (omitted > 0)
- sb.append("\n…[truncated
").append(omitted).append(" bytes]");
- } catch (Exception e) {
- sb.append("\n").append(e.getLocalizedMessage());
+ var byteCount = totalLength >= 0 ? totalLength : content.length;
+ var ctToken = contentTypeToken(contentType);
+
+ if (! isAllowDumpBodies()) {
+ appendPlaceholder(sb, label, byteCount, ctToken,
+ "; set " + ENV_ALLOW_DUMP_BODIES + " to
enable", "body suppressed");
+ return;
}
+
+ if (! isRenderable(contentType, contentEncoding)) {
+ appendPlaceholder(sb, label, byteCount, ctToken, ";
binary/non-renderable content", "body not rendered");
+ return;
+ }
+
+ var raw = new String(content, UTF8);
+ String bodyText;
+ if (bodyScrubber != null) {
+ String scrubbed;
+ try {
+ scrubbed = bodyScrubber.scrub(contentType, raw);
+ } catch (Throwable t) { // NOSONAR - fail closed on
any scrubber failure; never re-leak via the exception.
+ scrubbed = null;
+ }
+ if (scrubbed == null) {
+ appendPlaceholder(sb, label, byteCount,
ctToken, "; scrubber failed", "body suppressed");
+ return;
+ }
+ bodyText = scrubbed;
+ } else {
+ bodyText = raw;
+ }
+
+ sb.append("\n---").append(label).append(" Content---");
+ sb.append('\n').append(DebugTextSanitizer.sanitize(bodyText,
charCap()));
+ var omitted = byteCount - content.length;
+ if (omitted > 0)
+ sb.append("\n\u2026[truncated
").append(omitted).append(" bytes]");
+ }
+
+ private void appendPlaceholder(StringBuilder sb, String label, long
byteCount, String ctToken, String reason, String verb) {
+ sb.append("\n---").append(label).append(" Content---");
+ sb.append("\n[").append(verb).append(":
").append(byteCount).append(" bytes,
").append(ctToken).append(reason).append(']');
+ }
+
+ private String contentTypeToken(String contentType) {
+ if (contentType == null || contentType.isBlank())
+ return "unknown";
+ return DebugTextSanitizer.sanitize(contentType, maxFieldLength);
+ }
+
+ private boolean isRenderable(String contentType, String
contentEncoding) {
+ if (! isIdentityEncoding(contentEncoding))
+ return false;
+ return isBodyRenderable(contentType);
+ }
+
+ private static boolean isIdentityEncoding(String contentEncoding) {
+ if (contentEncoding == null)
+ return true;
+ var e = contentEncoding.trim();
+ return e.isEmpty() || e.equalsIgnoreCase("identity");
}
/**
- * Renders the basic status line ({@code [status] HTTP method uri})
directly from servlet objects.
+ * Returns the character-length cap for a dumped body, derived from
{@link #bodyCap()}.
*
* <p>
- * Shared by {@link #formatBasic(RestRequest,RestResponse)} and by the
pipeline's no-operation (404/405) path, which
- * has no {@link RestRequest}/{@link RestResponse} to render through.
- *
- * @param req The servlet request. Never <jk>null</jk>.
- * @param res The servlet response. Never <jk>null</jk>.
- * @return The rendered status line.
- */
- public static String statusLine(HttpServletRequest req,
HttpServletResponse res) {
- return new StringBuilder()
- .append('[').append(res.getStatus()).append("] ")
- .append("HTTP ").append(req.getMethod()).append(' ')
- .append(req.getRequestURI())
- .toString();
+ * Worst-case escaping expands one byte to a 6-character {@code
\\uXXXX} sequence, so the sanitized character cap is
+ * {@code bodyCap() * 6} (clamped to {@code Integer.MAX_VALUE}).
Capture already bounds the raw bytes; this second
+ * layer bounds a scrubber that returns an oversized string.
+ */
+ private int charCap() {
+ var cap = Math.max(bodyCap, 0) * 6L;
+ return cap > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) cap;
+ }
+
+ private Set<String> normalizedRedactedSet() {
+ var s = new HashSet<String>();
+ for (var n : redactedHeaders)
+ if (n != null)
+ s.add(normalizeHeaderName(n));
+ return s;
+ }
+
+ private static boolean isRedacted(String name, Set<String>
normalizedRedacted) {
+ return name != null &&
normalizedRedacted.contains(normalizeHeaderName(name));
+ }
+
+ /** Case-folds and strips {@code -}/{@code _} and whitespace so {@code
X-Auth-Token}/{@code X_Auth_Token}/{@code XAuthToken} all match. */
+ private static String normalizeHeaderName(String name) {
+ var sb = new StringBuilder(name.length());
+ for (var i = 0; i < name.length(); i++) {
+ var c = name.charAt(i);
+ if (c == '-' || c == '_' || Character.isWhitespace(c))
+ continue;
+ sb.append(Character.toLowerCase(c));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Returns whether body dumping is enabled by the {@code
JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment variable.
+ *
+ * <p>
+ * Resolved once from the environment and cached. Never reads a system
property.
+ *
+ * @return <jk>true</jk> if body dumping is enabled.
+ */
+ static boolean isAllowDumpBodies() {
+ var v = allowDumpBodiesCache;
+ if (v == null) {
+ v =
parseAllowDumpBodies(System.getenv(ENV_ALLOW_DUMP_BODIES));
+ allowDumpBodiesCache = v;
+ }
+ return v;
+ }
+
+ /**
+ * Parses a raw gate value with trim-then-parse truthy semantics.
+ *
+ * <p>
+ * The value is trimmed first; a non-empty trimmed value enables
(returns <jk>true</jk>) except case-insensitive
+ * {@code false} or {@code 0}; {@code null}/empty/all-whitespace
disables. This is a fail-safe kill switch, so
+ * ambiguous values resolve to disabled.
+ *
+ * @param raw The raw environment value. Can be <jk>null</jk>.
+ * @return <jk>true</jk> if the value enables body dumping.
+ */
+ static boolean parseAllowDumpBodies(String raw) {
+ if (raw == null)
+ return false;
+ var t = raw.trim();
+ if (t.isEmpty())
+ return false;
+ return ! (t.equalsIgnoreCase("false") || t.equals("0"));
+ }
+
+ /**
+ * Test-only seam for exercising both gate states without mutating the
process environment.
+ *
+ * <p>
+ * Package-private and unreachable from application code, and never
reads a system property. Production resolution
+ * stays env-only / read-once ({@link #isAllowDumpBodies()}).
+ *
+ * @param override {@code null} clears the cache so the next resolution
re-reads the environment once; a non-<jk>null</jk>
+ * value is cached directly as the test gate state.
+ */
+ static void resetAllowDumpBodiesForTest(Boolean override) {
+ allowDumpBodiesCache = override;
}
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugFormatter.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugFormatter.java
index 7ba9460fb2..daec25195b 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugFormatter.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugFormatter.java
@@ -16,8 +16,13 @@
*/
package org.apache.juneau.rest.server.logging;
+import java.util.*;
+
+import org.apache.juneau.http.*;
import org.apache.juneau.rest.server.*;
+import jakarta.servlet.http.*;
+
/**
* Per-tier formatter for JUL-level-driven REST debug logging.
*
@@ -35,6 +40,14 @@ import org.apache.juneau.rest.server.*;
* {@link #formatBasic(RestRequest,RestResponse) formatBasic} is
additive-safe. Most implementations instead extend
* {@link BasicRestDebugFormatter} and override only the tier(s) they wish to
change.
*
+ * <h5 class='section'>Secure by default</h5>
+ * <p>
+ * {@link BasicRestDebugFormatter} is secure by default: request/response
<b>bodies are never dumped</b> unless the
+ * operator has deliberately set the {@code
JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment variable (and even then only
+ * at {@code FINEST} on renderable content). Credential-bearing header values
are masked, every client-controlled string
+ * is escaped against log forging, and all output is length-bounded. See
+ * {@link BasicRestDebugFormatter} for the full contract.
+ *
* <p>
* The cached request/response bytes, the thrown exception, and the request
execution time are reachable through the
* {@link RestRequest}/{@link RestResponse} accessors ({@link
RestRequest#getCachedContent()},
@@ -49,6 +62,9 @@ import org.apache.juneau.rest.server.*;
*/
public interface RestDebugFormatter {
+ /** Default cap on the rendered URI length in the status line. */
+ int DEFAULT_MAX_URI_LENGTH = 2048;
+
/**
* Renders the basic ({@code INFO}-tier) portion of the debug record.
*
@@ -91,6 +107,68 @@ public interface RestDebugFormatter {
return "";
}
+ /**
+ * Renders the basic status line ({@code [status] HTTP method uri})
directly from servlet objects.
+ *
+ * <p>
+ * Used both by {@link #formatBasic(RestRequest,RestResponse)} and by
the pipeline's no-operation (404/405) path,
+ * which has no {@link RestRequest}/{@link RestResponse} to render
through. The method and URI are always
+ * {@link DebugTextSanitizer#sanitize(String,int) sanitized} (CR/LF and
control characters escaped) so a
+ * client-controlled URI cannot forge a log line, and the rendered URI
is length-capped. This default applies
+ * {@link #DEFAULT_MAX_URI_LENGTH}; {@link BasicRestDebugFormatter}
overrides it to apply its configurable
+ * {@code maxUriLength}.
+ *
+ * <p>
+ * The query string is intentionally excluded (matching {@link
HttpServletRequest#getRequestURI()}), so plaintext
+ * {@code ?token=…} disclosure is never re-introduced through this path.
+ *
+ * @param req The servlet request. Never <jk>null</jk>.
+ * @param res The servlet response. Never <jk>null</jk>.
+ * @return The rendered, sanitized status line.
+ */
+ default String statusLine(HttpServletRequest req, HttpServletResponse
res) {
+ var method = DebugTextSanitizer.sanitize(req.getMethod(),
DEFAULT_MAX_URI_LENGTH);
+ var uri = DebugTextSanitizer.sanitize(req.getRequestURI(),
DEFAULT_MAX_URI_LENGTH);
+ return new
StringBuilder().append('[').append(res.getStatus()).append("] HTTP
").append(method).append(' ').append(uri).toString();
+ }
+
+ /**
+ * Returns <jk>true</jk> if a body with the given content type is worth
rendering as text (as opposed to being
+ * binary/unknown).
+ *
+ * <p>
+ * This is a text-vs-binary renderability predicate, <b>not</b> a
redaction allowlist. It parses only the media type
+ * (type/subtype, ignoring parameters such as {@code ; charset=utf-8}),
case-folds, and treats the following as
+ * renderable: {@code text/*}, {@code application/json}, {@code
application/xml}, any {@code +json}/{@code +xml}
+ * suffix, and {@code application/x-www-form-urlencoded}. {@code
multipart/form-data} is explicitly non-renderable.
+ * An absent or blank content type is conservatively treated as
non-renderable.
+ *
+ * <p>
+ * {@code Content-Encoding} is not visible to this one-argument
predicate; a caller that dumps bodies must separately
+ * treat any non-identity encoding (e.g. {@code gzip}) as
non-renderable.
+ *
+ * @param contentType The body's content type. Can be <jk>null</jk>
(returns <jk>false</jk>).
+ * @return <jk>true</jk> if the content type is renderable as text.
+ */
+ default boolean isBodyRenderable(String contentType) {
+ if (contentType == null)
+ return false;
+ var ct = contentType.trim();
+ var semi = ct.indexOf(';');
+ if (semi >= 0)
+ ct = ct.substring(0, semi).trim();
+ ct = ct.toLowerCase(Locale.ROOT);
+ if (ct.isEmpty())
+ return false;
+ if (ct.startsWith("text/"))
+ return true;
+ if (ct.equals("multipart/form-data"))
+ return false;
+ if (ct.equals("application/json") ||
ct.equals("application/xml") || ct.equals("application/x-www-form-urlencoded"))
+ return true;
+ return ct.endsWith("+json") || ct.endsWith("+xml");
+ }
+
/**
* Returns the maximum number of request/response body bytes to capture
for the {@code FINEST}-tier body rendering.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
index dbe7844a17..0adff517c9 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
@@ -73,9 +73,10 @@ public class RestDebugPipeline {
private static String render(RestSession session, RestOpSession
opSession, Level level) {
// No operation resolved (404/no-op path): only the basic
status line is renderable — there is no
- // RestRequest/RestResponse to drive the formatter tiers
through.
+ // RestRequest/RestResponse to drive the formatter tiers
through. Resolve the formatter instance so the
+ // sanitized, length-capped instance statusLine (not a
static-only path) renders it.
if (opSession == null)
- return
BasicRestDebugFormatter.statusLine(session.getRequest(), session.getResponse());
+ return
resolveFormatter(session).statusLine(session.getRequest(),
session.getResponse());
var formatter = resolveFormatter(session);
var req = opSession.getRequest();
diff --git
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter_Test.java
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter_Test.java
index 38f6c0d379..7dd581c8f9 100644
---
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter_Test.java
+++
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/BasicRestDebugFormatter_Test.java
@@ -17,18 +17,25 @@
package org.apache.juneau.rest.server.logging;
import static java.util.Collections.*;
+import static org.apache.juneau.commons.utils.IoUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
+import static org.apache.juneau.rest.server.logging.BasicRestDebugFormatter.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
+import java.util.concurrent.atomic.*;
+import org.apache.juneau.http.*;
import org.apache.juneau.rest.server.*;
import org.junit.jupiter.api.*;
import jakarta.servlet.http.*;
/**
- * Unit tests for {@link BasicRestDebugFormatter} — per-tier rendering, header
redaction, and body truncation.
+ * Unit tests for {@link BasicRestDebugFormatter} — secure-by-default body
handling (env-gated no-dump), header
+ * redaction (all values, widened formatter-local set, separator folding),
sanitization/flood bounds, and the status
+ * line.
*
* @since 10.0.0
*/
@@ -54,16 +61,91 @@ class BasicRestDebugFormatter_Test {
when(sreq.getRequestURI()).thenReturn("/foo");
}
+ @AfterEach void tearDown() {
+ // Never leak forced gate state between tests; next resolution
re-reads the environment once.
+ resetAllowDumpBodiesForTest(null);
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // Helpers
+ //
-----------------------------------------------------------------------------------------
+
+ /** Asserts the planted secret is absent in plaintext, UTF-8
spaced-hex, Base64, and escaped code-unit forms. */
+ private static void assertAllEncodingsAbsent(String out, String secret)
{
+ assertFalse(out.contains(secret), () -> "plaintext secret
present in: " + out);
+ var bytes = secret.getBytes(UTF8);
+ assertFalse(out.contains(toSpacedHex(bytes)), () -> "spaced-hex
secret present in: " + out);
+
assertFalse(out.contains(Base64.getEncoder().encodeToString(bytes)), () ->
"base64 secret present in: " + out);
+ var esc = new StringBuilder();
+ for (var i = 0; i < secret.length(); i++)
+ esc.append(String.format("\\u%04X", (int)
secret.charAt(i)));
+ assertFalse(out.contains(esc.toString()), () ->
"escaped-code-unit secret present in: " + out);
+ }
+
+ /** Asserts no client-controllable raw control character survived —
only formatter-owned {@code \n} separators. */
+ private static void assertNoRawControlChars(String out) {
+ assertEquals(-1, out.indexOf('\r'), () -> "raw CR present in: "
+ out);
+ assertEquals(-1, out.indexOf('\u0085'), () -> "raw NEL present
in: " + out);
+ assertEquals(-1, out.indexOf('\u2028'), () -> "raw LS present
in: " + out);
+ assertEquals(-1, out.indexOf('\u2029'), () -> "raw PS present
in: " + out);
+ assertEquals(-1, out.indexOf('\u001B'), () -> "raw ESC present
in: " + out);
+ for (var c = '\u202A'; c <= '\u202E'; c++)
+ assertEquals(-1, out.indexOf(c), "raw bidi control
present");
+ }
+
+ private void stubRequestHeaders(LinkedHashMap<String,List<String>> h) {
+ when(sreq.getHeaderNames()).thenReturn(enumeration(new
ArrayList<>(h.keySet())));
+ h.forEach((k, v) ->
when(sreq.getHeaders(k)).thenReturn(enumeration(v)));
+ }
+
+ private void stubResponseHeaders(LinkedHashMap<String,List<String>> h) {
+ when(sres.getHeaderNames()).thenReturn(new
ArrayList<>(h.keySet()));
+ h.forEach((k, v) -> when(sres.getHeaders(k)).thenReturn(new
ArrayList<>(v)));
+ }
+
+ private void requestBody(String body, String contentType) {
+ var bytes = body.getBytes(UTF8);
+ when(req.getCachedContent()).thenReturn(bytes);
+ when(req.getCachedContentLength()).thenReturn((long)
bytes.length);
+ when(sreq.getContentType()).thenReturn(contentType);
+ when(res.getCachedContent()).thenReturn(new byte[0]);
+ when(res.getCachedContentLength()).thenReturn(0L);
+ }
+
//
-----------------------------------------------------------------------------------------
- // a — formatBasic
+ // a — formatBasic / statusLine (sanitization + maxUriLength)
//
-----------------------------------------------------------------------------------------
@Test void a01_formatBasic_statusLine() {
assertEquals("[200] HTTP GET /foo", f.formatBasic(req, res));
}
+ @Test void a02_statusLine_sanitizesMethodAndUri() {
+ when(sreq.getMethod()).thenReturn("GET");
+ when(sreq.getRequestURI()).thenReturn("/x\r\n[200] HTTP GET
/admin");
+ var s = f.formatBasic(req, res);
+ assertNoRawControlChars(s);
+ assertTrue(s.contains("\\r\\n"), s);
+ assertFalse(s.contains("\n[200] HTTP GET /admin"), "URI must
not forge a physical line: " + s);
+ }
+
+ @Test void a03_statusLine_appliesMaxUriLength() {
+ when(sreq.getRequestURI()).thenReturn("/".repeat(5000));
+ f.maxUriLength(32);
+ var s = f.formatBasic(req, res);
+ assertTrue(s.contains("[truncated]"), s);
+ assertTrue(s.length() < 100, "URI must be capped: " +
s.length());
+ }
+
+ @Test void a04_statusLine_doesNotAppendQueryString() {
+ when(sreq.getRequestURI()).thenReturn("/foo");
+ when(sreq.getQueryString()).thenReturn("token=secret");
+ var s = f.formatBasic(req, res);
+ assertFalse(s.contains("token=secret"), s);
+ }
+
//
-----------------------------------------------------------------------------------------
- // b — formatHeaders
+ // b — formatHeaders (redaction, all values, folding, bounds, injection)
//
-----------------------------------------------------------------------------------------
@Test void b01_formatHeaders_lengthsAndExecTime() {
@@ -83,9 +165,10 @@ class BasicRestDebugFormatter_Test {
@Test void b02_formatHeaders_redactsSensitive() {
when(req.getCachedContentLength()).thenReturn(-1L);
when(res.getCachedContentLength()).thenReturn(-1L);
-
when(sreq.getHeaderNames()).thenReturn(enumeration(List.of("Authorization",
"User-Agent")));
- when(sreq.getHeader("Authorization")).thenReturn("Bearer
secret");
- when(sreq.getHeader("User-Agent")).thenReturn("curl/8.0");
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("Authorization", List.of("Bearer secret"));
+ reqH.put("User-Agent", List.of("curl/8.0"));
+ stubRequestHeaders(reqH);
when(sres.getHeaderNames()).thenReturn(emptyList());
var s = f.formatHeaders(req, res);
@@ -94,35 +177,372 @@ class BasicRestDebugFormatter_Test {
assertTrue(s.contains("User-Agent: curl/8.0"), s);
}
+ @Test void b03_formatHeaders_allValuesRendered_eachMasked() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("Cookie", List.of("a=1", "b=2"));
+ stubRequestHeaders(reqH);
+ var resH = new LinkedHashMap<String,List<String>>();
+ resH.put("Set-Cookie", List.of("s1=x", "s2=y", "s3=z"));
+ stubResponseHeaders(resH);
+
+ var s = f.formatHeaders(req, res);
+ // Every value rendered, each sensitive value independently
masked.
+ assertEquals(5, s.split("\\[REDACTED\\]", -1).length - 1, s);
+ assertFalse(s.contains("a=1"), s);
+ assertFalse(s.contains("s3=z"), s);
+ }
+
+ @Test void b04_formatHeaders_separatorFolding_customSet() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+ f.redactedHeaders(List.of("X-Auth-Token"));
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("X-Auth-Token", List.of("v1"));
+ reqH.put("X_Auth_Token", List.of("v2"));
+ reqH.put("XAuthToken", List.of("v3"));
+ stubRequestHeaders(reqH);
+ when(sres.getHeaderNames()).thenReturn(emptyList());
+
+ var s = f.formatHeaders(req, res);
+ assertFalse(s.contains("v1"), s);
+ assertFalse(s.contains("v2"), s);
+ assertFalse(s.contains("v3"), s);
+ assertEquals(3, s.split("\\[REDACTED\\]", -1).length - 1, s);
+ }
+
+ @Test void b05_formatHeaders_builtinWidenedSet() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("X-Authorization", List.of("secretA"));
+ reqH.put("WWW-Authenticate", List.of("secretB"));
+ reqH.put("Referer", List.of("http://secretC"));
+ reqH.put("Authorization\t", List.of("Bearer secretD")); //
trailing tab must not dodge the match
+ stubRequestHeaders(reqH);
+ var resH = new LinkedHashMap<String,List<String>>();
+ resH.put("Location", List.of("http://secretE"));
+ stubResponseHeaders(resH);
+
+ var s = f.formatHeaders(req, res);
+ assertFalse(s.contains("secretA"), s);
+ assertFalse(s.contains("secretB"), s);
+ assertFalse(s.contains("secretC"), s);
+ assertFalse(s.contains("Bearer secretD"), s);
+ assertFalse(s.contains("secretE"), s);
+ }
+
+ @Test void
b06_formatHeaders_addRedactedHeaders_extends_redactedHeaders_replaces() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+
+ // addRedactedHeaders extends: built-in Authorization still
masked, plus the new one.
+ f.addRedactedHeaders(List.of("X-Custom-Secret"));
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("Authorization", List.of("Bearer keep-secret"));
+ reqH.put("X-Custom-Secret", List.of("custom-value"));
+ stubRequestHeaders(reqH);
+ when(sres.getHeaderNames()).thenReturn(emptyList());
+
+ var s = f.formatHeaders(req, res);
+ assertFalse(s.contains("Bearer keep-secret"), s);
+ assertFalse(s.contains("custom-value"), s);
+
+ // redactedHeaders replaces: now Authorization is NOT masked.
+ var f2 = new
BasicRestDebugFormatter().redactedHeaders(List.of("X-Only-This"));
+ var reqH2 = new LinkedHashMap<String,List<String>>();
+ reqH2.put("Authorization", List.of("Bearer now-visible"));
+ when(sreq.getHeaderNames()).thenReturn(enumeration(new
ArrayList<>(reqH2.keySet())));
+ reqH2.forEach((k, v) ->
when(sreq.getHeaders(k)).thenReturn(enumeration(v)));
+ var s2 = f2.formatHeaders(req, res);
+ assertTrue(s2.contains("Bearer now-visible"), s2);
+ }
+
+ @Test void b07_formatHeaders_maxFieldLength_capsValue() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+ f.maxFieldLength(16);
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("X-Big", List.of("v".repeat(5000)));
+ stubRequestHeaders(reqH);
+ when(sres.getHeaderNames()).thenReturn(emptyList());
+
+ var s = f.formatHeaders(req, res);
+ assertTrue(s.contains("[truncated]"), s);
+ assertFalse(s.contains("v".repeat(100)), "oversized value must
be capped: " + s.length());
+ }
+
+ @Test void b08_formatHeaders_maxHeaders_and_scan_bounds() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+ f.maxHeaders(3).maxHeaderScan(6);
+ var reqH = new LinkedHashMap<String,List<String>>();
+ for (var i = 0; i < 20; i++)
+ reqH.put("H" + i, List.of("val" + i));
+ stubRequestHeaders(reqH);
+ when(sres.getHeaderNames()).thenReturn(emptyList());
+
+ var s = f.formatHeaders(req, res);
+ // Only 3 emitted; scan stops at 6 so the approximate marker is
used (not full enumeration of 20).
+ assertTrue(s.contains("H0: val0"), s);
+ assertTrue(s.contains("H2: val2"), s);
+ assertFalse(s.contains("H3: val3"), s);
+ assertTrue(s.contains("more headers omitted"), s);
+ assertFalse(s.contains("H19"), s);
+ }
+
+ @Test void b09_formatHeaders_injectionInNameAndValue() {
+ when(req.getCachedContentLength()).thenReturn(-1L);
+ when(res.getCachedContentLength()).thenReturn(-1L);
+ var reqH = new LinkedHashMap<String,List<String>>();
+ reqH.put("X-Evil\r\n=== HTTP Call ===", List.of("v\r\n[200]
HTTP GET /admin"));
+ stubRequestHeaders(reqH);
+ when(sres.getHeaderNames()).thenReturn(emptyList());
+
+ var s = f.formatHeaders(req, res);
+ assertNoRawControlChars(s);
+ assertFalse(s.contains("\n=== HTTP Call ==="), "header name
must not forge a line: " + s);
+ assertFalse(s.contains("\n[200] HTTP GET /admin"), "header
value must not forge a line: " + s);
+ }
+
+ @Test void b10_redactedHeadersDefault_unchangedRegressionGuard() {
+ // Formatter-local widening must NOT mutate the shared default.
+ assertEquals(5, RedactedHeaders.DEFAULT.size());
+ assertTrue(RedactedHeaders.DEFAULT.containsAll(
+ List.of("Authorization", "Cookie", "Set-Cookie",
"Proxy-Authorization", "X-API-Key")));
+ // The formatter-local set is wider and still includes the
inherited defaults.
+ assertTrue(DEFAULT_REDACTED_HEADERS.contains("Cookie"));
+ assertTrue(DEFAULT_REDACTED_HEADERS.contains("X-Auth-Token"));
+ }
+
//
-----------------------------------------------------------------------------------------
- // c — formatBody
+ // c — formatBody (secure-by-default no-dump)
//
-----------------------------------------------------------------------------------------
- @Test void c01_formatBody_utf8AndHex() {
- when(req.getCachedContent()).thenReturn("hi".getBytes());
- when(req.getCachedContentLength()).thenReturn(2L);
+ @Test void c01_bodyGate_offVsOn_pairedContrast() {
+ var secret = "hunter2-PASSWORD";
+ requestBody("{\"password\":\"" + secret + "\"}",
"application/json");
+
+ resetAllowDumpBodiesForTest(false);
+ var off = f.formatBody(req, res);
+ assertTrue(off.contains("[body suppressed"), off);
+ assertTrue(off.contains("set " + ENV_ALLOW_DUMP_BODIES + " to
enable"), off);
+ assertAllEncodingsAbsent(off, secret);
+
+ resetAllowDumpBodiesForTest(true);
+ var on = f.formatBody(req, res);
+ assertTrue(on.contains(secret), on);
+ assertFalse(on.contains("Content Hex"), on);
+ assertFalse(on.contains("---Request Content UTF-8---"), on);
+ assertFalse(on.contains(toSpacedHex(secret.getBytes(UTF8))),
on);
+
+ assertNotEquals(off, on);
+ }
+
+ @Test void c02_bodyGateOff_scrubberNeverInvoked() {
+ var invoked = new AtomicBoolean(false);
+ f.bodyScrubber((ct, body) -> {
+ invoked.set(true);
+ return "SCRUBBED";
+ });
+ var secret = "do-not-leak";
+ requestBody("{\"p\":\"" + secret + "\"}", "application/json");
+
+ resetAllowDumpBodiesForTest(false);
+ var s = f.formatBody(req, res);
+ assertFalse(invoked.get(), "scrubber must not run while the
gate is off");
+ assertTrue(s.contains("[body suppressed"), s);
+ assertAllEncodingsAbsent(s, secret);
+ }
+
+ @Test void c03_bodyGateOn_scrubberSelected() {
+ f.bodyScrubber((ct, body) -> "SCRUBBED-OUTPUT");
+ var secret = "raw-secret-value";
+ requestBody("{\"p\":\"" + secret + "\"}", "application/json");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertTrue(s.contains("SCRUBBED-OUTPUT"), s);
+ assertAllEncodingsAbsent(s, secret);
+ }
+
+ @Test void c04_bodyGateOn_scrubberThrows_failsClosed() {
+ f.bodyScrubber((ct, body) -> {
+ throw new RuntimeException(body); // must not re-leak
the body through the exception
+ });
+ var secret = "throwing-secret";
+ requestBody("{\"p\":\"" + secret + "\"}", "application/json");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertTrue(s.contains("scrubber failed"), s);
+ assertFalse(s.contains("set " + ENV_ALLOW_DUMP_BODIES),
"scrubber-failed placeholder must not use gate-off wording: " + s);
+ assertAllEncodingsAbsent(s, secret);
+ }
+
+ @Test void c05_bodyGateOn_scrubberReturnsNull_failsClosed() {
+ f.bodyScrubber((ct, body) -> null);
+ var secret = "null-scrubber-secret";
+ requestBody("{\"p\":\"" + secret + "\"}", "application/json");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertTrue(s.contains("scrubber failed"), s);
+ assertAllEncodingsAbsent(s, secret);
+ }
+
+ @Test void c06_bodyGateOn_noScrubber_dumpsRawSanitized() {
+ var secret = "plain-visible-secret";
+ requestBody("body-with-" + secret, "text/plain");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertTrue(s.contains(secret), s);
+ }
+
+ @Test void c07_bodyGateOn_nonRenderable_octetStream_placeholder() {
+ var secret = "PLAINTEXT-BINARY-SECRET";
+ requestBody("prefix-" + secret + "-suffix",
"application/octet-stream");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertTrue(s.contains("[body not rendered"), s);
+ assertTrue(s.contains("binary/non-renderable content"), s);
+ assertAllEncodingsAbsent(s, secret);
+ }
+
+ @Test void c08_bodyGateOn_nonIdentityEncoding_placeholder() {
+ var secret = "COMPRESSED-SECRET";
+ var bytes = ("prefix-" + secret).getBytes(UTF8);
+ when(req.getCachedContent()).thenReturn(bytes);
+ when(req.getCachedContentLength()).thenReturn((long)
bytes.length);
+ when(sreq.getContentType()).thenReturn("application/json");
+ when(sreq.getHeader("Content-Encoding")).thenReturn("gzip");
when(res.getCachedContent()).thenReturn(new byte[0]);
- when(res.getCachedContentLength()).thenReturn(0L);
+ resetAllowDumpBodiesForTest(true);
var s = f.formatBody(req, res);
- assertTrue(s.contains("---Request Content UTF-8---"), s);
- assertTrue(s.contains("hi"), s);
- assertTrue(s.contains("---Request Content Hex---"), s);
+ assertTrue(s.contains("[body not rendered"), s);
+ assertAllEncodingsAbsent(s, secret);
}
- @Test void c02_formatBody_truncationMarker() {
- when(req.getCachedContent()).thenReturn("12".getBytes());
+ @Test void c09_bodyGateOn_bodyCapTruncation_noHex() {
+ when(req.getCachedContent()).thenReturn("0123".getBytes(UTF8));
when(req.getCachedContentLength()).thenReturn(10L);
+ when(sreq.getContentType()).thenReturn("text/plain");
when(res.getCachedContent()).thenReturn(new byte[0]);
- when(res.getCachedContentLength()).thenReturn(0L);
+ resetAllowDumpBodiesForTest(true);
var s = f.formatBody(req, res);
- assertTrue(s.contains("truncated 8 bytes"), s);
+ assertTrue(s.contains("0123"), s);
+ assertTrue(s.contains("truncated 6 bytes"), s);
+ assertFalse(s.contains("Content Hex"), s);
+ assertFalse(s.contains(toSpacedHex("0123".getBytes(UTF8))), s);
}
- @Test void c03_formatBody_emptyWhenNoContent() {
+ @Test void c10_bodyGateOn_charCap_boundsHugeScrubberOutput() {
+ f.bodyCap(10); // charCap = 60
+ f.bodyScrubber((ct, body) -> "X".repeat(5000));
+ requestBody("seed", "text/plain");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertTrue(s.contains("\u2026[truncated]"), s);
+ assertFalse(s.contains("X".repeat(200)), "scrubber output must
be char-capped: " + s.length());
+ }
+
+ @Test void c11_bodyGateOff_placeholder_sanitizesContentTypeToken() {
+ var bytes = "body".getBytes(UTF8);
+ when(req.getCachedContent()).thenReturn(bytes);
+ when(req.getCachedContentLength()).thenReturn((long)
bytes.length);
+ when(sreq.getContentType()).thenReturn("application/json\r\n===
HTTP Call ===");
+ when(res.getCachedContent()).thenReturn(new byte[0]);
+
+ resetAllowDumpBodiesForTest(false);
+ var s = f.formatBody(req, res);
+ assertNoRawControlChars(s);
+ assertFalse(s.contains("\n=== HTTP Call ==="), "content-type
must not forge a line in the placeholder: " + s);
+ }
+
+ @Test void c12_bodyGateOn_injectionInBody_noForgedLine() {
+ requestBody("safe\r\n=== HTTP Call ===\r\nmore", "text/plain");
+
+ resetAllowDumpBodiesForTest(true);
+ var s = f.formatBody(req, res);
+ assertNoRawControlChars(s);
+ assertFalse(s.contains("\n=== HTTP Call ==="), "body must not
forge a line: " + s);
+ assertTrue(s.contains("\\r\\n"), s);
+ }
+
+ @Test void c13_formatBody_emptyWhenNoContent() {
when(req.getCachedContent()).thenReturn(null);
when(res.getCachedContent()).thenReturn(null);
assertEquals("", f.formatBody(req, res));
}
+
+ //
-----------------------------------------------------------------------------------------
+ // d — env-var master gate parsing / seam
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void d01_truthyTable_disable() {
+ for (var v : Arrays.asList(null, "", " ", "false", "FALSE",
"0", " false ", " 0 "))
+ assertFalse(parseAllowDumpBodies(v), () -> "should
disable: [" + v + "]");
+ }
+
+ @Test void d02_truthyTable_enable() {
+ for (var v : List.of("1", "true", "yes", "on", "no", "off",
"please", " 1 "))
+ assertTrue(parseAllowDumpBodies(v), () -> "should
enable: [" + v + "]");
+ }
+
+ @Test void d03_seam_forcesBothStates_andReadOnce() {
+ resetAllowDumpBodiesForTest(true);
+ assertTrue(isAllowDumpBodies());
+ assertTrue(isAllowDumpBodies()); // cached; stable across calls
+ resetAllowDumpBodiesForTest(false);
+ assertFalse(isAllowDumpBodies());
+ }
+
+ @Test void d04_noSystemPropertyFallback() {
+ // Guard: only meaningful when the ambient env var is unset
(the seam neutralizes it for other tests).
+ Assumptions.assumeTrue(System.getenv(ENV_ALLOW_DUMP_BODIES) ==
null);
+ var prev = System.getProperty(ENV_ALLOW_DUMP_BODIES);
+ System.setProperty(ENV_ALLOW_DUMP_BODIES, "true");
+ try {
+ resetAllowDumpBodiesForTest(null); // re-resolve from
env only
+ assertFalse(isAllowDumpBodies(), "a system property
must NEVER enable the env-only gate");
+ } finally {
+ if (prev == null)
+ System.clearProperty(ENV_ALLOW_DUMP_BODIES);
+ else
+ System.setProperty(ENV_ALLOW_DUMP_BODIES, prev);
+ resetAllowDumpBodiesForTest(null);
+ }
+ }
+
+ //
-----------------------------------------------------------------------------------------
+ // e — isBodyRenderable predicate
+ //
-----------------------------------------------------------------------------------------
+
+ @Test void e01_renderable_textAndStructured() {
+ assertTrue(f.isBodyRenderable("text/plain"));
+ assertTrue(f.isBodyRenderable("text/html; charset=utf-8"));
+ assertTrue(f.isBodyRenderable("application/json"));
+ assertTrue(f.isBodyRenderable("application/json;
charset=utf-8"));
+ assertTrue(f.isBodyRenderable("application/xml"));
+ assertTrue(f.isBodyRenderable("application/hal+json"));
+ assertTrue(f.isBodyRenderable("application/atom+xml"));
+
assertTrue(f.isBodyRenderable("application/x-www-form-urlencoded"));
+ assertTrue(f.isBodyRenderable("APPLICATION/JSON"));
+ }
+
+ @Test void e02_nonRenderable_binaryAndMultipartAndAbsent() {
+ assertFalse(f.isBodyRenderable("application/octet-stream"));
+ assertFalse(f.isBodyRenderable("multipart/form-data"));
+ assertFalse(f.isBodyRenderable("image/png"));
+ assertFalse(f.isBodyRenderable(null));
+ assertFalse(f.isBodyRenderable(""));
+ assertFalse(f.isBodyRenderable(" "));
+ assertFalse(f.isBodyRenderable("; charset=utf-8"));
+ }
}