From 9c2454f06a41383b7657c8faa630fd041cbea23c Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Fri, 10 Nov 2023 16:37:46 +0100 Subject: [PATCH] feat: dispatch OPTIONS requests (#10011) --- .../jdk/OptionsRequestAttributesSpec.groovy | 31 ++++- .../netty/OptionsRequestAttributesSpec.groovy | 34 ++++- .../filter/options/OptionsFilterTest.java | 127 ++++++++++++++++++ .../http/server/HttpServerConfiguration.java | 27 ++++ .../micronaut/http/server/OptionsFilter.java | 93 +++++++++++++ .../http/server/cors/CorsFilter.java | 6 +- .../micronaut/http/server/cors/CorsUtil.java | 9 +- .../server/HttpServerConfigurationSpec.groovy | 34 +++++ .../http/server/OptionsFilterSpec.groovy | 33 +++++ src/main/docs/guide/httpServer/routing.adoc | 13 ++ 10 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/filter/options/OptionsFilterTest.java create mode 100644 http-server/src/main/java/io/micronaut/http/server/OptionsFilter.java create mode 100644 http-server/src/test/groovy/io/micronaut/http/server/HttpServerConfigurationSpec.groovy create mode 100644 http-server/src/test/groovy/io/micronaut/http/server/OptionsFilterSpec.groovy diff --git a/http-client-jdk/src/test/groovy/io/micronaut/http/client/jdk/OptionsRequestAttributesSpec.groovy b/http-client-jdk/src/test/groovy/io/micronaut/http/client/jdk/OptionsRequestAttributesSpec.groovy index e68b4d5748b..da5283fc30c 100644 --- a/http-client-jdk/src/test/groovy/io/micronaut/http/client/jdk/OptionsRequestAttributesSpec.groovy +++ b/http-client-jdk/src/test/groovy/io/micronaut/http/client/jdk/OptionsRequestAttributesSpec.groovy @@ -2,8 +2,11 @@ package io.micronaut.http.client.jdk import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires +import io.micronaut.core.util.StringUtils import io.micronaut.http.HttpAttributes +import io.micronaut.http.HttpHeaders import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse import io.micronaut.http.HttpStatus import io.micronaut.http.MutableHttpResponse import io.micronaut.http.annotation.Controller @@ -23,7 +26,7 @@ class OptionsRequestAttributesSpec extends Specification { def 'test OPTIONS requests attributes'() { EmbeddedServer server = ApplicationContext.run(EmbeddedServer, ['spec.name': 'OptionsRequestAttributesSpec']) - def ctx = server.applicationContext + ApplicationContext ctx = server.applicationContext HttpClient client = ctx.createBean(HttpClient, server.getURL()) when: @@ -32,6 +35,32 @@ class OptionsRequestAttributesSpec extends Specification { then: HttpClientResponseException e = thrown() e.response.status == HttpStatus.METHOD_NOT_ALLOWED + + cleanup: + ctx.close() + server.close() + } + + def 'test OPTIONS requests attributes with micronaut.server.dispatch-options-requests enabled'() { + EmbeddedServer server = ApplicationContext.run(EmbeddedServer, ['spec.name': 'OptionsRequestAttributesSpec', 'micronaut.server.dispatch-options-requests': StringUtils.TRUE]) + ApplicationContext ctx = server.applicationContext + HttpClient client = ctx.createBean(HttpClient, server.getURL()) + + when: + HttpResponse response = client.toBlocking().exchange(HttpRequest.OPTIONS('/foo'), String) + + then: + noExceptionThrown() + response.status == HttpStatus.OK + response.getHeaders().getAll(HttpHeaders.ALLOW) + 3 == response.getHeaders().getAll(HttpHeaders.ALLOW).size() + response.getHeaders().getAll(HttpHeaders.ALLOW).contains('GET') + response.getHeaders().getAll(HttpHeaders.ALLOW).contains('OPTIONS') + response.getHeaders().getAll(HttpHeaders.ALLOW).contains('HEAD') + + cleanup: + ctx.close() + server.close() } @Singleton diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/OptionsRequestAttributesSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/OptionsRequestAttributesSpec.groovy index c7bf6a3b916..df5db19511c 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/OptionsRequestAttributesSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/OptionsRequestAttributesSpec.groovy @@ -2,10 +2,8 @@ package io.micronaut.http.server.netty import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires -import io.micronaut.http.HttpAttributes -import io.micronaut.http.HttpRequest -import io.micronaut.http.HttpStatus -import io.micronaut.http.MutableHttpResponse +import io.micronaut.core.util.StringUtils +import io.micronaut.http.* import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Filter import io.micronaut.http.annotation.Get @@ -23,7 +21,7 @@ class OptionsRequestAttributesSpec extends Specification { def 'test OPTIONS requests attributes'() { EmbeddedServer server = ApplicationContext.run(EmbeddedServer, ['spec.name': 'OptionsRequestAttributesSpec']) - def ctx = server.applicationContext + ApplicationContext ctx = server.applicationContext HttpClient client = ctx.createBean(HttpClient, server.getURL()) when: @@ -32,6 +30,32 @@ class OptionsRequestAttributesSpec extends Specification { then: HttpClientResponseException e = thrown() e.response.status == HttpStatus.METHOD_NOT_ALLOWED + + cleanup: + ctx.close() + server.close() + } + + def 'test OPTIONS requests attributes with micronaut.server.dispatch-options-requests enabled'() { + EmbeddedServer server = ApplicationContext.run(EmbeddedServer, ['spec.name': 'OptionsRequestAttributesSpec', 'micronaut.server.dispatch-options-requests': StringUtils.TRUE]) + ApplicationContext ctx = server.applicationContext + HttpClient client = ctx.createBean(HttpClient, server.getURL()) + + when: + HttpResponse response = client.toBlocking().exchange(HttpRequest.OPTIONS('/foo'), String) + + then: + noExceptionThrown() + response.status == HttpStatus.OK + response.getHeaders().getAll(HttpHeaders.ALLOW) + 3 == response.getHeaders().getAll(HttpHeaders.ALLOW).size() + response.getHeaders().getAll(HttpHeaders.ALLOW).contains('GET') + response.getHeaders().getAll(HttpHeaders.ALLOW).contains('OPTIONS') + response.getHeaders().getAll(HttpHeaders.ALLOW).contains('HEAD') + + cleanup: + ctx.close() + server.close() } @Singleton diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/filter/options/OptionsFilterTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/filter/options/OptionsFilterTest.java new file mode 100644 index 00000000000..51b2fe2be43 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/filter/options/OptionsFilterTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.http.server.tck.tests.filter.options; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.*; +import io.micronaut.http.tck.AssertionUtils; +import io.micronaut.http.tck.HttpResponseAssertion; +import io.micronaut.http.tck.ServerUnderTest; +import io.micronaut.http.tck.TestScenario; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; +import java.util.function.BiConsumer; + +import static org.junit.jupiter.api.Assertions.*; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class OptionsFilterTest { + private static final String SPEC_NAME = "OptionsFilterTest"; + + @Test + public void optionsByDefaultResponds405() throws IOException { + TestScenario.builder() + .specName(SPEC_NAME) + .request(HttpRequest.OPTIONS("/foo/bar")) + .assertion(AssertionUtils.assertThrowsStatus(HttpStatus.METHOD_NOT_ALLOWED)) + .run(); + } + + @Test + public void getTest() throws IOException { + assertion(HttpRequest.GET("/foo/bar"), + (server, request) -> + AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .build())); + } + + @Test + public void optionsRoute() throws IOException { + assertion(HttpRequest.OPTIONS("/options/route"), + (server, request) -> + AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.I_AM_A_TEAPOT) + .build())); + } + + @Test + public void postTest() throws IOException { + assertion(HttpRequest.POST("/foo/bar", Collections.emptyMap()), + (server, request) -> + AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.CREATED) + .build())); + } + + @Test + public void optionsTest() throws IOException { + assertion(HttpRequest.OPTIONS("/foo/bar"), + (server, request) -> + AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(httpResponse -> { + assertNotNull(httpResponse.getHeaders().get(HttpHeaders.ALLOW)); + assertNotNull(httpResponse.getHeaders().getAll(HttpHeaders.ALLOW)); + assertEquals(4, httpResponse.getHeaders().getAll(HttpHeaders.ALLOW).size()); + assertTrue(httpResponse.getHeaders().getAll(HttpHeaders.ALLOW).stream().anyMatch(v -> v.equals(HttpMethod.GET.toString()))); + assertTrue(httpResponse.getHeaders().getAll(HttpHeaders.ALLOW).stream().anyMatch(v -> v.equals(HttpMethod.POST.toString()))); + assertTrue(httpResponse.getHeaders().getAll(HttpHeaders.ALLOW).stream().anyMatch(v -> v.equals(HttpMethod.OPTIONS.toString()))); + assertTrue(httpResponse.getHeaders().getAll(HttpHeaders.ALLOW).stream().anyMatch(v -> v.equals(HttpMethod.HEAD.toString()))); + }) + .build())); + } + + private static void assertion(HttpRequest request, BiConsumer> assertion) throws IOException { + TestScenario.builder() + .specName(SPEC_NAME) + .configuration(Collections.singletonMap("micronaut.server.dispatch-options-requests", StringUtils.TRUE)) + .request(request) + .assertion(assertion) + .run(); + } + + @Controller + @Requires(property = "spec.name", value = SPEC_NAME) + public static class MyController { + @Get("/foo/{id}") + @Status(HttpStatus.OK) + public void fooGet(String id) { + } + + @Post("/foo/{id}") + @Status(HttpStatus.CREATED) + public void fooPost(String id) { + } + + @Options("/options/route") + @Status(HttpStatus.I_AM_A_TEAPOT) + public void optionsRoute() { + } + + } +} diff --git a/http-server/src/main/java/io/micronaut/http/server/HttpServerConfiguration.java b/http-server/src/main/java/io/micronaut/http/server/HttpServerConfiguration.java index 498a1b725fd..1035814e5dd 100644 --- a/http-server/src/main/java/io/micronaut/http/server/HttpServerConfiguration.java +++ b/http-server/src/main/java/io/micronaut/http/server/HttpServerConfiguration.java @@ -116,6 +116,12 @@ public class HttpServerConfiguration implements ServerContextPathProvider { @SuppressWarnings("WeakerAccess") public static final boolean DEFAULT_HTTP_TO_HTTPS_REDIRECT = false; + + /** + * The default value whether to dispatch OPTIONS Requests. + */ + @SuppressWarnings("WeakerAccess") + public static final boolean DEFAULT_DISPATCH_OPTIONS_REQUESTS = false; private Integer port; private String host; private Integer readTimeout; @@ -134,6 +140,9 @@ public class HttpServerConfiguration implements ServerContextPathProvider { private String contextPath; private boolean dualProtocol = DEFAULT_DUAL_PROTOCOL; private boolean httpToHttpsRedirect = DEFAULT_HTTP_TO_HTTPS_REDIRECT; + + private boolean dispatchOptionsRequests = DEFAULT_DISPATCH_OPTIONS_REQUESTS; + private HttpVersion httpVersion = HttpVersion.HTTP_1_1; private final ApplicationConfiguration applicationConfiguration; private Charset defaultCharset; @@ -339,6 +348,15 @@ public boolean isHttpToHttpsRedirect() { return httpToHttpsRedirect; } + /** + * Set to true to dispatch OPTIONS requests. Default value ({@value #DEFAULT_DISPATCH_OPTIONS_REQUESTS}. + * @return Whether OPTIONS requests should be dispatched. + * @since 4.2.0 + */ + public boolean isDispatchOptionsRequests() { + return dispatchOptionsRequests; + } + /** * @param defaultCharset The default charset to use */ @@ -506,6 +524,15 @@ public void setHttpToHttpsRedirect(boolean httpToHttpsRedirect) { this.httpToHttpsRedirect = httpToHttpsRedirect; } + /** + * Set to true to dispatch OPTIONS requests. Default value ({@value #DEFAULT_DISPATCH_OPTIONS_REQUESTS}. + * @param dispatchOptionsRequests Set to true to dispatch OPTIONS requests. + * @since 4.2.0 + */ + public void setDispatchOptionsRequests(boolean dispatchOptionsRequests) { + this.dispatchOptionsRequests = dispatchOptionsRequests; + } + /** * Configuration for multipart handling. */ diff --git a/http-server/src/main/java/io/micronaut/http/server/OptionsFilter.java b/http-server/src/main/java/io/micronaut/http/server/OptionsFilter.java new file mode 100644 index 00000000000..2892a54ce40 --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/OptionsFilter.java @@ -0,0 +1,93 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.http.server; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.order.Ordered; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.*; +import io.micronaut.http.annotation.RequestFilter; +import io.micronaut.http.annotation.ServerFilter; +import io.micronaut.http.server.cors.CorsUtil; +import io.micronaut.web.router.Router; +import io.micronaut.web.router.UriRouteMatch; +import io.micronaut.web.router.RouteMatch; + +import static io.micronaut.http.annotation.Filter.MATCH_ALL_PATTERN; +import static io.micronaut.http.server.cors.CorsFilter.CORS_FILTER_ORDER; + +/** + * This Filter intercepts HTTP OPTIONS requests which are not CORS Preflight requests. + * It responds with a NO_CONTENT(204) response, and it populates the Allow HTTP Header with the supported HTTP methods for the request URI. + * @author Sergio del Amo + * @since 4.2.0 + */ +@Requires(property = OptionsFilter.PREFIX, value = StringUtils.TRUE, defaultValue = StringUtils.FALSE) +@ServerFilter(MATCH_ALL_PATTERN) +@Internal +public final class OptionsFilter implements Ordered { + + @SuppressWarnings("WeakerAccess") + public static final String PREFIX = HttpServerConfiguration.PREFIX + ".dispatch-options-requests"; + + private final Router router; + + /** + * + * @param router Router + */ + public OptionsFilter(Router router) { + this.router = router; + } + + @RequestFilter + @Nullable + @Internal + public HttpResponse filterRequest(HttpRequest request) { + if (request.getMethod() != HttpMethod.OPTIONS) { + return null; // proceed + } + if (CorsUtil.isPreflightRequest(request)) { + return null; // proceed + } + if (hasOptionsRouteMatch(request)) { + return null; // proceed + } + MutableHttpResponse mutableHttpResponse = HttpResponse.status(HttpStatus.OK); + router.findAny(request.getUri().toString(), request) + .map(UriRouteMatch::getHttpMethod) + .map(HttpMethod::toString) + .forEach(allow -> mutableHttpResponse.header(HttpHeaders.ALLOW, allow)); + mutableHttpResponse.header(HttpHeaders.ALLOW, HttpMethod.OPTIONS.toString()); + return mutableHttpResponse; + } + + private boolean hasOptionsRouteMatch(HttpRequest request) { + return request.getAttribute(HttpAttributes.ROUTE_MATCH, RouteMatch.class).map(routeMatch -> { + if (routeMatch instanceof UriRouteMatch uriRouteMatch) { + return uriRouteMatch.getHttpMethod() == HttpMethod.OPTIONS; + } + return true; + }).orElse(false); + } + + @Override + public int getOrder() { + return CORS_FILTER_ORDER + 10; + } +} diff --git a/http-server/src/main/java/io/micronaut/http/server/cors/CorsFilter.java b/http-server/src/main/java/io/micronaut/http/server/cors/CorsFilter.java index 8cc3e9662dc..c3d28764ea5 100644 --- a/http-server/src/main/java/io/micronaut/http/server/cors/CorsFilter.java +++ b/http-server/src/main/java/io/micronaut/http/server/cors/CorsFilter.java @@ -70,9 +70,11 @@ */ @ServerFilter(MATCH_ALL_PATTERN) public class CorsFilter implements Ordered { + public static final int CORS_FILTER_ORDER = ServerFilterPhase.METRICS.after(); + private static final Logger LOG = LoggerFactory.getLogger(CorsFilter.class); private static final ArgumentConversionContext CONVERSION_CONTEXT_HTTP_METHOD = ImmutableArgumentConversionContext.of(HttpMethod.class); - + protected final HttpServerConfiguration.CorsConfiguration corsConfiguration; @Nullable @@ -213,7 +215,7 @@ private boolean isOriginLocal(@NonNull String hostString) { @Override public int getOrder() { - return ServerFilterPhase.METRICS.after(); + return CORS_FILTER_ORDER; } @NonNull diff --git a/http-server/src/main/java/io/micronaut/http/server/cors/CorsUtil.java b/http-server/src/main/java/io/micronaut/http/server/cors/CorsUtil.java index 471afcbdd0f..00117546218 100644 --- a/http-server/src/main/java/io/micronaut/http/server/cors/CorsUtil.java +++ b/http-server/src/main/java/io/micronaut/http/server/cors/CorsUtil.java @@ -15,6 +15,7 @@ */ package io.micronaut.http.server.cors; +import io.micronaut.core.annotation.Internal; import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpMethod; import io.micronaut.http.HttpRequest; @@ -29,13 +30,17 @@ * @author Graeme Rocher * @since 1.0 */ -class CorsUtil { +@Internal +public final class CorsUtil { + private CorsUtil() { + + } /** * @param request The {@link HttpRequest} object * @return Return whether this request is a pre-flight request */ - static boolean isPreflightRequest(HttpRequest request) { + public static boolean isPreflightRequest(HttpRequest request) { HttpHeaders headers = request.getHeaders(); Optional origin = request.getOrigin(); return origin.isPresent() && headers.contains(ACCESS_CONTROL_REQUEST_METHOD) && HttpMethod.OPTIONS == request.getMethod(); diff --git a/http-server/src/test/groovy/io/micronaut/http/server/HttpServerConfigurationSpec.groovy b/http-server/src/test/groovy/io/micronaut/http/server/HttpServerConfigurationSpec.groovy new file mode 100644 index 00000000000..e31f4f83ef5 --- /dev/null +++ b/http-server/src/test/groovy/io/micronaut/http/server/HttpServerConfigurationSpec.groovy @@ -0,0 +1,34 @@ +package io.micronaut.http.server + +import io.micronaut.context.ApplicationContext +import io.micronaut.core.util.StringUtils +import spock.lang.Specification + +class HttpServerConfigurationSpec extends Specification { + + void dispatchOptionsRequestDefaultsToFalse() { + given: + ApplicationContext applicationContext = ApplicationContext.run() + HttpServerConfiguration httpServerConfiguration = applicationContext.getBean(HttpServerConfiguration) + + expect: + !httpServerConfiguration.dispatchOptionsRequests + + cleanup: + applicationContext.close() + } + + void dispatchOptionsRequestCanBeSetViaConfiguration() { + given: + ApplicationContext applicationContext = ApplicationContext.run([ + 'micronaut.server.dispatch-options-requests': StringUtils.TRUE + ]) + HttpServerConfiguration httpServerConfiguration = applicationContext.getBean(HttpServerConfiguration) + + expect: + httpServerConfiguration.dispatchOptionsRequests + + cleanup: + applicationContext.close() + } +} diff --git a/http-server/src/test/groovy/io/micronaut/http/server/OptionsFilterSpec.groovy b/http-server/src/test/groovy/io/micronaut/http/server/OptionsFilterSpec.groovy new file mode 100644 index 00000000000..ca1919c4a10 --- /dev/null +++ b/http-server/src/test/groovy/io/micronaut/http/server/OptionsFilterSpec.groovy @@ -0,0 +1,33 @@ +package io.micronaut.http.server + +import io.micronaut.core.order.OrderUtil +import io.micronaut.core.order.Ordered +import io.micronaut.http.server.cors.CorsFilter +import io.micronaut.http.server.util.HttpHostResolver +import io.micronaut.web.router.Router +import spock.lang.Specification + +class OptionsFilterSpec extends Specification { + + void "OptionsFilter after CorsFilter"() { + given: + OptionsFilter optionsFilter = new OptionsFilter(Mock(Router)) + CorsFilter corsFilter = new CorsFilter(Mock(HttpServerConfiguration.CorsConfiguration), Mock(HttpHostResolver)) + + when: + List filters = [optionsFilter, corsFilter] + OrderUtil.sort(filters) + + then: + filters[0] instanceof CorsFilter + filters[1] instanceof OptionsFilter + + when: + filters = [corsFilter, optionsFilter] + OrderUtil.sort(filters) + + then: + filters[0] instanceof CorsFilter + filters[1] instanceof OptionsFilter + } +} diff --git a/src/main/docs/guide/httpServer/routing.adoc b/src/main/docs/guide/httpServer/routing.adoc index 1dbd076fea7..b6aa02714e2 100644 --- a/src/main/docs/guide/httpServer/routing.adoc +++ b/src/main/docs/guide/httpServer/routing.adoc @@ -117,6 +117,19 @@ The previous example uses the link:{api}/io/micronaut/http/annotation/Get.html[@ NOTE: All the method annotations default to `/`. +== @Options + +<> support handles OPTIONS preflight requests. However, if you want to dispatch OPTIONS requests without an Origin HTTP Header, you can enable it via: + +[configuration] +---- +micronaut: + server: + dispatch-options-requests: true +---- + + + == Multiple URIs Each of the routing annotations supports multiple URI templates. For each template, a route is created. This feature is useful for example to change the path of the API and leave the existing path as is for backwards compatibility. For example: