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.server.ops; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.io.*; 023import java.nio.charset.*; 024import java.util.*; 025import java.util.stream.*; 026 027import org.apache.juneau.http.*; 028import org.apache.juneau.http.response.*; 029import org.apache.juneau.marshall.json.*; 030import org.apache.juneau.rest.server.*; 031 032/** 033 * Mixin that serves a request-echo / round-trip introspection endpoint at {@code /echo/*} 034 * (configurable). 035 * 036 * <p> 037 * Sibling of {@link AdminMixin} ({@code /admin/*}) and {@link RouteIndexMixin} 038 * ({@code /options}). All three classes live in the 039 * {@code org.apache.juneau.rest.server.ops} ops/introspection mixin pack. 040 * 041 * <p> 042 * Compose into a host resource via 043 * {@link Rest#mixins() @Rest(mixins=EchoMixin.class)}; the {@code /echo/*} URL becomes 044 * available alongside the host's own endpoints with no further wiring. 045 * 046 * <h5 class='section'>Configurable mount path:</h5> 047 * 048 * <p> 049 * The default mount {@code /echo/*} can be overridden via the SVL variable 050 * {@code ${juneau.echo.path:echo}} — set via system property 051 * ({@code -Djuneau.echo.path=introspect}), environment variable 052 * ({@code JUNEAU_ECHO_PATH=introspect}), or {@code Config} key 053 * ({@code juneau.echo.path = introspect}) to change the runtime mount without subclassing. 054 * Resolution happens once at {@link RestContext} construction time (SVL resolution in {@code @RestOp(path)}). 055 * 056 * <p> 057 * Override accepts bare token ({@code echo}), leading slash ({@code /echo}), trailing slash 058 * ({@code echo/}), or wildcard suffix ({@code /echo/*}) — all resolve to the same mount. 059 * 060 * <p> 061 * <b>Migration note (10.0.0):</b> Earlier development snapshots of this mixin mounted at both 062 * {@code /echo/*} <i>and</i> {@code /debug/echo/*} as historical aliases on a single op. That 063 * dual default has been collapsed to a single SVL-configurable mount as part of the 064 * "single path per op" principle. Deployers who relied on the 065 * {@code /debug/echo/*} alias must now either set {@code -Djuneau.echo.path=debug/echo} or 066 * compose a second instance with the override. 067 * 068 * <h5 class='section'>Mixin-only deployment:</h5> 069 * 070 * <p> 071 * This resource is designed for composition via {@code @Rest(mixins=...)}. The mount path is 072 * pinned at the op level by {@link RestOp @RestOp(path="/{juneau.echo.path:echo}/*")} on 073 * {@link #echo}; a class-level {@code @Rest(paths=...)} declaration would be silently ignored 074 * under the mixin pattern (see {@link Rest#paths() @Rest(paths)} Javadoc). 075 * 076 * <h5 class='figure'>Composition example:</h5> 077 * 078 * <p class='bjava'> 079 * <ja>@Rest</ja>( 080 * path=<js>"/api"</js>, 081 * mixins=EchoMixin.<jk>class</jk> 082 * ) 083 * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet { } 084 * 085 * <jc>// Enable the endpoint (default OFF) and, optionally, tighten the body cap or adjust</jc> 086 * <jc>// redacted headers via a @Bean factory:</jc> 087 * <ja>@Bean</ja> EchoMixin echo() { 088 * <jk>return</jk> EchoMixin.<jsm>create</jsm>() 089 * .enabled() 090 * .bodyLimit(64 * 1024L) 091 * .redactHeader(<js>"X-Internal-Trace"</js>) 092 * .build(); 093 * } 094 * </p> 095 * 096 * <h5 class='section'>Enablement:</h5> 097 * 098 * <p> 099 * The endpoint is <b>disabled by default</b>. Reachability is controlled by an explicit, non-logging switch and is 100 * <b>independent of the JUL logger level</b> — raising a resource's (or operation's) logger to 101 * {@link java.util.logging.Level#FINE FINE}/{@link java.util.logging.Level#FINEST FINEST} never exposes {@code /echo/*}. 102 * When the endpoint is disabled the handler returns {@code 404 Not Found} (so the existence of the endpoint isn't 103 * disclosed). 104 * 105 * <p> 106 * Enablement is tri-state, resolved per request: 107 * <ul class='spaced-list'> 108 * <li>An explicit {@link Builder#enabled(Boolean) builder value} ({@code true}/{@code false}) always wins. 109 * <li>When the builder value is left unset ({@code null}, the default), the handler resolves the SVL expression 110 * {@code ${juneau.echo.enabled:false}} through {@link RestRequest#getVarResolverSession()} — i.e. the 111 * system property {@code -Djuneau.echo.enabled=true} or the relaxed environment variable 112 * {@code JUNEAU_ECHO_ENABLED=true}. A per-resource {@code Config} key is <b>not</b> consulted for this switch. 113 * </ul> 114 * 115 * <p> 116 * <b>GLOBAL blast-radius warning:</b> {@code juneau.echo.enabled=true} is a <b>process-wide</b> system-property / 117 * environment switch. It enables {@code /echo/*} on <b>every</b> host in the JVM that mixes in {@code EchoMixin} 118 * — including hosts the operator did not intend to open — and, because it is resolved per request, setting 119 * it flips this <b>security</b> switch mid-process without a restart. It is a convenience for single-app deployments 120 * and local testing, <b>not</b> the recommended production posture. 121 * 122 * <p> 123 * <b>Recommended production posture:</b> leave echo OFF by default and enable it per host explicitly via a 124 * {@code @Bean EchoMixin} factory that calls {@link Builder#enabled()}, gated behind an authorization guard chain. 125 * Host-level {@link Rest#guards() @Rest(guards=...)} inherit onto the mixed-in {@code /echo/*} operation, so declaring 126 * guards at the host authorizes the endpoint. 127 * 128 * <h5 class='section'>Sensitive-header redaction:</h5> 129 * 130 * <p> 131 * Token-bearing headers must never be reflected back to the caller (would defeat any auth scheme 132 * in front of the endpoint). The default redacted list is — case-insensitively — 133 * {@code Authorization}, {@code Cookie}, {@code Set-Cookie}, {@code Proxy-Authorization}, and 134 * {@code X-API-Key}. Each redacted header surfaces in the echo body with the literal value 135 * {@value #REDACTED} so the caller can see the header was present without leaking its value. 136 * Override the default list via {@link Builder#redactedHeaders(String...)} (replaces) or extend 137 * via {@link Builder#redactHeader(String)} (additive). 138 * 139 * <p> 140 * {@link #REDACTED} and {@link #DEFAULT_REDACTED_HEADERS} are re-exports of 141 * {@link RedactedHeaders#REDACTED} and {@link RedactedHeaders#DEFAULT} respectively; masking is 142 * performed by delegating to {@link RedactedHeaders#redact(String, String, java.util.Collection)}. 143 * 144 * <h5 class='section'>Body capture and truncation:</h5> 145 * 146 * <p> 147 * The handler reads up to {@link Builder#bodyLimit(long) bodyLimit} bytes (default 148 * {@value #DEFAULT_BODY_LIMIT_DOC} = 1 MB) of the inbound body and emits it as a UTF-8 string in 149 * the {@code content} field of the JSON response. When the body exceeds the cap, the captured 150 * portion is truncated and the {@code truncated} flag is set to {@code true} so callers can see 151 * the response is incomplete. 152 * 153 * <h5 class='section'>Response shape:</h5> 154 * 155 * <p class='bjson'> 156 * { 157 * <jok>"method"</jok>: <jov>"POST"</jov>, 158 * <jok>"path"</jok>: <jov>"/echo/foo/bar"</jov>, 159 * <jok>"queryString"</jok>: <jov>"x=1"</jov>, 160 * <jok>"pathRemainder"</jok>: <jov>"foo/bar"</jov>, 161 * <jok>"headers"</jok>: { <jok>"User-Agent"</jok>: <jov>"curl"</jov>, <jok>"Authorization"</jok>: <jov>"[REDACTED]"</jov> }, 162 * <jok>"queryParams"</jok>: { <jok>"x"</jok>: <jov>"1"</jov> }, 163 * <jok>"attributes"</jok>: { }, 164 * <jok>"contentLength"</jok>: <jov>5</jov>, 165 * <jok>"content"</jok>: <jov>"hello"</jov>, 166 * <jok>"truncated"</jok>: <jov>false</jov> 167 * } 168 * </p> 169 * 170 * <p> 171 * The endpoint is excluded from generated Swagger / OpenAPI specs via 172 * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}. 173 * 174 * <h5 class='section'>See Also:</h5><ul> 175 * <li class='jc'>{@link AdminMixin} 176 * <li class='jc'>{@link RouteIndexMixin} 177 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server — Mixins and Multi-Mount Paths</a> 178 * </ul> 179 * 180 * @since 10.0.0 181 */ 182// @formatter:off 183@Rest 184public class EchoMixin { 185 186 /** Sentinel value emitted in place of redacted header values. Re-exports {@link RedactedHeaders#REDACTED}. */ 187 public static final String REDACTED = RedactedHeaders.REDACTED; 188 189 /** Default body capture cap, in bytes (1 MB). */ 190 public static final long DEFAULT_BODY_LIMIT = 1_048_576L; 191 192 /** Default body capture cap rendered for the class-level javadoc — do not use programmatically. */ 193 static final String DEFAULT_BODY_LIMIT_DOC = "1048576"; 194 195 /** Default redacted-header set (case-insensitive lookup). Re-exports {@link RedactedHeaders#DEFAULT}. */ 196 protected static final Set<String> DEFAULT_REDACTED_HEADERS = RedactedHeaders.DEFAULT; 197 198 /** 199 * Creates a new builder. 200 * 201 * @return A new builder. 202 */ 203 public static Builder create() { 204 return new Builder(); 205 } 206 207 private final long bodyLimit; 208 private final Set<String> redactedHeaders; 209 private final Set<String> redactedHeadersLower; 210 private final Boolean enabled; 211 212 /** No-arg constructor — uses {@link #DEFAULT_BODY_LIMIT} and {@link #DEFAULT_REDACTED_HEADERS}. */ 213 public EchoMixin() { 214 this(create()); 215 } 216 217 /** 218 * Builder constructor. 219 * 220 * @param builder The builder. Must not be {@code null}. 221 */ 222 protected EchoMixin(Builder builder) { 223 bodyLimit = builder.bodyLimit; 224 redactedHeaders = u(new LinkedHashSet<>(builder.redactedHeaders)); 225 var s = builder.redactedHeaders.stream().map(h -> h.toLowerCase(Locale.ROOT)).collect(Collectors.toCollection(LinkedHashSet::new)); 226 redactedHeadersLower = u(s); 227 enabled = builder.enabled; 228 } 229 230 /** 231 * [* /echo/*] — emit an introspection echo of the inbound request. 232 * 233 * <p> 234 * Returns {@code 404 Not Found} when the endpoint is not {@link Builder#enabled(Boolean) enabled} for the current 235 * request. Reachability is independent of the JUL logger level (see the class-level Javadoc for the enablement 236 * rules and the global-fallback blast-radius warning). 237 * 238 * @param req The current REST request. 239 * @param res The current REST response. 240 * @param remainder The path remainder after the mount prefix (multi-segment, may be empty). 241 * @throws IOException If an I/O error occurs while reading the request or writing the response. 242 * @throws NotFound When the endpoint resolves to disabled for the current request. 243 */ 244 @RestOp( 245 method="*", 246 path="/#{pathToken(${juneau.echo.path:echo})}/*", 247 summary="Request echo", 248 description="Round-trip introspection of the inbound request. Disabled by default; enable via EchoMixin enablement.", 249 swagger=@OpSwagger(ignore=true) 250 ) 251 @SuppressWarnings({ 252 "resource" // sreq.getInputStream() is the servlet container's request body stream; it must not be closed by application code (lifecycle is tied to the request, not this handler). 253 }) 254 public void echo(RestRequest req, RestResponse res, @Path("/*") String remainder) throws IOException { 255 var sreq = req.getHttpServletRequest(); 256 var on = enabled != null ? enabled.booleanValue() : Boolean.parseBoolean(req.getVarResolverSession().resolve("${juneau.echo.enabled:false}")); 257 if (! on) 258 throw new NotFound("Echo endpoint disabled."); 259 260 var headers = new LinkedHashMap<String,String>(); 261 var names = sreq.getHeaderNames(); 262 while (names != null && names.hasMoreElements()) { 263 var name = names.nextElement(); 264 headers.put(name, RedactedHeaders.redact(name, sreq.getHeader(name), redactedHeaders)); 265 } 266 267 var queryParams = new LinkedHashMap<String,String>(); 268 for (var p : req.getQueryParams()) 269 queryParams.put(p.getName(), p.asString().orElse(null)); 270 271 var attributes = new LinkedHashMap<String,String>(); 272 req.getAttributes().asMap().forEach((k, v) -> attributes.put(k, String.valueOf(v))); 273 274 var capture = readBoundedBody(sreq.getInputStream(), bodyLimit); 275 276 Map<String,Object> out = m( 277 "method", req.getMethod(), 278 "path", sreq.getRequestURI(), 279 "queryString", sreq.getQueryString(), 280 "pathRemainder", remainder == null ? "" : remainder, 281 "headers", headers, 282 "queryParams", queryParams, 283 "attributes", attributes, 284 "contentLength", capture.bytesRead 285 ); 286 if (capture.bytesRead > 0) 287 out.put("content", new String(capture.bytes, StandardCharsets.UTF_8)); 288 out.put("truncated", capture.truncated); 289 290 try (var w = res.getDirectWriter("application/json")) { 291 JsonSerializer.DEFAULT_READABLE.write(out, w); 292 } 293 } 294 295 /** 296 * Returns the configured body-capture cap (test/inspection helper). 297 * 298 * @return The body capture cap, in bytes. 299 */ 300 public long getBodyLimit() { 301 return bodyLimit; 302 } 303 304 /** 305 * Returns the redacted-header set as lowercased, immutable strings (test/inspection helper). 306 * 307 * @return The redacted-header set. 308 */ 309 public Set<String> getRedactedHeadersLower() { 310 return redactedHeadersLower; 311 } 312 313 /** 314 * Returns the explicit enablement tri-state configured on the builder (test/inspection helper). 315 * 316 * @return {@code Boolean.TRUE}/{@code Boolean.FALSE} when explicitly configured, or {@code null} when unset 317 * (deferred to the {@code ${juneau.echo.enabled:false}} deployment fallback). 318 */ 319 public Boolean getEnabled() { 320 return enabled; 321 } 322 323 @SuppressWarnings({ 324 "java:S135" // The two breaks are distinct truncation exits (limit-reached vs. partial-final-chunk); merging them would obscure the bounded-read logic. 325 }) 326 private static BodyCapture readBoundedBody(InputStream in, long limit) throws IOException { 327 if (in == null) 328 return new BodyCapture(new byte[0], 0, false); 329 var buf = new ByteArrayOutputStream(); 330 var chunk = new byte[8192]; 331 var truncated = false; 332 long total = 0; 333 int n; 334 while ((n = in.read(chunk)) != -1) { 335 var room = limit - total; 336 if (room <= 0) { 337 truncated = true; 338 break; 339 } 340 if (n > room) { 341 buf.write(chunk, 0, (int) room); 342 total += room; 343 truncated = true; 344 break; 345 } 346 buf.write(chunk, 0, n); 347 total += n; 348 } 349 return new BodyCapture(buf.toByteArray(), total, truncated); 350 } 351 352 private static final class BodyCapture { 353 final byte[] bytes; 354 final long bytesRead; 355 final boolean truncated; 356 357 BodyCapture(byte[] bytes, long bytesRead, boolean truncated) { 358 this.bytes = bytes; 359 this.bytesRead = bytesRead; 360 this.truncated = truncated; 361 } 362 } 363 364 /** 365 * Builder for {@link EchoMixin} instances. 366 */ 367 public static class Builder { 368 369 private long bodyLimit = DEFAULT_BODY_LIMIT; 370 private final Set<String> redactedHeaders; 371 private Boolean enabled; 372 373 /** Constructor — package access for {@link EchoMixin#create()}. */ 374 protected Builder() { 375 redactedHeaders = new LinkedHashSet<>(DEFAULT_REDACTED_HEADERS); 376 } 377 378 /** 379 * Sets the explicit enablement tri-state for the {@code /echo/*} endpoint. 380 * 381 * <p> 382 * This is a non-logging switch that is independent of the JUL logger level. The default is unset 383 * ({@code null}), which defers to the {@code ${juneau.echo.enabled:false}} deployment fallback (system 384 * property / relaxed environment variable) — see the class-level Javadoc for the resolution rules and 385 * the global-fallback blast-radius warning. 386 * 387 * @param value {@code Boolean.TRUE} to enable, {@code Boolean.FALSE} to explicitly disable (overriding the 388 * global fallback), or {@code null} to leave unset (defer to the fallback). 389 * @return This object. 390 */ 391 public Builder enabled(Boolean value) { 392 enabled = value; 393 return this; 394 } 395 396 /** 397 * Enables the {@code /echo/*} endpoint — shorthand for {@link #enabled(Boolean) enabled(Boolean.TRUE)}. 398 * 399 * @return This object. 400 */ 401 public Builder enabled() { 402 return enabled(Boolean.TRUE); 403 } 404 405 /** 406 * Sets the body capture cap. 407 * 408 * <p> 409 * Captured content is truncated when the inbound body exceeds this size; the 410 * {@code truncated} flag in the response is set to {@code true}. 411 * 412 * @param value Cap, in bytes. Must be {@code >= 0}. 413 * @return This object. 414 */ 415 public Builder bodyLimit(long value) { 416 if (value < 0) 417 throw new IllegalArgumentException("bodyLimit must be >= 0"); 418 bodyLimit = value; 419 return this; 420 } 421 422 /** 423 * Replaces the redacted-header set with the supplied values. 424 * 425 * <p> 426 * Header names are matched case-insensitively. Pass an empty array to disable redaction 427 * (not recommended outside of integration tests). 428 * 429 * @param values The header names to redact. Can be {@code null} (equivalent to an empty array 430 * — disables redaction); {@code null} or blank elements are skipped. 431 * @return This object. 432 */ 433 public Builder redactedHeaders(String...values) { 434 redactedHeaders.clear(); 435 if (values != null) 436 for (var v : values) 437 if (inb(v)) 438 redactedHeaders.add(v); 439 return this; 440 } 441 442 /** 443 * Adds an additional header name to the redacted-header set. 444 * 445 * @param value The header name to redact (case-insensitive). Must not be {@code null} or 446 * blank. 447 * @return This object. 448 */ 449 public Builder redactHeader(String value) { 450 if (isBlank(value)) 451 throw new IllegalArgumentException("Argument 'value' must not be null or blank"); 452 redactedHeaders.add(value); 453 return this; 454 } 455 456 /** 457 * Builds a {@link EchoMixin} instance. 458 * 459 * @return A configured instance. 460 */ 461 public EchoMixin build() { 462 return new EchoMixin(this); 463 } 464 } 465}