/*
 * 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 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.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>
 * Installation
 * <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>
 * &#064;Override
 * protected void init()
 * {
 * 	// ...
 * 	getRequestCycleListeners().add(new CsrfPreventionRequestCycleListener());
 * 	// ...
 * }
 * </pre>
 * <p>
 * Configuration
 * <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>
 * &#064;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 = 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";

	/**
	 * 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)
	{
	}
}
