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 static java.util.Arrays.asList;
020
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Set;
026
027import jakarta.servlet.http.HttpServletRequest;
028import jakarta.servlet.http.HttpServletResponse;
029
030import org.apache.wicket.RestartResponseException;
031import org.apache.wicket.core.request.handler.IPageRequestHandler;
032import org.apache.wicket.core.request.handler.RenderPageRequestHandler;
033import org.apache.wicket.protocol.http.IResourceIsolationPolicy.ResourceIsolationOutcome;
034import org.apache.wicket.request.IRequestHandler;
035import org.apache.wicket.request.IRequestHandlerDelegate;
036import org.apache.wicket.request.component.IRequestablePage;
037import org.apache.wicket.request.cycle.IRequestCycleListener;
038import org.apache.wicket.request.cycle.RequestCycle;
039import org.apache.wicket.request.http.WebResponse;
040import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException;
041import org.apache.wicket.util.lang.Classes;
042import org.apache.wicket.util.string.Strings;
043import org.slf4j.Logger;
044import org.slf4j.LoggerFactory;
045
046/**
047 * This {@link RequestCycle} listener ensures resource isolation, adding a layer of protection for
048 * modern browsers that prevent <em>Cross-Site Request Forgery</em> attacks.
049 * <p>
050 * It uses the {@link FetchMetadataResourceIsolationPolicy} and
051 * {@link OriginResourceIsolationPolicy} by default and can be customized with additional
052 * {@link IResourceIsolationPolicy}s.
053 * <p>
054 * URL paths that are intended to be used cross-site can be excempted from these policies.
055 * <p>
056 * Learn more about Fetch Metadata and resource isolation at
057 * <a href="https://web.dev/fetch-metadata">https://web.dev/fetch-metadata/</a>
058 *
059 * @author Santiago Diaz - saldiaz@google.com
060 * @author Ecenaz Jen Ozmen - ecenazo@google.com
061 */
062public class ResourceIsolationRequestCycleListener implements IRequestCycleListener
063{
064        private static final Logger log = LoggerFactory
065                .getLogger(ResourceIsolationRequestCycleListener.class);
066
067        public static final String ERROR_MESSAGE = "The request was blocked by a resource isolation policy";
068
069        /**
070         * The action to perform when the outcome of the resource isolation policy is DISALLOWED or
071         * UNKNOWN. 
072         */
073        public enum CsrfAction
074        {
075                /** Aborts the request and throws an exception when a CSRF request is detected. */
076                ABORT {
077                        @Override
078                        public String toString()
079                        {
080                                return "aborted";
081                        }
082                        
083                        @Override
084                        void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request,
085                                IRequestablePage page)
086                        {
087                                listener.abortHandler(request, page);
088                        }
089                },
090
091                /**
092                 * Ignores the action of a CSRF request, and just renders the page it was targeted against.
093                 */
094                SUPPRESS {
095                        @Override
096                        public String toString()
097                        {
098                                return "suppressed";
099                        }
100                        
101                        @Override
102                        void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request,
103                                IRequestablePage page)
104                        {
105                                listener.suppressHandler(request, page);
106                        }
107                },
108
109                /** Detects a CSRF request, logs it and allows the request to continue. */
110                ALLOW {
111                        @Override
112                        public String toString()
113                        {
114                                return "allowed";
115                        }
116                        
117                        @Override
118                        void apply(ResourceIsolationRequestCycleListener listener, HttpServletRequest request,
119                                IRequestablePage page)
120                        {
121                                listener.allowHandler(request, page);
122                        }
123                };
124
125                abstract void apply(ResourceIsolationRequestCycleListener listener,
126                        HttpServletRequest request, IRequestablePage page);
127        }
128
129        /**
130         * Action to perform when no resource isolation policy can determine the validity of the
131         * request.
132         */
133        private CsrfAction unknownOutcomeAction = CsrfAction.ABORT;
134
135        /**
136         * Action to perform when {@link ResourceIsolationOutcome#DISALLOWED} is reported by a
137         * resource isolation policy.
138         */
139        private CsrfAction disallowedOutcomeAction = CsrfAction.ABORT;
140
141        /**
142         * The error code to report when the action to take for a CSRF request is
143         * {@link CsrfAction#ABORT}. Default {@code 403 FORBIDDEN}.
144         */
145        private int errorCode = jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
146
147        /**
148         * The error message to report when the action to take for a CSRF request is {@code ERROR}.
149         * Default {@code "The request was blocked by a resource isolation policy"}.
150         */
151        private String errorMessage = ERROR_MESSAGE;
152
153        private final Set<String> exemptedPaths = new HashSet<>();
154        
155        private final List<IResourceIsolationPolicy> resourceIsolationPolicies = new ArrayList<>();
156
157        /**
158         * Create a new listener with the given policies. If no policies are given,
159         * {@link FetchMetadataResourceIsolationPolicy} and {@link OriginResourceIsolationPolicy} will
160         * be used. The policies are checked in order. The first outcome that's not
161         * {@link ResourceIsolationOutcome#UNKNOWN} will be used.
162         * 
163         * @param policies
164         *            the policies to check requests against.
165         */
166        public ResourceIsolationRequestCycleListener(IResourceIsolationPolicy... policies)
167        {
168                this.resourceIsolationPolicies.addAll(asList(policies));
169                if (policies.length == 0)
170                {
171                        this.resourceIsolationPolicies.addAll(asList(new FetchMetadataResourceIsolationPolicy(),
172                                new OriginResourceIsolationPolicy()));
173                }
174        }
175
176        /**
177         * Sets the action when none of the resource isolation policies can come to an outcome. Default
178         * {@code ABORT}.
179         *
180         * @param action
181         *            the alternate action
182         *
183         * @return this (for chaining)
184         */
185        public ResourceIsolationRequestCycleListener setUnknownOutcomeAction(CsrfAction action)
186        {
187                this.unknownOutcomeAction = action;
188                return this;
189        }
190
191        /**
192         * Sets the action when a request is disallowed by a resource isolation policy. Default is
193         * {@code ABORT}.
194         *
195         * @param action
196         *            the alternate action
197         *
198         * @return this
199         */
200        public ResourceIsolationRequestCycleListener setDisallowedOutcomeAction(CsrfAction action)
201        {
202                this.disallowedOutcomeAction = action;
203                return this;
204        }
205
206        /**
207         * Modifies the HTTP error code in the exception when a disallowed request is detected.
208         *
209         * @param errorCode
210         *            the alternate HTTP error code, default {@code 403 FORBIDDEN}
211         *
212         * @return this
213         */
214        public ResourceIsolationRequestCycleListener setErrorCode(int errorCode)
215        {
216                this.errorCode = errorCode;
217                return this;
218        }
219
220        /**
221         * Modifies the HTTP message in the exception when a disallowed request is detected.
222         *
223         * @param errorMessage
224         *            the alternate message
225         *
226         * @return this
227         */
228        public ResourceIsolationRequestCycleListener setErrorMessage(String errorMessage)
229        {
230                this.errorMessage = errorMessage;
231                return this;
232        }
233
234        public void addExemptedPaths(String... exemptions)
235        {
236                Arrays.stream(exemptions).filter(e -> !Strings.isEmpty(e)).forEach(exemptedPaths::add);
237        }
238
239        @Override
240        public void onBeginRequest(RequestCycle cycle)
241        {
242                HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest()
243                        .getContainerRequest();
244
245                log.debug("Processing request to: {}", containerRequest.getPathInfo());
246        }
247
248        /**
249         * Dynamic override for enabling/disabling the CSRF detection. Might be handy for specific
250         * tenants in a multi-tenant application. When false, the CSRF detection is not performed for
251         * the running request. Default {@code true}
252         *
253         * @return {@code true} when the CSRF checks need to be performed.
254         */
255        protected boolean isEnabled()
256        {
257                return true;
258        }
259
260        /**
261         * Override to limit whether the request to the specific page should be checked for a possible
262         * CSRF attack.
263         *
264         * @param targetedPage
265         *            the page that is the target for the action
266         * @return {@code true} when the request to the page should be checked for CSRF issues.
267         */
268        protected boolean isChecked(IRequestablePage targetedPage)
269        {
270                return true;
271        }
272
273        /**
274         * Override to change the request handler types that are checked. Currently only action handlers
275         * (form submits, link clicks, AJAX events) are checked.
276         *
277         * @param handler
278         *            the handler that is currently processing
279         * @return true when resource isolation should be checked for this {@code handler}
280         */
281        protected boolean isChecked(IRequestHandler handler)
282        {
283                return handler instanceof IPageRequestHandler
284                        && !(handler instanceof RenderPageRequestHandler);
285        }
286
287        @Override
288        public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler)
289        {
290                if (!isEnabled())
291                {
292                        log.trace("CSRF listener is disabled, no checks performed");
293                        return;
294                }
295
296                handler = unwrap(handler);
297                if (isChecked(handler))
298                {
299                        IPageRequestHandler pageRequestHandler = (IPageRequestHandler)handler;
300                        IRequestablePage targetedPage = pageRequestHandler.getPage();
301                        HttpServletRequest containerRequest = (HttpServletRequest)cycle.getRequest()
302                                .getContainerRequest();
303
304                        if (!isChecked(targetedPage))
305                        {
306                                if (log.isDebugEnabled())
307                                {
308                                        log.debug("Targeted page {} was opted out of resource isolation, allowed",
309                                                targetedPage.getClass().getName());
310                                }
311                                return;
312                        }
313
314                        String pathInfo = containerRequest.getPathInfo();
315                        if (exemptedPaths.contains(pathInfo))
316                        {
317                                if (log.isDebugEnabled())
318                                {
319                                        log.debug("Allowing request to {} because it matches an exempted path",
320                                                new Object[] { pathInfo });
321                                }
322                                return;
323                        }
324
325                        for (IResourceIsolationPolicy policy : resourceIsolationPolicies)
326                        {
327                                ResourceIsolationOutcome outcome = policy
328                                        .isRequestAllowed(containerRequest, targetedPage);
329                                if (ResourceIsolationOutcome.DISALLOWED.equals(outcome))
330                                {
331                                        log.debug("Isolation policy {} has rejected a request to {}",
332                                                Classes.simpleName(policy.getClass()), pathInfo);
333                                        disallowedOutcomeAction.apply(this, containerRequest, targetedPage);
334                                        return;
335                                }
336                                else if (ResourceIsolationOutcome.ALLOWED.equals(outcome))
337                                {
338                                        return;
339                                }
340                        }
341                        unknownOutcomeAction.apply(this, containerRequest, targetedPage);
342                }
343                else
344                {
345                        if (log.isTraceEnabled())
346                                log.trace("Resolved handler {} is not checked, no CSRF check performed",
347                                        handler.getClass().getName());
348                }
349        }
350
351        /**
352         * Allow isolation policy to add headers.
353         * 
354         * @see IResourceIsolationPolicy#setHeaders(HttpServletResponse)
355         */
356        @Override
357        public void onEndRequest(RequestCycle cycle)
358        {
359                if (cycle.getResponse() instanceof WebResponse)
360                {
361                        WebResponse webResponse = (WebResponse)cycle.getResponse();
362                        if (webResponse.isHeaderSupported())
363                        {
364                                for (IResourceIsolationPolicy resourceIsolationPolicy : resourceIsolationPolicies)
365                                {
366                                        resourceIsolationPolicy
367                                                .setHeaders((HttpServletResponse)webResponse.getContainerResponse());
368                                }
369                        }
370                }
371        }
372
373        /**
374         * Allow the execution of the listener in the request because the outcome results in
375         * {@link CsrfAction#ALLOW}.
376         *
377         * @param request
378         *            the request
379         * @param page
380         *            the page that is targeted with this request
381         */
382        protected void allowHandler(HttpServletRequest request, IRequestablePage page)
383        {
384                log.info("Possible CSRF attack, request URL: {}, action: allowed", request.getRequestURL());
385        }
386
387        /**
388         * Suppress the execution of the listener in the request because the outcome results in
389         * {@link CsrfAction#SUPPRESS}.
390         *
391         * @param request
392         *            the request
393         * @param page
394         *            the page that is targeted with this request
395         */
396        protected void suppressHandler(HttpServletRequest request, IRequestablePage page)
397        {
398                log.info("Possible CSRF attack, request URL: {}, action: suppressed",
399                        request.getRequestURL());
400                throw new RestartResponseException(page);
401        }
402
403        /**
404         * Abort the request because the outcome results in {@link CsrfAction#ABORT}.
405         *
406         * @param request
407         *            the request
408         * @param page
409         *            the page that is targeted with this request
410         */
411        protected void abortHandler(HttpServletRequest request, IRequestablePage page)
412        {
413                log.info("Possible CSRF attack, request URL: {}, action: aborted with error {} {}",
414                        request.getRequestURL(), errorCode, errorMessage);
415                throw new AbortWithHttpErrorCodeException(errorCode, errorMessage);
416        }
417
418        private static IRequestHandler unwrap(IRequestHandler handler)
419        {
420                while (handler instanceof IRequestHandlerDelegate)
421                {
422                        handler = ((IRequestHandlerDelegate)handler).getDelegateHandler();
423                }
424                return handler;
425        }
426}