You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@wicket.apache.org by da...@apache.org on 2015/06/07 16:47:32 UTC

wicket git commit: Added a CSRF prevention measure

Repository: wicket
Updated Branches:
  refs/heads/master 6bacb2ec8 -> a6150ae4d


Added a CSRF prevention measure


Project: http://git-wip-us.apache.org/repos/asf/wicket/repo
Commit: http://git-wip-us.apache.org/repos/asf/wicket/commit/a6150ae4
Tree: http://git-wip-us.apache.org/repos/asf/wicket/tree/a6150ae4
Diff: http://git-wip-us.apache.org/repos/asf/wicket/diff/a6150ae4

Branch: refs/heads/master
Commit: a6150ae4d5d153dca2de822796010811e5f54a9b
Parents: 6bacb2e
Author: Martijn Dashorst <ma...@gmail.com>
Authored: Sun Jun 7 16:34:55 2015 +0200
Committer: Martijn Dashorst <ma...@gmail.com>
Committed: Sun Jun 7 16:34:55 2015 +0200

----------------------------------------------------------------------
 .../CsrfPreventionRequestCycleListener.java     | 754 +++++++++++++++++++
 .../CsrfPreventionRequestCycleListenerTest.java | 639 ++++++++++++++++
 .../apache/wicket/protocol/http/ThirdPage.html  |  10 +
 .../apache/wicket/protocol/http/ThirdPage.java  |  44 ++
 4 files changed, 1447 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/wicket/blob/a6150ae4/wicket-core/src/main/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListener.java
