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.wicket.protocol.http; 018 019import static java.util.Arrays.asList; 020 021import java.util.ArrayList; 022import java.util.Arrays; 023import java.util.HashSet; 024import java.util.List; 025import java.util.Set; 026 027import jakarta.servlet.http.HttpServletRequest; 028import jakarta.servlet.http.HttpServletResponse; 029 030import org.apache.wicket.RestartResponseException; 031import org.apache.wicket.core.request.handler.IPageRequestHandler; 032import org.apache.wicket.core.request.handler.RenderPageRequestHandler; 033import org.apache.wicket.protocol.http.IResourceIsolationPolicy.ResourceIsolationOutcome; 034import org.apache.wicket.request.IRequestHandler; 035import org.apache.wicket.request.IRequestHandlerDelegate; 036import org.apache.wicket.request.component.IRequestablePage; 037import org.apache.wicket.request.cycle.IRequestCycleListener; 038import org.apache.wicket.request.cycle.RequestCycle; 039import org.apache.wicket.request.http.WebResponse; 040import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException; 041import org.apache.wicket.util.lang.Classes; 042import org.apache.wicket.util.string.Strings; 043import org.slf4j.Logger; 044import org.slf4j.LoggerFactory; 045 046/** 047 * This {@link RequestCycle} listener ensures resource isolation, adding a layer of protection for 048 * modern browsers that prevent <em>Cross-Site Request Forgery</em> attacks. 049 * <p> 050 * It uses the {@link FetchMetadataResourceIsolationPolicy} and 051 * {@link OriginResourceIsolationPolicy} by default and can be customized with additional 052 * {@link IResourceIsolationPolicy}s. 053 * <p> 054 * URL paths that are intended to be used cross-site can be excempted from these policies. 055 * <p> 056 * Learn more about Fetch Metadata and resource isolation at 057 * <a href="https://web.dev/fetch-metadata">https://web.dev/fetch-metadata/</a> 058 * 059 * @author Santiago Diaz - [email protected] 060 * @author Ecenaz Jen Ozmen - [email protected] 061 */ 062public class ResourceIsolationRequestCycleListener implements IRequestCycleListener 063{ 064 private static final Logger log = LoggerFactory 065 .getLogger(ResourceIsolationRequestCycleListener.class); 066 067 public static final String ERROR_MESSAGE = "The request was blocked by a resource isolation policy"; 068 069 /** 070 * The action to perform when the outcome of the resource isolation policy is DISALLOWED or 071 * UNKNOWN. 072 */ 073 public enum CsrfAction 074 { 075 /** Aborts the request and throws an exception when a CSRF request is detected. */ 076 ABORT { 077 @Override 078 public String toString() 079 { 080 return "aborted"; 081 } 082 083 @Override 084 void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request, 085 IRequestablePage page) 086 { 087 listener.abortHandler(request, page); 088 } 089 }, 090 091 /** 092 * Ignores the action of a CSRF request, and just renders the page it was targeted against. 093 */ 094 SUPPRESS { 095 @Override 096 public String toString() 097 { 098 return "suppressed"; 099 } 100 101 @Override 102 void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request, 103 IRequestablePage page) 104 { 105 listener.suppressHandler(request, page); 106 } 107 }, 108 109 /** Detects a CSRF request, logs it and allows the request to continue. */ 110 ALLOW { 111 @Override 112 public String toString() 113 { 114 return "allowed"; 115 } 116 117 @Override 118 void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request, 119 IRequestablePage page) 120 { 121 listener.allowHandler(request, page); 122 } 123 }; 124 125 abstract void apply(ResourceIsolationRequestCycleListener listener, 126 HttpServletRequest request, IRequestablePage page); 127 } 128 129 /** 130 * Action to perform when no resource isolation policy can determine the validity of the 131 * request. 132 */ 133 private CsrfAction unknownOutcomeAction = CsrfAction.ABORT; 134 135 /** 136 * Action to perform when {@link ResourceIsolationOutcome#DISALLOWED} is reported by a 137 * resource isolation policy. 138 */ 139 private CsrfAction disallowedOutcomeAction = CsrfAction.ABORT; 140 141 /** 142 * The error code to report when the action to take for a CSRF request is 143 * {@link CsrfAction#ABORT}. Default {@code 403 FORBIDDEN}. 144 */ 145 private int errorCode = jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; 146 147 /** 148 * The error message to report when the action to take for a CSRF request is {@code ERROR}. 149 * Default {@code "The request was blocked by a resource isolation policy"}. 150 */ 151 private String errorMessage = ERROR_MESSAGE; 152 153 private final Set<String> exemptedPaths = new HashSet<>(); 154 155 private final List<IResourceIsolationPolicy> resourceIsolationPolicies = new ArrayList<>(); 156 157 /** 158 * Create a new listener with the given policies. If no policies are given, 159 * {@link FetchMetadataResourceIsolationPolicy} and {@link OriginResourceIsolationPolicy} will 160 * be used. The policies are checked in order. The first outcome that's not 161 * {@link ResourceIsolationOutcome#UNKNOWN} will be used. 162 * 163 * @param policies 164 * the policies to check requests against. 165 */ 166 public ResourceIsolationRequestCycleListener(IResourceIsolationPolicy... policies) 167 { 168 this.resourceIsolationPolicies.addAll(asList(policies)); 169 if (policies.length == 0) 170 { 171 this.resourceIsolationPolicies.addAll(asList(new FetchMetadataResourceIsolationPolicy(), 172 new OriginResourceIsolationPolicy())); 173 } 174 } 175 176 /** 177 * Sets the action when none of the resource isolation policies can come to an outcome. Default 178 * {@code ABORT}. 179 * 180 * @param action 181 * the alternate action 182 * 183 * @return this (for chaining) 184 */ 185 public ResourceIsolationRequestCycleListener setUnknownOutcomeAction(CsrfAction action) 186 { 187 this.unknownOutcomeAction = action; 188 return this; 189 } 190 191 /** 192 * Sets the action when a request is disallowed by a resource isolation policy. Default is 193 * {@code ABORT}. 194 * 195 * @param action 196 * the alternate action 197 * 198 * @return this 199 */ 200 public ResourceIsolationRequestCycleListener setDisallowedOutcomeAction(CsrfAction action) 201 { 202 this.disallowedOutcomeAction = action; 203 return this; 204 } 205 206 /** 207 * Modifies the HTTP error code in the exception when a disallowed request is detected. 208 * 209 * @param errorCode 210 * the alternate HTTP error code, default {@code 403 FORBIDDEN} 211 * 212 * @return this 213 */ 214 public ResourceIsolationRequestCycleListener setErrorCode(int errorCode) 215 { 216 this.errorCode = errorCode; 217 return this; 218 } 219 220 /** 221 * Modifies the HTTP message in the exception when a disallowed request is detected. 222 * 223 * @param errorMessage 224 * the alternate message 225 * 226 * @return this 227 */ 228 public ResourceIsolationRequestCycleListener setErrorMessage(String errorMessage) 229 { 230 this.errorMessage = errorMessage; 231 return this; 232 } 233 234 public void addExemptedPaths(String... exemptions) 235 { 236 Arrays.stream(exemptions).filter(e -> !Strings.isEmpty(e)).forEach(exemptedPaths::add); 237 } 238 239 @Override 240 public void onBeginRequest(RequestCycle cycle) 241 { 242 HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest() 243 .getContainerRequest(); 244 245 log.debug("Processing request to: {}", containerRequest.getPathInfo()); 246 } 247 248 /** 249 * Dynamic override for enabling/disabling the CSRF detection. Might be handy for specific 250 * tenants in a multi-tenant application. When false, the CSRF detection is not performed for 251 * the running request. Default {@code true} 252 * 253 * @return {@code true} when the CSRF checks need to be performed. 254 */ 255 protected boolean isEnabled() 256 { 257 return true; 258 } 259 260 /** 261 * Override to limit whether the request to the specific page should be checked for a possible 262 * CSRF attack. 263 * 264 * @param targetedPage 265 * the page that is the target for the action 266 * @return {@code true} when the request to the page should be checked for CSRF issues. 267 */ 268 protected boolean isChecked(IRequestablePage targetedPage) 269 { 270 return true; 271 } 272 273 /** 274 * Override to change the request handler types that are checked. Currently only action handlers 275 * (form submits, link clicks, AJAX events) are checked. 276 * 277 * @param handler 278 * the handler that is currently processing 279 * @return true when resource isolation should be checked for this {@code handler} 280 */ 281 protected boolean isChecked(IRequestHandler handler) 282 { 283 return handler instanceof IPageRequestHandler 284 && !(handler instanceof RenderPageRequestHandler); 285 } 286 287 @Override 288 public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler) 289 { 290 if (!isEnabled()) 291 { 292 log.trace("CSRF listener is disabled, no checks performed"); 293 return; 294 } 295 296 handler = unwrap(handler); 297 if (isChecked(handler)) 298 { 299 IPageRequestHandler pageRequestHandler = (IPageRequestHandler)handler; 300 IRequestablePage targetedPage = pageRequestHandler.getPage(); 301 HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest() 302 .getContainerRequest(); 303 304 if (!isChecked(targetedPage)) 305 { 306 if (log.isDebugEnabled()) 307 { 308 log.debug("Targeted page {} was opted out of resource isolation, allowed", 309 targetedPage.getClass().getName()); 310 } 311 return; 312 } 313 314 String pathInfo = containerRequest.getPathInfo(); 315 if (exemptedPaths.contains(pathInfo)) 316 { 317 if (log.isDebugEnabled()) 318 { 319 log.debug("Allowing request to {} because it matches an exempted path", 320 new Object[] { pathInfo }); 321 } 322 return; 323 } 324 325 for (IResourceIsolationPolicy policy : resourceIsolationPolicies) 326 { 327 ResourceIsolationOutcome outcome = policy 328 .isRequestAllowed(containerRequest, targetedPage); 329 if (ResourceIsolationOutcome.DISALLOWED.equals(outcome)) 330 { 331 log.debug("Isolation policy {} has rejected a request to {}", 332 Classes.simpleName(policy.getClass()), pathInfo); 333 disallowedOutcomeAction.apply(this, containerRequest, targetedPage); 334 return; 335 } 336 else if (ResourceIsolationOutcome.ALLOWED.equals(outcome)) 337 { 338 return; 339 } 340 } 341 unknownOutcomeAction.apply(this, containerRequest, targetedPage); 342 } 343 else 344 { 345 if (log.isTraceEnabled()) 346 log.trace("Resolved handler {} is not checked, no CSRF check performed", 347 handler.getClass().getName()); 348 } 349 } 350 351 /** 352 * Allow isolation policy to add headers. 353 * 354 * @see IResourceIsolationPolicy#setHeaders(HttpServletResponse) 355 */ 356 @Override 357 public void onEndRequest(RequestCycle cycle) 358 { 359 if (cycle.getResponse() instanceof WebResponse) 360 { 361 WebResponse webResponse = (WebResponse)cycle.getResponse(); 362 if (webResponse.isHeaderSupported()) 363 { 364 for (IResourceIsolationPolicy resourceIsolationPolicy : resourceIsolationPolicies) 365 { 366 resourceIsolationPolicy 367 .setHeaders((HttpServletResponse)webResponse.getContainerResponse()); 368 } 369 } 370 } 371 } 372 373 /** 374 * Allow the execution of the listener in the request because the outcome results in 375 * {@link CsrfAction#ALLOW}. 376 * 377 * @param request 378 * the request 379 * @param page 380 * the page that is targeted with this request 381 */ 382 protected void allowHandler(HttpServletRequest request, IRequestablePage page) 383 { 384 log.info("Possible CSRF attack, request URL: {}, action: allowed", request.getRequestURL()); 385 } 386 387 /** 388 * Suppress the execution of the listener in the request because the outcome results in 389 * {@link CsrfAction#SUPPRESS}. 390 * 391 * @param request 392 * the request 393 * @param page 394 * the page that is targeted with this request 395 */ 396 protected void suppressHandler(HttpServletRequest request, IRequestablePage page) 397 { 398 log.info("Possible CSRF attack, request URL: {}, action: suppressed", 399 request.getRequestURL()); 400 throw new RestartResponseException(page); 401 } 402 403 /** 404 * Abort the request because the outcome results in {@link CsrfAction#ABORT}. 405 * 406 * @param request 407 * the request 408 * @param page 409 * the page that is targeted with this request 410 */ 411 protected void abortHandler(HttpServletRequest request, IRequestablePage page) 412 { 413 log.info("Possible CSRF attack, request URL: {}, action: aborted with error {} {}", 414 request.getRequestURL(), errorCode, errorMessage); 415 throw new AbortWithHttpErrorCodeException(errorCode, errorMessage); 416 } 417 418 private static IRequestHandler unwrap(IRequestHandler handler) 419 { 420 while (handler instanceof IRequestHandlerDelegate) 421 { 422 handler = ((IRequestHandlerDelegate)handler).getDelegateHandler(); 423 } 424 return handler; 425 } 426}