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>) &rarr; <c>\r</c>, LF (<c>\\u000A</c>) &rarr; 
<c>\n</c>.
+ *     <li>All other C0 controls (<c>\\u0000</c>&ndash;<c>\\u001F</c>) 
<b>except TAB</b> (<c>\\u0009</c>) &rarr; <c>\\uXXXX</c>.
+ *     <li>DEL and C1 controls (<c>\\u007F</c>&ndash;<c>\\u009F</c>, which 
includes NEL <c>\\u0085</c>) &rarr; <c>\\uXXXX</c>.
+ *     <li>Line separator (<c>\\u2028</c>), paragraph separator 
(<c>\\u2029</c>), and bidi controls
+ *             (<c>\\u202A</c>&ndash;<c>\\u202E</c>, 
<c>\\u2066</c>&ndash;<c>\\u2069</c>) &rarr; <c>\\uXXXX</c>.
+ *     <li>TAB (<c>\\u0009</c>) is <b>preserved</b> &mdash; 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>&hellip;[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 &mdash; secure by default.
  *
  * <p>
  * Renders, cumulatively by tier:
  * <ul>
- *     <li><b>Basic ({@code INFO})</b> &mdash; a single status line ({@code 
[status] HTTP method uri}).
- *     <li><b>Headers ({@code FINE})</b> &mdash; request/response header 
blocks (credential-bearing values masked), plus
- *             request/response lengths and the request execution time.
- *     <li><b>Body ({@code FINEST})</b> &mdash; 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> &mdash; 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> &mdash; 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> &mdash; 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
+ *             &mdash; 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 &mdash; 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
+ *             &mdash; 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 &rarr; escape &rarr; cap</b> order.
  *
+ * <h5 class='section'>Bounds</h5>
  * <p>
- * Bodies are captured up to {@link #bodyCap()} bytes (default 
<b>8&nbsp;KB</b>). This default is deliberately smaller than
- * {@code EchoMixin}'s 1&nbsp;MB body cap &mdash; debug logging is intended 
for low-volume operator diagnostics where an
- * 8&nbsp;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&nbsp;KB</b>) 
at capture time; headers by
+ * {@link #maxHeaders(int)}/{@link #maxHeaderScan(int)}/{@link 
#maxFieldLength(int)}; the URI by {@link #maxUriLength(int)}.
+ * The 8&nbsp;KB body cap is deliberately smaller than {@code EchoMixin}'s 
1&nbsp;MB body cap &mdash; 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&nbsp;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 &ge; 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 &ge; 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 &ge; 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 &ge; 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"));
+       }
 }

Reply via email to