----------------------------------------------------------------------
diff --git a/wicket-core/src/main/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListener.java b/wicket-core/src/main/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListener.java
new file mode 100644
index 0000000..21d5569
--- /dev/null
+++ b/wicket-core/src/main/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListener.java
@@ -0,0 +1,754 @@
+/*
+ * 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.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.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} HTTP header 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>
+ * &#064;Override
+ * protected void init()
+ * {
+ * 	// ...
+ * 	getRequestCycleListeners().add(new CsrfPreventionRequestCycleListener());
+ * 	// ...
+ * }
+ * </pre>
+ * <p>
+ * <h3>Configuration</h3>
+ * <p>
+ * A missing {@code Origin} HTTP header is (by default) handled as if it were a good request and
+ * accepted. You can {@link #setNoOriginAction(CsrfAction) configure the specific action} to a
+ * different value, suppressing or aborting the request when the {@code Origin} HTTP header is
+ * 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 conflicting origin action. You can
+ * customize what happens in those actions by overriding the respective {@code onXXXX} methods.
+ * <p>
+ * When the {@code Origin} 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>
+ * 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.getPage() 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 override 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 extends AbstractRequestCycleListener
+	implements
+		IRequestCycleListener
+{
+	private static final Logger log = LoggerFactory.getLogger(CsrfPreventionRequestCycleListener.class);
+
+	/**
+	 * The action to perform when a missing or conflicting Origin header is detected.
+	 */
+	public static 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.ALLOW;
+
+	/**
+	 * Action to perform when a conflicing Origin header is found.
+	 */
+	private CsrfAction conflictingOriginAction = CsrfAction.ABORT;
+
+	/**
+	 * The error code to report when the action to take for a CSRF request is {@code ERROR}. 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
+	 * &lt;domainname&gt;.&lt;TLD&gt;. 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
+	 * &lt;domainname&gt;.&lt;TLD&gt;, 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();
+			String origin = containerRequest.getHeader("Origin");
+			log.debug("Request header Origin: {}", origin);
+		}
+	}
+
+	/**
+	 * 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);
+	}
+
+	@Override
+	public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler)
+	{
+		if (!isEnabled())
+		{
+			log.trace("CSRF listener is disabled, no checks performed");
+			return;
+		}
+
+		// 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 origin = containerRequest.getHeader("Origin");
+
+			// Check if the page should be CSRF protected
+			if (isChecked(targetedPage))
+			{
+				// if so check the Origin HTTP header
+				checkOrigin(containerRequest, origin, targetedPage);
+			}
+			else
+			{
+				log.debug("Targeted page {} was opted out of the CSRF origin checks, allowed",
+					targetedPage.getClass().getName());
+				allowHandler(containerRequest, origin, targetedPage);
+			}
+		}
+		else
+		{
+			if (log.isTraceEnabled())
+				log.trace("Resolved handler {} doesn't target a page, no CSRF check performed",
+					handler.getClass().getName());
+		}
+	}
+
+	/**
+	 * Performs the check of the {@code Origin} header that is targeted at the {@code page}.
+	 *
+	 * @param request
+	 *            the current container request
+	 * @param origin
+	 *            the {@code Origin} header
+	 * @param page
+	 *            the page that is the target of the request
+	 */
+	private void checkOrigin(HttpServletRequest request, String origin, IRequestablePage page)
+	{
+		if (origin == null || origin.isEmpty())
+		{
+			log.debug("Origin-header not present in request, {}", noOriginAction);
+			switch (noOriginAction)
+			{
+				case ALLOW :
+					allowHandler(request, origin, page);
+					break;
+				case SUPPRESS :
+					suppressHandler(request, origin, page);
+					break;
+				case ABORT :
+					abortHandler(request, origin, page);
+					break;
+			}
+			return;
+		}
+		origin = origin.toLowerCase();
+
+		// if the origin is a know and trusted origin, don't check any further but allow the request
+		if (isWhitelistedOrigin(origin))
+		{
+			whitelistedHandler(request, origin, page);
+			return;
+		}
+
+		// check if the origin HTTP header matches the request URI
+		if (!isLocalOrigin(request, origin))
+		{
+			log.debug("Origin-header conflicts with request origin, {}", conflictingOriginAction);
+			switch (conflictingOriginAction)
+			{
+				case ALLOW :
+					allowHandler(request, origin, page);
+					break;
+				case SUPPRESS :
+					suppressHandler(request, origin, page);
+					break;
+				case ABORT :
+					abortHandler(request, origin, page);
+					break;
+			}
+		}
+		else
+		{
+			matchingOrigin(request, origin, page);
+		}
+	}
+
+	/**
+	 * Checks whether the domain part of the {@code Origin} HTTP header is whitelisted.
+	 * 
+	 * @param origin
+	 *            the {@code Origin} HTTP header
+	 * @return {@code true} when the origin domain was whitelisted
+	 */
+	private boolean isWhitelistedOrigin(final String origin)
+	{
+		try
+		{
+			final URI originUri = new URI(origin);
+			final String originHost = originUri.getHost();
+			if (Strings.isEmpty(originHost))
+				return false;
+			for (String whitelistedOrigin : acceptedOrigins)
+			{
+				if (originHost.equalsIgnoreCase(whitelistedOrigin) ||
+					originHost.endsWith("." + whitelistedOrigin))
+				{
+					log.trace("Origin {} matched whitelisted origin {}, request accepted", origin,
+						whitelistedOrigin);
+					return true;
+				}
+			}
+		}
+		catch (URISyntaxException e)
+		{
+			log.debug("Origin: {} not parseable as an URI. Whitelisted-origin check skipped.",
+				origin);
+		}
+
+		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
+	 */
+	private boolean isLocalOrigin(HttpServletRequest containerRequest, String originHeader)
+	{
+		// Make comparable strings from Origin and Location
+		String origin = getOriginHeaderOrigin(originHeader);
+		if (origin == null)
+			return false;
+
+		String request = getLocationHeaderOrigin(containerRequest);
+		if (request == null)
+			return false;
+
+		return origin.equalsIgnoreCase(request);
+	}
+
+	/**
+	 * Creates a RFC-6454 comparable origin from the {@code origin} string.
+	 * 
+	 * @param origin
+	 *            the contents of the Origin HTTP header
+	 * @return only the scheme://host[:port] part, or {@code null} when the origin string is not
+	 *         compliant
+	 */
+	private String getOriginHeaderOrigin(String origin)
+	{
+		// 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 ("null".equals(origin))
+			return null;
+
+		StringBuilder target = new StringBuilder();
+
+		try
+		{
+			URI originUri = new URI(origin);
+			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();
+			if (port != -1 && "http".equals(scheme) && port != 80 || "https".equals(scheme) &&
+				port != 443)
+			{
+				target.append(':');
+				target.append(port);
+			}
+			return target.toString();
+		}
+		catch (URISyntaxException e)
+		{
+			log.debug("Invalid Origin header provided: {}, marked conflicting", origin);
+			return null;
+		}
+	}
+
+	/**
+	 * Creates a RFC-6454 comparable origin 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
+	 */
+	private String getLocationHeaderOrigin(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
+	 */
+	private 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
+	 */
+	private 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
+	 */
+	private 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
+	 */
+	private 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
+	 */
+	private 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)
+	{
+	}
+}

