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..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 @@ -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, 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. + +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 the `CrossOriginHandler` is configured 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..7bc59d951156 --- /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..24d6356a6c7d --- /dev/null +++ b/jetty-core/jetty-server/src/main/config/modules/cross-origin.mod @@ -0,0 +1,48 @@ +# 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.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= + +## 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..e5e9a703deb4 --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/CrossOriginHandler.java @@ -0,0 +1,521 @@ +// +// ======================================================================== +// 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.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; +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 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"); + private Set allowedMethods = Set.of("GET", "POST", "HEAD"); + 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 accessControlExposeHeadersField; + private PreEncodedHttpField accessControlMaxAge; + + /** + * @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 immutable 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 = Set.copyOf(headers); + } + + /** + * @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() + { + 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 = Set.copyOf(methods); + } + + /** + * @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() + { + 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 = Set.copyOf(origins); + } + + /** + * @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() + { + 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 = 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 isDeliverPreflightRequests() + { + 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 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 immutable 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 = Set.copyOf(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(); + 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(); + } + + @Override + 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().ensureField(VARY_ORIGIN); + + String origins = request.getHeaders().get(HttpHeader.ORIGIN); + 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("cross-origin request matches allowed origins: {} {}", request, getAllowedOriginPatterns()); + + 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 (timingOriginMatches(origins)) + { + if (LOG.isDebugEnabled()) + 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 (!isDeliverPreflightRequests()) + { + if (LOG.isDebugEnabled()) + LOG.debug("preflight cross-origin request not delivered to child handler {}", request); + callback.succeeded(); + return true; + } + } + else + { + if (isWebSocketUpgrade(request) && !isDeliverNonAllowedOriginWebSocketUpgradeRequests()) + { + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "origin not allowed"); + return true; + } + } + + if (LOG.isDebugEnabled()) + LOG.debug("cross-origin request delivered to child handler {}", request); + + return super.handle(request, response, callback); + } + else + { + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "origin not allowed"); + return true; + } + } + } + + private boolean originMatches(String origins) + { + if (anyOriginAllowed) + return true; + if (allowedOriginPatterns.isEmpty()) + return false; + return originMatches(origins, allowedOriginPatterns); + } + + private boolean timingOriginMatches(String origins) + { + if (anyTimingOriginAllowed) + return true; + if (allowedTimingOriginPatterns.isEmpty()) + return false; + 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 boolean isWebSocketUpgrade(Request request) + { + return request.getHeaders().contains(HttpHeader.SEC_WEBSOCKET_VERSION); + } + + private void handlePreflightResponse(String origins, Response response) + { + HttpFields.Mutable headers = response.getHeaders(); + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origins); + if (isAllowCredentials()) + headers.put(ACCESS_CONTROL_ALLOW_CREDENTIALS_TRUE); + Set allowedMethods = getAllowedMethods(); + if (!allowedMethods.isEmpty()) + headers.put(accessControlAllowMethodsField); + Set allowedHeaders = getAllowedHeaders(); + if (!allowedHeaders.isEmpty()) + headers.put(accessControlAllowHeadersField); + long seconds = getPreflightMaxAge().toSeconds(); + if (seconds > 0) + headers.put(accessControlMaxAge); + } + + private void handleSimpleResponse(String origin, Response response) + { + HttpFields.Mutable headers = response.getHeaders(); + headers.put(HttpHeader.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + if (isAllowCredentials()) + headers.put(ACCESS_CONTROL_ALLOW_CREDENTIALS_TRUE); + Set exposedHeaders = getExposedHeaders(); + if (!exposedHeaders.isEmpty()) + headers.put(accessControlExposeHeadersField); + } + + 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, Pattern.CASE_INSENSITIVE)); + } + } + + 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, Pattern.CASE_INSENSITIVE)); + } + } + + 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..e641b67b36c3 --- /dev/null +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/CrossOriginHandlerTest.java @@ -0,0 +1,616 @@ +// +// ======================================================================== +// 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.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 + { + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of("http://localhost")); + 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.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 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 + { + 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 = "http://127\\.0\\.0\\.1"; + CrossOriginHandler crossOriginHandler = new CrossOriginHandler(); + crossOriginHandler.setAllowedOriginPatterns(Set.of(origin, otherOrigin)); + 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.setDeliverPreflightRequests(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)); + 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 + Sec-WebSocket-Version: 13\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 + Sec-WebSocket-Version: 13 + 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 + { + 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)); + } + } + } }