blob: 5858b38fe26c5383c67d5dfb165e72a4feb9cc6d [file] [log] [blame]
/*
* 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 static java.util.Arrays.asList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.protocol.http.IResourceIsolationPolicy.ResourceIsolationOutcome;
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.WebResponse;
import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException;
import org.apache.wicket.util.lang.Classes;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This {@link RequestCycle} listener ensures resource isolation, adding a layer of protection for
* modern browsers that prevent <em>Cross-Site Request Forgery</em> attacks.
* <p>
* It uses the {@link FetchMetadataResourceIsolationPolicy} and
* {@link OriginResourceIsolationPolicy} by default and can be customized with additional
* {@link IResourceIsolationPolicy}s.
* <p>
* URL paths that are intended to be used cross-site can be excempted from these policies.
* <p>
* Learn more about Fetch Metadata and resource isolation at
* <a href="https://web.dev/fetch-metadata">https://web.dev/fetch-metadata/</a>
*
* @author Santiago Diaz - saldiaz@google.com
* @author Ecenaz Jen Ozmen - ecenazo@google.com
*/
public class ResourceIsolationRequestCycleListener implements IRequestCycleListener
{
private static final Logger log = LoggerFactory
.getLogger(ResourceIsolationRequestCycleListener.class);
public static final String ERROR_MESSAGE = "The request was blocked by a resource isolation policy";
/**
* The action to perform when the outcome of the resource isolation policy is DISALLOWED or
* UNKNOWN.
*/
public enum CsrfAction
{
/** Aborts the request and throws an exception when a CSRF request is detected. */
ABORT {
@Override
public String toString()
{
return "aborted";
}
@Override
void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request,
IRequestablePage page)
{
listener.abortHandler(request, page);
}
},
/**
* Ignores the action of a CSRF request, and just renders the page it was targeted against.
*/
SUPPRESS {
@Override
public String toString()
{
return "suppressed";
}
@Override
void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request,
IRequestablePage page)
{
listener.suppressHandler(request, page);
}
},
/** Detects a CSRF request, logs it and allows the request to continue. */
ALLOW {
@Override
public String toString()
{
return "allowed";
}
@Override
void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request,
IRequestablePage page)
{
listener.allowHandler(request, page);
}
};
abstract void apply(ResourceIsolationRequestCycleListener listener,
HttpServletRequest request, IRequestablePage page);
}
/**
* Action to perform when no resource isolation policy can determine the validity of the
* request.
*/
private CsrfAction unknownOutcomeAction = CsrfAction.ABORT;
/**
* Action to perform when {@link ResourceIsolationOutcome#DISALLOWED} is reported by a
* resource isolation policy.
*/
private CsrfAction disallowedOutcomeAction = CsrfAction.ABORT;
/**
* The error code to report when the action to take for a CSRF request is
* {@link CsrfAction#ABORT}. Default {@code 403 FORBIDDEN}.
*/
private int errorCode = jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
/**
* The error message to report when the action to take for a CSRF request is {@code ERROR}.
* Default {@code "The request was blocked by a resource isolation policy"}.
*/
private String errorMessage = ERROR_MESSAGE;
private final Set<String> exemptedPaths = new HashSet<>();
private final List<IResourceIsolationPolicy> resourceIsolationPolicies = new ArrayList<>();
/**
* Create a new listener with the given policies. If no policies are given,
* {@link FetchMetadataResourceIsolationPolicy} and {@link OriginResourceIsolationPolicy} will
* be used. The policies are checked in order. The first outcome that's not
* {@link ResourceIsolationOutcome#UNKNOWN} will be used.
*
* @param policies
* the policies to check requests against.
*/
public ResourceIsolationRequestCycleListener(IResourceIsolationPolicy... policies)
{
this.resourceIsolationPolicies.addAll(asList(policies));
if (policies.length == 0)
{
this.resourceIsolationPolicies.addAll(asList(new FetchMetadataResourceIsolationPolicy(),
new OriginResourceIsolationPolicy()));
}
}
/**
* Sets the action when none of the resource isolation policies can come to an outcome. Default
* {@code ABORT}.
*
* @param action
* the alternate action
*
* @return this (for chaining)
*/
public ResourceIsolationRequestCycleListener setUnknownOutcomeAction(CsrfAction action)
{
this.unknownOutcomeAction = action;
return this;
}
/**
* Sets the action when a request is disallowed by a resource isolation policy. Default is
* {@code ABORT}.
*
* @param action
* the alternate action
*
* @return this
*/
public ResourceIsolationRequestCycleListener setDisallowedOutcomeAction(CsrfAction action)
{
this.disallowedOutcomeAction = action;
return this;
}
/**
* Modifies the HTTP error code in the exception when a disallowed request is detected.
*
* @param errorCode
* the alternate HTTP error code, default {@code 403 FORBIDDEN}
*
* @return this
*/
public ResourceIsolationRequestCycleListener setErrorCode(int errorCode)
{
this.errorCode = errorCode;
return this;
}
/**
* Modifies the HTTP message in the exception when a disallowed request is detected.
*
* @param errorMessage
* the alternate message
*
* @return this
*/
public ResourceIsolationRequestCycleListener setErrorMessage(String errorMessage)
{
this.errorMessage = errorMessage;
return this;
}
public void addExemptedPaths(String... exemptions)
{
Arrays.stream(exemptions).filter(e -> !Strings.isEmpty(e)).forEach(exemptedPaths::add);
}
@Override
public void onBeginRequest(RequestCycle cycle)
{
HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest()
.getContainerRequest();
log.debug("Processing request to: {}", containerRequest.getPathInfo());
}
/**
* 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.
*
* @param handler
* the handler that is currently processing
* @return true when resource isolation should be checked for this {@code handler}
*/
protected boolean isChecked(IRequestHandler handler)
{
return handler instanceof IPageRequestHandler
&& !(handler instanceof RenderPageRequestHandler);
}
@Override
public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler)
{
if (!isEnabled())
{
log.trace("CSRF listener is disabled, no checks performed");
return;
}
handler = unwrap(handler);
if (isChecked(handler))
{
IPageRequestHandler pageRequestHandler = (IPageRequestHandler)handler;
IRequestablePage targetedPage = pageRequestHandler.getPage();
HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest()
.getContainerRequest();
if (!isChecked(targetedPage))
{
if (log.isDebugEnabled())
{
log.debug("Targeted page {} was opted out of resource isolation, allowed",
targetedPage.getClass().getName());
}
return;
}
String pathInfo = containerRequest.getPathInfo();
if (exemptedPaths.contains(pathInfo))
{
if (log.isDebugEnabled())
{
log.debug("Allowing request to {} because it matches an exempted path",
new Object[] { pathInfo });
}
return;
}
for (IResourceIsolationPolicy policy : resourceIsolationPolicies)
{
ResourceIsolationOutcome outcome = policy
.isRequestAllowed(containerRequest, targetedPage);
if (ResourceIsolationOutcome.DISALLOWED.equals(outcome))
{
log.debug("Isolation policy {} has rejected a request to {}",
Classes.simpleName(policy.getClass()), pathInfo);
disallowedOutcomeAction.apply(this, containerRequest, targetedPage);
return;
}
else if (ResourceIsolationOutcome.ALLOWED.equals(outcome))
{
return;
}
}
unknownOutcomeAction.apply(this, containerRequest, targetedPage);
}
else
{
if (log.isTraceEnabled())
log.trace("Resolved handler {} is not checked, no CSRF check performed",
handler.getClass().getName());
}
}
/**
* Allow isolation policy to add headers.
*
* @see IResourceIsolationPolicy#setHeaders(HttpServletResponse)
*/
@Override
public void onEndRequest(RequestCycle cycle)
{
if (cycle.getResponse() instanceof WebResponse)
{
WebResponse webResponse = (WebResponse)cycle.getResponse();
if (webResponse.isHeaderSupported())
{
for (IResourceIsolationPolicy resourceIsolationPolicy : resourceIsolationPolicies)
{
resourceIsolationPolicy
.setHeaders((HttpServletResponse)webResponse.getContainerResponse());
}
}
}
}
/**
* Allow the execution of the listener in the request because the outcome results in
* {@link CsrfAction#ALLOW}.
*
* @param request
* the request
* @param page
* the page that is targeted with this request
*/
protected void allowHandler(HttpServletRequest request, IRequestablePage page)
{
log.info("Possible CSRF attack, request URL: {}, action: allowed", request.getRequestURL());
}
/**
* Suppress the execution of the listener in the request because the outcome results in
* {@link CsrfAction#SUPPRESS}.
*
* @param request
* the request
* @param page
* the page that is targeted with this request
*/
protected void suppressHandler(HttpServletRequest request, IRequestablePage page)
{
log.info("Possible CSRF attack, request URL: {}, action: suppressed",
request.getRequestURL());
throw new RestartResponseException(page);
}
/**
* Abort the request because the outcome results in {@link CsrfAction#ABORT}.
*
* @param request
* the request
* @param page
* the page that is targeted with this request
*/
protected void abortHandler(HttpServletRequest request, IRequestablePage page)
{
log.info("Possible CSRF attack, request URL: {}, action: aborted with error {} {}",
request.getRequestURL(), errorCode, errorMessage);
throw new AbortWithHttpErrorCodeException(errorCode, errorMessage);
}
private static IRequestHandler unwrap(IRequestHandler handler)
{
while (handler instanceof IRequestHandlerDelegate)
{
handler = ((IRequestHandlerDelegate)handler).getDelegateHandler();
}
return handler;
}
}