/* * 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.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.Locale; import javax.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.AbstractRequestCycleListener; 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.lang.Checks; 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> */ public class CsrfPreventionRequestCycleListener 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 = javax.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"; /** * A white list of accepted origins (host names/domain names) presented as * <domainname>.<TLD>. The domain part can contain subdomains. */ private Collection<String> acceptedOrigins = new ArrayList<>(); /** * 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; } /** * Adds an origin (host name/domain name) to the white list. An origin is in the form of * <domainname>.<TLD>, and can contain a subdomain. Every Origin header that matches * a domain from the whitelist is accepted and not checked any further for CSRF issues. * * E.g. when {@code example.com} is in the white list, this allows requests from (i.e. with an * {@code Origin:} header containing) {@code example.com} and {@code blabla.example.com} but * rejects requests from {@code blablaexample.com} and {@code example2.com}. * * @param acceptedOrigin * the acceptable origin * @return this */ public CsrfPreventionRequestCycleListener addAcceptedOrigin(String acceptedOrigin) { Checks.notNull("acceptedOrigin", acceptedOrigin); // strip any leading dot characters final int len = acceptedOrigin.length(); int i = 0; while (i < len && acceptedOrigin.charAt(i) == '.') { i++; } acceptedOrigins.add(acceptedOrigin.substring(i)); 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(); // 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); } } /** * Checks whether the domain part of the {@code sourceUri} ({@code Origin} or {@code Referer} * header) is whitelisted. * * @param sourceUri * the contents of the {@code Origin} or {@code Referer} HTTP header * @return {@code true} when the source domain was whitelisted */ protected boolean isWhitelistedHost(final String sourceUri) { try { final String sourceHost = new URI(sourceUri).getHost(); if (Strings.isEmpty(sourceHost)) return false; for (String whitelistedOrigin : acceptedOrigins) { if (sourceHost.equalsIgnoreCase(whitelistedOrigin) || sourceHost.endsWith("." + whitelistedOrigin)) { log.trace("Origin {} matched whitelisted origin {}, request accepted", sourceUri, whitelistedOrigin); return true; } } } catch (URISyntaxException e) { log.debug("Origin: {} not parseable as an URI. Whitelisted-origin check skipped.", sourceUri); } return false; } /** * Checks whether the {@code Origin} HTTP header of the request matches where the request came * from. * * @param containerRequest * the current container request * @param originHeader * the contents of the {@code Origin} HTTP header * @return {@code true} when the origin of the request matches the {@code Origin} HTTP header */ protected boolean isLocalOrigin(HttpServletRequest containerRequest, String originHeader) { // Make comparable strings from Origin and Location String origin = normalizeUri(originHeader); if (origin == null) return false; String request = getTargetUriFromRequest(containerRequest); if (request == null) return false; return origin.equalsIgnoreCase(request); } /** * Creates a RFC-6454 comparable URI from the {@code uri} string. * * @param uri * the contents of the Origin or Referer HTTP header * @return only the scheme://host[:port] part, or {@code null} when the URI string is not * compliant */ protected final String normalizeUri(String uri) { // the request comes from a privacy sensitive context, flag as non-local origin. If // alternative action is required, an implementor can override any of the onAborted, // onSuppressed or onAllowed and implement such needed action. if (Strings.isEmpty(uri) || "null".equals(uri)) return null; StringBuilder target = new StringBuilder(); try { URI originUri = new URI(uri); String scheme = originUri.getScheme(); if (scheme == null) { return null; } else { scheme = scheme.toLowerCase(Locale.ENGLISH); } target.append(scheme); target.append("://"); String host = originUri.getHost(); if (host == null) { return null; } target.append(host); int port = originUri.getPort(); boolean portIsSpecified = port != -1; boolean isAlternateHttpPort = "http".equals(scheme) && port != 80; boolean isAlternateHttpsPort = "https".equals(scheme) && port != 443; if (portIsSpecified && (isAlternateHttpPort || isAlternateHttpsPort)) { target.append(':'); target.append(port); } return target.toString(); } catch (URISyntaxException e) { log.debug("Invalid URI provided: {}, marked conflicting", uri); return null; } } /** * Creates a RFC-6454 comparable URI from the {@code request} requested resource. * * @param request * the incoming request * @return only the scheme://host[:port] part, or {@code null} when the origin string is not * compliant */ protected final String getTargetUriFromRequest(HttpServletRequest request) { // Build scheme://host:port from request StringBuilder target = new StringBuilder(); String scheme = request.getScheme(); if (scheme == null) { return null; } else { scheme = scheme.toLowerCase(Locale.ENGLISH); } target.append(scheme); target.append("://"); String host = request.getServerName(); if (host == null) { return null; } target.append(host); int port = request.getServerPort(); if ("http".equals(scheme) && port != 80 || "https".equals(scheme) && port != 443) { target.append(':'); target.append(port); } return target.toString(); } /** * 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) { } }