http://git-wip-us.apache.org/repos/asf/wicket/blob/a6150ae4/wicket-core/src/test/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListenerTest.java
----------------------------------------------------------------------
diff --git a/wicket-core/src/test/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListenerTest.java b/wicket-core/src/test/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListenerTest.java
new file mode 100644
index 0000000..4dca4b8
--- /dev/null
+++ b/wicket-core/src/test/java/org/apache/wicket/protocol/http/CsrfPreventionRequestCycleListenerTest.java
@@ -0,0 +1,639 @@
+/*
+ * 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 org.hamcrest.CoreMatchers.is;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.wicket.RestartResponseException;
+import org.apache.wicket.WicketTestCase;
+import org.apache.wicket.protocol.http.CsrfPreventionRequestCycleListener.CsrfAction;
+import org.apache.wicket.request.IRequestHandler;
+import org.apache.wicket.request.component.IRequestablePage;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Test cases for the CsrfPreventionRequestCycleListener. FirstPage has a link that when clicked
+ * should render SecondPage.
+ */
+public class CsrfPreventionRequestCycleListenerTest extends WicketTestCase
+{
+	/**
+	 * Sets up the test cases. Installs the CSRF listener and renders the FirstPage.
+	 */
+	@Before
+	public void startWithFirstPageRender()
+	{
+		WebApplication application = tester.getApplication();
+
+		csrfListener = new MockCsrfPreventionRequestCycleListener();
+		setErrorCode(errorCode);
+		setErrorMessage(errorMessage);
+		application.getRequestCycleListeners().add(csrfListener);
+
+		// Rendering a page is allowed, regardless of Origin (this allows external links into your
+		// website to function)
+
+		tester.addRequestHeader("Origin", "https://google.com/");
+
+		tester.startPage(FirstPage.class);
+		tester.assertRenderedPage(FirstPage.class);
+	}
+
+	/** Tests that disabling the CSRF listener doesn't check Origin headers. */
+	@Test
+	public void disabledListenerDoesntCheckAnything()
+	{
+		csrfEnabled = false;
+		tester.clickLink("link");
+
+		assertOriginsNotChecked();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests that disabling the CSRF listener doesn't check Origin headers. */
+	@Test
+	public void disabledListenerDoesntCheckMismatchedOrigin()
+	{
+		csrfEnabled = false;
+		tester.addRequestHeader("Origin", "http://malicioussite.com/");
+		tester.clickLink("link");
+		assertOriginsNotChecked();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests the default setting of allowing a missing Origin. */
+	@Test
+	public void withoutOriginAllowed()
+	{
+		tester.clickLink("link");
+		assertConflictingOriginsRequestAllowed();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests the alternative action of suppressing a request without Origin header */
+	@Test
+	public void withoutOriginSuppressed()
+	{
+		csrfListener.setNoOriginAction(CsrfAction.SUPPRESS);
+		tester.clickLink("link");
+		tester.assertRenderedPage(FirstPage.class);
+		assertConflictingOriginsRequestSuppressed();
+	}
+
+	/** Tests the alternative action of aborting a request without Origin header */
+	@Test
+	public void withoutOriginAborted()
+	{
+		csrfListener.setNoOriginAction(CsrfAction.ABORT);
+		tester.clickLink("link");
+		assertConflictingOriginsRequestAborted();
+	}
+
+	/** Tests when the Origin header matches the request. */
+	@Test
+	public void matchingOriginsAllowed()
+	{
+		csrfListener.setConflictingOriginAction(CsrfAction.ALLOW);
+		tester.addRequestHeader("Origin", "http://localhost/");
+
+		tester.clickLink("link");
+
+		assertOriginsMatched();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests when the default action is changed to ALLOW when origins conflict. */
+	@Test
+	public void conflictingOriginsAllowed()
+	{
+		csrfListener.setConflictingOriginAction(CsrfAction.ALLOW);
+		tester.addRequestHeader("Origin", "http://example.com/");
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAllowed();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests when the default action is changed to SUPPRESS when origins conflict. */
+	@Test
+	public void conflictingOriginsSuppressed()
+	{
+		tester.addRequestHeader("Origin", "http://example.com/");
+		csrfListener.setConflictingOriginAction(CsrfAction.SUPPRESS);
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestSuppressed();
+		tester.assertRenderedPage(FirstPage.class);
+	}
+
+	/** Tests the default action to ABORT when origins conflict. */
+	@Test
+	public void conflictingOriginsAborted()
+	{
+		tester.addRequestHeader("Origin", "http://example.com/");
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAborted();
+	}
+
+	/** Tests custom error code/message when the default action is ABORT. */
+	@Test
+	public void conflictingOriginsAbortedWith401Unauhorized()
+	{
+		setErrorCode(401);
+		setErrorMessage("NOT AUTHORIZED");
+
+		tester.addRequestHeader("Origin", "http://example.com/");
+		csrfListener.setNoOriginAction(CsrfAction.ABORT);
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAborted();
+	}
+
+	/** Tests whitelisting for conflicting origins. */
+	@Test
+	public void conflictingButWhitelistedOriginAllowed()
+	{
+		csrfListener.setConflictingOriginAction(CsrfAction.ALLOW);
+		csrfListener.addAcceptedOrigin("example.com");
+		tester.addRequestHeader("Origin", "http://example.com/");
+
+		tester.clickLink("link");
+
+		assertOriginsWhitelisted();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests whitelisting with conflicting subdomain origin. */
+	@Test
+	public void conflictingButWhitelistedSubdomainOriginAllowed()
+	{
+		csrfListener.addAcceptedOrigin("example.com");
+		csrfListener.setConflictingOriginAction(CsrfAction.ALLOW);
+
+		tester.addRequestHeader("Origin", "http://foo.example.com/");
+
+		tester.clickLink("link");
+
+		tester.assertRenderedPage(SecondPage.class);
+		assertOriginsWhitelisted();
+	}
+
+	/**
+	 * Tests when the listener is disabled for a specific page (by overriding
+	 * {@link CsrfPreventionRequestCycleListener#isChecked(IRequestablePage)})
+	 */
+	@Test
+	public void conflictingOriginPageNotCheckedAllowed()
+	{
+		tester.addRequestHeader("Origin", "http://example.com/");
+		csrfListener.setConflictingOriginAction(CsrfAction.ABORT);
+
+		// disable the check for this page
+		checkPage = false;
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAllowed();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests overriding the onSuppressed method for a conflicting origin. */
+	@Test
+	public void conflictingOriginSuppressedCallsCustomHandler()
+	{
+		// redirect to third page to ensure we are not suppressed to the first page, nor that the
+		// request was not suppressed and the second page was rendered erroneously
+
+		Runnable thirdPageRedirect = new Runnable()
+		{
+			@Override
+			public void run()
+			{
+				throw new RestartResponseException(new ThirdPage());
+			}
+		};
+		setSuppressHandler(thirdPageRedirect);
+		csrfListener.setConflictingOriginAction(CsrfAction.SUPPRESS);
+
+		tester.addRequestHeader("Origin", "http://example.com/");
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestSuppressed();
+		tester.assertRenderedPage(ThirdPage.class);
+	}
+
+	/** Tests overriding the onAllowed method for a conflicting origin. */
+	@Test
+	public void conflictingOriginAllowedCallsCustomHandler()
+	{
+		// redirect to third page to ensure we are not suppressed to the first page, nor that the
+		// request was not allowed and the second page was rendered erroneously
+
+		Runnable thirdPageRedirect = new Runnable()
+		{
+			@Override
+			public void run()
+			{
+				throw new RestartResponseException(new ThirdPage());
+			}
+		};
+		setAllowHandler(thirdPageRedirect);
+		csrfListener.setConflictingOriginAction(CsrfAction.ALLOW);
+
+		tester.addRequestHeader("Origin", "http://example.com/");
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAllowed();
+		tester.assertRenderedPage(ThirdPage.class);
+	}
+
+	/** Tests overriding the onAborted method for a conflicting origin. */
+	@Test
+	public void conflictingOriginAbortedCallsCustomHandler()
+	{
+		// redirect to third page to ensure we are not suppressed to the first page, nor that the
+		// request was not aborted and the second page was rendered erroneously
+
+		Runnable thirdPageRedirect = new Runnable()
+		{
+			@Override
+			public void run()
+			{
+				throw new RestartResponseException(new ThirdPage());
+			}
+		};
+		setAbortHandler(thirdPageRedirect);
+
+		tester.addRequestHeader("Origin", "http://example.com/");
+		csrfListener.setConflictingOriginAction(CsrfAction.ABORT);
+
+		tester.clickLink("link");
+
+		// have to check manually, as the assert checks the error code (which is not set due to our
+		// custom handler)
+
+		if (!aborted)
+			throw new AssertionError("Request was not aborted");
+
+		tester.assertRenderedPage(ThirdPage.class);
+	}
+
+	/** Tests whether a different port, but same scheme and hostname is considered a conflict. */
+	@Test
+	public void differentPortOriginAborted()
+	{
+		tester.addRequestHeader("Origin", "http://localhost:8080");
+		csrfListener.setConflictingOriginAction(CsrfAction.ABORT);
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAborted();
+	}
+
+	/** Tests whether a different scheme, but same port and hostname is considered a conflict. */
+	@Test
+	public void differentSchemeOriginAborted()
+	{
+		tester.addRequestHeader("Origin", "https://localhost");
+		csrfListener.setConflictingOriginAction(CsrfAction.ABORT);
+
+		tester.clickLink("link");
+
+		assertConflictingOriginsRequestAborted();
+	}
+
+	/** Tests whether only the hostname is considered when matching the Origin header. */
+	@Test
+	public void longerOriginAllowed()
+	{
+		tester.addRequestHeader("Origin", "http://localhost/supercalifragilisticexpialidocious");
+		csrfListener.setConflictingOriginAction(CsrfAction.ABORT);
+
+		tester.clickLink("link");
+
+		assertOriginsMatched();
+		tester.assertRenderedPage(SecondPage.class);
+	}
+
+	/** Tests whether AJAX Links are checked through the CSRF listener */
+	@Test
+	public void simulatedCsrfAttackThroughAjaxIsPrevented()
+	{
+		csrfListener.setConflictingOriginAction(CsrfAction.ABORT);
+
+		// first render a page in the user's session
+		tester.addRequestHeader("Origin", "http://localhost");
+		tester.startPage(ThirdPage.class);
+
+		assertOriginsNotChecked();
+		tester.assertRenderedPage(ThirdPage.class);
+
+		// then click on a link from another external page
+		tester.addRequestHeader("Origin", "http://attacker.com/");
+		tester.clickLink("link", true);
+
+		assertConflictingOriginsRequestAborted();
+	}
+
+	/** Tests whether AJAX Links are checked through the CSRF listener */
+	@Test
+	public void simulatedCsrfAttackIsSuppressed()
+	{
+		csrfListener.setConflictingOriginAction(CsrfAction.SUPPRESS);
+
+		// first render a page in the user's session
+		tester.addRequestHeader("Origin", "http://localhost");
+		tester.startPage(ThirdPage.class);
+
+		assertOriginsNotChecked();
+		tester.assertRenderedPage(ThirdPage.class);
+
+		// then click on a link from another external page
+		tester.addRequestHeader("Origin", "http://attacker.com/");
+		tester.clickLink("link", true);
+
+		assertConflictingOriginsRequestSuppressed();
+		tester.assertRenderedPage(ThirdPage.class);
+	}
+
+	/** Tests whether form submits are checked through the CSRF listener */
+	@Test
+	public void simulatedCsrfAttackOnFormIsSuppressed()
+	{
+		csrfListener.setConflictingOriginAction(CsrfAction.SUPPRESS);
+
+		// first render a page in the user's session
+		tester.addRequestHeader("Origin", "http://localhost");
+		tester.startPage(ThirdPage.class);
+
+		assertOriginsNotChecked();
+		tester.assertRenderedPage(ThirdPage.class);
+
+		// then click on a link from another external page
+		tester.addRequestHeader("Origin", "http://attacker.com/");
+		tester.submitForm("form");
+
+		assertConflictingOriginsRequestSuppressed();
+		tester.assertRenderedPage(ThirdPage.class);
+	}
+
+	/*
+	 * Infrastructure code for these test cases starts here.
+	 */
+
+	/** The listener under test */
+	private CsrfPreventionRequestCycleListener csrfListener;
+
+	/** Flag for enabling/disabling the CSRF listener */
+	private boolean csrfEnabled = true;
+
+	/** Flag for enabling/disabling the page check of the CSRF listener */
+	private boolean checkPage = true;
+
+	/** Value for reporting the error code when the request was aborted */
+	private int errorCode = 400;
+
+	/** Value for reporting the error message when the request was aborted */
+	private String errorMessage = "BAD REQUEST";
+
+	/** Checks for asserting the functionality of the CSRF listener */
+	private boolean matched, whitelisted, aborted, allowed, suppressed;
+
+	/**
+	 * Manner to override the default check whether the current request handler should be checked
+	 * for CSRF attacks.
+	 */
+	private Predicate<IRequestHandler> customRequestHandlerCheck;
+
+	/**
+	 * Handlers for specific tests (ensures that the listener calls the right handler in the right
+	 * circumstance.
+	 */
+	private Runnable abortHandler, allowHandler, suppressHandler, matchedHandler, whitelistHandler;
+
+	private void setErrorCode(int errorCode)
+	{
+		this.errorCode = errorCode;
+		csrfListener.setErrorCode(errorCode);
+	}
+
+	private void setCustomRequestHandlerCheck(Predicate<IRequestHandler> check)
+	{
+		this.customRequestHandlerCheck = check;
+	}
+
+	private void setErrorMessage(String errorMessage)
+	{
+		this.errorMessage = errorMessage;
+		csrfListener.setErrorMessage(errorMessage);
+	}
+
+	private void setAbortHandler(Runnable abortHandler)
+	{
+		this.abortHandler = abortHandler;
+	}
+
+	private void setAllowHandler(Runnable allowHandler)
+	{
+		this.allowHandler = allowHandler;
+	}
+
+	private void setSuppressHandler(Runnable suppressHandler)
+	{
+		this.suppressHandler = suppressHandler;
+	}
+
+	private void setWhitelistHandler(Runnable whitelistHandler)
+	{
+		this.whitelistHandler = whitelistHandler;
+	}
+
+	private void setMatchedHandler(Runnable matchedHandler)
+	{
+		this.matchedHandler = matchedHandler;
+	}
+
+	/**
+	 * Asserts that the origins were checked, and found matching.
+	 */
+	private void assertOriginsMatched()
+	{
+		if (!matched)
+			throw new AssertionError("Origins were not matched");
+	}
+
+	/**
+	 * Asserts that the origins were not checked, because the origin was on the whitelist.
+	 */
+	private void assertOriginsWhitelisted()
+	{
+		if (!whitelisted)
+			throw new AssertionError("Origins were not whitelisted");
+	}
+
+	/**
+	 * Asserts that the origins were checked, found conflicting, had an action "ABORTED" and returns
+	 * a HTTP error.
+	 */
+	private void assertConflictingOriginsRequestAborted()
+	{
+		if (!aborted)
+			throw new AssertionError("Request was not aborted");
+
+		assertThat("Response error code", tester.getLastResponse().getStatus(), is(errorCode));
+		assertThat("Response error message", tester.getLastResponse().getErrorMessage(),
+			is(errorMessage));
+	}
+
+	/**
+	 * Asserts that the origins were checked, found conflicting and had an action "SUPPRESS".
+	 */
+	private void assertConflictingOriginsRequestSuppressed()
+	{
+		if (!suppressed)
+			throw new AssertionError("Request was not suppressed");
+	}
+
+	/**
+	 * Asserts that the origins were checked, found conflicting and had an action "ALLOWED".
+	 */
+	private void assertConflictingOriginsRequestAllowed()
+	{
+		if (!allowed)
+			throw new AssertionError("Request was not allowed");
+	}
+
+	/**
+	 * Asserts that the origins were checked and found non-conflicting.
+	 */
+	private void assertOriginsCheckedButNotConflicting()
+	{
+		if (aborted)
+			throw new AssertionError("Origin was checked and aborted");
+		if (suppressed)
+			throw new AssertionError("Origin was checked and suppressed");
+		if (allowed)
+			throw new AssertionError("Origin was checked and allowed");
+		if (whitelisted)
+			throw new AssertionError("Origin was whitelisted");
+		if (!matched)
+			throw new AssertionError("Origin was not checked");
+	}
+
+	/**
+	 * Asserts that no check was performed at all.
+	 */
+	private void assertOriginsNotChecked()
+	{
+		if (aborted)
+			throw new AssertionError("Request was checked and aborted");
+		if (suppressed)
+			throw new AssertionError("Request was checked and suppressed");
+		if (allowed)
+			throw new AssertionError("Request was checked and allowed");
+		if (whitelisted)
+			throw new AssertionError("Origin was whitelisted");
+		if (matched)
+			throw new AssertionError("Origin was checked and matched");
+	}
+
+	private final class MockCsrfPreventionRequestCycleListener extends
+		CsrfPreventionRequestCycleListener
+	{
+		@Override
+		protected boolean isEnabled()
+		{
+			return csrfEnabled;
+		}
+
+		@Override
+		protected boolean isChecked(IRequestHandler handler)
+		{
+			if (customRequestHandlerCheck != null)
+				return customRequestHandlerCheck.apply(handler);
+
+			return super.isChecked(handler);
+		}
+
+		@Override
+		protected boolean isChecked(IRequestablePage targetedPage)
+		{
+			return checkPage;
+		}
+
+		@Override
+		protected void onAborted(HttpServletRequest containerRequest, String origin,
+			IRequestablePage page)
+		{
+			aborted = true;
+			if (abortHandler != null)
+				abortHandler.run();
+		}
+
+		@Override
+		protected void onAllowed(HttpServletRequest containerRequest, String origin,
+			IRequestablePage page)
+		{
+			allowed = true;
+			if (allowHandler != null)
+				allowHandler.run();
+		}
+
+		@Override
+		protected void onSuppressed(HttpServletRequest containerRequest, String origin,
+			IRequestablePage page)
+		{
+			suppressed = true;
+			if (suppressHandler != null)
+				suppressHandler.run();
+		}
+
+		@Override
+		protected void onMatchingOrigin(HttpServletRequest containerRequest, String origin,
+			IRequestablePage page)
+		{
+			matched = true;
+			if (matchedHandler != null)
+				matchedHandler.run();
+		}
+
+		@Override
+		protected void onWhitelisted(HttpServletRequest containerRequest, String origin,
+			IRequestablePage page)
+		{
+			whitelisted = true;
+			if (whitelistHandler != null)
+				whitelistHandler.run();
+		}
+	}
+
+	// Remove when migration to Java 8 is completed
+	private interface Predicate<T>
+	{
+		boolean apply(T t);
+	}
+}

http://git-wip-us.apache.org/repos/asf/wicket/blob/a6150ae4/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.html
----------------------------------------------------------------------
diff --git a/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.html b/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.html
new file mode 100644
index 0000000..f081737
--- /dev/null
+++ b/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html xmlns:wicket>
+<body>
+<h1>Third page</h1>
+<a href="#" wicket:id="link">AJAX Fallback Link</a>
+<form wicket:id="form">
+<input type="submit">
+</form>
+</body>
+</html>

http://git-wip-us.apache.org/repos/asf/wicket/blob/a6150ae4/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.java
----------------------------------------------------------------------
diff --git a/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.java b/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.java
new file mode 100644
index 0000000..28ef342
--- /dev/null
+++ b/wicket-core/src/test/java/org/apache/wicket/protocol/http/ThirdPage.java
@@ -0,0 +1,44 @@
+/*
+ * 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 org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.AjaxLink;
+import org.apache.wicket.markup.html.WebPage;
+import org.apache.wicket.markup.html.form.Form;
+
+/** */
+public class ThirdPage extends WebPage
+{
+	private static final long serialVersionUID = 1L;
+
+	/** */
+	public ThirdPage()
+	{
+		add(new AjaxLink<Void>("link")
+		{
+			private static final long serialVersionUID = 1L;
+
+			@Override
+			public void onClick(AjaxRequestTarget target)
+			{
+				setResponsePage(FirstPage.class);
+			}
+		});
+		add(new Form<Void>("form"));
+	}
+}