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.microservice.examples;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022import java.util.regex.*;
023
024import org.apache.juneau.microservice.*;
025import org.apache.juneau.rest.server.filter.*;
026
027import jakarta.servlet.*;
028
029/**
030 * The CSRF boundary for the admin resources {@link ConfigResource}, {@link LogsResource} and {@link ShutdownResource}.
031 *
032 * <p>
033 * {@link #registration(String, String...)} returns a {@link ServletFilterRegistration} that puts the three resources
034 * behind their own {@link CsrfBoundary}.  A write from another site's page is refused with {@code 403} and an
035 * {@value RequestBoundaryFilter#REJECTION_HEADER} header naming the reason.  So is a write that a browser could
036 * have sent cross-site without a CORS preflight and that carries no {@code Origin}: a urlencoded, multipart or
037 * {@code text/plain} {@code POST}, or a {@code GET} claiming another method through {@code ?method=} or an override
038 * header.  Reads are allowed.
039 *
040 * <p>
041 * The admin resources have no HTML forms or write links, so no page ever carries a token, and a browser write to them
042 * always fails.  Writes come from REST clients and the command line as requests a browser could not send cross-site
043 * without a preflight, and the boundary admits those with no token:
044 * <p class='bcode'>
045 *    curl -X PUT -H 'Content-Type: text/plain' -d 'newValue' http://localhost:10000/config/MySection/myKey
046 *    curl -X DELETE http://localhost:10000/logs/old.log
047 *    curl -X POST -H 'Content-Type: application/json' http://localhost:10000/shutdown
048 * </p>
049 *
050 * <p>
051 * Every request, reads included, must name one of the allowed hosts in its {@code Host} header, or it is refused with
052 * {@code 421}.  Unless other host names are given, only the loopback names {@link #LOOPBACK_HOSTS} are answered, so a
053 * page on a rebound DNS name cannot read the configuration or the logs.  A server reached under any other name must
054 * list that name.
055 *
056 * <p>
057 * The boundary answers only CSRF and DNS rebinding.  It does not authenticate anyone, and a direct network caller
058 * passes it.  The admin resources have no authentication of their own, so do not expose them on an untrusted network.
059 *
060 * <p>
061 * The filter is mapped on the request path as the servlet container normalizes it, while Juneau routes on the path
062 * segments it receives.  The two agree under the container's default URI handling (for Jetty 12,
063 * {@code UriCompliance.DEFAULT}, which rejects ambiguous paths such as encoded separators and dot segments).  A
064 * container configured to accept ambiguous paths could route a request to an admin resource without the filter seeing
065 * it, so keep the default, and verify any other container the same way.
066 *
067 * <h5 class='section'>Example:</h5>
068 * <p>
069 * In a Juneau microservice whose root resource mounts the three resources as children, contribute the registration
070 * as a <c>@Bean</c>:
071 * <p class='bjava'>
072 *    <ja>@Bean</ja>(name=<js>"adminBoundary"</js>)
073 *    <jk>public</jk> ServletFilterRegistration adminBoundary() {
074 *       <jk>return</jk> AdminBoundary.<jsm>registration</jsm>(<js>""</js>, <js>"app.example.com"</js>);
075 *    }
076 * </p>
077 *
078 * <h5 class='section'>See Also:</h5><ul>
079 *    <li class='jc'>{@link CsrfBoundary}
080 *    <li class='jc'>{@link ServletFilterRegistration}
081 * </ul>
082 *
083 * @since 10.0.0
084 */
085public final class AdminBoundary {
086
087   /** The host names answered when none are given: {@code localhost}, {@code 127.0.0.1} and {@code [::1]}, on any port. */
088   public static final List<String> LOOPBACK_HOSTS = List.of("localhost", "127.0.0.1", "[::1]");
089
090   /** The order the registration runs at: ahead of authentication, behind anything registered at the very front. */
091   public static final int ORDER = ServletFilterRegistration.HIGHEST_PRECEDENCE + 20;
092
093   private static final String[] PATHS = { "/config", "/logs", "/shutdown" };
094
095   // One or more "/segment" parts of unreserved characters, so every pattern built on it is one the container matches.
096   private static final Pattern PREFIX = Pattern.compile("(?:/[A-Za-z0-9_~-][A-Za-z0-9._~-]*+)*+");
097
098   private AdminBoundary() {}
099
100   /**
101    * A registration of a new CSRF boundary over the three admin resources.
102    *
103    * <p>
104    * The registration maps each resource's path both exactly and as a prefix ({@code /config} and
105    * {@code /config/*}, and so on: six patterns), for {@code REQUEST} dispatches only, at {@link #ORDER}.  Each call
106    * builds its own filter, so the registration never shares an instance with another boundary.  The boundary's token
107    * is per process; no admin page carries one.
108    *
109    * @param prefix The path the three resources are mounted under: <js>""</js> when they are children of the root
110    *    resource, otherwise a path such as <js>"/admin"</js>: one or more {@code /}-separated segments of letters,
111    *    digits, {@code .}, {@code _}, {@code ~} or {@code -}, none starting with {@code .}, and no trailing {@code /}.
112    *    A prefix the container would not match as written is refused, so the resources are never left uncovered.
113    *    Must not be <jk>null</jk>.
114    * @param allowedHosts The host names this server is reached under, without ports.  A request to the admin
115    *    resources under any other {@code Host} is refused with {@code 421}, reads included: this is the DNS-rebinding
116    *    defence a server on plain HTTP needs.  With none, {@link #LOOPBACK_HOSTS} applies.  The list replaces the
117    *    loopback names rather than adding to them.  Must not be <jk>null</jk>, and no entry may be <jk>null</jk> or
118    *    blank.
119    * @return A new registration.
120    * @throws IllegalArgumentException If {@code prefix} is malformed, or {@code allowedHosts} or an entry is
121    *    <jk>null</jk>, blank or not a bare host name.
122    */
123   public static ServletFilterRegistration registration(String prefix, String...allowedHosts) {
124      if (prefix == null || ! PREFIX.matcher(prefix).matches())
125         throw iaex("Argument 'prefix' must be empty or a path such as '/admin': '/'-separated segments of letters, "
126            + "digits, '.', '_', '~' or '-', none starting with '.'.");
127      if (allowedHosts == null)
128         throw iaex("Argument 'allowedHosts' must not be null.");
129      var boundary = CsrfBoundary.create()
130         .tokens(CsrfTokens.perProcess())
131         .allowedHosts(allowedHosts.length == 0 ? LOOPBACK_HOSTS.toArray(String[]::new) : allowedHosts)
132         .build();
133      var patterns = new ArrayList<String>();
134      for (var p : PATHS) {
135         patterns.add(prefix + p);
136         patterns.add(prefix + p + "/*");
137      }
138      return ServletFilterRegistration.create(new RequestBoundaryFilter(boundary))
139         .urlPatterns(patterns.toArray(String[]::new))
140         .dispatcherTypes(DispatcherType.REQUEST)
141         .order(ORDER)
142         .build();
143   }
144}