| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package org.apache.wicket.protocol.http; |
| |
| import java.util.Locale; |
| import jakarta.servlet.http.HttpServletRequest; |
| import org.apache.wicket.RestartResponseException; |
| import org.apache.wicket.core.request.handler.IPageRequestHandler; |
| import org.apache.wicket.core.request.handler.RenderPageRequestHandler; |
| import org.apache.wicket.request.IRequestHandler; |
| import org.apache.wicket.request.IRequestHandlerDelegate; |
| import org.apache.wicket.request.component.IRequestablePage; |
| import org.apache.wicket.request.cycle.IRequestCycleListener; |
| import org.apache.wicket.request.cycle.RequestCycle; |
| import org.apache.wicket.request.http.WebRequest; |
| import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException; |
| import org.apache.wicket.util.string.Strings; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| /** |
| * Prevents CSRF attacks on Wicket components by checking the {@code Origin} and {@code Referer} |
| * HTTP headers for cross domain requests. By default only checks requests that try to perform an |
| * action on a component, such as a form submit, or link click. |
| * <p> |
| * <h3>Installation</h3> |
| * <p> |
| * You can enable this CSRF prevention filter by adding it to the request cycle listeners in your |
| * {@link WebApplication#init() application's init method}: |
| * |
| * <pre> |
| * @Override |
| * protected void init() |
| * { |
| * // ... |
| * getRequestCycleListeners().add(new CsrfPreventionRequestCycleListener()); |
| * // ... |
| * } |
| * </pre> |
| * <p> |
| * <h3>Configuration</h3> |
| * <p> |
| * When the {@code Origin} or {@code Referer} HTTP header is present but doesn't match the requested |
| * URL this listener will by default throw a HTTP error ( {@code 400 BAD REQUEST}) and abort the |
| * request. You can {@link #setConflictingOriginAction(CsrfAction) configure} this specific action. |
| * <p> |
| * A missing {@code Origin} and {@code Referer} HTTP header is handled as if it were a bad request |
| * and rejected. You can {@link #setNoOriginAction(CsrfAction) configure the specific action} to a |
| * different value, suppressing or allowing the request when the HTTP headers are missing. |
| * <p> |
| * When the {@code Origin} HTTP header is present and has the value {@code null} it is considered to |
| * be from a "privacy-sensitive" context and will trigger the no origin action. You can customize |
| * what happens in those actions by overriding the respective {@code onXXXX} methods. |
| * <p> |
| * When you want to accept certain cross domain request from a range of hosts, you can |
| * {@link #addAcceptedOrigin(String) whitelist those domains}. |
| * <p> |
| * You can {@link #isEnabled() enable or disable} this listener by overriding {@link #isEnabled()}. |
| * <p> |
| * You can {@link #isChecked(IRequestablePage) customize} whether a particular page should be |
| * checked for CSRF requests. For example you can skip checking pages that have a |
| * {@code @NoCsrfCheck} annotation, or only those pages that extend your base secure page class. For |
| * example: |
| * |
| * <pre> |
| * @Override |
| * protected boolean isChecked(IRequestablePage requestedPage) |
| * { |
| * return requestedPage instanceof SecurePage; |
| * } |
| * </pre> |
| * <p> |
| * You can also tweak the request handlers that are checked. The CSRF prevention request cycle |
| * listener checks only action handlers, not render handlers. Override |
| * {@link #isChecked(IRequestHandler)} to customize this behavior. |
| * </p> |
| * <p> |
| * You can customize the default actions that are performed by overriding the event handlers for |
| * them: |
| * <ul> |
| * <li>{@link #onWhitelisted(HttpServletRequest, String, IRequestablePage)} when an origin was |
| * whitelisted</li> |
| * <li>{@link #onMatchingOrigin(HttpServletRequest, String, IRequestablePage)} when an origin was |
| * matching</li> |
| * <li>{@link #onAborted(HttpServletRequest, String, IRequestablePage)} when an origin was in |
| * conflict and the request should be aborted</li> |
| * <li>{@link #onAllowed(HttpServletRequest, String, IRequestablePage)} when an origin was in |
| * conflict and the request should be allowed</li> |
| * <li>{@link #onSuppressed(HttpServletRequest, String, IRequestablePage)} when an origin was in |
| * conflict and the request should be suppressed</li> |
| * </ul> |
| * |
| * @deprecated Use {@link FetchMetadataResourceIsolationPolicy} instead |
| */ |
| @Deprecated(since = "9.1.0") |
| public class CsrfPreventionRequestCycleListener extends OriginResourceIsolationPolicy implements IRequestCycleListener |
| { |
| private static final Logger log = LoggerFactory |
| .getLogger(CsrfPreventionRequestCycleListener.class); |
| |
| /** |
| * The action to perform when a missing or conflicting source URI is detected. |
| */ |
| public enum CsrfAction { |
| /** Aborts the request and throws an exception when a CSRF request is detected. */ |
| ABORT { |
| @Override |
| public String toString() |
| { |
| return "aborted"; |
| } |
| }, |
| |
| /** |
| * Ignores the action of a CSRF request, and just renders the page it was targeted against. |
| */ |
| SUPPRESS { |
| @Override |
| public String toString() |
| { |
| return "suppressed"; |
| } |
| }, |
| |
| /** Detects a CSRF request, logs it and allows the request to continue. */ |
| ALLOW { |
| @Override |
| public String toString() |
| { |
| return "allowed"; |
| } |
| }, |
| } |
| |
| /** |
| * Action to perform when no Origin header is present in the request. |
| */ |
| private CsrfAction noOriginAction = CsrfAction.ABORT; |
| |
| /** |
| * Action to perform when a conflicting Origin header is found. |
| */ |
| private CsrfAction conflictingOriginAction = CsrfAction.ABORT; |
| |
| /** |
| * The error code to report when the action to take for a CSRF request is |
| * {@link CsrfAction#ABORT}. Default {@code 400 BAD REQUEST}. |
| */ |
| private int errorCode = jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; |
| |
| /** |
| * The error message to report when the action to take for a CSRF request is {@code ERROR}. |
| * Default {@code "Origin does not correspond to request"}. |
| */ |
| private String errorMessage = "Origin does not correspond to request"; |
| |
| /** |
| * TODO remove in Wicket 10 |
| */ |
| @Override |
| public CsrfPreventionRequestCycleListener addAcceptedOrigin(String acceptedOrigin) |
| { |
| super.addAcceptedOrigin(acceptedOrigin); |
| |
| return this; |
| } |
| |
| /** |
| * Sets the action when no Origin header is present in the request. Default {@code ALLOW}. |
| * |
| * @param action |
| * the alternate action |
| * |
| * @return this (for chaining) |
| */ |
| public CsrfPreventionRequestCycleListener setNoOriginAction(CsrfAction action) |
| { |
| this.noOriginAction = action; |
| return this; |
| } |
| |
| /** |
| * Sets the action when a conflicting Origin header is detected. Default is {@code ERROR}. |
| * |
| * @param action |
| * the alternate action |
| * |
| * @return this |
| */ |
| public CsrfPreventionRequestCycleListener setConflictingOriginAction(CsrfAction action) |
| { |
| this.conflictingOriginAction = action; |
| return this; |
| } |
| |
| /** |
| * Modifies the HTTP error code in the exception when a conflicting Origin header is detected. |
| * |
| * @param errorCode |
| * the alternate HTTP error code, default {@code 400 BAD REQUEST} |
| * |
| * @return this |
| */ |
| public CsrfPreventionRequestCycleListener setErrorCode(int errorCode) |
| { |
| this.errorCode = errorCode; |
| return this; |
| } |
| |
| /** |
| * Modifies the HTTP message in the exception when a conflicting Origin header is detected. |
| * |
| * @param errorMessage |
| * the alternate message |
| * |
| * @return this |
| */ |
| public CsrfPreventionRequestCycleListener setErrorMessage(String errorMessage) |
| { |
| this.errorMessage = errorMessage; |
| return this; |
| } |
| |
| @Override |
| public void onBeginRequest(RequestCycle cycle) |
| { |
| if (log.isDebugEnabled()) |
| { |
| HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest() |
| .getContainerRequest(); |
| log.debug("Request Source URI: {}", getSourceUri(containerRequest)); |
| } |
| } |
| |
| /** |
| * Dynamic override for enabling/disabling the CSRF detection. Might be handy for specific |
| * tenants in a multi-tenant application. When false, the CSRF detection is not performed for |
| * the running request. Default {@code true} |
| * |
| * @return {@code true} when the CSRF checks need to be performed. |
| */ |
| protected boolean isEnabled() |
| { |
| return true; |
| } |
| |
| /** |
| * Override to limit whether the request to the specific page should be checked for a possible |
| * CSRF attack. |
| * |
| * @param targetedPage |
| * the page that is the target for the action |
| * @return {@code true} when the request to the page should be checked for CSRF issues. |
| */ |
| protected boolean isChecked(IRequestablePage targetedPage) |
| { |
| return true; |
| } |
| |
| /** |
| * Override to change the request handler types that are checked. Currently only action handlers |
| * (form submits, link clicks, AJAX events) are checked for a matching Origin HTTP header. |
| * |
| * @param handler |
| * the handler that is currently processing |
| * @return true when the Origin HTTP header should be checked for this {@code handler} |
| */ |
| protected boolean isChecked(IRequestHandler handler) |
| { |
| return handler instanceof IPageRequestHandler && |
| !(handler instanceof RenderPageRequestHandler); |
| } |
| |
| /** |
| * Unwraps the handler if it is a {@code IRequestHandlerDelegate} down to the deepest nested |
| * handler. |
| * |
| * @param handler |
| * The handler to unwrap |
| * @return the deepest handler that does not implement {@code IRequestHandlerDelegate} |
| */ |
| protected IRequestHandler unwrap(IRequestHandler handler) |
| { |
| while (handler instanceof IRequestHandlerDelegate) |
| handler = ((IRequestHandlerDelegate)handler).getDelegateHandler(); |
| return handler; |
| } |
| |
| @Override |
| public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler) |
| { |
| if (!isEnabled()) |
| { |
| log.trace("CSRF listener is disabled, no checks performed"); |
| return; |
| } |
| |
| handler = unwrap(handler); |
| |
| // check if the request is targeted at a page |
| if (isChecked(handler)) |
| { |
| IPageRequestHandler prh = (IPageRequestHandler)handler; |
| IRequestablePage targetedPage = prh.getPage(); |
| HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest() |
| .getContainerRequest(); |
| String sourceUri = getSourceUri(containerRequest); |
| |
| // Check if the page should be CSRF protected |
| if (isChecked(targetedPage)) |
| { |
| // if so check the Origin HTTP header |
| checkRequest(containerRequest, sourceUri, targetedPage); |
| } |
| else |
| { |
| if (log.isDebugEnabled()) |
| { |
| log.debug("Targeted page {} was opted out of the CSRF origin checks, allowed", |
| targetedPage.getClass().getName()); |
| } |
| allowHandler(containerRequest, sourceUri, targetedPage); |
| } |
| } |
| else |
| { |
| if (log.isTraceEnabled()) |
| log.trace( |
| "Resolved handler {} doesn't target an action on a page, no CSRF check performed", |
| handler.getClass().getName()); |
| } |
| } |
| |
| /** |
| * Resolves the source URI from the request headers ({@code Origin} or {@code Referer}). |
| * |
| * @param containerRequest |
| * the current container request |
| * @return the normalized source URI. |
| */ |
| protected String getSourceUri(HttpServletRequest containerRequest) |
| { |
| String sourceUri = containerRequest.getHeader(WebRequest.HEADER_ORIGIN); |
| if (Strings.isEmpty(sourceUri)) |
| { |
| sourceUri = containerRequest.getHeader(WebRequest.HEADER_REFERER); |
| } |
| return normalizeUri(sourceUri); |
| } |
| |
| /** |
| * Performs the check of the {@code Origin} or {@code Referer} header that is targeted at the |
| * {@code page}. |
| * |
| * @param request |
| * the current container request |
| * @param sourceUri |
| * the source URI |
| * @param page |
| * the page that is the target of the request |
| */ |
| protected void checkRequest(HttpServletRequest request, String sourceUri, IRequestablePage page) |
| { |
| if (sourceUri == null || sourceUri.isEmpty()) |
| { |
| log.debug("Source URI not present in request, {}", noOriginAction); |
| switch (noOriginAction) |
| { |
| case ALLOW : |
| allowHandler(request, sourceUri, page); |
| break; |
| case SUPPRESS : |
| suppressHandler(request, sourceUri, page); |
| break; |
| case ABORT : |
| abortHandler(request, sourceUri, page); |
| break; |
| } |
| return; |
| } |
| sourceUri = sourceUri.toLowerCase(Locale.ROOT); |
| |
| // if the origin is a know and trusted origin, don't check any further but allow the request |
| if (isWhitelistedHost(sourceUri)) |
| { |
| whitelistedHandler(request, sourceUri, page); |
| return; |
| } |
| |
| // check if the origin HTTP header matches the request URI |
| if (!isLocalOrigin(request, sourceUri)) |
| { |
| log.debug("Source URI conflicts with request origin, {}", conflictingOriginAction); |
| switch (conflictingOriginAction) |
| { |
| case ALLOW : |
| allowHandler(request, sourceUri, page); |
| break; |
| case SUPPRESS : |
| suppressHandler(request, sourceUri, page); |
| break; |
| case ABORT : |
| abortHandler(request, sourceUri, page); |
| break; |
| } |
| } |
| else |
| { |
| matchingOrigin(request, sourceUri, page); |
| } |
| } |
| |
| /** |
| * Handles the case where an origin is in the whitelist. Default action is to allow the |
| * whitelisted origin. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void whitelistedHandler(HttpServletRequest request, String origin, |
| IRequestablePage page) |
| { |
| onWhitelisted(request, origin, page); |
| if (log.isDebugEnabled()) |
| { |
| log.debug("CSRF Origin {} was whitelisted, allowed for page {}", origin, |
| page.getClass().getName()); |
| } |
| } |
| |
| /** |
| * Called when the origin was available in the whitelist. Override this method to implement your |
| * own custom action. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void onWhitelisted(HttpServletRequest request, String origin, IRequestablePage page) |
| { |
| } |
| |
| /** |
| * Handles the case where an origin was checked and matched the request origin. Default action |
| * is to allow the whitelisted origin. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void matchingOrigin(HttpServletRequest request, String origin, |
| IRequestablePage page) |
| { |
| onMatchingOrigin(request, origin, page); |
| if (log.isDebugEnabled()) |
| { |
| log.debug("CSRF Origin {} matched requested resource, allowed for page {}", origin, |
| page.getClass().getName()); |
| } |
| } |
| |
| /** |
| * Called when the origin HTTP header matched the request. Override this method to implement |
| * your own custom action. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void onMatchingOrigin(HttpServletRequest request, String origin, |
| IRequestablePage page) |
| { |
| } |
| |
| /** |
| * Handles the case where an Origin HTTP header was not present or did not match the request |
| * origin, and the corresponding action ({@link #noOriginAction} or |
| * {@link #conflictingOriginAction}) is set to {@code ALLOW}. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header, may be {@code null} or empty |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void allowHandler(HttpServletRequest request, String origin, |
| IRequestablePage page) |
| { |
| onAllowed(request, origin, page); |
| log.info("Possible CSRF attack, request URL: {}, Origin: {}, action: allowed", |
| request.getRequestURL(), origin); |
| } |
| |
| /** |
| * Override this method to customize the case where an Origin HTTP header was not present or did |
| * not match the request origin, and the corresponding action ({@link #noOriginAction} or |
| * {@link #conflictingOriginAction}) is set to {@code ALLOW}. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header, may be {@code null} or empty |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void onAllowed(HttpServletRequest request, String origin, IRequestablePage page) |
| { |
| } |
| |
| /** |
| * Handles the case where an Origin HTTP header was not present or did not match the request |
| * origin, and the corresponding action ({@link #noOriginAction} or |
| * {@link #conflictingOriginAction}) is set to {@code SUPPRESS}. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header, may be {@code null} or empty |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void suppressHandler(HttpServletRequest request, String origin, |
| IRequestablePage page) |
| { |
| onSuppressed(request, origin, page); |
| log.info("Possible CSRF attack, request URL: {}, Origin: {}, action: suppressed", |
| request.getRequestURL(), origin); |
| throw new RestartResponseException(page); |
| } |
| |
| /** |
| * Override this method to customize the case where an Origin HTTP header was not present or did |
| * not match the request origin, and the corresponding action ({@link #noOriginAction} or |
| * {@link #conflictingOriginAction}) is set to {@code SUPPRESSED}. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header, may be {@code null} or empty |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void onSuppressed(HttpServletRequest request, String origin, IRequestablePage page) |
| { |
| } |
| |
| /** |
| * Handles the case where an Origin HTTP header was not present or did not match the request |
| * origin, and the corresponding action ({@link #noOriginAction} or |
| * {@link #conflictingOriginAction}) is set to {@code ABORT}. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header, may be {@code null} or empty |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void abortHandler(HttpServletRequest request, String origin, |
| IRequestablePage page) |
| { |
| onAborted(request, origin, page); |
| log.info( |
| "Possible CSRF attack, request URL: {}, Origin: {}, action: aborted with error {} {}", |
| request.getRequestURL(), origin, errorCode, errorMessage); |
| throw new AbortWithHttpErrorCodeException(errorCode, errorMessage); |
| } |
| |
| /** |
| * Override this method to customize the case where an Origin HTTP header was not present or did |
| * not match the request origin, and the corresponding action ({@link #noOriginAction} or |
| * {@link #conflictingOriginAction}) is set to {@code ABORTED}. |
| * |
| * @param request |
| * the request |
| * @param origin |
| * the contents of the {@code Origin} HTTP header, may be {@code null} or empty |
| * @param page |
| * the page that is targeted with this request |
| */ |
| protected void onAborted(HttpServletRequest request, String origin, IRequestablePage page) |
| { |
| } |
| } |