001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.wicket.protocol.http;
018
019import java.util.Locale;
020import javax.servlet.http.HttpServletRequest;
021import org.apache.wicket.RestartResponseException;
022import org.apache.wicket.core.request.handler.IPageRequestHandler;
023import org.apache.wicket.core.request.handler.RenderPageRequestHandler;
024import org.apache.wicket.request.IRequestHandler;
025import org.apache.wicket.request.IRequestHandlerDelegate;
026import org.apache.wicket.request.component.IRequestablePage;
027import org.apache.wicket.request.cycle.IRequestCycleListener;
028import org.apache.wicket.request.cycle.RequestCycle;
029import org.apache.wicket.request.http.WebRequest;
030import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException;
031import org.apache.wicket.util.string.Strings;
032import org.slf4j.Logger;
033import org.slf4j.LoggerFactory;
034
035/**
036 * Prevents CSRF attacks on Wicket components by checking the {@code Origin} and {@code Referer}
037 * HTTP headers for cross domain requests. By default only checks requests that try to perform an
038 * action on a component, such as a form submit, or link click.
039 * <p>
040 * Installation
041 * <p>
042 * You can enable this CSRF prevention filter by adding it to the request cycle listeners in your
043 * {@link WebApplication#init() application's init method}:
044 *
045 * <pre>
046 * &#064;Override
047 * protected void init()
048 * {
049 *      // ...
050 *      getRequestCycleListeners().add(new CsrfPreventionRequestCycleListener());
051 *      // ...
052 * }
053 * </pre>
054 * <p>
055 * Configuration
056 * <p>
057 * When the {@code Origin} or {@code Referer} HTTP header is present but doesn't match the requested
058 * URL this listener will by default throw a HTTP error ( {@code 400 BAD REQUEST}) and abort the
059 * request. You can {@link #setConflictingOriginAction(CsrfAction) configure} this specific action.
060 * <p>
061 * A missing {@code Origin} and {@code Referer} HTTP header is handled as if it were a bad request
062 * and rejected. You can {@link #setNoOriginAction(CsrfAction) configure the specific action} to a
063 * different value, suppressing or allowing the request when the HTTP headers are missing.
064 * <p>
065 * When the {@code Origin} HTTP header is present and has the value {@code null} it is considered to
066 * be from a "privacy-sensitive" context and will trigger the no origin action. You can customize
067 * what happens in those actions by overriding the respective {@code onXXXX} methods.
068 * <p>
069 * When you want to accept certain cross domain request from a range of hosts, you can
070 * {@link #addAcceptedOrigin(String) whitelist those domains}.
071 * <p>
072 * You can {@link #isEnabled() enable or disable} this listener by overriding {@link #isEnabled()}.
073 * <p>
074 * You can {@link #isChecked(IRequestablePage) customize} whether a particular page should be
075 * checked for CSRF requests. For example you can skip checking pages that have a
076 * {@code @NoCsrfCheck} annotation, or only those pages that extend your base secure page class. For
077 * example:
078 *
079 * <pre>
080 * &#064;Override
081 * protected boolean isChecked(IRequestablePage requestedPage)
082 * {
083 *      return requestedPage instanceof SecurePage;
084 * }
085 * </pre>
086 * <p>
087 * You can also tweak the request handlers that are checked. The CSRF prevention request cycle
088 * listener checks only action handlers, not render handlers. Override
089 * {@link #isChecked(IRequestHandler)} to customize this behavior.
090 * </p>
091 * <p>
092 * You can customize the default actions that are performed by overriding the event handlers for
093 * them:
094 * <ul>
095 * <li>{@link #onWhitelisted(HttpServletRequest, String, IRequestablePage)} when an origin was
096 * whitelisted</li>
097 * <li>{@link #onMatchingOrigin(HttpServletRequest, String, IRequestablePage)} when an origin was
098 * matching</li>
099 * <li>{@link #onAborted(HttpServletRequest, String, IRequestablePage)} when an origin was in
100 * conflict and the request should be aborted</li>
101 * <li>{@link #onAllowed(HttpServletRequest, String, IRequestablePage)} when an origin was in
102 * conflict and the request should be allowed</li>
103 * <li>{@link #onSuppressed(HttpServletRequest, String, IRequestablePage)} when an origin was in
104 * conflict and the request should be suppressed</li>
105 * </ul>
106 *
107 * @deprecated Use {@link FetchMetadataResourceIsolationPolicy} instead
108 */
109@Deprecated(since = "9.1.0")
110public class CsrfPreventionRequestCycleListener extends OriginResourceIsolationPolicy implements IRequestCycleListener
111{
112        private static final Logger log = LoggerFactory
113                        .getLogger(CsrfPreventionRequestCycleListener.class);
114
115        /**
116         * The action to perform when a missing or conflicting source URI is detected.
117         */
118        public enum CsrfAction {
119                /** Aborts the request and throws an exception when a CSRF request is detected. */
120                ABORT {
121                        @Override
122                        public String toString()
123                        {
124                                return "aborted";
125                        }
126                },
127
128                /**
129                 * Ignores the action of a CSRF request, and just renders the page it was targeted against.
130                 */
131                SUPPRESS {
132                        @Override
133                        public String toString()
134                        {
135                                return "suppressed";
136                        }
137                },
138
139                /** Detects a CSRF request, logs it and allows the request to continue. */
140                ALLOW {
141                        @Override
142                        public String toString()
143                        {
144                                return "allowed";
145                        }
146                },
147        }
148
149        /**
150         * Action to perform when no Origin header is present in the request.
151         */
152        private CsrfAction noOriginAction = CsrfAction.ABORT;
153
154        /**
155         * Action to perform when a conflicting Origin header is found.
156         */
157        private CsrfAction conflictingOriginAction = CsrfAction.ABORT;
158
159        /**
160         * The error code to report when the action to take for a CSRF request is
161         * {@link CsrfAction#ABORT}. Default {@code 400 BAD REQUEST}.
162         */
163        private int errorCode = javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
164
165        /**
166         * The error message to report when the action to take for a CSRF request is {@code ERROR}.
167         * Default {@code "Origin does not correspond to request"}.
168         */
169        private String errorMessage = "Origin does not correspond to request";
170
171        /**
172         * TODO remove in Wicket 10
173         */
174        @Override
175        public CsrfPreventionRequestCycleListener addAcceptedOrigin(String acceptedOrigin)
176        {
177                super.addAcceptedOrigin(acceptedOrigin);
178                
179                return this;
180        }
181
182        /**
183         * Sets the action when no Origin header is present in the request. Default {@code ALLOW}.
184         *
185         * @param action
186         *            the alternate action
187         *
188         * @return this (for chaining)
189         */
190        public CsrfPreventionRequestCycleListener setNoOriginAction(CsrfAction action)
191        {
192                this.noOriginAction = action;
193                return this;
194        }
195
196        /**
197         * Sets the action when a conflicting Origin header is detected. Default is {@code ERROR}.
198         *
199         * @param action
200         *            the alternate action
201         *
202         * @return this
203         */
204        public CsrfPreventionRequestCycleListener setConflictingOriginAction(CsrfAction action)
205        {
206                this.conflictingOriginAction = action;
207                return this;
208        }
209
210        /**
211         * Modifies the HTTP error code in the exception when a conflicting Origin header is detected.
212         *
213         * @param errorCode
214         *            the alternate HTTP error code, default {@code 400 BAD REQUEST}
215         *
216         * @return this
217         */
218        public CsrfPreventionRequestCycleListener setErrorCode(int errorCode)
219        {
220                this.errorCode = errorCode;
221                return this;
222        }
223
224        /**
225         * Modifies the HTTP message in the exception when a conflicting Origin header is detected.
226         *
227         * @param errorMessage
228         *            the alternate message
229         *
230         * @return this
231         */
232        public CsrfPreventionRequestCycleListener setErrorMessage(String errorMessage)
233        {
234                this.errorMessage = errorMessage;
235                return this;
236        }
237
238        @Override
239        public void onBeginRequest(RequestCycle cycle)
240        {
241                if (log.isDebugEnabled())
242                {
243                        HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest()
244                                        .getContainerRequest();
245                        log.debug("Request Source URI: {}", getSourceUri(containerRequest));
246                }
247        }
248
249        /**
250         * Dynamic override for enabling/disabling the CSRF detection. Might be handy for specific
251         * tenants in a multi-tenant application. When false, the CSRF detection is not performed for
252         * the running request. Default {@code true}
253         *
254         * @return {@code true} when the CSRF checks need to be performed.
255         */
256        protected boolean isEnabled()
257        {
258                return true;
259        }
260
261        /**
262         * Override to limit whether the request to the specific page should be checked for a possible
263         * CSRF attack.
264         *
265         * @param targetedPage
266         *            the page that is the target for the action
267         * @return {@code true} when the request to the page should be checked for CSRF issues.
268         */
269        protected boolean isChecked(IRequestablePage targetedPage)
270        {
271                return true;
272        }
273
274        /**
275         * Override to change the request handler types that are checked. Currently only action handlers
276         * (form submits, link clicks, AJAX events) are checked for a matching Origin HTTP header.
277         *
278         * @param handler
279         *            the handler that is currently processing
280         * @return true when the Origin HTTP header should be checked for this {@code handler}
281         */
282        protected boolean isChecked(IRequestHandler handler)
283        {
284                return handler instanceof IPageRequestHandler &&
285                                !(handler instanceof RenderPageRequestHandler);
286        }
287
288        /**
289         * Unwraps the handler if it is a {@code IRequestHandlerDelegate} down to the deepest nested
290         * handler.
291         *
292         * @param handler
293         *            The handler to unwrap
294         * @return the deepest handler that does not implement {@code IRequestHandlerDelegate}
295         */
296        protected IRequestHandler unwrap(IRequestHandler handler)
297        {
298                while (handler instanceof IRequestHandlerDelegate)
299                        handler = ((IRequestHandlerDelegate)handler).getDelegateHandler();
300                return handler;
301        }
302
303        @Override
304        public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler)
305        {
306                if (!isEnabled())
307                {
308                        log.trace("CSRF listener is disabled, no checks performed");
309                        return;
310                }
311
312                handler = unwrap(handler);
313
314                // check if the request is targeted at a page
315                if (isChecked(handler))
316                {
317                        IPageRequestHandler prh = (IPageRequestHandler)handler;
318                        IRequestablePage targetedPage = prh.getPage();
319                        HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest()
320                                        .getContainerRequest();
321                        String sourceUri = getSourceUri(containerRequest);
322
323                        // Check if the page should be CSRF protected
324                        if (isChecked(targetedPage))
325                        {
326                                // if so check the Origin HTTP header
327                                checkRequest(containerRequest, sourceUri, targetedPage);
328                        }
329                        else
330                        {
331                                if (log.isDebugEnabled())
332                                {
333                                        log.debug("Targeted page {} was opted out of the CSRF origin checks, allowed",
334                                                        targetedPage.getClass().getName());
335                                }
336                                allowHandler(containerRequest, sourceUri, targetedPage);
337                        }
338                }
339                else
340                {
341                        if (log.isTraceEnabled())
342                                log.trace(
343                                                "Resolved handler {} doesn't target an action on a page, no CSRF check performed",
344                                                handler.getClass().getName());
345                }
346        }
347
348        /**
349         * Resolves the source URI from the request headers ({@code Origin} or {@code Referer}).
350         *
351         * @param containerRequest
352         *            the current container request
353         * @return the normalized source URI.
354         */
355        protected String getSourceUri(HttpServletRequest containerRequest)
356        {
357                String sourceUri = containerRequest.getHeader(WebRequest.HEADER_ORIGIN);
358                if (Strings.isEmpty(sourceUri))
359                {
360                        sourceUri = containerRequest.getHeader(WebRequest.HEADER_REFERER);
361                }
362                return normalizeUri(sourceUri);
363        }
364
365        /**
366         * Performs the check of the {@code Origin} or {@code Referer} header that is targeted at the
367         * {@code page}.
368         *
369         * @param request
370         *            the current container request
371         * @param sourceUri
372         *            the source URI
373         * @param page
374         *            the page that is the target of the request
375         */
376        protected void checkRequest(HttpServletRequest request, String sourceUri, IRequestablePage page)
377        {
378                if (sourceUri == null || sourceUri.isEmpty())
379                {
380                        log.debug("Source URI not present in request, {}", noOriginAction);
381                        switch (noOriginAction)
382                        {
383                                case ALLOW :
384                                        allowHandler(request, sourceUri, page);
385                                        break;
386                                case SUPPRESS :
387                                        suppressHandler(request, sourceUri, page);
388                                        break;
389                                case ABORT :
390                                        abortHandler(request, sourceUri, page);
391                                        break;
392                        }
393                        return;
394                }
395                sourceUri = sourceUri.toLowerCase(Locale.ROOT);
396
397                // if the origin is a know and trusted origin, don't check any further but allow the request
398                if (isWhitelistedHost(sourceUri))
399                {
400                        whitelistedHandler(request, sourceUri, page);
401                        return;
402                }
403
404                // check if the origin HTTP header matches the request URI
405                if (!isLocalOrigin(request, sourceUri))
406                {
407                        log.debug("Source URI conflicts with request origin, {}", conflictingOriginAction);
408                        switch (conflictingOriginAction)
409                        {
410                                case ALLOW :
411                                        allowHandler(request, sourceUri, page);
412                                        break;
413                                case SUPPRESS :
414                                        suppressHandler(request, sourceUri, page);
415                                        break;
416                                case ABORT :
417                                        abortHandler(request, sourceUri, page);
418                                        break;
419                        }
420                }
421                else
422                {
423                        matchingOrigin(request, sourceUri, page);
424                }
425        }
426
427        /**
428         * Handles the case where an origin is in the whitelist. Default action is to allow the
429         * whitelisted origin.
430         *
431         * @param request
432         *            the request
433         * @param origin
434         *            the contents of the {@code Origin} HTTP header
435         * @param page
436         *            the page that is targeted with this request
437         */
438        protected void whitelistedHandler(HttpServletRequest request, String origin,
439                        IRequestablePage page)
440        {
441                onWhitelisted(request, origin, page);
442                if (log.isDebugEnabled())
443                {
444                        log.debug("CSRF Origin {} was whitelisted, allowed for page {}", origin,
445                                        page.getClass().getName());
446                }
447        }
448
449        /**
450         * Called when the origin was available in the whitelist. Override this method to implement your
451         * own custom action.
452         *
453         * @param request
454         *            the request
455         * @param origin
456         *            the contents of the {@code Origin} HTTP header
457         * @param page
458         *            the page that is targeted with this request
459         */
460        protected void onWhitelisted(HttpServletRequest request, String origin, IRequestablePage page)
461        {
462        }
463
464        /**
465         * Handles the case where an origin was checked and matched the request origin. Default action
466         * is to allow the whitelisted origin.
467         *
468         * @param request
469         *            the request
470         * @param origin
471         *            the contents of the {@code Origin} HTTP header
472         * @param page
473         *            the page that is targeted with this request
474         */
475        protected void matchingOrigin(HttpServletRequest request, String origin,
476                        IRequestablePage page)
477        {
478                onMatchingOrigin(request, origin, page);
479                if (log.isDebugEnabled())
480                {
481                        log.debug("CSRF Origin {} matched requested resource, allowed for page {}", origin,
482                                        page.getClass().getName());
483                }
484        }
485
486        /**
487         * Called when the origin HTTP header matched the request. Override this method to implement
488         * your own custom action.
489         *
490         * @param request
491         *            the request
492         * @param origin
493         *            the contents of the {@code Origin} HTTP header
494         * @param page
495         *            the page that is targeted with this request
496         */
497        protected void onMatchingOrigin(HttpServletRequest request, String origin,
498                        IRequestablePage page)
499        {
500        }
501
502        /**
503         * Handles the case where an Origin HTTP header was not present or did not match the request
504         * origin, and the corresponding action ({@link #noOriginAction} or
505         * {@link #conflictingOriginAction}) is set to {@code ALLOW}.
506         *
507         * @param request
508         *            the request
509         * @param origin
510         *            the contents of the {@code Origin} HTTP header, may be {@code null} or empty
511         * @param page
512         *            the page that is targeted with this request
513         */
514        protected void allowHandler(HttpServletRequest request, String origin,
515                        IRequestablePage page)
516        {
517                onAllowed(request, origin, page);
518                log.info("Possible CSRF attack, request URL: {}, Origin: {}, action: allowed",
519                                request.getRequestURL(), origin);
520        }
521
522        /**
523         * Override this method to customize the case where an Origin HTTP header was not present or did
524         * not match the request origin, and the corresponding action ({@link #noOriginAction} or
525         * {@link #conflictingOriginAction}) is set to {@code ALLOW}.
526         *
527         * @param request
528         *            the request
529         * @param origin
530         *            the contents of the {@code Origin} HTTP header, may be {@code null} or empty
531         * @param page
532         *            the page that is targeted with this request
533         */
534        protected void onAllowed(HttpServletRequest request, String origin, IRequestablePage page)
535        {
536        }
537
538        /**
539         * Handles the case where an Origin HTTP header was not present or did not match the request
540         * origin, and the corresponding action ({@link #noOriginAction} or
541         * {@link #conflictingOriginAction}) is set to {@code SUPPRESS}.
542         *
543         * @param request
544         *            the request
545         * @param origin
546         *            the contents of the {@code Origin} HTTP header, may be {@code null} or empty
547         * @param page
548         *            the page that is targeted with this request
549         */
550        protected void suppressHandler(HttpServletRequest request, String origin,
551                        IRequestablePage page)
552        {
553                onSuppressed(request, origin, page);
554                log.info("Possible CSRF attack, request URL: {}, Origin: {}, action: suppressed",
555                                request.getRequestURL(), origin);
556                throw new RestartResponseException(page);
557        }
558
559        /**
560         * Override this method to customize the case where an Origin HTTP header was not present or did
561         * not match the request origin, and the corresponding action ({@link #noOriginAction} or
562         * {@link #conflictingOriginAction}) is set to {@code SUPPRESSED}.
563         *
564         * @param request
565         *            the request
566         * @param origin
567         *            the contents of the {@code Origin} HTTP header, may be {@code null} or empty
568         * @param page
569         *            the page that is targeted with this request
570         */
571        protected void onSuppressed(HttpServletRequest request, String origin, IRequestablePage page)
572        {
573        }
574
575        /**
576         * Handles the case where an Origin HTTP header was not present or did not match the request
577         * origin, and the corresponding action ({@link #noOriginAction} or
578         * {@link #conflictingOriginAction}) is set to {@code ABORT}.
579         *
580         * @param request
581         *            the request
582         * @param origin
583         *            the contents of the {@code Origin} HTTP header, may be {@code null} or empty
584         * @param page
585         *            the page that is targeted with this request
586         */
587        protected void abortHandler(HttpServletRequest request, String origin,
588                        IRequestablePage page)
589        {
590                onAborted(request, origin, page);
591                log.info(
592                        "Possible CSRF attack, request URL: {}, Origin: {}, action: aborted with error {} {}",
593                        request.getRequestURL(), origin, errorCode, errorMessage);
594                throw new AbortWithHttpErrorCodeException(errorCode, errorMessage);
595        }
596
597        /**
598         * Override this method to customize the case where an Origin HTTP header was not present or did
599         * not match the request origin, and the corresponding action ({@link #noOriginAction} or
600         * {@link #conflictingOriginAction}) is set to {@code ABORTED}.
601         *
602         * @param request
603         *            the request
604         * @param origin
605         *            the contents of the {@code Origin} HTTP header, may be {@code null} or empty
606         * @param page
607         *            the page that is targeted with this request
608         */
609        protected void onAborted(HttpServletRequest request, String origin, IRequestablePage page)
610        {
611        }
612}