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.petstore.console; 018 019import java.io.*; 020import java.util.*; 021import java.util.concurrent.atomic.*; 022 023import org.apache.juneau.commons.settings.*; 024import org.apache.juneau.rest.server.MethodSafety; 025import org.apache.juneau.rest.server.filter.*; 026 027import jakarta.servlet.*; 028import jakarta.servlet.http.*; 029 030/** 031 * The console's CSRF boundary: a {@link LoopbackBoundaryFilter} applied to {@code /console} and everything under it. 032 * 033 * <p> 034 * On console requests the boundary pins the {@code Host}, and on console writes it also requires a same-origin 035 * {@code Origin}, a JSON body and the per-process {@link SynchronizerToken}. An allowed console request carries 036 * the token to the page renderer, which stamps it on {@code <body data-juneau-csrf>} for the console toolkit to send 037 * back. 038 * 039 * <h5 class='section'>The effective method</h5> 040 * <p> 041 * A request is judged on the method it claims, not only on its request line. A {@code method} query parameter or 042 * an {@code X-Method}, {@code X-HTTP-Method-Override}, {@code X-HTTP-Method} or {@code X-Method-Override} header 043 * naming anything other than a safe method makes the request a write, whether or not the resource is configured to 044 * honour that override. So {@code GET /console/pets/1?method=DELETE} needs the same Origin and token a real 045 * {@code DELETE} does, and a later change to a resource's allowed method overrides cannot open a hole here. 046 * 047 * <h5 class='section'>The API's token</h5> 048 * <p> 049 * The {@code /petstore} API is not covered by this boundary: it sits behind its own, {@link PetstoreApiBoundary}, 050 * which has a different token. The console's one write to the API, its photo upload 051 * ({@code PUT /petstore/pets/{id}/photo}), carries the API's token, not this one. So on every console request it 052 * allows, this boundary also publishes a fresh API token as a {@link String} under 053 * {@link PetstoreApiBoundary#TOKEN_ATTRIBUTE}, for the upload form to stamp. A refused request publishes nothing. 054 * 055 * <p> 056 * A runner must build this boundary with the {@link PetstoreApiBoundary} instance it mounts on the API. The no-arg 057 * constructor builds a private one that nobody mounts, so the console's uploads are then refused: visibly, and 058 * closed rather than open. 059 * 060 * <p> 061 * This boundary does not mark the response {@code no-store} for the API's token: the console page shell already 062 * does so for every page carrying the console's token. When the response is already committed, the shell drops 063 * only its own token attributes, so on that rare path the API's token can still be emitted, possibly without 064 * {@code no-store}. That is accepted, as {@link PetstoreApiBoundary} describes. 065 * 066 * <h5 class='section'>What is outside</h5> 067 * <p> 068 * The {@code /petstore-ui} app (which only reads) is covered by neither boundary. The Jetty deployment puts the 069 * microservice admin resources behind a third boundary of their own. 070 * 071 * <h5 class='section'>The authority</h5> 072 * <p> 073 * The single accepted {@code Host} is the {@value #AUTHORITY_PROPERTY} setting when set, else 074 * {@code localhost:<port>} with the port the request arrived on. It is fixed on the first console request, after 075 * the server has bound, so a runner that falls back to a random port still gets the right value. Any other 076 * spelling of the same server ({@code 127.0.0.1}, a LAN host name) is answered with {@code 421} on console paths. 077 * 078 * <p> 079 * Set {@value #AUTHORITY_PROPERTY} whenever the browser's {@code Host} differs from the bound port: behind a Docker 080 * port mapping or a reverse proxy, the default answers {@code 421}. A browser leaves the port out of {@code Host} 081 * for ports 80 and 443, so the value is then the bare host name. 082 * 083 * <p> 084 * The boundary expects the {@code Origin} {@code http://<authority>}, so this works for plain HTTP only. An HTTPS 085 * deployment's console stays plain-HTTP-loopback only. The API boundary reads no forwarded headers either, and the 086 * demo does not configure the framework boundary's {@code CONTAINER} origin source, so behind a TLS-terminating proxy 087 * the API refuses writes too (see {@link PetstoreApiBoundary}). 088 * 089 * <h5 class='section'>Example:</h5> 090 * <p class='bjava'> 091 * <jk>var</jk> <jv>reg</jv> = <jk>new</jk> FilterRegistrationBean<>(<jk>new</jk> PetstoreConsoleBoundary(<jv>apiBoundary</jv>)); 092 * <jv>reg</jv>.addUrlPatterns(PetstoreConsoleBoundary.<jsf>URL_PATTERN</jsf>); 093 * </p> 094 * 095 * <p> 096 * In a Juneau microservice, contribute a <c>ServletFilterRegistration</c> <c>@Bean</c> instead: 097 * <p class='bjava'> 098 * <ja>@Bean</ja> 099 * <jk>public</jk> ServletFilterRegistration consoleBoundary() { 100 * <jk>return</jk> ServletFilterRegistration.<jsm>create</jsm>(<jk>new</jk> PetstoreConsoleBoundary(<jf>apiBoundary</jf>)) 101 * .urlPatterns(PetstoreConsoleBoundary.<jsf>URL_PATTERN</jsf>) 102 * .dispatcherTypes(DispatcherType.<jsf>REQUEST</jsf>, DispatcherType.<jsf>FORWARD</jsf>, DispatcherType.<jsf>ASYNC</jsf>, DispatcherType.<jsf>ERROR</jsf>) 103 * .order(ServletFilterRegistration.<jsf>HIGHEST_PRECEDENCE</jsf>) 104 * .build(); 105 * } 106 * </p> 107 * <p> 108 * where {@code apiBoundary} is the {@link PetstoreApiBoundary} the runner mounts on the API. 109 */ 110public class PetstoreConsoleBoundary implements Filter { 111 112 /** The covered path: the console resource's mount point. */ 113 public static final String PATH = "/console"; 114 115 /** The servlet URL pattern the boundary is registered under. */ 116 public static final String URL_PATTERN = PATH + "/*"; 117 118 /** Setting (system property, environment, or any registered source) naming the one accepted console host and port. */ 119 public static final String AUTHORITY_PROPERTY = "petstore.console.authority"; 120 121 private final SynchronizerToken token = SynchronizerToken.generate(); 122 private final AtomicReference<LoopbackBoundaryFilter> filter = new AtomicReference<>(); 123 private final PetstoreApiBoundary api; 124 125 /** 126 * Constructor for a console boundary with an API boundary of its own. 127 * 128 * <p> 129 * Nothing mounts that API boundary, so the API refuses the tokens this boundary publishes. A runner that mounts 130 * {@link PetstoreApiBoundary} uses {@link #PetstoreConsoleBoundary(PetstoreApiBoundary)} instead. 131 */ 132 public PetstoreConsoleBoundary() { 133 this(new PetstoreApiBoundary()); 134 } 135 136 /** 137 * Constructor. 138 * 139 * @param api The API boundary the runner mounts, whose tokens console pages carry. Must not be <jk>null</jk>. 140 */ 141 public PetstoreConsoleBoundary(PetstoreApiBoundary api) { 142 this.api = Objects.requireNonNull(api, "api"); 143 } 144 145 /** 146 * Applies the boundary to console requests and passes every other request straight through. 147 * 148 * <p> 149 * An allowed console request also carries the API's token (see {@link PetstoreApiBoundary}). 150 * 151 * <p> 152 * The path check makes the boundary safe to call for every request (for example when registered under a broader 153 * pattern), not only when a container maps it to {@link #URL_PATTERN}. 154 */ 155 @Override /* Filter */ 156 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { 157 if (! (req instanceof HttpServletRequest req2 && covers(req2.getServletPath(), req2.getPathInfo()))) { 158 chain.doFilter(req, res); 159 return; 160 } 161 // The boundary sees the effective method; the resource still gets the original request. 162 var method = MethodSafety.effectiveMethod(req2.getMethod(), req2.getQueryString(), req2::getHeader); 163 var checked = method.equals(req2.getMethod()) ? req2 : new HttpServletRequestWrapper(req2) { 164 @Override /* HttpServletRequest */ 165 public String getMethod() { 166 return method; 167 } 168 }; 169 filter(req2).doFilter(checked, res, (rq, rs) -> { 170 req2.setAttribute(PetstoreApiBoundary.TOKEN_ATTRIBUTE, api.tokenFor(req2)); 171 chain.doFilter(req, res); 172 }); 173 } 174 175 private LoopbackBoundaryFilter filter(HttpServletRequest req) { 176 var f = filter.get(); 177 if (f != null) 178 return f; 179 var boundary = LoopbackBoundary.create() 180 .authority(authority(Settings.get().get(AUTHORITY_PROPERTY).orElse(null), req.getLocalPort())) 181 .token(token) 182 .build(); 183 filter.compareAndSet(null, new LoopbackBoundaryFilter(boundary)); 184 return filter.get(); 185 } 186 187 /** 188 * The accepted console authority. 189 * 190 * @param setting The {@value #AUTHORITY_PROPERTY} setting. Can be <jk>null</jk> or blank (not set). 191 * @param localPort The port the server is bound to. 192 * @return The setting, trimmed, when set, else {@code localhost:<localPort>}. 193 */ 194 static String authority(String setting, int localPort) { 195 return setting == null || setting.isBlank() ? "localhost:" + localPort : setting.trim(); 196 } 197 198 /** 199 * Whether a request path is a console path. 200 * 201 * @param servletPath The request's servlet path. Can be <jk>null</jk>. 202 * @param pathInfo The request's path info. Can be <jk>null</jk>. 203 * @return <jk>true</jk> for {@code /console} and every path under it. 204 */ 205 static boolean covers(String servletPath, String pathInfo) { 206 var p = (servletPath == null ? "" : servletPath) + (pathInfo == null ? "" : pathInfo); 207 return p.equals(PATH) || p.startsWith(PATH + "/"); 208 } 209}