From 4e99df957f9e64dfa72c70bda61a6cadd9909d0a Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Fri, 22 Dec 2023 11:56:14 +0100 Subject: [PATCH 1/8] Fixes #10220 - Implement CrossOriginHandler. Introduced CrossOriginHandler. Added cross-origin Jetty module. Added CrossOriginHandler documentation to the programming guide. Added CrossOriginHandler documentation to the operations guide. Added cross-origin headers to the HttpHeader enum. Added test cases. Deprecated ee10 CrossOriginFilter. Signed-off-by: Simone Bordet --- .../modules/module-cross-origin.adoc | 27 + .../modules/modules-standard.adoc | 1 + .../server/http/server-http-handler-use.adoc | 47 ++ .../websocket/server-websocket-filter.adoc | 12 +- .../server/http/HTTPServerDocs.java | 26 +- .../org/eclipse/jetty/http/HttpHeader.java | 10 +- .../main/config/etc/jetty-cross-origin.xml | 71 +++ .../src/main/config/modules/cross-origin.mod | 42 ++ .../server/handler/CrossOriginHandler.java | 419 ++++++++++++++ .../handler/CrossOriginHandlerTest.java | 544 ++++++++++++++++++ .../ee10/servlets/CrossOriginFilter.java | 3 + .../tests/distribution/DistributionTests.java | 54 ++ 12 files changed, 1239 insertions(+), 17 deletions(-) create mode 100644 documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/module-cross-origin.adoc create mode 100644 jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml create mode 100644 jetty-core/jetty-server/src/main/config/modules/cross-origin.mod create mode 100644 jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java create mode 100644 jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java diff --git a/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/module-cross-origin.adoc b/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/module-cross-origin.adoc new file mode 100644 index 000000000000..8bde6ba208bf --- /dev/null +++ b/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/module-cross-origin.adoc @@ -0,0 +1,27 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +[[og-module-cross-origin]] +===== Module `cross-origin` + +The `cross-origin` module provides support for the link:https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS[CORS protocol] implemented by browsers when performing cross-origin requests. + +This module installs the xref:{prog-guide}#pg-server-http-handler-use-cross-origin[`CrossOriginHandler`] in the `Handler` tree; `CrossOriginHandler` inspects cross-origin requests and adds the relevant CORS response headers. + +`CrossOriginHandler` should be used when an application performs cross-origin requests to your server, to protect from link:https://owasp.org/www-community/attacks/csrf[cross-site request forgery] attacks. + +The module properties are: + +---- +include::{jetty-home}/modules/cross-origin.mod[tags=documentation] +---- diff --git a/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/modules-standard.adoc b/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/modules-standard.adoc index ae5bdb1f717e..c36e6a294fca 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/modules-standard.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/operations-guide/modules/modules-standard.adoc @@ -18,6 +18,7 @@ include::module-alpn.adoc[] include::module-bytebufferpool.adoc[] include::module-console-capture.adoc[] include::module-core-deploy.adoc[] +include::module-cross-origin.adoc[] include::module-eeN-deploy.adoc[] include::module-http.adoc[] include::module-http2.adoc[] diff --git a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc index e7742c5a0a52..96210fb8cbb7 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc @@ -404,6 +404,53 @@ Server applications must configure a `HttpConfiguration` object with the secure include::../../{doc_code}/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java[tags=securedHandler] ---- +[[pg-server-http-handler-use-cross-origin]] +====== CrossOriginHandler + +`CrossOriginHandler` supports the server-side requirements of the link:https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS[CORS protocol] implemented by browsers when performing cross-origin requests. + +An example of a cross-origin request is when a script downloaded from the origin domain `+http://domain.com+` uses `fetch()` or `XMLHttpRequest` to make a request to a cross domain such as `+http://cross.domain.com+` (a subdomain of the origin domain) or to `+http://example.com+` (a completely different domain). + +This is common, for example, when you embed reusable components such as a chat component into a web page: the web page and the chat component files are downloaded from `+http://domain.com+`, but the chat server is at `+http://chat.domain.com+`, so the chat component must make cross-origin requests to the chat server. + +This kind of setup exposes to link:https://owasp.org/www-community/attacks/csrf[cross-site request forgery attacks], and the CORS protocol has been established to protect against this kind of attacks. + +For security reasons, browser by default do not allow cross-origin requests, unless the response from the cross domain contains the right CORS headers. + +`CrossOriginHandler` relieves server-side web applications from handling CORS headers explicitly. +You can set up your `Handler` tree with the `CrossOriginHandler`, configure it, and it will take care of the CORS headers separately from your application, where you can concentrate on the business logic. + +The `Handler` tree structure looks like the following: + +[source,screen] +---- +Server +└── CrossOriginHandler + └── ContextHandler /app + └── AppHandler +---- + +The most important `CrossOriginHandler` configuration parameter is `allowedOrigins`, which by default is `*`, allowing any origin. + +You may want to restrict your server to only origins you trust. +From the chat example above, the chat server at `+http://chat.domain.com+` knows that the chat component is downloaded from the origin server at `+http://domain.com+`, so it configures the `CrossOriginHandler` in this way: + +[source,java,indent=0] +---- +include::../../{doc_code}/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java[tags=crossOriginAllowedOrigins] +---- + +Browsers send cross-origin request in two ways: + +* Directly, if the cross-origin request meets some simple criteria. +* By issuing a hidden _preflight_ request before the actual cross-origin request, to verify with the server if it is willing to reply properly to the actual cross-origin request. + +Both preflight requests and cross-origin requests will be handled by `CrossOriginHandler`, which will analyze the request and possibly add appropriate CORS response headers. + +By default, preflight requests are not delivered to the `CrossOriginHandler` child `Handler`, but it is possible to configure `CrossOriginHandler` by setting `deliverPreflightRequests=true` so that the web application can fine-tune the CORS response headers. + +For more `CrossOriginHandler` configuration options, refer to the link:{javadoc-url}/org/eclipse/jetty/server/handler/CrossOriginHandler.html[`CrossOriginHandler` javadocs]. + [[pg-server-http-handler-use-default]] ====== DefaultHandler diff --git a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/websocket/server-websocket-filter.adoc b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/websocket/server-websocket-filter.adoc index f04ba63862c9..a4acf64bfc93 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/websocket/server-websocket-filter.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/websocket/server-websocket-filter.adoc @@ -22,7 +22,7 @@ However, if the `WebSocketUpgradeFilter` is already present in `web.xml` under t This allows you to customize: -* The filter order; for example, by configuring the `CrossOriginFilter` (or other filters) for increased security or authentication _before_ the `WebSocketUpgradeFilter`. +* The filter order; for example, by configuring filters for increased security or authentication _before_ the `WebSocketUpgradeFilter`. * The `WebSocketUpgradeFilter` configuration via ``init-param``s, that affects all `Session` instances created by this filter. * The `WebSocketUpgradeFilter` path mapping. Rather than the default mapping of `+/*+`, you can map the `WebSocketUpgradeFilter` to a more specific path such as `+/ws/*+`. * The possibility to have multiple ``WebSocketUpgradeFilter``s, mapped to different paths, each with its own configuration. @@ -38,14 +38,14 @@ For example: version="5.0"> My WebSocket WebApp - + - cross-origin - org.eclipse.jetty.{ee-current}.servlets.CrossOriginFilter + security + com.acme.SecurityFilter true - cross-origin + security /* @@ -69,7 +69,7 @@ For example: ---- -<1> The `CrossOriginFilter` is the first to protect against link:https://owasp.org/www-community/attacks/csrf[cross-site request forgery attacks]. +<1> The custom `SecurityFilter` is the first, to apply custom security. <2> The configuration for the _default_ `WebSocketUpgradeFilter`. <3> Note the use of the _default_ `WebSocketUpgradeFilter` name. <4> Specific configuration for `WebSocketUpgradeFilter` parameters. diff --git a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java index 591f586b2f03..96254ab73817 100644 --- a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java +++ b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java @@ -18,12 +18,11 @@ import java.nio.file.Path; import java.security.Security; import java.time.Duration; -import java.util.EnumSet; import java.util.List; +import java.util.Set; import java.util.TimeZone; import java.util.concurrent.CompletableFuture; -import jakarta.servlet.DispatcherType; import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; @@ -31,10 +30,8 @@ import org.conscrypt.OpenSSLProvider; import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; import org.eclipse.jetty.ee10.servlet.DefaultServlet; -import org.eclipse.jetty.ee10.servlet.FilterHolder; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; -import org.eclipse.jetty.ee10.servlets.CrossOriginFilter; import org.eclipse.jetty.ee10.webapp.WebAppContext; import org.eclipse.jetty.http.HttpCompliance; import org.eclipse.jetty.http.HttpFields; @@ -72,6 +69,7 @@ import org.eclipse.jetty.server.SslConnectionFactory; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.server.handler.ContextHandlerCollection; +import org.eclipse.jetty.server.handler.CrossOriginHandler; import org.eclipse.jetty.server.handler.DefaultHandler; import org.eclipse.jetty.server.handler.EventsHandler; import org.eclipse.jetty.server.handler.QoSHandler; @@ -1004,22 +1002,21 @@ public void servletContextHandler() throws Exception Connector connector = new ServerConnector(server); server.addConnector(connector); + // Add the CrossOriginHandler to protect from CSRF attacks. + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + server.setHandler(crossOriginHandler); + // Create a ServletContextHandler with contextPath. ServletContextHandler context = new ServletContextHandler(); context.setContextPath("/shop"); // Link the context to the server. - server.setHandler(context); + crossOriginHandler.setHandler(context); // Add the Servlet implementing the cart functionality to the context. ServletHolder servletHolder = context.addServlet(ShopCartServlet.class, "/cart/*"); // Configure the Servlet with init-parameters. servletHolder.setInitParameter("maxItems", "128"); - // Add the CrossOriginFilter to protect from CSRF attacks. - FilterHolder filterHolder = context.addFilter(CrossOriginFilter.class, "/*", EnumSet.of(DispatcherType.REQUEST)); - // Configure the filter. - filterHolder.setAsyncSupported(true); - server.start(); // end::servletContextHandler-setup[] } @@ -1401,6 +1398,15 @@ public void securedHandler() throws Exception // end::securedHandler[] } + public void crossOriginAllowedOrigins() + { + // tag::crossOriginAllowedOrigins[] + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + // The allowed origins are regex patterns. + crossOriginHandler.setAllowedOriginPatterns(Set.of("http://domain\\.com")); + // end::crossOriginAllowedOrigins[] + } + public void defaultHandler() throws Exception { // tag::defaultHandler[] diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java index 9f364575c435..0329464ac41a 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java @@ -21,7 +21,6 @@ public enum HttpHeader { - /** * General Fields. */ @@ -59,6 +58,8 @@ public enum HttpHeader ACCEPT_CHARSET("Accept-Charset"), ACCEPT_ENCODING("Accept-Encoding"), ACCEPT_LANGUAGE("Accept-Language"), + ACCESS_CONTROL_REQUEST_HEADERS("Access-Control-Request-Headers"), + ACCESS_CONTROL_REQUEST_METHOD("Access-Control-Request-Method"), AUTHORIZATION("Authorization"), EXPECT("Expect"), FORWARDED("Forwarded"), @@ -87,6 +88,12 @@ public enum HttpHeader * Response Fields. */ ACCEPT_RANGES("Accept-Ranges"), + ACCESS_CONTROL_ALLOW_ORIGIN("Access-Control-Allow-Origin"), + ACCESS_CONTROL_ALLOW_METHODS("Access-Control-Allow-Methods"), + ACCESS_CONTROL_ALLOW_HEADERS("Access-Control-Allow-Headers"), + ACCESS_CONTROL_MAX_AGE("Access-Control-Max-Age"), + ACCESS_CONTROL_ALLOW_CREDENTIALS("Access-Control-Allow-Credentials"), + ACCESS_CONTROL_EXPOSE_HEADERS("Access-Control-Expose-Headers"), AGE("Age"), ALT_SVC("Alt-Svc"), ETAG("ETag"), @@ -96,6 +103,7 @@ public enum HttpHeader RETRY_AFTER("Retry-After"), SERVER("Server"), SERVLET_ENGINE("Servlet-Engine"), + TIMING_ALLOW_ORIGIN("Timing-Allow-Origin"), VARY("Vary"), WWW_AUTHENTICATE("WWW-Authenticate"), diff --git a/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml b/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml new file mode 100644 index 000000000000..18a6d64334f6 --- /dev/null +++ b/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod b/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod new file mode 100644 index 000000000000..139865e1c8e6 --- /dev/null +++ b/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod @@ -0,0 +1,42 @@ +# DO NOT EDIT THIS FILE - See: https://eclipse.dev/jetty/documentation/ + +[description] +Enables CrossOriginHandler to support the CORS protocol and protect from cross-site request forgery (CSRF) attacks. + +[tags] +server +handler +csrf + +[depend] +server + +[xml] +etc/jetty-cross-origin.xml + +[ini-template] +#tag::documentation[] +## Whether cross-origin requests can include credentials such as cookies or authentication headers. +# jetty.crossorigin.allowCredentials=true + +## A comma-separated list of headers allowed in cross-origin requests. +# jetty.crossorigin.allowedHeaders=Content-Type + +## A comma-separated list of HTTP methods allowed in cross-origin requests. +# jetty.crossorigin.allowedMethods=GET,POST,HEAD + +## A comma-separated list of origins regex patterns allowed in cross-origin requests. +# jetty.crossorigin.allowedOriginPatterns=* + +## A comma-separated list of timing origins regex patterns allowed in cross-origin requests. +# jetty.crossorigin.allowedTimingOriginPatterns= + +## Whether preflight requests are delivered to the child Handler of CrossOriginHandler. +# jetty.crossorigin.deliverPreflightRequest=false + +## A comma-separated list of headers allowed in cross-origin responses. +# jetty.crossorigin.exposedHeaders= + +## How long the preflight results can be cached by browsers, in seconds. +# jetty.crossorigin.preflightMaxAge=60 +#end::documentation[] diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java new file mode 100644 index 000000000000..af4cfdfc1f8e --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -0,0 +1,419 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server.handler; + +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.annotation.ManagedAttribute; +import org.eclipse.jetty.util.annotation.ManagedObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

