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}