001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.client; 018 019import static org.apache.juneau.commons.utils.IoUtils.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.util.*; 023import java.util.stream.*; 024 025import org.apache.juneau.http.*; 026 027/** 028 * Default {@link RestClientDebugFormatter} implementation — secure by default. 029 * 030 * <p> 031 * Request/response bodies are <b>never dumped by default</b>; dumping is a deliberate operator opt-in behind the 032 * {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment-variable master gate, mirroring 033 * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s server-side contract. See 034 * {@link #bodyScrubber(RestDebugBodyScrubber)} and {@link RestClientDebugFormatter#isBodyRenderable(String)} for the 035 * rest of the pipeline. 036 * 037 * @since 10.0.0 038 */ 039public class BasicRestClientDebugFormatter implements RestClientDebugFormatter { 040 041 /** Default body capture cap, in bytes (8 KB). */ 042 public static final int DEFAULT_BODY_CAP = 8 * 1024; 043 044 /** 045 * The environment variable that gates body dumping. Keep byte-identical to 046 * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart — the same operator-facing 047 * kill switch must protect both the server and client debug pipelines. 048 */ 049 static final String ENV_ALLOW_DUMP_BODIES = "JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES"; 050 051 /** 052 * The widened, formatter-local default set of header names whose values are masked. Starts from a copy of 053 * {@link RedactedHeaders#DEFAULT} and adds the credential-bearing headers the shared default omits. 054 * 055 * <p> 056 * Keep byte-identical to {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart. 057 */ 058 static final Set<String> DEFAULT_REDACTED_HEADERS; 059 static { 060 var s = new LinkedHashSet<>(RedactedHeaders.DEFAULT); 061 s.add("X-Auth-Token"); 062 s.add("X-Authorization"); 063 s.add("WWW-Authenticate"); 064 s.add("Referer"); 065 s.add("Location"); 066 DEFAULT_REDACTED_HEADERS = Collections.unmodifiableSet(s); 067 } 068 069 /** 070 * Cached resolution of the body-dump master gate. {@code null} means "not yet resolved"; resolution reads the 071 * environment exactly once. Not a system property, and there is no system-property fallback. 072 */ 073 private static volatile Boolean allowDumpBodiesCache; 074 075 /** The set of header names whose values are masked. */ 076 protected Set<String> redactedHeaders = DEFAULT_REDACTED_HEADERS; 077 078 /** Optional body scrubber applied to body text when the master gate is set. */ 079 protected RestDebugBodyScrubber bodyScrubber; 080 081 /** The body capture cap in bytes. */ 082 protected int bodyCap = DEFAULT_BODY_CAP; 083 084 /** Constructor. */ 085 public BasicRestClientDebugFormatter() { 086 /* no state to initialize */ 087 } 088 089 /** 090 * Overrides the redacted-header set. 091 * 092 * @param value The new set of header names to mask. 093 * @return This object. 094 */ 095 public BasicRestClientDebugFormatter redactedHeaders(Collection<String> value) { 096 redactedHeaders = new LinkedHashSet<>(value); 097 return this; 098 } 099 100 /** 101 * Sets the body scrubber applied to body text when the {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} gate is set. 102 * 103 * <p> 104 * The scrubber only ever runs once dumping is already permitted by the gate; it chooses scrubbed-vs-raw body 105 * text. It is fail-closed: a throw or a <jk>null</jk> return yields a placeholder, never the raw body. 106 * 107 * @param value The scrubber, or <jk>null</jk> for none (the default, which dumps raw when gated). 108 * @return This object. 109 */ 110 public BasicRestClientDebugFormatter bodyScrubber(RestDebugBodyScrubber value) { 111 bodyScrubber = value; 112 return this; 113 } 114 115 /** 116 * Overrides the body capture cap. 117 * 118 * @param value The new cap in bytes. 119 * @return This object. 120 */ 121 public BasicRestClientDebugFormatter bodyCap(int value) { 122 bodyCap = value; 123 return this; 124 } 125 126 @Override /* RestClientDebugFormatter */ 127 public int bodyCap() { 128 return bodyCap; 129 } 130 131 @Override /* RestClientDebugFormatter */ 132 public String formatBasic(RestRequest req, RestResponse res) { 133 var uri = req.getUri(); 134 var method = DebugTextSanitizer.sanitize(req.getMethod()); 135 var uriStr = DebugTextSanitizer.sanitize(uri != null ? uri.toString() : ""); 136 var sb = new StringBuilder(); 137 // Prepend the resolved correlation id (same effective-id order as the emit stamp): the confirmed echoed id 138 // when present, else the sent id. Both are already safe to interpolate (client-minted Uuid7 values are safe; 139 // a server-echoed value was sanitized server-side), so no second sanitizer pass is needed for this token. 140 var effectiveId = effectiveRequestId(req, res); 141 if (effectiveId != null) 142 sb.append("[requestId=").append(effectiveId).append("] "); 143 return sb 144 .append('[').append(res.getStatusCode()).append("] ") 145 .append("HTTP ").append(method).append(' ') 146 .append(uriStr) 147 .append(" (").append(req.getExecTime() != null ? req.getExecTime().toMillis() : 0).append("ms)") 148 .toString(); 149 } 150 151 // The confirmed echoed id when present, else the sent id. 152 private static String effectiveRequestId(RestRequest req, RestResponse res) { 153 if (res != null && res.getRequestId() != null) 154 return res.getRequestId(); 155 return req.getRequestId(); 156 } 157 158 @Override /* RestClientDebugFormatter */ 159 public String formatHeaders(RestRequest req, RestResponse res) { 160 var sb = new StringBuilder(); 161 162 var reqLen = req.getCachedContentLength(); 163 if (reqLen >= 0) 164 sb.append("\n\tRequest length: ").append(reqLen).append(" bytes"); 165 166 sb.append("\n\tResponse code: ").append(res.getStatusCode()); 167 168 var resLen = res.getCachedContentLength(); 169 if (resLen >= 0) 170 sb.append("\n\tResponse length: ").append(resLen).append(" bytes"); 171 172 var normalizedRedacted = normalizedRedactedSet(); 173 174 if (!req.getResolvedHeaders().isEmpty()) { 175 sb.append("\n---Request Headers---"); 176 for (var h : req.getResolvedHeaders()) 177 appendHeaderLine(sb, h.name(), h.value(), normalizedRedacted); 178 } 179 180 if (!res.getHeaders().isEmpty()) { 181 sb.append("\n---Response Headers---"); 182 for (var h : res.getHeaders()) 183 appendHeaderLine(sb, h.name(), h.value(), normalizedRedacted); 184 } 185 return sb.toString(); 186 } 187 188 private void appendHeaderLine(StringBuilder sb, String name, String value, Set<String> normalizedRedacted) { 189 // Order: mask (name-based, on the raw value) → escape (name, then whatever survived masking) → cap (none here). 190 var masked = isRedacted(name, normalizedRedacted) ? RedactedHeaders.REDACTED : value; 191 sb.append("\n\t").append(DebugTextSanitizer.sanitize(name)).append(": ").append(DebugTextSanitizer.sanitize(masked)); 192 } 193 194 private Set<String> normalizedRedactedSet() { 195 return redactedHeaders.stream() 196 .filter(Objects::nonNull) 197 .map(BasicRestClientDebugFormatter::normalizeHeaderName) 198 .collect(Collectors.toCollection(HashSet::new)); 199 } 200 201 private static boolean isRedacted(String name, Set<String> normalizedRedacted) { 202 return name != null && normalizedRedacted.contains(normalizeHeaderName(name)); 203 } 204 205 /** Case-folds and strips {@code -}/{@code _} and whitespace so {@code X-Auth-Token}/{@code X_Auth_Token}/{@code XAuthToken} all match. */ 206 private static String normalizeHeaderName(String name) { 207 var sb = new StringBuilder(name.length()); 208 for (var i = 0; i < name.length(); i++) { 209 var c = name.charAt(i); 210 if (c == '-' || c == '_' || Character.isWhitespace(c)) 211 continue; 212 sb.append(Character.toLowerCase(c)); 213 } 214 return sb.toString(); 215 } 216 217 @Override /* RestClientDebugFormatter */ 218 public String formatBody(RestRequest req, RestResponse res) { 219 var sb = new StringBuilder(); 220 // Content-Type/Content-Encoding are resolved independently per side, each from that side's own finalized, 221 // wire-order header list — never reused across sides (Risk 5). 222 var reqCt = header(req.getFirstHeader("Content-Type")); 223 var reqCe = header(req.getFirstHeader("Content-Encoding")); 224 var resCt = header(res.getFirstHeader("Content-Type")); 225 var resCe = header(res.getFirstHeader("Content-Encoding")); 226 appendBody(sb, "Request", req.getCachedContent(), req.getCachedContentLength(), reqCt, reqCe); 227 appendBody(sb, "Response", res.getCachedContent(), res.getCachedContentLength(), resCt, resCe); 228 return sb.toString(); 229 } 230 231 private static String header(TransportHeader h) { 232 return h == null ? null : h.value(); 233 } 234 235 private void appendBody(StringBuilder sb, String label, byte[] content, long totalLength, String contentType, String contentEncoding) { 236 if (content == null || content.length == 0) 237 return; 238 var byteCount = totalLength >= 0 ? totalLength : content.length; 239 var ctToken = contentTypeToken(contentType); 240 241 if (! isAllowDumpBodies()) { 242 appendPlaceholder(sb, label, byteCount, ctToken, "; set " + ENV_ALLOW_DUMP_BODIES + " to enable", "body suppressed"); 243 return; 244 } 245 246 if (! isRenderable(contentType, contentEncoding)) { 247 appendPlaceholder(sb, label, byteCount, ctToken, "; binary/non-renderable content", "body not rendered"); 248 return; 249 } 250 251 var raw = new String(content, UTF8); 252 String bodyText; 253 if (bodyScrubber != null) { 254 String scrubbed; 255 try { 256 scrubbed = bodyScrubber.scrub(contentType, raw); 257 } catch (Throwable t) { // NOSONAR - fail closed on any scrubber failure; never re-leak via the exception. 258 scrubbed = null; 259 } 260 if (scrubbed == null) { 261 appendPlaceholder(sb, label, byteCount, ctToken, "; scrubber failed", "body suppressed"); 262 return; 263 } 264 bodyText = scrubbed; 265 } else { 266 bodyText = raw; 267 } 268 269 sb.append("\n---").append(label).append(" Content---"); 270 sb.append('\n').append(DebugTextSanitizer.sanitize(bodyText, charCap())); 271 var omitted = byteCount - content.length; 272 if (omitted > 0) 273 sb.append("\n\u2026[truncated ").append(omitted).append(" bytes]"); 274 } 275 276 private void appendPlaceholder(StringBuilder sb, String label, long byteCount, String ctToken, String reason, String verb) { 277 sb.append("\n---").append(label).append(" Content---"); 278 sb.append("\n[").append(verb).append(": ").append(byteCount).append(" bytes, ").append(ctToken).append(reason).append(']'); 279 } 280 281 private String contentTypeToken(String contentType) { 282 if (ib(contentType)) 283 return "unknown"; 284 return DebugTextSanitizer.sanitize(contentType); 285 } 286 287 private boolean isRenderable(String contentType, String contentEncoding) { 288 if (! isIdentityEncoding(contentEncoding)) 289 return false; 290 return isBodyRenderable(contentType); 291 } 292 293 private static boolean isIdentityEncoding(String contentEncoding) { 294 if (contentEncoding == null) 295 return true; 296 var e = contentEncoding.trim(); 297 return e.isEmpty() || eqic(e, "identity"); 298 } 299 300 /** 301 * Returns the character-length cap for a dumped body, derived from {@link #bodyCap()}. 302 * 303 * <p> 304 * Worst-case escaping expands one byte to a 6-character {@code \\uXXXX} sequence, so the sanitized character cap 305 * is {@code bodyCap() * 6} (clamped to {@code Integer.MAX_VALUE}). Capture already bounds the raw bytes; this 306 * second layer bounds a scrubber that returns an oversized string. 307 */ 308 private int charCap() { 309 var cap = Math.max(bodyCap, 0) * 6L; 310 return cap > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) cap; 311 } 312 313 /** 314 * Returns whether body dumping is enabled by the {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment 315 * variable. 316 * 317 * <p> 318 * Resolved once from the environment and cached. Never reads a system property. Keep byte-identical to 319 * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart. 320 * 321 * @return <jk>true</jk> if body dumping is enabled. 322 */ 323 static boolean isAllowDumpBodies() { 324 var v = allowDumpBodiesCache; 325 if (v == null) { 326 v = parseAllowDumpBodies(System.getenv(ENV_ALLOW_DUMP_BODIES)); 327 allowDumpBodiesCache = v; 328 } 329 return v; 330 } 331 332 /** 333 * Parses a raw gate value with trim-then-parse truthy semantics. 334 * 335 * <p> 336 * The value is trimmed first; a non-empty trimmed value enables (returns <jk>true</jk>) except case-insensitive 337 * {@code false} or {@code 0}; {@code null}/empty/all-whitespace disables. This is a fail-safe kill switch, so 338 * ambiguous values resolve to disabled. Keep byte-identical to 339 * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart. 340 * 341 * @param raw The raw environment value. Can be <jk>null</jk>. 342 * @return <jk>true</jk> if the value enables body dumping. 343 */ 344 static boolean parseAllowDumpBodies(String raw) { 345 if (raw == null) 346 return false; 347 var t = raw.trim(); 348 if (t.isEmpty()) 349 return false; 350 return ! (eqic(t, "false") || eq(t, "0")); 351 } 352 353 /** 354 * Test-only seam for exercising both gate states without mutating the process environment. 355 * 356 * <p> 357 * Package-private and unreachable from application code, and never reads a system property. Production 358 * resolution stays env-only / read-once ({@link #isAllowDumpBodies()}). 359 * 360 * @param override {@code null} clears the cache so the next resolution re-reads the environment once; a 361 * non-<jk>null</jk> value is cached directly as the test gate state. 362 */ 363 static void resetAllowDumpBodiesForTest(Boolean override) { 364 allowDumpBodiesCache = override; 365 } 366}