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}} &mdash; 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/*}) &mdash; 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="/&#123;juneau.echo.path:echo&#125;/*")} 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> &mdash; 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()} &mdash; 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 * &mdash; including hosts the operator did not intend to open &mdash; 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 &mdash; case-insensitively &mdash;
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&nbsp;MB). */
190   public static final long DEFAULT_BODY_LIMIT = 1_048_576L;
191
192   /** Default body capture cap rendered for the class-level javadoc &mdash; 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 &mdash; 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/*] &mdash; 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 &mdash; 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) &mdash; 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 &mdash; 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       *    &mdash; 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}