Implementation of the CORS protocol defined by the + * fetch standard.

+ *

This {@link Handler} should be present in the {@link Handler} tree to prevent + * cross site request forgery attacks.

+ *

A typical case is a web page containing a script downloaded from the origin server at + * {@code domain.com}, where the script makes requests to the cross server at {@code cross.domain.com}. + * The cross server at {@code cross.domain.com} has the {@link CrossOriginHandler} installed and will + * see requests such as:

+ *
{@code
+ * GET / HTTP/1.1
+ * Host: cross.domain.com
+ * Origin: http://domain.com
+ * }
+ *

The cross server at {@code cross.domain.com} must decide whether these cross-origin requests + * are allowed or not, and it may easily do so by configuring the {@link CrossOriginHandler}, + * for example configuring the {@link #setAllowedOriginPatterns(Set) allowed origins} to contain only + * the origin server with origin {@code http://domain.com}.

+ */ +@ManagedObject +public class CrossOriginHandler extends Handler.Wrapper +{ + private static final Logger LOG = LoggerFactory.getLogger(CrossOriginHandler.class); + + private boolean allowCredentials = true; + private Set allowedHeaders = Set.of("Content-Type"); + private Set allowedMethods = Set.of("GET", "POST", "HEAD"); + private Set allowedOrigins = Set.of("*"); + private Set allowedTimingOrigins = Set.of(); + private boolean deliverPreflight = false; + private Set exposedHeaders = Set.of(); + private Duration preflightMaxAge = Duration.ofSeconds(60); + private boolean anyOriginAllowed; + private final Set allowedOriginPatterns = new LinkedHashSet<>(); + private boolean anyTimingOriginAllowed; + private final Set allowedTimingOriginPatterns = new LinkedHashSet<>(); + + /** + * @return whether the cross server allows cross-origin requests to include credentials + */ + @ManagedAttribute("Whether the server allows cross-origin requests to include credentials (cookies, authentication headers, etc.)") + public boolean isAllowCredentials() + { + return allowCredentials; + } + + /** + *

Sets whether the cross server allows cross-origin requests to include credentials + * such as cookies or authentication headers.

+ *

For example, when the cross server allows credentials to be included, cross-origin + * requests will contain cookies, otherwise they will not.

+ *

The default is {@code true}.

+ * + * @param allow whether the cross server allows cross-origin requests to include credentials + */ + public void setAllowCredentials(boolean allow) + { + throwIfStarted(); + allowCredentials = allow; + } + + /** + * @return the set of allowed headers in a cross-origin request + */ + @ManagedAttribute("The set of allowed headers in a cross-origin request") + public Set getAllowedHeaders() + { + return allowedHeaders; + } + + /** + *

Sets the set of allowed headers in a cross-origin request.

+ *

The cross server receives a preflight request that specifies the headers + * of the cross-origin request, and the cross server replies to the preflight + * request with the set of allowed headers. + * Browsers are responsible to check whether the headers of the cross-origin + * request are allowed, and if they are not produce an error.

+ *

The headers can be either the character {@code *} to indicate any + * header, or actual header names.

+ * + * @param headers the set of allowed headers in a cross-origin request + */ + public void setAllowedHeaders(Set headers) + { + throwIfStarted(); + allowedHeaders = headers; + } + + /** + * @return the set of allowed methods in a cross-origin request + */ + @ManagedAttribute("The set of allowed methods in a cross-origin request") + public Set getAllowedMethods() + { + return allowedMethods; + } + + /** + *

Sets the set of allowed methods in a cross-origin request.

+ *

The cross server receives a preflight request that specifies the method + * of the cross-origin request, and the cross server replies to the preflight + * request with the set of allowed methods. + * Browsers are responsible to check whether the method of the cross-origin + * request is allowed, and if it is not produce an error.

+ * + * @param methods the set of allowed methods in a cross-origin request + */ + public void setAllowedMethods(Set methods) + { + throwIfStarted(); + allowedMethods = methods; + } + + /** + * @return the set of allowed origin regex strings in a cross-origin request + */ + @ManagedAttribute("The set of allowed origin regex strings in a cross-origin request") + public Set getAllowedOriginPatterns() + { + return allowedOrigins; + } + + /** + *

Sets the set of allowed origin regex strings in a cross-origin request.

+ *

The cross server receives a preflight or a cross-origin request + * specifying the {@link HttpHeader#ORIGIN}, and replies with the + * same origin if allowed, otherwise the {@link HttpHeader#ACCESS_CONTROL_ALLOW_ORIGIN} + * is not added to the response (and the client should fail the + * cross-origin or preflight request).

+ *

The origins are either the character {@code *}, or regular expressions, + * so dot characters separating domain segments must be escaped:

+ *
{@code
+     * crossOriginHandler.setAllowedOriginPatterns(Set.of("https://.*\\.domain\\.com"));
+     * }
+ *

The default value is {@code *}.

+ * + * @param origins the set of allowed origin regex strings in a cross-origin request + */ + public void setAllowedOriginPatterns(Set origins) + { + throwIfStarted(); + allowedOrigins = origins; + } + + /** + * @return the set of allowed timing origin regex strings in a cross-origin request + */ + @ManagedAttribute("The set of allowed timing origin regex strings in a cross-origin request") + public Set getAllowedTimingOriginPatterns() + { + return allowedTimingOrigins; + } + + /** + *

Sets the set of allowed timing origin regex strings in a cross-origin request.

+ * + * @param origins the set of allowed timing origin regex strings in a cross-origin request + */ + public void setAllowedTimingOriginPatterns(Set origins) + { + throwIfStarted(); + allowedTimingOrigins = origins; + } + + /** + * @return whether preflight requests are delivered to the child Handler + */ + @ManagedAttribute("whether preflight requests are delivered to the child Handler") + public boolean isDeliverPreflightRequest() + { + return deliverPreflight; + } + + /** + *

Sets whether preflight requests are delivered to the child {@link Handler}.

+ *

Default value is {@code false}.

+ * + * @param deliver whether preflight requests are delivered to the child Handler + */ + public void setDeliverPreflightRequest(boolean deliver) + { + throwIfStarted(); + deliverPreflight = deliver; + } + + /** + * @return the set of headers exposed in a cross-origin response + */ + @ManagedAttribute("The set of headers exposed in a cross-origin response") + public Set getExposedHeaders() + { + return exposedHeaders; + } + + /** + *

Sets the set of headers exposed in a cross-origin response.

+ *

The cross server receives a cross-origin request and indicates + * which response headers are exposed to scripts running in the browser.

+ * + * @param headers the set of headers exposed in a cross-origin response + */ + public void setExposedHeaders(Set headers) + { + throwIfStarted(); + exposedHeaders = headers; + } + + /** + * @return how long the preflight results can be cached by browsers + */ + @ManagedAttribute("How long the preflight results can be cached by browsers") + public Duration getPreflightMaxAge() + { + return preflightMaxAge; + } + + /** + * @param duration how long the preflight results can be cached by browsers + */ + public void setPreflightMaxAge(Duration duration) + { + throwIfStarted(); + preflightMaxAge = duration; + } + + @Override + protected void doStart() throws Exception + { + resolveAllowedOrigins(); + resolveAllowedTimingOrigins(); + super.doStart(); + } + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + response.getHeaders().add(HttpHeader.VARY, HttpHeader.ORIGIN.asString()); + String origins = request.getHeaders().get(HttpHeader.ORIGIN); + if (origins != null) + { + if (LOG.isDebugEnabled()) + LOG.debug("handling cross-origin request {}", request); + + boolean preflight = isPreflight(request); + + if (originMatches(origins)) + { + if (LOG.isDebugEnabled()) + LOG.debug("cross-origin request matches allowed origins: {} {}", request, getAllowedOriginPatterns()); + + if (preflight) + { + if (LOG.isDebugEnabled()) + LOG.debug("preflight cross-origin request {}", request); + handlePreflightResponse(origins, response); + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("simple cross-origin request {}", request); + handleSimpleResponse(origins, response); + } + + if (timingOriginMatches(origins)) + { + if (LOG.isDebugEnabled()) + LOG.debug("cross-origin request matches allowed timing origins: {} {}", request, getAllowedTimingOriginPatterns()); + response.getHeaders().put(HttpHeader.TIMING_ALLOW_ORIGIN, origins); + } + } + + if (preflight && !isDeliverPreflightRequest()) + { + if (LOG.isDebugEnabled()) + LOG.debug("preflight cross-origin request not delivered to child handler {}", request); + callback.succeeded(); + return true; + } + } + return super.handle(request, response, callback); + } + + private boolean originMatches(String origins) + { + if (anyOriginAllowed) + return true; + return originMatches(origins, allowedOriginPatterns); + } + + private boolean timingOriginMatches(String origins) + { + if (anyTimingOriginAllowed) + return true; + return originMatches(origins, allowedTimingOriginPatterns); + } + + private boolean originMatches(String origins, Set allowedOriginPatterns) + { + for (String origin : origins.split(" ")) + { + origin = origin.trim(); + if (origin.isEmpty()) + continue; + for (Pattern pattern : allowedOriginPatterns) + { + if (pattern.matcher(origin).matches()) + return true; + } + } + return false; + } + + private boolean isPreflight(Request request) + { + return HttpMethod.OPTIONS.is(request.getMethod()) && request.getHeaders().contains(HttpHeader.ACCESS_CONTROL_REQUEST_METHOD); + } + + private void handlePreflightResponse(String origin, Response response) + { + HttpFields.Mutable headers = response.getHeaders(); + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + if (isAllowCredentials()) + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + Set allowedMethods = getAllowedMethods(); + if (!allowedMethods.isEmpty()) + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS, String.join(",", allowedMethods)); + Set allowedHeaders = getAllowedHeaders(); + if (!allowedHeaders.isEmpty()) + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS, String.join(",", allowedHeaders)); + long seconds = getPreflightMaxAge().toSeconds(); + if (seconds > 0) + headers.put(HttpHeader.ACCESS_CONTROL_MAX_AGE, seconds); + } + + private void handleSimpleResponse(String origin, Response response) + { + HttpFields.Mutable headers = response.getHeaders(); + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + if (isAllowCredentials()) + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + Set exposedHeaders = getExposedHeaders(); + if (!exposedHeaders.isEmpty()) + headers.put(HttpHeader.ACCESS_CONTROL_EXPOSE_HEADERS, String.join(",", exposedHeaders)); + } + + private void resolveAllowedOrigins() + { + for (String allowedOrigin : getAllowedOriginPatterns()) + { + allowedOrigin = allowedOrigin.trim(); + if (allowedOrigin.isEmpty()) + continue; + + if ("*".equals(allowedOrigin)) + { + anyOriginAllowed = true; + return; + } + + allowedOriginPatterns.add(Pattern.compile(allowedOrigin)); + } + } + + private void resolveAllowedTimingOrigins() + { + for (String allowedTimingOrigin : getAllowedTimingOriginPatterns()) + { + allowedTimingOrigin = allowedTimingOrigin.trim(); + if (allowedTimingOrigin.isEmpty()) + continue; + + if ("*".equals(allowedTimingOrigin)) + { + anyTimingOriginAllowed = true; + return; + } + + allowedTimingOriginPatterns.add(Pattern.compile(allowedTimingOrigin)); + } + } + + private void throwIfStarted() + { + if (isStarted()) + throw new IllegalStateException("Cannot configure after start"); + } +} diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java new file mode 100644 index 000000000000..680c494f9bb8 --- /dev/null +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java @@ -0,0 +1,544 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.server.handler; + +import java.util.List; +import java.util.Set; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.LocalConnector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CrossOriginHandlerTest +{ + private Server server; + private LocalConnector connector; + + public void start(CrossOriginHandler crossOriginHandler) throws Exception + { + server = new Server(); + connector = new LocalConnector(server); + server.addConnector(connector); + ContextHandler context = new ContextHandler("/"); + server.setHandler(context); + context.setHandler(crossOriginHandler); + crossOriginHandler.setHandler(new ApplicationHandler()); + server.start(); + } + + @AfterEach + public void destroy() + { + LifeCycle.stop(server); + } + + @Test + public void testRequestWithNoOriginArrivesToApplication() throws Exception + { + start(new CrossOriginHandler()); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertThat(response.get(HttpHeader.VARY), is(HttpHeader.ORIGIN.asString())); + } + + @Test + public void testSimpleRequestWithNonMatchingOrigin() throws Exception + { + String origin = "http://localhost"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin)); + start(crossOriginHandler); + + String otherOrigin = StringUtil.replace(origin, "localhost", "127.0.0.1"); + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(otherOrigin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testSimpleRequestWithWildcardOrigin() throws Exception + { + String origin = "http://foo.example.com"; + start(new CrossOriginHandler()); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithMatchingWildcardOrigin() throws Exception + { + String origin = "http://subdomain.example.com"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of("http://.*\\.example\\.com")); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithMatchingWildcardOriginAndMultipleSubdomains() throws Exception + { + String origin = "http://subdomain.subdomain.example.com"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of("http://.*\\.example\\.com")); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithMatchingOriginAndWithoutTimingOrigin() throws Exception + { + String origin = "http://localhost"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin)); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertFalse(response.contains(HttpHeader.TIMING_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithMatchingOriginAndNonMatchingTimingOrigin() throws Exception + { + String origin = "http://localhost"; + String timingOrigin = "http://127.0.0.1"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin)); + crossOriginHandler.setAllowedTimingOriginPatterns(Set.of(timingOrigin.replace(".", "\\."))); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertFalse(response.contains(HttpHeader.TIMING_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithMatchingOriginAndMatchingTimingOrigin() throws Exception + { + String origin = "http://localhost"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin)); + crossOriginHandler.setAllowedTimingOriginPatterns(Set.of(origin)); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s\r + \r + """.formatted(origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.TIMING_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithMatchingMultipleOrigins() throws Exception + { + String origin = "http://localhost"; + String otherOrigin = StringUtil.replace(origin, "localhost", "127.0.0.1"); + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin, otherOrigin.replace(".", "\\."))); + start(crossOriginHandler); + + // Use 2 spaces as separator in the Origin header + // to test that the implementation does not fail. + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: %s %s\r + \r + """.formatted(otherOrigin, origin); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.VARY)); + } + + @Test + public void testSimpleRequestWithoutCredentials() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowCredentials(false); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testNonSimpleRequestWithoutPreflight() throws Exception + { + // We cannot know if an actual request has performed the preflight before: + // we'll trust browsers to do it right, so responses to actual requests + // will contain the CORS response headers. + + start(new CrossOriginHandler()); + + String request = """ + PUT / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testOptionsRequestButNotPreflight() throws Exception + { + // We cannot know if an actual request has performed the preflight before: + // we'll trust browsers to do it right, so responses to actual requests + // will contain the CORS response headers. + + start(new CrossOriginHandler()); + + String request = """ + OPTIONS / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testPreflightWithWildcardCustomHeaders() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedHeaders(Set.of("*")); + start(crossOriginHandler); + + String request = """ + OPTIONS / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Access-Control-Request-Headers: X-Foo-Bar\r + Access-Control-Request-Method: GET\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testPUTRequestWithPreflight() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedMethods(Set.of("PUT")); + start(crossOriginHandler); + + // Preflight request. + String request = """ + OPTIONS / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Access-Control-Request-Method: PUT\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_MAX_AGE)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS)); + + // Preflight request was ok, now make the actual request. + request = """ + PUT / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: http://localhost\r + \r + """; + response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testDELETERequestWithPreflightAndAllowedCustomHeaders() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedMethods(Set.of("GET", "HEAD", "POST", "PUT", "DELETE")); + crossOriginHandler.setAllowedHeaders(Set.of("X-Requested-With")); + start(crossOriginHandler); + + // Preflight request. + String request = """ + OPTIONS / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Access-Control-Request-Method: DELETE\r + Access-Control-Request-Headers: origin,x-custom,x-requested-with\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_MAX_AGE)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS)); + + // Preflight request was ok, now make the actual request. + request = """ + DELETE / HTTP/1.1\r + Host: localhost\r + Connection: close\r + X-Custom: value\r + X-Requested-With: local\r + Origin: http://localhost\r + \r + """; + response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testDELETERequestWithPreflightAndNotAllowedCustomHeaders() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedMethods(Set.of("GET", "HEAD", "POST", "PUT", "DELETE")); + start(crossOriginHandler); + + // Preflight request. + String request = """ + OPTIONS / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Access-Control-Request-Method: DELETE\r + Access-Control-Request-Headers: origin, x-custom, x-requested-with\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + List allowedHeaders = response.getValuesList(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS); + assertFalse(allowedHeaders.contains("x-custom")); + // The preflight request failed because header X-Custom is not allowed, actual request not issued. + } + + @Test + public void testSimpleRequestWithExposedHeaders() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setExposedHeaders(Set.of("Content-Length")); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_EXPOSE_HEADERS)); + } + + @Test + public void testDoNotDeliverPreflightRequest() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setDeliverPreflightRequest(false); + start(crossOriginHandler); + + // Preflight request. + String request = """ + OPTIONS / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Access-Control-Request-Method: PUT\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + } + + public static class ApplicationHandler extends Handler.Abstract + { + private static final String APPLICATION_HEADER = "X-Application"; + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + response.getHeaders().put(APPLICATION_HEADER, "true"); + callback.succeeded(); + return true; + } + } +} diff --git a/jetty-ee10/jetty-ee10-servlets/src/main/java/org/eclipse/jetty/ee10/servlets/CrossOriginFilter.java b/jetty-ee10/jetty-ee10-servlets/src/main/java/org/eclipse/jetty/ee10/servlets/CrossOriginFilter.java index b576a7aa2c8a..6615beb62a91 100644 --- a/jetty-ee10/jetty-ee10-servlets/src/main/java/org/eclipse/jetty/ee10/servlets/CrossOriginFilter.java +++ b/jetty-ee10/jetty-ee10-servlets/src/main/java/org/eclipse/jetty/ee10/servlets/CrossOriginFilter.java @@ -116,7 +116,10 @@ * ... * </web-app> * + * + * @deprecated Use {@link org.eclipse.jetty.server.handler.CrossOriginHandler} instead */ +@Deprecated public class CrossOriginFilter implements Filter { private static final Logger LOG = LoggerFactory.getLogger(CrossOriginFilter.class); diff --git a/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java b/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java index cf5171f7b246..c624c1811291 100644 --- a/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java +++ b/tests/test-distribution/test-distribution-common/src/test/java/org/eclipse/jetty/tests/distribution/DistributionTests.java @@ -1725,4 +1725,58 @@ public void testInetAccessHandler() throws Exception } } } + + @Test + public void testCrossOriginModule() throws Exception + { + String jettyVersion = System.getProperty("jettyVersion"); + JettyHomeTester distribution = JettyHomeTester.Builder.newInstance() + .jettyVersion(jettyVersion) + .build(); + + try (JettyHomeTester.Run run1 = distribution.start("--add-modules=http,cross-origin,demo-handler")) + { + run1.awaitFor(START_TIMEOUT, TimeUnit.SECONDS); + assertThat(run1.getExitValue(), is(0)); + + int httpPort1 = distribution.freePort(); + try (JettyHomeTester.Run run2 = distribution.start(List.of("jetty.http.port=" + httpPort1))) + { + assertThat(run2.awaitConsoleLogsFor("Started oejs.Server", START_TIMEOUT, TimeUnit.SECONDS), is(true)); + startHttpClient(); + + ContentResponse response = client.newRequest("http://localhost:" + httpPort1 + "/demo-handler/") + .headers(headers -> headers.put(HttpHeader.ORIGIN, "http://localhost:" + httpPort1)) + .timeout(15, TimeUnit.SECONDS) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertThat(response.getContentAsString(), containsString("Hello World")); + // Verify that the CORS headers are present. + assertTrue(response.getHeaders().contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + int httpPort2 = distribution.freePort(); + List args = List.of( + "jetty.http.port=" + httpPort2, + // Allow a different origin. + "jetty.crossorigin.allowedOriginPatterns=http://localhost" + ); + try (JettyHomeTester.Run run2 = distribution.start(args)) + { + assertThat(run2.awaitConsoleLogsFor("Started oejs.Server", START_TIMEOUT, TimeUnit.SECONDS), is(true)); + startHttpClient(); + + ContentResponse response = client.newRequest("http://localhost:" + httpPort2 + "/demo-handler/") + .headers(headers -> headers.put(HttpHeader.ORIGIN, "http://localhost:" + httpPort2)) + .timeout(15, TimeUnit.SECONDS) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertThat(response.getContentAsString(), containsString("Hello World")); + // Verify that the CORS headers are not present, as the allowed origin is different. + assertFalse(response.getHeaders().contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + } + } } From dde225f2c7014a8e65f2699605684e7c22ae2a1b Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Thu, 28 Dec 2023 15:54:36 +0100 Subject: [PATCH 2/8] Added optimizations in origin and timingOrigin matching. Signed-off-by: Simone Bordet --- .../org/eclipse/jetty/server/handler/CrossOriginHandler.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java index af4cfdfc1f8e..2e60760e6aa1 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -316,6 +316,8 @@ private boolean originMatches(String origins) { if (anyOriginAllowed) return true; + if (allowedOriginPatterns.isEmpty()) + return false; return originMatches(origins, allowedOriginPatterns); } @@ -323,6 +325,8 @@ private boolean timingOriginMatches(String origins) { if (anyTimingOriginAllowed) return true; + if (allowedTimingOriginPatterns.isEmpty()) + return false; return originMatches(origins, allowedTimingOriginPatterns); } From 4ce8a4e283735de311f900385329e2a5020f3403 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 10 Jan 2024 16:27:00 +0100 Subject: [PATCH 3/8] Updates after review. Signed-off-by: Simone Bordet --- .../server/handler/CrossOriginHandler.java | 175 ++++++++++++++---- .../handler/CrossOriginHandlerTest.java | 88 ++++++++- 2 files changed, 216 insertions(+), 47 deletions(-) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java index 2e60760e6aa1..69cbe756047b 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -21,6 +21,8 @@ import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.PreEncodedHttpField; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; @@ -53,6 +55,8 @@ public class CrossOriginHandler extends Handler.Wrapper { private static final Logger LOG = LoggerFactory.getLogger(CrossOriginHandler.class); + private static final PreEncodedHttpField ACCESS_CONTROL_ALLOW_CREDENTIALS_TRUE = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + private static final PreEncodedHttpField VARY_ORIGIN = new PreEncodedHttpField(HttpHeader.VARY, HttpHeader.ORIGIN.asString()); private boolean allowCredentials = true; private Set allowedHeaders = Set.of("Content-Type"); @@ -60,12 +64,17 @@ public class CrossOriginHandler extends Handler.Wrapper private Set allowedOrigins = Set.of("*"); private Set allowedTimingOrigins = Set.of(); private boolean deliverPreflight = false; + private boolean deliverNonAllowedOrigin = true; + private boolean deliverNonAllowedOriginWebSocketUpgrade = false; private Set exposedHeaders = Set.of(); private Duration preflightMaxAge = Duration.ofSeconds(60); private boolean anyOriginAllowed; private final Set allowedOriginPatterns = new LinkedHashSet<>(); private boolean anyTimingOriginAllowed; private final Set allowedTimingOriginPatterns = new LinkedHashSet<>(); + private PreEncodedHttpField accessControlAllowMethodsField; + private PreEncodedHttpField accessControlAllowHeadersField; + private PreEncodedHttpField accessControlMaxAge; /** * @return whether the cross server allows cross-origin requests to include credentials @@ -115,7 +124,7 @@ public Set getAllowedHeaders() public void setAllowedHeaders(Set headers) { throwIfStarted(); - allowedHeaders = headers; + allowedHeaders = Set.copyOf(headers); } /** @@ -140,7 +149,7 @@ public Set getAllowedMethods() public void setAllowedMethods(Set methods) { throwIfStarted(); - allowedMethods = methods; + allowedMethods = Set.copyOf(methods); } /** @@ -171,7 +180,7 @@ public Set getAllowedOriginPatterns() public void setAllowedOriginPatterns(Set origins) { throwIfStarted(); - allowedOrigins = origins; + allowedOrigins = Set.copyOf(origins); } /** @@ -191,14 +200,14 @@ public Set getAllowedTimingOriginPatterns() public void setAllowedTimingOriginPatterns(Set origins) { throwIfStarted(); - allowedTimingOrigins = origins; + allowedTimingOrigins = Set.copyOf(origins); } /** * @return whether preflight requests are delivered to the child Handler */ @ManagedAttribute("whether preflight requests are delivered to the child Handler") - public boolean isDeliverPreflightRequest() + public boolean isDeliverPreflightRequests() { return deliverPreflight; } @@ -209,12 +218,52 @@ public boolean isDeliverPreflightRequest() * * @param deliver whether preflight requests are delivered to the child Handler */ - public void setDeliverPreflightRequest(boolean deliver) + public void setDeliverPreflightRequests(boolean deliver) { throwIfStarted(); deliverPreflight = deliver; } + /** + * @return whether requests whose origin is not allowed are delivered to the child Handler + */ + @ManagedAttribute("whether requests whose origin is not allowed are delivered to the child Handler") + public boolean isDeliverNonAllowedOriginRequests() + { + return deliverNonAllowedOrigin; + } + + /** + *

Sets whether requests whose origin is not allowed are delivered to the child Handler.

+ *

Default value is {@code true}.

+ * + * @param deliverNonAllowedOrigin whether requests whose origin is not allowed are delivered to the child Handler + */ + public void setDeliverNonAllowedOriginRequests(boolean deliverNonAllowedOrigin) + { + this.deliverNonAllowedOrigin = deliverNonAllowedOrigin; + } + + /** + * @return whether WebSocket upgrade requests whose origin is not allowed are delivered to the child Handler + */ + @ManagedAttribute("whether WebSocket upgrade requests whose origin is not allowed are delivered to the child Handler") + public boolean isDeliverNonAllowedOriginWebSocketUpgradeRequests() + { + return deliverNonAllowedOriginWebSocketUpgrade; + } + + /** + *

Sets whether WebSocket upgrade requests whose origin is not allowed are delivered to the child Handler.

+ *

Default value is {@code false}.

+ * + * @param deliverNonAllowedOriginWebSocketUpgrade whether WebSocket upgrade requests whose origin is not allowed are delivered to the child Handler + */ + public void setDeliverNonAllowedOriginWebSocketUpgradeRequests(boolean deliverNonAllowedOriginWebSocketUpgrade) + { + this.deliverNonAllowedOriginWebSocketUpgrade = deliverNonAllowedOriginWebSocketUpgrade; + } + /** * @return the set of headers exposed in a cross-origin response */ @@ -234,7 +283,7 @@ public Set getExposedHeaders() public void setExposedHeaders(Set headers) { throwIfStarted(); - exposedHeaders = headers; + exposedHeaders = Set.copyOf(headers); } /** @@ -260,56 +309,101 @@ protected void doStart() throws Exception { resolveAllowedOrigins(); resolveAllowedTimingOrigins(); + accessControlAllowMethodsField = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS, String.join(",", getAllowedMethods())); + accessControlAllowHeadersField = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS, String.join(",", getAllowedHeaders())); + accessControlMaxAge = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_MAX_AGE, getPreflightMaxAge().toSeconds()); super.doStart(); } @Override public boolean handle(Request request, Response response, Callback callback) throws Exception { - response.getHeaders().add(HttpHeader.VARY, HttpHeader.ORIGIN.asString()); + // The response may change if the Origin header is present, so always add Vary. + response.getHeaders().add(VARY_ORIGIN); + String origins = request.getHeaders().get(HttpHeader.ORIGIN); - if (origins != null) + if (origins == null) + return super.handle(request, response, callback); + + if (LOG.isDebugEnabled()) + LOG.debug("handling cross-origin request {}", request); + + boolean preflight = isPreflight(request); + + if (originMatches(origins)) { if (LOG.isDebugEnabled()) - LOG.debug("handling cross-origin request {}", request); + LOG.debug("cross-origin request matches allowed origins: {} {}", request, getAllowedOriginPatterns()); - boolean preflight = isPreflight(request); + if (preflight) + { + if (LOG.isDebugEnabled()) + LOG.debug("preflight cross-origin request {}", request); + handlePreflightResponse(origins, response); + if (!isDeliverPreflightRequests()) + { + if (LOG.isDebugEnabled()) + LOG.debug("preflight cross-origin request not delivered to child handler {}", request); + callback.succeeded(); + return true; + } + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("simple cross-origin request {}", request); + handleSimpleResponse(origins, response); + } - if (originMatches(origins)) + if (timingOriginMatches(origins)) { if (LOG.isDebugEnabled()) - LOG.debug("cross-origin request matches allowed origins: {} {}", request, getAllowedOriginPatterns()); + LOG.debug("cross-origin request matches allowed timing origins: {} {}", request, getAllowedTimingOriginPatterns()); + response.getHeaders().put(HttpHeader.TIMING_ALLOW_ORIGIN, origins); + } + + return super.handle(request, response, callback); + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("cross-origin request does not match allowed origins: {} {}", request, getAllowedOriginPatterns()); + if (isDeliverNonAllowedOriginRequests()) + { if (preflight) { - if (LOG.isDebugEnabled()) - LOG.debug("preflight cross-origin request {}", request); - handlePreflightResponse(origins, response); + if (!isDeliverPreflightRequests()) + { + if (LOG.isDebugEnabled()) + LOG.debug("preflight cross-origin request not delivered to child handler {}", request); + callback.succeeded(); + return true; + } } else { - if (LOG.isDebugEnabled()) - LOG.debug("simple cross-origin request {}", request); - handleSimpleResponse(origins, response); + if (isWebSocketUpgrade(request)) + { + if (!isDeliverNonAllowedOriginWebSocketUpgradeRequests()) + { + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "origin not allowed"); + return true; + } + } } - if (timingOriginMatches(origins)) - { - if (LOG.isDebugEnabled()) - LOG.debug("cross-origin request matches allowed timing origins: {} {}", request, getAllowedTimingOriginPatterns()); - response.getHeaders().put(HttpHeader.TIMING_ALLOW_ORIGIN, origins); - } - } + if (LOG.isDebugEnabled()) + LOG.debug("cross-origin request delivered to child handler {}", request); - if (preflight && !isDeliverPreflightRequest()) + return super.handle(request, response, callback); + } + else { - if (LOG.isDebugEnabled()) - LOG.debug("preflight cross-origin request not delivered to child handler {}", request); - callback.succeeded(); + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "origin not allowed"); return true; } } - return super.handle(request, response, callback); } private boolean originMatches(String origins) @@ -351,21 +445,26 @@ private boolean isPreflight(Request request) return HttpMethod.OPTIONS.is(request.getMethod()) && request.getHeaders().contains(HttpHeader.ACCESS_CONTROL_REQUEST_METHOD); } - private void handlePreflightResponse(String origin, Response response) + private boolean isWebSocketUpgrade(Request request) + { + return request.getHeaders().contains(HttpHeader.UPGRADE, "websocket"); + } + + private void handlePreflightResponse(String origins, Response response) { HttpFields.Mutable headers = response.getHeaders(); - headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origins); if (isAllowCredentials()) - headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + headers.put(ACCESS_CONTROL_ALLOW_CREDENTIALS_TRUE); Set allowedMethods = getAllowedMethods(); if (!allowedMethods.isEmpty()) - headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS, String.join(",", allowedMethods)); + headers.put(accessControlAllowMethodsField); Set allowedHeaders = getAllowedHeaders(); if (!allowedHeaders.isEmpty()) - headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS, String.join(",", allowedHeaders)); + headers.put(accessControlAllowHeadersField); long seconds = getPreflightMaxAge().toSeconds(); if (seconds > 0) - headers.put(HttpHeader.ACCESS_CONTROL_MAX_AGE, seconds); + headers.put(accessControlMaxAge); } private void handleSimpleResponse(String origin, Response response) @@ -373,7 +472,7 @@ private void handleSimpleResponse(String origin, Response response) HttpFields.Mutable headers = response.getHeaders(); headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origin); if (isAllowCredentials()) - headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + headers.put(ACCESS_CONTROL_ALLOW_CREDENTIALS_TRUE); Set exposedHeaders = getExposedHeaders(); if (!exposedHeaders.isEmpty()) headers.put(HttpHeader.ACCESS_CONTROL_EXPOSE_HEADERS, String.join(",", exposedHeaders)); diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java index 680c494f9bb8..8c360f182b08 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java @@ -25,7 +25,6 @@ import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.Callback; -import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.component.LifeCycle; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -79,19 +78,17 @@ public void testRequestWithNoOriginArrivesToApplication() throws Exception @Test public void testSimpleRequestWithNonMatchingOrigin() throws Exception { - String origin = "http://localhost"; CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); - crossOriginHandler.setAllowedOriginPatterns(Set.of(origin)); + crossOriginHandler.setAllowedOriginPatterns(Set.of("http://localhost")); start(crossOriginHandler); - String otherOrigin = StringUtil.replace(origin, "localhost", "127.0.0.1"); String request = """ GET / HTTP/1.1\r Host: localhost\r Connection: close\r - Origin: %s\r + Origin: http://127.0.0.1\r \r - """.formatted(otherOrigin); + """; HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); assertThat(response.getStatus(), is(HttpStatus.OK_200)); @@ -100,6 +97,29 @@ public void testSimpleRequestWithNonMatchingOrigin() throws Exception assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); } + @Test + public void testSimpleRequestWithNonMatchingOriginNotDelivered() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of("http://localhost")); + crossOriginHandler.setDeliverNonAllowedOriginRequests(false); + start(crossOriginHandler); + + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: close\r + Origin: http://127.0.0.1\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.BAD_REQUEST_400)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + @Test public void testSimpleRequestWithWildcardOrigin() throws Exception { @@ -252,9 +272,9 @@ public void testSimpleRequestWithMatchingOriginAndMatchingTimingOrigin() throws public void testSimpleRequestWithMatchingMultipleOrigins() throws Exception { String origin = "http://localhost"; - String otherOrigin = StringUtil.replace(origin, "localhost", "127.0.0.1"); + String otherOrigin = "http://127\\.0\\.0\\.1"; CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); - crossOriginHandler.setAllowedOriginPatterns(Set.of(origin, otherOrigin.replace(".", "\\."))); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin, otherOrigin)); start(crossOriginHandler); // Use 2 spaces as separator in the Origin header @@ -510,7 +530,7 @@ public void testSimpleRequestWithExposedHeaders() throws Exception public void testDoNotDeliverPreflightRequest() throws Exception { CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); - crossOriginHandler.setDeliverPreflightRequest(false); + crossOriginHandler.setDeliverPreflightRequests(false); start(crossOriginHandler); // Preflight request. @@ -525,8 +545,58 @@ public void testDoNotDeliverPreflightRequest() throws Exception HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS)); + } + + @Test + public void testDeliverWebSocketUpgradeRequest() throws Exception + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + start(crossOriginHandler); + + // Preflight request. + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: Upgrade\r + Upgrade: websocket\r + Origin: http://localhost\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertTrue(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertThat(response.get(HttpHeader.VARY), is(HttpHeader.ORIGIN.asString())); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + @Test + public void testDoNotDeliverNonMatchingWebSocketUpgradeRequest() throws Exception + { + String origin = "http://localhost"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin)); + start(crossOriginHandler); + + // Preflight request. + String request = """ + GET / HTTP/1.1\r + Host: localhost\r + Connection: Upgrade\r + Upgrade: websocket\r + Origin: http://127.0.0.1\r + \r + """; + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request)); + + assertThat(response.getStatus(), is(HttpStatus.BAD_REQUEST_400)); assertFalse(response.contains(ApplicationHandler.APPLICATION_HEADER)); + assertThat(response.get(HttpHeader.VARY), is(HttpHeader.ORIGIN.asString())); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertFalse(response.contains(HttpHeader.ACCESS_CONTROL_ALLOW_CREDENTIALS)); } public static class ApplicationHandler extends Handler.Abstract From b2a0ded11f6741718e7ce4b3bbf399e050bd9510 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 10 Jan 2024 16:45:07 +0100 Subject: [PATCH 4/8] Updates after review. Signed-off-by: Simone Bordet --- .../server/http/server-http-handler-use.adoc | 4 ++-- .../src/main/config/etc/jetty-cross-origin.xml | 8 ++++---- .../jetty-server/src/main/config/modules/cross-origin.mod | 8 +++++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc index 96210fb8cbb7..166d89687bc9 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc @@ -415,7 +415,7 @@ This is common, for example, when you embed reusable components such as a chat c This kind of setup exposes to link:https://owasp.org/www-community/attacks/csrf[cross-site request forgery attacks], and the CORS protocol has been established to protect against this kind of attacks. -For security reasons, browser by default do not allow cross-origin requests, unless the response from the cross domain contains the right CORS headers. +For security reasons, browsers by default do not allow cross-origin requests, unless the response from the cross domain contains the right CORS headers. `CrossOriginHandler` relieves server-side web applications from handling CORS headers explicitly. You can set up your `Handler` tree with the `CrossOriginHandler`, configure it, and it will take care of the CORS headers separately from your application, where you can concentrate on the business logic. @@ -433,7 +433,7 @@ Server The most important `CrossOriginHandler` configuration parameter is `allowedOrigins`, which by default is `*`, allowing any origin. You may want to restrict your server to only origins you trust. -From the chat example above, the chat server at `+http://chat.domain.com+` knows that the chat component is downloaded from the origin server at `+http://domain.com+`, so it configures the `CrossOriginHandler` in this way: +From the chat example above, the chat server at `+http://chat.domain.com+` knows that the chat component is downloaded from the origin server at `+http://domain.com+`, so the `CrossOriginHandler` is configured in this way: [source,java,indent=0] ---- diff --git a/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml b/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml index 18a6d64334f6..7bc59d951156 100644 --- a/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml +++ b/jetty-core/jetty-server/src/main/config/etc/jetty-cross-origin.xml @@ -44,9 +44,9 @@ - - - + + + @@ -60,7 +60,7 @@ - + diff --git a/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod b/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod index 139865e1c8e6..24d6356a6c7d 100644 --- a/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod +++ b/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod @@ -32,7 +32,13 @@ etc/jetty-cross-origin.xml # jetty.crossorigin.allowedTimingOriginPatterns= ## Whether preflight requests are delivered to the child Handler of CrossOriginHandler. -# jetty.crossorigin.deliverPreflightRequest=false +# jetty.crossorigin.deliverPreflightRequests=false + +## Whether requests whose origin is not allowed are delivered to the child Handler of CrossOriginHandler. +# jetty.crossorigin.deliverNonAllowedOriginRequests=true + +## Whether WebSocket upgrade requests whose origin is not allowed are delivered to the child Handler of CrossOriginHandler. +# jetty.crossorigin.deliverNonAllowedOriginWebSocketUpgradeRequests=false ## A comma-separated list of headers allowed in cross-origin responses. # jetty.crossorigin.exposedHeaders= From d73593316cfa7a95501907dcae998eae4bac9412 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 10 Jan 2024 23:08:33 +0100 Subject: [PATCH 5/8] Updates after review. Signed-off-by: Simone Bordet --- .../server/handler/CrossOriginHandler.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java index 69cbe756047b..b188598a7dc0 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -74,6 +74,7 @@ public class CrossOriginHandler extends Handler.Wrapper private final Set allowedTimingOriginPatterns = new LinkedHashSet<>(); private PreEncodedHttpField accessControlAllowMethodsField; private PreEncodedHttpField accessControlAllowHeadersField; + private PreEncodedHttpField accessControlExposeHeadersField; private PreEncodedHttpField accessControlMaxAge; /** @@ -101,7 +102,7 @@ public void setAllowCredentials(boolean allow) } /** - * @return the set of allowed headers in a cross-origin request + * @return the immutable set of allowed headers in a cross-origin request */ @ManagedAttribute("The set of allowed headers in a cross-origin request") public Set getAllowedHeaders() @@ -128,7 +129,7 @@ public void setAllowedHeaders(Set headers) } /** - * @return the set of allowed methods in a cross-origin request + * @return the immutable set of allowed methods in a cross-origin request */ @ManagedAttribute("The set of allowed methods in a cross-origin request") public Set getAllowedMethods() @@ -153,7 +154,7 @@ public void setAllowedMethods(Set methods) } /** - * @return the set of allowed origin regex strings in a cross-origin request + * @return the immutable set of allowed origin regex strings in a cross-origin request */ @ManagedAttribute("The set of allowed origin regex strings in a cross-origin request") public Set getAllowedOriginPatterns() @@ -184,7 +185,7 @@ public void setAllowedOriginPatterns(Set origins) } /** - * @return the set of allowed timing origin regex strings in a cross-origin request + * @return the immutable set of allowed timing origin regex strings in a cross-origin request */ @ManagedAttribute("The set of allowed timing origin regex strings in a cross-origin request") public Set getAllowedTimingOriginPatterns() @@ -265,7 +266,7 @@ public void setDeliverNonAllowedOriginWebSocketUpgradeRequests(boolean deliverNo } /** - * @return the set of headers exposed in a cross-origin response + * @return the immutable set of headers exposed in a cross-origin response */ @ManagedAttribute("The set of headers exposed in a cross-origin response") public Set getExposedHeaders() @@ -311,6 +312,7 @@ protected void doStart() throws Exception resolveAllowedTimingOrigins(); accessControlAllowMethodsField = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_ALLOW_METHODS, String.join(",", getAllowedMethods())); accessControlAllowHeadersField = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_ALLOW_HEADERS, String.join(",", getAllowedHeaders())); + accessControlExposeHeadersField = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_EXPOSE_HEADERS, String.join(",", getExposedHeaders())); accessControlMaxAge = new PreEncodedHttpField(HttpHeader.ACCESS_CONTROL_MAX_AGE, getPreflightMaxAge().toSeconds()); super.doStart(); } @@ -475,7 +477,7 @@ private void handleSimpleResponse(String origin, Response response) headers.put(ACCESS_CONTROL_ALLOW_CREDENTIALS_TRUE); Set exposedHeaders = getExposedHeaders(); if (!exposedHeaders.isEmpty()) - headers.put(HttpHeader.ACCESS_CONTROL_EXPOSE_HEADERS, String.join(",", exposedHeaders)); + headers.put(accessControlExposeHeadersField); } private void resolveAllowedOrigins() @@ -492,7 +494,7 @@ private void resolveAllowedOrigins() return; } - allowedOriginPatterns.add(Pattern.compile(allowedOrigin)); + allowedOriginPatterns.add(Pattern.compile(allowedOrigin, Pattern.CASE_INSENSITIVE)); } } @@ -510,7 +512,7 @@ private void resolveAllowedTimingOrigins() return; } - allowedTimingOriginPatterns.add(Pattern.compile(allowedTimingOrigin)); + allowedTimingOriginPatterns.add(Pattern.compile(allowedTimingOrigin, Pattern.CASE_INSENSITIVE)); } } From ea5db96efce1ba236c3eed12eee98d65d3034ec3 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Thu, 11 Jan 2024 10:11:48 +0100 Subject: [PATCH 6/8] Updates after review. Signed-off-by: Simone Bordet --- .../jetty/server/handler/CrossOriginHandler.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java index b188598a7dc0..71e0a73da2b0 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -385,13 +385,10 @@ public boolean handle(Request request, Response response, Callback callback) thr } else { - if (isWebSocketUpgrade(request)) + if (isWebSocketUpgrade(request) && !isDeliverNonAllowedOriginWebSocketUpgradeRequests()) { - if (!isDeliverNonAllowedOriginWebSocketUpgradeRequests()) - { - Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "origin not allowed"); - return true; - } + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "origin not allowed"); + return true; } } @@ -449,7 +446,7 @@ private boolean isPreflight(Request request) private boolean isWebSocketUpgrade(Request request) { - return request.getHeaders().contains(HttpHeader.UPGRADE, "websocket"); + return request.getHeaders().contains(HttpHeader.SEC_WEBSOCKET_VERSION, "13"); } private void handlePreflightResponse(String origins, Response response) From b1290335d235f87a196542444528f2e3ab504985 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Thu, 11 Jan 2024 12:33:06 +0100 Subject: [PATCH 7/8] Updates after review. Signed-off-by: Simone Bordet --- .../org/eclipse/jetty/server/handler/CrossOriginHandler.java | 2 +- .../eclipse/jetty/server/handler/CrossOriginHandlerTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java index 71e0a73da2b0..a7a7e86cff29 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -446,7 +446,7 @@ private boolean isPreflight(Request request) private boolean isWebSocketUpgrade(Request request) { - return request.getHeaders().contains(HttpHeader.SEC_WEBSOCKET_VERSION, "13"); + return request.getHeaders().contains(HttpHeader.SEC_WEBSOCKET_VERSION); } private void handlePreflightResponse(String origins, Response response) diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java index 8c360f182b08..e641b67b36c3 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java @@ -561,6 +561,7 @@ public void testDeliverWebSocketUpgradeRequest() throws Exception Host: localhost\r Connection: Upgrade\r Upgrade: websocket\r + Sec-WebSocket-Version: 13\r Origin: http://localhost\r \r """; @@ -587,6 +588,7 @@ public void testDoNotDeliverNonMatchingWebSocketUpgradeRequest() throws Exceptio Host: localhost\r Connection: Upgrade\r Upgrade: websocket\r + Sec-WebSocket-Version: 13 Origin: http://127.0.0.1\r \r """; From 0b0ae7e87d2de903bf702d81139d1cfae192d9ed Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Thu, 11 Jan 2024 23:09:14 +0100 Subject: [PATCH 8/8] Updates after review. Signed-off-by: Simone Bordet --- .../org/eclipse/jetty/server/handler/CrossOriginHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java index a7a7e86cff29..e5e9a703deb4 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -321,7 +321,7 @@ protected void doStart() throws Exception public boolean handle(Request request, Response response, Callback callback) throws Exception { // The response may change if the Origin header is present, so always add Vary. - response.getHeaders().add(VARY_ORIGIN); + response.getHeaders().ensureField(VARY_ORIGIN); String origins = request.getHeaders().get(HttpHeader.ORIGIN); if (origins == null)