| /* |
| * 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 jakarta.servlet.http.HttpServletRequest; |
| |
| import org.apache.wicket.request.component.IRequestablePage; |
| import org.apache.wicket.request.http.WebRequest; |
| import org.apache.wicket.util.lang.Checks; |
| import org.apache.wicket.util.string.Strings; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| /** |
| * {@link IResourceIsolationPolicy} based on {@link WebRequest#HEADER_ORIGIN} and |
| * {@link WebRequest#HEADER_REFERER} headers. |
| * <p> |
| * This origin-based listener can be used in combination with the |
| * {@link ResourceIsolationRequestCycleListener} to add support for legacy browsers that don't send |
| * Sec-Fetch-* headers yet. |
| * |
| */ |
| public class OriginResourceIsolationPolicy implements IResourceIsolationPolicy |
| { |
| private static final Logger log = LoggerFactory.getLogger(OriginResourceIsolationPolicy.class); |
| |
| /** |
| * A white list of accepted origins (host names/domain names) presented as |
| * <domainname>.<TLD>. The domain part can contain subdomains. |
| */ |
| private Collection<String> acceptedOrigins = new ArrayList<>(); |
| |
| /** |
| * Adds an origin (host name/domain name) to the white list. An origin is in the form of |
| * <domainname>.<TLD>, 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 OriginResourceIsolationPolicy 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; |
| } |
| |
| /** |
| * @return whether the request is allowed based on its origin |
| */ |
| @Override |
| public ResourceIsolationOutcome isRequestAllowed(HttpServletRequest request, |
| IRequestablePage targetPage) |
| { |
| String sourceUri = getSourceUri(request); |
| |
| if (sourceUri == null || sourceUri.isEmpty()) |
| { |
| log.debug("Source URI not present in request to {}", request.getPathInfo()); |
| return ResourceIsolationOutcome.UNKNOWN; |
| } |
| sourceUri = sourceUri.toLowerCase(Locale.ROOT); |
| |
| // if the origin is a know and trusted origin, don't check any further but allow the request |
| if (isWhitelistedHost(sourceUri)) |
| { |
| return ResourceIsolationOutcome.ALLOWED; |
| } |
| |
| // check if the origin HTTP header matches the request URI |
| if (!isLocalOrigin(request, sourceUri)) |
| { |
| log.debug("Source URI conflicts with request origin"); |
| return ResourceIsolationOutcome.DISALLOWED; |
| } |
| |
| return ResourceIsolationOutcome.ALLOWED; |
| } |
| |
| /** |
| * 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 |
| */ |
| protected boolean isLocalOrigin(HttpServletRequest containerRequest, String originHeader) |
| { |
| // Make comparable strings from Origin and Location |
| String origin = normalizeUri(originHeader); |
| if (origin == null) |
| return false; |
| |
| String request = getTargetUriFromRequest(containerRequest); |
| if (request == null) |
| return false; |
| |
| return origin.equalsIgnoreCase(request); |
| } |
| |
| /** |
| * Creates a RFC-6454 comparable URI 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 |
| */ |
| protected final String getTargetUriFromRequest(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.ROOT); |
| } |
| 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(); |
| } |
| |
| /** |
| * Resolves the source URI from the request headers ({@code Origin} or {@code Referer}). |
| * |
| * @param containerRequest |
| * the current container request |
| * @return the normalized source URI. |
| */ |
| private String getSourceUri(HttpServletRequest containerRequest) |
| { |
| String sourceUri = containerRequest.getHeader(WebRequest.HEADER_ORIGIN); |
| if (Strings.isEmpty(sourceUri)) |
| { |
| sourceUri = containerRequest.getHeader(WebRequest.HEADER_REFERER); |
| } |
| return normalizeUri(sourceUri); |
| } |
| |
| /** |
| * Creates a RFC-6454 comparable URI from the {@code uri} string. |
| * |
| * @param uri |
| * the contents of the Origin or Referer HTTP header |
| * @return only the scheme://host[:port] part, or {@code null} when the URI string is not |
| * compliant |
| */ |
| protected final String normalizeUri(String uri) |
| { |
| // 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 (Strings.isEmpty(uri) || "null".equals(uri)) |
| return null; |
| |
| StringBuilder target = new StringBuilder(); |
| |
| try |
| { |
| URI originUri = new URI(uri); |
| String scheme = originUri.getScheme(); |
| if (scheme == null) |
| { |
| return null; |
| } |
| else |
| { |
| scheme = scheme.toLowerCase(Locale.ROOT); |
| } |
| |
| target.append(scheme); |
| target.append("://"); |
| |
| String host = originUri.getHost(); |
| if (host == null) |
| { |
| return null; |
| } |
| target.append(host); |
| |
| int port = originUri.getPort(); |
| boolean portIsSpecified = port != -1; |
| boolean isAlternateHttpPort = "http".equals(scheme) && port != 80; |
| boolean isAlternateHttpsPort = "https".equals(scheme) && port != 443; |
| |
| if (portIsSpecified && (isAlternateHttpPort || isAlternateHttpsPort)) |
| { |
| target.append(':'); |
| target.append(port); |
| } |
| return target.toString(); |
| } |
| catch (URISyntaxException e) |
| { |
| log.debug("Invalid URI provided: {}, marked conflicting", uri); |
| return null; |
| } |
| } |
| |
| /** |
| * Checks whether the domain part of the {@code sourceUri} ({@code Origin} or {@code Referer} |
| * header) is whitelisted. |
| * |
| * @param sourceUri |
| * the contents of the {@code Origin} or {@code Referer} HTTP header |
| * @return {@code true} when the source domain was whitelisted |
| */ |
| protected boolean isWhitelistedHost(final String sourceUri) |
| { |
| try |
| { |
| final String sourceHost = new URI(sourceUri).getHost(); |
| if (Strings.isEmpty(sourceHost)) |
| return false; |
| for (String whitelistedOrigin : acceptedOrigins) |
| { |
| if (sourceHost.equalsIgnoreCase(whitelistedOrigin) |
| || sourceHost.endsWith("." + whitelistedOrigin)) |
| { |
| log.trace("Origin {} matched whitelisted origin {}, request accepted", |
| sourceUri, whitelistedOrigin); |
| return true; |
| } |
| } |
| } |
| catch (URISyntaxException e) |
| { |
| log.debug("Origin: {} not parseable as an URI. Whitelisted-origin check skipped.", |
| sourceUri); |
| } |
| |
| return false; |
| } |
| } |