From 74d98faa10e16ed9757abc324217330484f5093b Mon Sep 17 00:00:00 2001 From: Grady Johnson <41174775+Gradsta@users.noreply.github.com> Date: Thu, 9 Mar 2023 07:59:54 -0600 Subject: [PATCH 01/21] Fix bug preventing scheduled jobs from being ran in the specified time zone (#8911) Fixes #8085 --- .../main/java/io/micronaut/scheduling/NextFireTime.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/context/src/main/java/io/micronaut/scheduling/NextFireTime.java b/context/src/main/java/io/micronaut/scheduling/NextFireTime.java index 6f1d8eea3b2..5735523a136 100644 --- a/context/src/main/java/io/micronaut/scheduling/NextFireTime.java +++ b/context/src/main/java/io/micronaut/scheduling/NextFireTime.java @@ -34,6 +34,7 @@ final class NextFireTime implements Supplier { private Duration duration; private ZonedDateTime nextFireTime; private final CronExpression cron; + private final ZoneId zoneId; /** * Default constructor. @@ -41,8 +42,7 @@ final class NextFireTime implements Supplier { * @param cron A cron expression */ NextFireTime(CronExpression cron) { - this.cron = cron; - nextFireTime = ZonedDateTime.now(); + this(cron, ZoneId.systemDefault()); } /** @@ -51,12 +51,13 @@ final class NextFireTime implements Supplier { */ NextFireTime(CronExpression cron, ZoneId zoneId) { this.cron = cron; + this.zoneId = zoneId; nextFireTime = ZonedDateTime.now(zoneId); } @Override public Duration get() { - ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime now = ZonedDateTime.now(zoneId); // check if the task have fired too early computeNextFireTime(now.isAfter(nextFireTime) ? now : nextFireTime); return duration; @@ -64,6 +65,6 @@ public Duration get() { private void computeNextFireTime(ZonedDateTime currentFireTime) { nextFireTime = cron.nextTimeAfter(currentFireTime); - duration = Duration.ofMillis(nextFireTime.toInstant().toEpochMilli() - ZonedDateTime.now().toInstant().toEpochMilli()); + duration = Duration.ofMillis(nextFireTime.toInstant().toEpochMilli() - ZonedDateTime.now(zoneId).toInstant().toEpochMilli()); } } From 71efba6e264eb01d0288145f400aaa65fdeccbcb Mon Sep 17 00:00:00 2001 From: micronaut-build <65172877+micronaut-build@users.noreply.github.com> Date: Fri, 10 Mar 2023 14:54:20 +0100 Subject: [PATCH 02/21] Bump micronaut-openapi to 4.8.5 (#8921) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32146f71d2c..aada27d0e49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -100,7 +100,7 @@ managed-micronaut-neo4j = "5.2.0" managed-micronaut-nats = "3.1.0" managed-micronaut-netflix = "2.1.0" managed-micronaut-object-storage = "1.1.0" -managed-micronaut-openapi = "4.8.4" +managed-micronaut-openapi = "4.8.5" managed-micronaut-oraclecloud = "2.3.4" managed-micronaut-picocli = "4.3.0" managed-micronaut-problem = "2.6.0" From 27121d9ed5d0ef56c4b15ff717381a82eccb14d1 Mon Sep 17 00:00:00 2001 From: Dean Wette Date: Tue, 14 Mar 2023 05:23:19 -0500 Subject: [PATCH 03/21] Add support for annotation-based CORS configuration (8558) (#8580) --- .../server/netty/cors/CorsFilterSpec.groovy | 18 +- .../netty/cors/CrossOriginUtilSpec.groovy | 178 +++++++ .../http/server/tck/CorsAssertion.java | 182 +++++++ .../micronaut/http/server/tck/CorsUtils.java | 95 ++++ .../http/server/tck/tests/HeadersTest.java | 2 +- .../tests/cors/CorsDisabledByDefaultTest.java | 11 +- .../tck/tests/cors/CorsSimpleRequestTest.java | 15 +- .../tck/tests/cors/CrossOriginTest.java | 448 ++++++++++++++++++ .../http/server/cors/CorsFilter.java | 36 +- .../server/cors/CorsOriginConfiguration.java | 26 +- .../http/server/cors/CorsOriginConverter.java | 5 + .../http/server/cors/CrossOrigin.java | 91 ++++ .../http/server/cors/CrossOriginUtil.java | 86 ++++ src/main/docs/guide/appendix/breaks.adoc | 4 + .../cors/annotationBasedCors.adoc | 27 ++ .../cors/corsAllowCredentials.adoc | 4 +- .../cors/corsAllowedHeaders.adoc | 6 +- .../cors/corsAllowedMethods.adoc | 6 +- .../cors/corsAllowedOrigins.adoc | 12 +- .../cors/corsExposedHeaders.adoc | 4 +- .../serverConfiguration/cors/corsMaxAge.adoc | 2 +- src/main/docs/guide/toc.yml | 1 + .../http/server/cors/CorsController.groovy | 29 ++ .../server/cors/CorsControllerSpec.groovy | 61 +++ .../docs/http/server/cors/CorsController.kt | 28 ++ .../http/server/cors/CorsControllerTest.kt | 47 ++ .../docs/http/server/cors/CorsController.java | 28 ++ .../http/server/cors/CorsControllerTest.java | 53 +++ 28 files changed, 1449 insertions(+), 56 deletions(-) create mode 100644 http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CrossOriginUtilSpec.groovy create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsAssertion.java create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsUtils.java create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CrossOriginTest.java create mode 100644 http-server/src/main/java/io/micronaut/http/server/cors/CrossOrigin.java create mode 100644 http-server/src/main/java/io/micronaut/http/server/cors/CrossOriginUtil.java create mode 100644 src/main/docs/guide/httpServer/serverConfiguration/cors/annotationBasedCors.adoc create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsController.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsControllerSpec.groovy create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsController.kt create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsControllerTest.kt create mode 100644 test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsController.java create mode 100644 test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsControllerTest.java diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CorsFilterSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CorsFilterSpec.groovy index d7b3be1f0db..69ffddb7728 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CorsFilterSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CorsFilterSpec.groovy @@ -93,13 +93,13 @@ class CorsFilterSpec extends Specification { } @Unroll - void "regex matching configuration"(List regex, String origin) { + void "regex matching configuration"(String regex, String origin) { given: HttpRequest request = createRequest(origin) request.getAttribute(HttpAttributes.ROUTE_MATCH, RouteMatch.class) >> Optional.empty() CorsOriginConfiguration originConfig = new CorsOriginConfiguration() - originConfig.allowedOrigins = regex + originConfig.allowedOriginsRegex = regex HttpServerConfiguration.CorsConfiguration config = enabledCorsConfiguration([foo: originConfig]) CorsFilter corsHandler = buildCorsHandler(config) @@ -124,13 +124,13 @@ class CorsFilterSpec extends Specification { where: regex | origin - ['.*'] | 'http://www.bar.com' - ['^http://www\\.(foo|bar)\\.com$'] | 'http://www.bar.com' - ['^http://www\\.(foo|bar)\\.com$'] | 'http://www.foo.com' - ['.*bar$', '.*foo$'] | 'asdfasdf foo' - ['.*bar$', '.*foo$'] | 'asdfasdf bar' - ['.*bar$', '.*foo$'] | 'http://asdfasdf.foo' - ['.*bar$', '.*foo$'] | 'http://asdfasdf.bar' + '.*' | 'http://www.bar.com' + '^http://www\\.(foo|bar)\\.com$' | 'http://www.bar.com' + '^http://www\\.(foo|bar)\\.com$' | 'http://www.foo.com' + '.*(bar|foo)$' | 'asdfasdf foo' + '.*(bar|foo)$' | 'asdfasdf bar' + '.*(bar|foo)$' | 'http://asdfasdf.foo' + '.*(bar|foo)$' | 'http://asdfasdf.bar' } void "test handleRequest with disallowed method"() { diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CrossOriginUtilSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CrossOriginUtilSpec.groovy new file mode 100644 index 00000000000..0dffce3aee1 --- /dev/null +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/cors/CrossOriginUtilSpec.groovy @@ -0,0 +1,178 @@ +package io.micronaut.http.server.netty.cors + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires +import io.micronaut.core.util.CollectionUtils +import io.micronaut.http.HttpHeaders +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.client.HttpClient +import io.micronaut.http.server.cors.CorsOriginConfiguration +import io.micronaut.http.server.cors.CrossOrigin +import io.micronaut.http.server.cors.CrossOriginUtil +import io.micronaut.runtime.server.EmbeddedServer +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +class CrossOriginUtilSpec extends Specification { + + private static final String SPECNAME = "CrossOriginUtilSpec" + + @Shared + @AutoCleanup + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, ["spec.name": SPECNAME]) + + @Shared + @AutoCleanup + HttpClient client = embeddedServer.applicationContext.createBean(HttpClient, embeddedServer.URL) + + void "test CrossOrigin on method annotation maps to CorsOriginConfiguration"() { + when: + HttpRequest req = HttpRequest.GET("/methodexample").accept(MediaType.TEXT_PLAIN) + client.toBlocking().retrieve(req, String.class) + CorsOriginConfiguration config = embeddedServer.applicationContext.getBean(TestMethodController).config + + then: + config + config.allowedOrigins == [ "https://foo.com" ] + !config.allowedOriginsRegex.isPresent() + config.allowedHeaders == [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ] + config.exposedHeaders == [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ] + config.allowedMethods == [ HttpMethod.GET, HttpMethod.POST ] + !config.allowCredentials + config.maxAge == -1L + + cleanup: + embeddedServer.applicationContext.getBean(TestMethodController).config = null + } + + void "test CrossOrigin with value on method annotation maps to CorsOriginConfiguration allowedOrigin"() { + when: + HttpRequest req = HttpRequest.GET("/anothermethod").accept(MediaType.TEXT_PLAIN) + client.toBlocking().retrieve(req, String.class) + CorsOriginConfiguration config = embeddedServer.applicationContext.getBean(TestMethodController).config + + then: + config + config.allowedOrigins == [ "https://foo.com" ] + !config.allowedOriginsRegex + config.allowedHeaders == CorsOriginConfiguration.ANY + CollectionUtils.isEmpty(config.exposedHeaders) + config.allowedMethods == CorsOriginConfiguration.ANY_METHOD + config.allowCredentials + config.maxAge == 1800L + + cleanup: + embeddedServer.applicationContext.getBean(TestMethodController).config = null + } + + void "test CrossOrigin on class annotation maps to CorsOriginConfiguration"() { + when: + HttpRequest req = HttpRequest.GET("/classexample").accept(MediaType.TEXT_PLAIN) + client.toBlocking().retrieve(req, String.class) + CorsOriginConfiguration config = embeddedServer.applicationContext.getBean(TestClassController).config + + then: + config + config.allowedOrigins == [ "https://bar.com" ] + config.allowedHeaders == [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ] + config.exposedHeaders == [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ] + config.allowedMethods == [ HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE ] + !config.allowCredentials + config.maxAge == 3600L + + cleanup: + embeddedServer.applicationContext.getBean(TestMethodController).config = null + } + + void "test CrossOrigin with value on class annotation maps to CorsOriginConfiguration allowedOrigin"() { + when: + HttpRequest req = HttpRequest.GET("/anotherclass").accept(MediaType.TEXT_PLAIN) + client.toBlocking().retrieve(req, String.class) + CorsOriginConfiguration config = embeddedServer.applicationContext.getBean(TestAnotherClassController).config + + then: + config + config.allowedOrigins == [ "https://bar.com" ] + config.allowedHeaders == CorsOriginConfiguration.ANY + CollectionUtils.isEmpty(config.exposedHeaders) + config.allowedMethods == CorsOriginConfiguration.ANY_METHOD + config.allowCredentials + config.maxAge == 1800L + + cleanup: + embeddedServer.applicationContext.getBean(TestMethodController).config = null + } + + @Requires(property = 'spec.name', value = SPECNAME) + @Controller + static class TestMethodController { + CorsOriginConfiguration config + + @CrossOrigin( + allowedOrigins = "https://foo.com", + allowedHeaders = [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ], + exposedHeaders = [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ], + allowedMethods = [ HttpMethod.GET, HttpMethod.POST ], + allowCredentials = false, + maxAge = -1L + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/methodexample") + String method(HttpRequest req) { + this.config = CrossOriginUtil.getCorsOriginConfigurationForRequest(req).orElse(null) + return "method" + } + + @CrossOrigin( + "https://foo.com" + // allowedOriginsRegex = false - is the default + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/anothermethod") + String example(HttpRequest req) { + this.config = CrossOriginUtil.getCorsOriginConfigurationForRequest(req).orElse(null) + return "anothermethod" + } + } + + @Requires(property = 'spec.name', value = SPECNAME) + @Controller + @CrossOrigin( + allowedOrigins = "https://bar.com", + allowedHeaders = [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ], + exposedHeaders = [ HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION ], + allowedMethods = [ HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE ], + allowCredentials = false, + maxAge = 3600L + ) + static class TestClassController{ + CorsOriginConfiguration config + + @Produces(MediaType.TEXT_PLAIN) + @Get("/classexample") + String example(HttpRequest req) { + this.config = CrossOriginUtil.getCorsOriginConfigurationForRequest(req).orElse(null) + return "class" + } + } + + @Requires(property = 'spec.name', value = SPECNAME) + @Controller + @CrossOrigin("https://bar.com") + static class TestAnotherClassController{ + CorsOriginConfiguration config + + @Produces(MediaType.TEXT_PLAIN) + @Get("/anotherclass") + String example(HttpRequest req) { + this.config = CrossOriginUtil.getCorsOriginConfigurationForRequest(req).orElse(null) + return "class" + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsAssertion.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsAssertion.java new file mode 100644 index 00000000000..4fd7c06ea71 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsAssertion.java @@ -0,0 +1,182 @@ +/* + * Copyright 2017-2022 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; + +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpResponse; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * CORS assertion. + * @author Sergio del Amo + * @since 3.9.0 + */ +public final class CorsAssertion { + + private final String vary; + private final String accessControlAllowCredentials; + + private final String origin; + + private final List allowMethods; + + private final String maxAge; + + private CorsAssertion(String vary, + String accessControlAllowCredentials, + String origin, + List allowMethods, + String maxAge) { + this.vary = vary; + this.accessControlAllowCredentials = accessControlAllowCredentials; + this.origin = origin; + this.allowMethods = allowMethods; + this.maxAge = maxAge; + } + + /** + * Validate the CORS assertions. + * @param response HTTP Response to run CORS assertions against it. + */ + public void validate(HttpResponse response) { + if (StringUtils.isNotEmpty(vary)) { + assertEquals(vary, response.getHeaders().get(HttpHeaders.VARY)); + } + if (StringUtils.isNotEmpty(accessControlAllowCredentials)) { + assertEquals(accessControlAllowCredentials, response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + if (StringUtils.isNotEmpty(origin)) { + assertEquals(origin, response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + if (CollectionUtils.isNotEmpty(allowMethods)) { + assertEquals(allowMethods.stream().map(HttpMethod::toString).collect(Collectors.joining(",")), + response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)); + } + if (StringUtils.isNotEmpty(maxAge)) { + assertEquals(maxAge, response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_MAX_AGE)); + } + } + + /** + * + * @return a CORS Assertion Builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * CORS Assertion Builder. + */ + public static class Builder { + private String vary; + private String accessControlAllowCredentials; + + private String origin; + + private List allowMethods; + + private String maxAge; + + /** + * + * @param varyValue The expected value for the HTTP Header {@value HttpHeaders#VARY}. + * @return The Builder + */ + public Builder vary(String varyValue) { + this.vary = varyValue; + return this; + } + + /** + * + * @param accessControlAllowCredentials The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_CREDENTIALS}. + * @return The Builder + */ + public Builder allowCredentials(String accessControlAllowCredentials) { + this.accessControlAllowCredentials = accessControlAllowCredentials; + return this; + } + + /** + * + * Set expectation of value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_CREDENTIALS} to {@value StringUtils#TRUE}. + * @return The Builder + */ + public Builder allowCredentials() { + return allowCredentials(StringUtils.TRUE); + } + + /** + * + * Set expectation of value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_CREDENTIALS} to {@value StringUtils#TRUE}. + * @param allowCredentials Set expectation of value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_CREDENTIALS} + * @return The Builder + */ + public Builder allowCredentials(boolean allowCredentials) { + return allowCredentials ? allowCredentials(StringUtils.TRUE) : allowCredentials(""); + } + + /** + * + * @param origin The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_ORIGIN}. + * @return The Builder + */ + public Builder allowOrigin(String origin) { + this.origin = origin; + return this; + } + + /** + * + * @param method The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_METHODS}. + * @return The Builder + */ + public Builder allowMethods(HttpMethod method) { + if (allowMethods == null) { + this.allowMethods = new ArrayList<>(); + } + this.allowMethods.add(method); + return this; + } + + /** + * + * @param maxAge The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_MAX_AGE}. + * @return The Builder + */ + public Builder maxAge(String maxAge) { + this.maxAge = maxAge; + return this; + } + + /** + * + * @return A CORS assertion. + */ + public CorsAssertion build() { + return new CorsAssertion(vary, accessControlAllowCredentials, origin, allowMethods, maxAge); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsUtils.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsUtils.java new file mode 100644 index 00000000000..b689b370eb5 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/CorsUtils.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017-2022 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; + +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Utility class to do CORS related assertions. + * @author Sergio del Amo + * @since 3.9.0 + */ +public final class CorsUtils { + private CorsUtils() { + + } + + /** + * @param response HTTP Response to run CORS assertions against it. + */ + public static void assertCorsHeadersNotPresent(HttpResponse response) { + assertFalse(response.getHeaders().names().contains(HttpHeaders.VARY)); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_MAX_AGE)); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + } + + /** + * @param response HTTP Response to run CORS assertions against it. + * @param origin The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_ORIGIN}. + * @param method The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_METHODS}. + */ + public static void assertCorsHeaders(HttpResponse response, String origin, HttpMethod method) { + CorsAssertion.builder() + .vary("Origin") + .allowCredentials() + .allowOrigin(origin) + .allowMethods(method) + .maxAge("1800") + .build() + .validate(response); + } + + /** + * @param response HTTP Response to run CORS assertions against it. + * @param origin The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_ORIGIN}. + * @param method The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_METHODS}. + * @param allowCredentials The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_CREDENTIALS}. + */ + public static void assertCorsHeaders(HttpResponse response, String origin, HttpMethod method, boolean allowCredentials) { + CorsAssertion.builder() + .vary("Origin") + .allowCredentials(allowCredentials) + .allowOrigin(origin) + .allowMethods(method) + .maxAge("1800") + .build() + .validate(response); + } + + /** + * @param response HTTP Response to run CORS assertions against it. + * @param origin The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_ORIGIN}. + * @param method The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_ALLOW_METHODS}. + * @param maxAge The expected value for the HTTP Header {@value HttpHeaders#ACCESS_CONTROL_MAX_AGE}. + */ + public static void assertCorsHeaders(HttpResponse response, String origin, HttpMethod method, String maxAge) { + CorsAssertion.builder() + .vary("Origin") + .allowCredentials() + .allowOrigin(origin) + .allowMethods(method) + .maxAge(maxAge) + .build() + .validate(response); + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HeadersTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HeadersTest.java index c08fd2e82c9..c2f3efe01bf 100644 --- a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HeadersTest.java +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HeadersTest.java @@ -39,7 +39,7 @@ public class HeadersTest { public static final String SPEC_NAME = "HeadersTest"; /** - * Message header field names are case-insensitive + * Message header field names are case-insensitive. * * @see HTTP/1.1 Message Headers */ diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsDisabledByDefaultTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsDisabledByDefaultTest.java index cc52de62568..038b52236c6 100644 --- a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsDisabledByDefaultTest.java +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsDisabledByDefaultTest.java @@ -25,6 +25,7 @@ import io.micronaut.http.annotation.Post; import io.micronaut.http.annotation.Status; import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.CorsUtils; import io.micronaut.http.server.tck.HttpResponseAssertion; import io.micronaut.http.server.util.HttpHostResolver; import jakarta.inject.Singleton; @@ -34,7 +35,6 @@ import java.util.Collections; import static io.micronaut.http.server.tck.TestScenario.asserts; -import static org.junit.jupiter.api.Assertions.assertNull; @SuppressWarnings({ "java:S2259", // The tests will show if it's null @@ -56,14 +56,7 @@ void corsDisabledByDefault() throws IOException { (server, request) -> { AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() .status(HttpStatus.OK) - .assertResponse(response -> { - assertNull(response.getHeaders().get("Access-Control-Allow-Origin")); - assertNull(response.getHeaders().get("Vary")); - assertNull(response.getHeaders().get("Access-Control-Allow-Credentials")); - assertNull(response.getHeaders().get("Access-Control-Allow-Methods")); - assertNull(response.getHeaders().get("Access-Control-Allow-Headers")); - assertNull(response.getHeaders().get("Access-Control-Max-Age")); - }) + .assertResponse(CorsUtils::assertCorsHeadersNotPresent) .build()); }); } diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsSimpleRequestTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsSimpleRequestTest.java index aff8053c321..6dc7342aad3 100644 --- a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsSimpleRequestTest.java +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CorsSimpleRequestTest.java @@ -29,6 +29,7 @@ import io.micronaut.http.annotation.Status; import io.micronaut.http.client.multipart.MultipartBody; import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.CorsAssertion; import io.micronaut.http.server.tck.HttpResponseAssertion; import io.micronaut.http.server.tck.RequestSupplier; import io.micronaut.http.server.tck.ServerUnderTest; @@ -190,14 +191,12 @@ void corsSimpleRequestForLocalhostCanBeAllowedViaConfiguration() throws IOExcept AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() .status(HttpStatus.OK) - .assertResponse(response -> { - assertNotNull(response.getHeaders().get("Access-Control-Allow-Origin")); - assertNotNull(response.getHeaders().get("Vary")); - assertNotNull(response.getHeaders().get("Access-Control-Allow-Credentials")); - assertNull(response.getHeaders().get("Access-Control-Allow-Methods")); - assertNull(response.getHeaders().get("Access-Control-Allow-Headers")); - assertNull(response.getHeaders().get("Access-Control-Max-Age")); - }) + .assertResponse(response -> CorsAssertion.builder() + .vary("Origin") + .allowCredentials() + .allowOrigin("https://foo.com") + .build() + .validate(response)) .build()); assertEquals(1, refreshCounter.getRefreshCount()); }); diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CrossOriginTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CrossOriginTest.java new file mode 100644 index 00000000000..de678b9d434 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/cors/CrossOriginTest.java @@ -0,0 +1,448 @@ +/* + * Copyright 2017-2022 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.cors; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.CollectionUtils; +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.MediaType; +import io.micronaut.http.MutableHttpRequest; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.cors.CrossOrigin; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.CorsUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import io.micronaut.http.server.tck.ServerUnderTest; +import io.micronaut.http.server.util.HttpHostResolver; +import io.micronaut.http.uri.UriBuilder; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.util.function.BiConsumer; + +import static io.micronaut.http.server.tck.CorsUtils.*; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings({ + "java:S2259", // The tests will show if it's null + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class CrossOriginTest { + + private static final String SPECNAME = "CrossOriginTest"; + + @Test + void crossOriginAnnotationWithMatchingOrigin() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/foo").path("bar"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + }) + .build())); + } + + @Test + void crossOriginAnnotationWithNoMatchingOrigin() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/foo").path("bar"), "https://bar.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.METHOD_NOT_ALLOWED) + .assertResponse(CorsUtils::assertCorsHeadersNotPresent) + .build())); + } + + @Test + void verifyHttpMethodIsValidatedInACorsRequest() { + assertAll( + () -> asserts(SPECNAME, + preflight(UriBuilder.of("/methods").path("getit"), "https://www.google.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://www.google.com", HttpMethod.GET); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + }) + .build())), + () -> asserts(SPECNAME, + preflight(UriBuilder.of("/methods").path("postit").path("id"), "https://www.google.com", HttpMethod.POST), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://www.google.com", HttpMethod.POST); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + }) + .build())), + () -> asserts(SPECNAME, + preflight(UriBuilder.of("/methods").path("deleteit").path("id"), "https://www.google.com", HttpMethod.DELETE), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.FORBIDDEN) + .assertResponse(CorsUtils::assertCorsHeadersNotPresent) + .build())) + ); + } + + @Test + void allowedOriginsRegexHappyPath() throws IOException { + URI uri = UriBuilder.of("/allowedoriginsregex").path("foo").build(); + String origin = "https://foo.com"; + asserts(SPECNAME, preflight(uri, origin, HttpMethod.GET), happyPathAssertion(origin)); + origin = "http://foo.com"; + asserts(SPECNAME, preflight(uri, origin, HttpMethod.GET), happyPathAssertion(origin)); + } + + private static BiConsumer> happyPathAssertion(String origin) { + return (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, origin, HttpMethod.GET); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + }) + .build()); + } + + @Test + void allowedOriginsRegexFailure() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/allowedoriginsregex").path("bar"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.METHOD_NOT_ALLOWED) + .assertResponse(CorsUtils::assertCorsHeadersNotPresent) + .build())); + } + + @Test + void allowedHeadersHappyPath() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/allowedheaders").path("bar"), "https://foo.com", HttpMethod.GET) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, HttpHeaders.AUTHORIZATION + "," + HttpHeaders.CONTENT_TYPE), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET); + assertTrue(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + }) + .build())); + } + + /** + * Access-Control-Allow-Headers + * The Access-Control-Allow-Headers response header is used in response to a preflight request which includes the Access-Control-Request-Headers to indicate which HTTP headers can be used during the actual request. + */ + @Test + void allowedHeadersFailure() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/allowedheaders").path("bar"), "https://foo.com", HttpMethod.GET) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "foo"), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.FORBIDDEN) + .assertResponse(CorsUtils::assertCorsHeadersNotPresent) + .build())); + } + + /** + * The Access-Control-Expose-Headers header adds the specified headers to the allowlist that JavaScript (such as getResponseHeader()) in browsers is allowed to access. + * @see Access-Control-Expose-Headers + */ + @Test + void defaultAccessControlExposeHeaderValueIsNotSet() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/exposedheaders").path("foo"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS)); + }) + .build())); + } + + /** + * The Access-Control-Expose-Headers header adds the specified headers to the allowlist that JavaScript (such as getResponseHeader()) in browsers is allowed to access. + * @see Access-Control-Expose-Headers + */ + @Test + void httHeaderValueAccessControlExposeHeaderValueCanBeSetViaCrossOriginAnnotation() throws IOException { + asserts(SPECNAME, + CollectionUtils.mapOf("micronaut.server.cors.single-header", StringUtils.TRUE), + preflight(UriBuilder.of("/exposedheaders").path("bar"), "https://foo.com", HttpMethod.GET) + .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "foo"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET); + assertTrue(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS)); + assertEquals("Content-Encoding,Kuma-Revision", response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS)); + }) + .build())); + } + + /** + * The Access-Control-Allow-Credentials response header tells browsers whether to expose the response to the frontend JavaScript code when the request's credentials mode (Request.credentials) is include. + * @see Access-Control-Allow-Credentials + */ + @Test + void defaultAccessControlAllowCredentialsValueIsNotSet() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/credentials").path("foo"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET, false); + assertFalse(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + }) + .build())); + } + + /** + * The Access-Control-Allow-Credentials response header tells browsers whether to expose the response to the frontend JavaScript code when the request's credentials mode (Request.credentials) is include. + * @see Access-Control-Allow-Credentials + */ + @Test + void defaultAccessControlAllowCredentialsValueIsSet() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/credentials").path("bar"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET); + assertTrue(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assertEquals("true", response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + }) + .build())); + } + + /** + * The Access-Control-Max-Age response header indicates how long the results of a preflight request (that is the information contained in the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers) can be cached. + * @see Access-Control-Max-Age + */ + @Test + void defaultAccessControlMaxAgeValueIsSet() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/maxage").path("foo"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET); + assertTrue(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_MAX_AGE)); + assertEquals("1800", response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_MAX_AGE)); + }) + .build())); + } + + /** + * The Access-Control-Max-Age response header indicates how long the results of a preflight request (that is the information contained in the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers) can be cached. + * @see Access-Control-Max-Age + */ + @Test + void accessControlMaxAgeValueIsSet() throws IOException { + asserts(SPECNAME, + preflight(UriBuilder.of("/maxage").path("bar"), "https://foo.com", HttpMethod.GET), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .assertResponse(response -> { + assertCorsHeaders(response, "https://foo.com", HttpMethod.GET, "1000"); + assertTrue(response.getHeaders().names().contains(HttpHeaders.ACCESS_CONTROL_MAX_AGE)); + assertEquals("1000", response.getHeaders().get(HttpHeaders.ACCESS_CONTROL_MAX_AGE)); + }) + .build())); + } + + private static MutableHttpRequest preflight(UriBuilder uriBuilder, String originValue, HttpMethod method) { + return preflight(uriBuilder.build(), originValue, method); + } + + private static MutableHttpRequest preflight(URI uri, String originValue, HttpMethod method) { + return HttpRequest.OPTIONS(uri) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .header(HttpHeaders.ORIGIN, originValue) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method); + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/foo") + static class Foo { + @CrossOrigin("https://foo.com") + @Produces(MediaType.TEXT_PLAIN) + @Get("/bar") + String index() { + return "bar"; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/allowedoriginsregex") + static class AllowedOriginsRegex { + + @CrossOrigin( + allowedOriginsRegex = "^http(|s):\\/\\/foo\\.com$" + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/foo") + String foo() { + return "foo"; + } + + @CrossOrigin( + value = "^http(|s):\\/\\/foo\\.com$" + // allowedOriginsRegex defaults to false + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/bar") + String bar() { + return "bar"; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/methods") + @CrossOrigin( + allowedOrigins = "https://www.google.com", + allowedMethods = { HttpMethod.GET, HttpMethod.POST } + ) + static class AllowedMethods { + @Produces(MediaType.TEXT_PLAIN) + @Get("/getit") + String canGet() { + return "get"; + } + + @Produces(MediaType.TEXT_PLAIN) + @Post("/postit/{id}") + String canPost(@PathVariable String id) { + return id; + } + + @Delete("/deleteit/{id}") + String cantDelete(@PathVariable String id) { + return id; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/allowedheaders") + @CrossOrigin( + value = "https://foo.com", + allowedHeaders = { HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION } + ) + static class AllowedHeaders { + @Produces(MediaType.TEXT_PLAIN) + @Get("/bar") + String index() { + return "bar"; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/exposedheaders") + static class ExposedHeaders { + @CrossOrigin( + value = "https://foo.com", + exposedHeaders = { "Content-Encoding", "Kuma-Revision" } + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/bar") + String bar() { + return "bar"; + } + + @CrossOrigin( + value = "https://foo.com" + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/foo") + String foo() { + return "foo"; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/credentials") + static class Credentials { + @CrossOrigin( + value = "https://foo.com", + allowCredentials = false + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/foo") + String foo() { + return "foo"; + } + + @CrossOrigin( + value = "https://foo.com" + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/bar") + String bar() { + return "bar"; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Controller("/maxage") + static class MaxAge { + @CrossOrigin( + value = "https://foo.com" + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/foo") + String foo() { + return "foo"; + } + + @CrossOrigin( + value = "https://foo.com", + maxAge = 1000L + ) + @Produces(MediaType.TEXT_PLAIN) + @Get("/bar") + String bar() { + return "bar"; + } + } + + @Requires(property = "spec.name", value = SPECNAME) + @Replaces(HttpHostResolver.class) + @Singleton + static class HttpHostResolverReplacement implements HttpHostResolver { + @Override + public String resolve(@Nullable HttpRequest request) { + return "https://micronautexample.com"; + } + } +} 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 58eef88f473..7bf75191a32 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 @@ -97,7 +97,7 @@ public Publisher> doFilter(HttpRequest request, Server LOG.trace("Http Header " + HttpHeaders.ORIGIN + " not present. Proceeding with the request."); return chain.proceed(request); } - CorsOriginConfiguration corsOriginConfiguration = getConfiguration(origin).orElse(null); + CorsOriginConfiguration corsOriginConfiguration = getConfiguration(request).orElse(null); if (corsOriginConfiguration != null) { if (CorsUtil.isPreflightRequest(request)) { return handlePreflightRequest(request, chain, corsOriginConfiguration); @@ -317,31 +317,45 @@ protected void setMaxAge(long maxAge, MutableHttpResponse response) { } @NonNull - private Optional getConfiguration(@NonNull String requestOrigin) { + private Optional getConfiguration(@NonNull HttpRequest request) { + String requestOrigin = request.getHeaders().getOrigin().orElse(null); + if (requestOrigin == null) { + return Optional.empty(); + } + Optional originConfiguration = CrossOriginUtil.getCorsOriginConfigurationForRequest(request); + if (originConfiguration.isPresent() && matchesOrigin(originConfiguration.get(), requestOrigin)) { + return originConfiguration; + } if (!corsConfiguration.isEnabled()) { return Optional.empty(); } return corsConfiguration.getConfigurations().values().stream() - .filter(config -> { - List allowedOrigins = config.getAllowedOrigins(); - return !allowedOrigins.isEmpty() && (isAny(allowedOrigins) || allowedOrigins.stream().anyMatch(origin -> matchesOrigin(origin, requestOrigin))); - }).findFirst(); + .filter(config -> matchesOrigin(config, requestOrigin)) + .findFirst(); } - private boolean matchesOrigin(@NonNull String origin, @NonNull String requestOrigin) { - if (origin.equals(requestOrigin)) { + private static boolean matchesOrigin(@NonNull CorsOriginConfiguration config, String requestOrigin) { + if (config.getAllowedOriginsRegex().map(regex -> matchesOrigin(regex, requestOrigin)).orElse(false)) { return true; } - Pattern p = Pattern.compile(origin); + List allowedOrigins = config.getAllowedOrigins(); + return !allowedOrigins.isEmpty() && ( + (!config.getAllowedOriginsRegex().isPresent() && isAny(allowedOrigins)) || + allowedOrigins.stream().anyMatch(origin -> origin.equals(requestOrigin)) + ); + } + + private static boolean matchesOrigin(@NonNull String originRegex, @NonNull String requestOrigin) { + Pattern p = Pattern.compile(originRegex); Matcher m = p.matcher(requestOrigin); return m.matches(); } - private boolean isAny(List values) { + private static boolean isAny(List values) { return values == CorsOriginConfiguration.ANY; } - private boolean isAnyMethod(List allowedMethods) { + private static boolean isAnyMethod(List allowedMethods) { return allowedMethods == CorsOriginConfiguration.ANY_METHOD; } diff --git a/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConfiguration.java b/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConfiguration.java index 8632dc27e90..e0be0f806a4 100644 --- a/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConfiguration.java +++ b/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConfiguration.java @@ -15,11 +15,13 @@ */ package io.micronaut.http.server.cors; +import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.StringUtils; import io.micronaut.http.HttpMethod; - import java.util.Collections; import java.util.List; +import java.util.Optional; /** * Stores configuration for CORS. @@ -29,7 +31,6 @@ * @since 1.0 */ public class CorsOriginConfiguration { - /** * Constant to represent any value. */ @@ -41,6 +42,7 @@ public class CorsOriginConfiguration { public static final List ANY_METHOD = Collections.emptyList(); private List allowedOrigins = ANY; + private String allowedOriginsRegex; private List allowedMethods = ANY_METHOD; private List allowedHeaders = ANY; private List exposedHeaders = Collections.emptyList(); @@ -65,6 +67,26 @@ public void setAllowedOrigins(@Nullable List allowedOrigins) { } } + /** + * @return a regular expression for matching Allowed Origins. + */ + @NonNull + public Optional getAllowedOriginsRegex() { + if (allowedOriginsRegex == null || allowedOriginsRegex.equals(StringUtils.EMPTY_STRING)) { + return Optional.empty(); + } + return Optional.ofNullable(allowedOriginsRegex); + } + + /** + * Sets a regular expression for matching Allowed Origins. + * + * @param allowedOriginsRegex a regular expression for matching Allowed Origins. + */ + public void setAllowedOriginsRegex(String allowedOriginsRegex) { + this.allowedOriginsRegex = allowedOriginsRegex; + } + /** * @return The allowed methods */ diff --git a/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConverter.java b/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConverter.java index a5b4da4112b..baa6cd42f37 100644 --- a/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConverter.java +++ b/http-server/src/main/java/io/micronaut/http/server/cors/CorsOriginConverter.java @@ -40,6 +40,7 @@ public class CorsOriginConverter implements TypeConverter, CorsOriginConfiguration> { private static final String ALLOWED_ORIGINS = "allowed-origins"; + private static final String ALLOWED_ORIGINS_REGEX = "allowed-origins-regex"; private static final String ALLOWED_METHODS = "allowed-methods"; private static final String ALLOWED_HEADERS = "allowed-headers"; private static final String EXPOSED_HEADERS = "exposed-headers"; @@ -57,6 +58,10 @@ public Optional convert(Map object, Cla .get(ALLOWED_ORIGINS, ConversionContext.LIST_OF_STRING) .ifPresent(configuration::setAllowedOrigins); + convertibleValues + .get(ALLOWED_ORIGINS_REGEX, ConversionContext.STRING) + .ifPresent(configuration::setAllowedOriginsRegex); + convertibleValues .get(ALLOWED_METHODS, CONVERSION_CONTEXT_LIST_OF_HTTP_METHOD) .ifPresent(configuration::setAllowedMethods); diff --git a/http-server/src/main/java/io/micronaut/http/server/cors/CrossOrigin.java b/http-server/src/main/java/io/micronaut/http/server/cors/CrossOrigin.java new file mode 100644 index 00000000000..4eeed2849b0 --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/cors/CrossOrigin.java @@ -0,0 +1,91 @@ +/* + * Copyright 2017-2022 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.cors; + +import io.micronaut.context.annotation.AliasFor; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpMethod; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Support CORs configuration via annotation. For example, it will enable Micronaut developers only + * to allow CORS for a few routes in their applications. Thus, having more secure + * applications. + * @since 3.9.0 + */ +@Documented +@Inherited +@Retention(RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface CrossOrigin { + + /** + * + * @return the origins for which cross-origin requests are allowed + */ + @AliasFor(member = "allowedOrigins") + String[] value() default {}; + + /** + * + * @return the origins for which cross-origin requests are allowed + */ + @AliasFor(member = "value") + String[] allowedOrigins() default {}; + + /** + * + * @return regular expression to match allowed origins + */ + String allowedOriginsRegex() default StringUtils.EMPTY_STRING; + + /** + * + * @return request headers permitted in requests + */ + String[] allowedHeaders() default {}; + + /** + * + * @return response headers that user-agent will allow client to access on actual response + */ + String[] exposedHeaders() default {}; + + /** + * + * @return supported HTTP request methods + */ + HttpMethod[] allowedMethods() default {}; + + /** + * + * @return whether the browser should send credentials + */ + boolean allowCredentials() default true; + + /** + * + * @return maximum age (in seconds) of the cache duration for preflight responses + */ + long maxAge() default 1800L; +} diff --git a/http-server/src/main/java/io/micronaut/http/server/cors/CrossOriginUtil.java b/http-server/src/main/java/io/micronaut/http/server/cors/CrossOriginUtil.java new file mode 100644 index 00000000000..4c7cf81bccd --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/cors/CrossOriginUtil.java @@ -0,0 +1,86 @@ +/* + * Copyright 2017-2022 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.cors; + +import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.http.HttpAttributes; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Utility classes to work with {@link CrossOrigin}. + * @author Sergio del Amo + * @since 3.9.0 + */ +public final class CrossOriginUtil { + + public static final String MEMBER_ALLOWED_ORIGINS = "allowedOrigins"; + public static final String MEMBER_ALLOWED_ORIGINS_REGEX = "allowedOriginsRegex"; + public static final String MEMBER_ALLOWED_HEADERS = "allowedHeaders"; + public static final String MEMBER_EXPOSED_HEADERS = "exposedHeaders"; + public static final String MEMBER_ALLOWED_METHODS = "allowedMethods"; + public static final String MEMBER_ALLOW_CREDENTIALS = "allowCredentials"; + public static final String MEMBER_MAX_AGE = "maxAge"; + + private CrossOriginUtil() { + } + + /** + * @param request the HTTP request for the configuration + * @return the cors origin configuration for the given request + */ + @NonNull + public static Optional getCorsOriginConfigurationForRequest(@NonNull HttpRequest request) { + return request.getAttribute(HttpAttributes.ROUTE_MATCH, AnnotationMetadata.class) + .flatMap(CrossOriginUtil::getCorsOriginConfiguration); + } + + private static Optional getCorsOriginConfiguration(@NonNull AnnotationMetadata annotationMetadata) { + if (!annotationMetadata.hasAnnotation(CrossOrigin.class)) { + return Optional.empty(); + } + CorsOriginConfiguration config = new CorsOriginConfiguration(); + config.setAllowedOrigins(Arrays.asList(annotationMetadata.stringValues(CrossOrigin.class, MEMBER_ALLOWED_ORIGINS))); + annotationMetadata.stringValue(CrossOrigin.class, MEMBER_ALLOWED_ORIGINS_REGEX) + .ifPresent(config::setAllowedOriginsRegex); + + String[] allowedHeaders = annotationMetadata.stringValues(CrossOrigin.class, MEMBER_ALLOWED_HEADERS); + List allowedHeadersList = allowedHeaders.length == 0 ? CorsOriginConfiguration.ANY : Arrays.asList(allowedHeaders); + config.setAllowedHeaders(allowedHeadersList); + config.setExposedHeaders(Arrays.asList(annotationMetadata.stringValues(CrossOrigin.class, MEMBER_EXPOSED_HEADERS))); + + + List allowedMethods = Stream.of(annotationMetadata.stringValues(CrossOrigin.class, MEMBER_ALLOWED_METHODS)) + .map(HttpMethod::parse) + .filter(method -> method != HttpMethod.CUSTOM) + .collect(Collectors.toList()); + config.setAllowedMethods(CollectionUtils.isNotEmpty(allowedMethods) ? allowedMethods : CorsOriginConfiguration.ANY_METHOD); + + annotationMetadata.booleanValue(CrossOrigin.class, MEMBER_ALLOW_CREDENTIALS) + .ifPresent(config::setAllowCredentials); + annotationMetadata.longValue(CrossOrigin.class, MEMBER_MAX_AGE) + .ifPresent(config::setMaxAge); + return Optional.of(config); + } +} diff --git a/src/main/docs/guide/appendix/breaks.adoc b/src/main/docs/guide/appendix/breaks.adoc index e5129b3aaeb..93fcd101773 100644 --- a/src/main/docs/guide/appendix/breaks.adoc +++ b/src/main/docs/guide/appendix/breaks.adoc @@ -1,5 +1,9 @@ This section documents breaking changes between Micronaut versions +== 3.9.0 + +Since Micronaut Framework 3.9.0, CORS `allowed-origins` configuration does not support regular expressions to prevent accidentally exposing your API. You can use `allowed-origins-regex`, if you wish to support a regular expression. + == 3.8.7 Micronaut Framework 3.8.7 updates to https://bitbucket.org/snakeyaml/snakeyaml/wiki/Changes[SnakeYAML 2.0] which addresses https://nvd.nist.gov/vuln/detail/CVE-2022-1471[CVE-2022-1471]. Many organizations' policies forbid their teams to use Micronaut Framework if the framework depends on a vulnerable dependency, even if the framework is unaffected. Micronaut Framework is not affected by https://nvd.nist.gov/vuln/detail/CVE-2022-1471[CVE-2022-1471]. diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/annotationBasedCors.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/annotationBasedCors.adoc new file mode 100644 index 00000000000..62229e41cda --- /dev/null +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/annotationBasedCors.adoc @@ -0,0 +1,27 @@ +Micronaut CORS configuration applies by default to all endpoints in the running application. + +As an alternative, <> can be applied in a more fine-grained manner to specific routes using the +link:{api}/io/micronaut/http/server/cors/CrossOrigin.html[@CrossOrigin] annotation. This is applied to `@Controller` to apply the CORS configuration to all endpoints in the controller. Alternatively, the annotation can be applied to specific endpoints on a controller, for even more fine-grained control. + +The `@CrossOrigin` annotation maps with a one-to-one correspondence to application-wide link:{api}/io/micronaut/http/server/cors/CorsOriginConfiguration.html[CorsOriginConfiguration] configuration properties. To specify just an allowed origin, use `@CrossOrigin("https://foo.com")`. To specify additional configuration details, use a combination of annotation attributes the same as you would for specifying global `CorsOriginConfiguration` properties. + +[source,java] +---- +@CrossOrigin( + allowedOrigins = { "http://foo.com" }, + allowedOriginsRegex = "^http(|s):\\/\\/www\\.google\\.com$", + allowedMethods = { HttpMethod.POST, HttpMethod.PUT }, + allowedHeaders = { HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION }, + exposedHeaders = { HttpHeaders.CONTENT_TYPE, HttpHeaders.AUTHORIZATION }, + allowCredentials = false, + maxAge = 3600 +) +---- + +The following example demonstrates how the annotation might be applied to a specific endpoint. To enable CORS for all endpoints in the controller, move the annotation to the class level and configure it appropriately. + +snippet::io.micronaut.docs.http.server.cors.CorsController[tags="imports,controller", indent=0, title="@CrossOrigin Example"] + +<1> The ann:http.server.cors.CrossOrigin[] annotation is applied to a specific endpoint, making the CORS configuration fine-grained. +<2> The `GET /hello` endpoint has "https://myui.com" as an allowed cross origin endpoint +<3> The `GET /hello/nocors` endpoint cannot use "https://myui.com" as an origin, since it doesn't have a CORS configuration that allows it. diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowCredentials.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowCredentials.adoc index 65c5b6b121e..af5b6b4ade8 100644 --- a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowCredentials.adoc +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowCredentials.adoc @@ -1,4 +1,4 @@ -Credentials are allowed by default for CORS requests. To disallow credentials, set the `allowCredentials` option to `false`. +Credentials are allowed by default for CORS requests. To disallow credentials, set the `allow-credentials` option to `false`. .Example CORS Configuration [configuration] @@ -9,5 +9,5 @@ micronaut: enabled: true configurations: web: - allowCredentials: false + allow-credentials: false ---- diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedHeaders.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedHeaders.adoc index ea08df88177..fb6e540e8a7 100644 --- a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedHeaders.adoc +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedHeaders.adoc @@ -1,6 +1,6 @@ -To allow any request header for a given configuration, don't include the `allowedHeaders` key in your configuration. +To allow any request header for a given configuration, don't include the `allowed-headers` key in your configuration. -For multiple allowed headers, set the `allowedHeaders` key of the configuration to a list of strings. +For multiple allowed headers, set the `allowed-headers` key of the configuration to a list of strings. .Example CORS Configuration [configuration] @@ -11,7 +11,7 @@ micronaut: enabled: true configurations: web: - allowedHeaders: + allowed-headers: - Content-Type - Authorization ---- diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedMethods.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedMethods.adoc index a2b033ae3d5..c34bc16f788 100644 --- a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedMethods.adoc +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedMethods.adoc @@ -1,6 +1,6 @@ -To allow any request method for a given configuration, don't include the `allowedMethods` key in your configuration. +To allow any request method for a given configuration, don't include the `allowed-methods` key in your configuration. -For multiple allowed methods, set the `allowedMethods` key of the configuration to a list of strings. +For multiple allowed methods, set the `allowed-methods` key of the configuration to a list of strings. .Example CORS Configuration [configuration] @@ -11,7 +11,7 @@ micronaut: enabled: true configurations: web: - allowedMethods: + allowed-methods: - POST - PUT ---- diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedOrigins.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedOrigins.adoc index a1a10186380..580473c5a0e 100644 --- a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedOrigins.adoc +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsAllowedOrigins.adoc @@ -1,8 +1,8 @@ -To allow any origin for a given configuration, don't include the `allowedOrigins` key in your configuration. +Don't define `allowed-origins` or `allowed-origins-regex` to allow any origin for a given configuration. -For multiple valid origins, set the `allowedOrigins` key of the configuration to a list of strings. Each value can either be a static value (`http://www.foo.com`) or a regular expression (`^http(|s)://www\.google\.com$`). +For multiple valid origins, set the `allowed-origins` key of the configuration to a list of strings. -Regular expressions are passed to link:{javase}java/util/regex/Pattern.html#compile-java.lang.String-[Pattern#compile] and compared to the request origin with link:{javase}java/util/regex/Matcher.html#matches--[Matcher#matches]. +You can also define via `allowed-origins-regex` a regular expression (`^http(|s)://www\.google\.com$`). The Regular expression is passed to link:{javase}java/util/regex/Pattern.html#compile-java.lang.String-[Pattern#compile] and compared to the request origin with link:{javase}java/util/regex/Matcher.html#matches--[Matcher#matches]. .Example CORS Configuration [configuration] @@ -13,7 +13,9 @@ micronaut: enabled: true configurations: web: - allowedOrigins: + allowed-origins-regex: '^http(|s):\/\/www\.google\.com$' + allowed-origins: - http://foo.com - - ^http(|s):\/\/www\.google\.com$ ---- + +WARNING: Use the `allowed-origins-regex` configuration judiciously. You may accidentally make an insecure configuration which could be targeted by an attacker registering domains targeting the regular expression. diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsExposedHeaders.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsExposedHeaders.adoc index ed2aeb6df5f..77bbf99c226 100644 --- a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsExposedHeaders.adoc +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsExposedHeaders.adoc @@ -1,4 +1,4 @@ -To configure the headers that are sent in the response to a CORS request through the `Access-Control-Expose-Headers` header, include a list of strings for the `exposedHeaders` key in your configuration. None are exposed by default. +To configure the headers that are sent in the response to a CORS request through the `Access-Control-Expose-Headers` header, include a list of strings for the `exposed-headers` key in your configuration. None are exposed by default. .Example CORS Configuration [configuration] @@ -9,7 +9,7 @@ micronaut: enabled: true configurations: web: - exposedHeaders: + exposed-headers: - Content-Type - Authorization ---- diff --git a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsMaxAge.adoc b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsMaxAge.adoc index 93f2c2557f9..34523504b66 100644 --- a/src/main/docs/guide/httpServer/serverConfiguration/cors/corsMaxAge.adoc +++ b/src/main/docs/guide/httpServer/serverConfiguration/cors/corsMaxAge.adoc @@ -9,5 +9,5 @@ micronaut: enabled: true configurations: web: - maxAge: 3600 # 1 hour + max-age: 3600 # 1 hour ---- diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index f4f4d21ff63..f28965377d5 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -130,6 +130,7 @@ httpServer: listener: Advanced Listener Configuration cors: title: Configuring CORS + annotationBasedCors: Annotation-based CORS Configuration corsConfiguration: CORS via Configuration corsAllowedOrigins: Allowed Origins corsAllowedMethods: Allowed Methods diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsController.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsController.groovy new file mode 100644 index 00000000000..50f8d05c4f0 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsController.groovy @@ -0,0 +1,29 @@ +package io.micronaut.docs.http.server.cors + +import io.micronaut.context.annotation.Requires + +// tag::imports[] +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.server.cors.CrossOrigin +// end::imports[] + +@Requires(property = "spec.name", value = "CorsControllerSpec") +// tag::controller[] +@Controller("/hello") +class CorsController { + @CrossOrigin("https://myui.com") // <1> + @Get(produces = MediaType.TEXT_PLAIN) // <2> + String cors() { + return "Welcome to the worlds of CORS" + } + + @Produces(MediaType.TEXT_PLAIN) + @Get("/nocors") // <3> + String nocorstoday() { + return "No more CORS for you" + } +} +// end::controller[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsControllerSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsControllerSpec.groovy new file mode 100644 index 00000000000..0e147c50bd1 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/http/server/cors/CorsControllerSpec.groovy @@ -0,0 +1,61 @@ +package io.micronaut.docs.http.server.cors + +import io.micronaut.context.ApplicationContext +import io.micronaut.core.util.CollectionUtils +import io.micronaut.http.HttpHeaders +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.MutableHttpRequest +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.http.uri.UriBuilder +import io.micronaut.runtime.server.EmbeddedServer +import spock.lang.Specification + +class CorsControllerSpec extends Specification { + + void "CrossOrigin with allowed Origin"() { + given: + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, CollectionUtils.mapOf("spec.name", "CorsControllerSpec")) + HttpRequest request = preflight(UriBuilder.of("/hello"), "https://myui.com", HttpMethod.GET) + HttpClient httpClient = embeddedServer.applicationContext.createBean(HttpClient, embeddedServer.URL) + BlockingHttpClient client = httpClient.toBlocking() + + when: + client.exchange(request) + + then: + noExceptionThrown() + + cleanup: + httpClient.close() + embeddedServer.close() + } + + void "CrossOrigin with not allowed Origin"() { + given: + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, CollectionUtils.mapOf("spec.name", "CorsControllerSpec")) + HttpRequest request = preflight(UriBuilder.of("/hello"), "https://google.com", HttpMethod.GET) + HttpClient httpClient = embeddedServer.applicationContext.createBean(HttpClient, embeddedServer.URL) + BlockingHttpClient client = httpClient.toBlocking() + + when: + client.exchange(request) + + then: + thrown(HttpClientResponseException) + + cleanup: + httpClient.close() + embeddedServer.close() + } + + private static MutableHttpRequest preflight(UriBuilder uriBuilder, String originValue, HttpMethod method) { + return HttpRequest.OPTIONS(uriBuilder.build()) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .header(HttpHeaders.ORIGIN, originValue) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + } +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsController.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsController.kt new file mode 100644 index 00000000000..682b1be033b --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsController.kt @@ -0,0 +1,28 @@ +package io.micronaut.docs.http.server.cors + +// tag::imports[] +import io.micronaut.context.annotation.Requires +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.server.cors.CrossOrigin +// end::imports[] + +@Requires(property = "spec.name", value = "CorsControllerSpec") +// tag::controller[] +@Controller("/hello") +class CorsController { + @CrossOrigin("https://myui.com") // <1> + @Get(produces = [MediaType.TEXT_PLAIN]) // <2> + fun cors(): String { + return "Welcome to the worlds of CORS" + } + + @Produces(MediaType.TEXT_PLAIN) + @Get("/nocors") // <3> + fun nocorstoday(): String { + return "No more CORS for you" + } +} +// end::controller[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsControllerTest.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsControllerTest.kt new file mode 100644 index 00000000000..799c09ecc25 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/http/server/cors/CorsControllerTest.kt @@ -0,0 +1,47 @@ +package io.micronaut.docs.http.server.cors + +import io.micronaut.context.ApplicationContext +import io.micronaut.http.* +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.http.uri.UriBuilder +import io.micronaut.runtime.server.EmbeddedServer +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class CorsControllerTest { + @Test + fun crossOriginWithAllowedOrigin() { + val embeddedServer = ApplicationContext.run(EmbeddedServer::class.java, mapOf("spec.name" to "CorsControllerSpec")) + val request: HttpRequest = preflight(UriBuilder.of("/hello"), "https://myui.com", HttpMethod.GET) + val httpClient = embeddedServer.applicationContext.createBean(HttpClient::class.java, embeddedServer.url) + val client = httpClient.toBlocking() + Assertions.assertDoesNotThrow> { + client.exchange(request) + } + httpClient.close() + embeddedServer.close() + } + + @Test + fun crossOriginWithNotAllowedOrigin() { + val embeddedServer = ApplicationContext.run(EmbeddedServer::class.java, mapOf("spec.name" to "CorsControllerSpec")) + val request: HttpRequest = preflight(UriBuilder.of("/hello"), "https://google.com", HttpMethod.GET) + val httpClient = embeddedServer.applicationContext.createBean( + HttpClient::class.java, embeddedServer.url + ) + val client = httpClient.toBlocking() + Assertions.assertThrows(HttpClientResponseException::class.java) { + val response: HttpResponse = client.exchange(request) + } + httpClient.close() + embeddedServer.close() + } + + fun preflight(uriBuilder: UriBuilder, originValue: String, method: HttpMethod): HttpRequest { + return HttpRequest.OPTIONS(uriBuilder.build()) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .header(HttpHeaders.ORIGIN, originValue) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + } +} diff --git a/test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsController.java b/test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsController.java new file mode 100644 index 00000000000..b84e3ab8bcc --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsController.java @@ -0,0 +1,28 @@ +package io.micronaut.docs.http.server.cors; + +// tag::imports[] +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.cors.CrossOrigin; +// end::imports[] + +@Requires(property = "spec.name", value = "CorsControllerSpec") +// tag::controller[] +@Controller("/hello") +public class CorsController { + @CrossOrigin("https://myui.com") // <1> + @Get(produces = MediaType.TEXT_PLAIN) // <2> + public String cors() { + return "Welcome to the worlds of CORS"; + } + + @Produces(MediaType.TEXT_PLAIN) + @Get("/nocors") // <3> + public String nocorstoday() { + return "No more CORS for you"; + } +} +// end::controller[] diff --git a/test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsControllerTest.java b/test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsControllerTest.java new file mode 100644 index 00000000000..be05521708e --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/http/server/cors/CorsControllerTest.java @@ -0,0 +1,53 @@ +package io.micronaut.docs.http.server.cors; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.http.*; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.uri.UriBuilder; +import io.micronaut.runtime.server.EmbeddedServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CorsControllerTest { + + @Test + void crossOriginWithAllowedOrigin() { + + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer.class, CollectionUtils.mapOf("spec.name", "CorsControllerSpec")); + HttpRequest request = preflight(UriBuilder.of("/hello"), "https://myui.com", HttpMethod.GET); + HttpClient httpClient = embeddedServer.getApplicationContext().createBean(HttpClient.class, embeddedServer.getURL()); + BlockingHttpClient client = httpClient.toBlocking(); + + assertDoesNotThrow(() -> client.exchange(request)); + + httpClient.close(); + embeddedServer.close(); + } + + @Test + void crossOriginWithNotAllowedOrigin() { + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer.class, CollectionUtils.mapOf("spec.name", "CorsControllerSpec")); + HttpRequest request = preflight(UriBuilder.of("/hello"), "https://google.com", HttpMethod.GET); + HttpClient httpClient = embeddedServer.getApplicationContext().createBean(HttpClient.class, embeddedServer.getURL()); + BlockingHttpClient client = httpClient.toBlocking(); + + Executable e = () -> client.exchange(request); + assertThrows(HttpClientResponseException.class, e); + + httpClient.close(); + embeddedServer.close(); + } + + private static MutableHttpRequest preflight(UriBuilder uriBuilder, String originValue, HttpMethod method) { + return HttpRequest.OPTIONS(uriBuilder.build()) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .header(HttpHeaders.ORIGIN, originValue) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method); + } +} From a18658afdabe7bb7429c1c0fd6f60c50a0ea2cfd Mon Sep 17 00:00:00 2001 From: Jonas Konrad Date: Wed, 15 Mar 2023 11:25:55 +0100 Subject: [PATCH 04/21] test: More mTLS test cases (#8944) This PR adds more test cases for the server side of mTLS. These came from an internal user that reported expired certs being accepted. The test cases check a normal cert, an expired cert, and an untrusted cert. The previous RequestCertificateSpec only tests the "happy path" with the valid cert. These tests will prevent issues similar to #4116. It turns out that the behavior for expired certs is correct. When a cert is directly added to the trust store (not just its CA), the JDK does not check expiry. I think we should match that behavior. Also contains a small change to SelfSignedSslBuilder to make it actually use the configured trust store. This has no security implications, it just makes the tests work. --- .../netty/ssl/SelfSignedSslBuilder.java | 3 +- .../netty/ssl/RequestCertificateSpec2.groovy | 206 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 http-server-netty/src/test/groovy/io/micronaut/http/server/netty/ssl/RequestCertificateSpec2.groovy diff --git a/http-server-netty/src/main/java/io/micronaut/http/server/netty/ssl/SelfSignedSslBuilder.java b/http-server-netty/src/main/java/io/micronaut/http/server/netty/ssl/SelfSignedSslBuilder.java index 14f55bd2da7..e9d6fca960c 100644 --- a/http-server-netty/src/main/java/io/micronaut/http/server/netty/ssl/SelfSignedSslBuilder.java +++ b/http-server-netty/src/main/java/io/micronaut/http/server/netty/ssl/SelfSignedSslBuilder.java @@ -87,7 +87,8 @@ public Optional build(SslConfiguration ssl, HttpVersion httpVersion) LOG.warn("HTTP Server is configured to use a self-signed certificate ('build-self-signed' is set to true). This configuration should not be used in a production environment as self-signed certificates are inherently insecure."); } SelfSignedCertificate ssc = new SelfSignedCertificate(); - final SslContextBuilder sslBuilder = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()); + final SslContextBuilder sslBuilder = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()) + .trustManager(getTrustManagerFactory(ssl)); CertificateProvidedSslBuilder.setupSslBuilder(sslBuilder, ssl, httpVersion); return Optional.of(sslBuilder.build()); } catch (CertificateException | SSLException e) { diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/ssl/RequestCertificateSpec2.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/ssl/RequestCertificateSpec2.groovy new file mode 100644 index 00000000000..5bfccea6154 --- /dev/null +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/ssl/RequestCertificateSpec2.groovy @@ -0,0 +1,206 @@ +package io.micronaut.http.server.netty.ssl + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires +import io.micronaut.http.HttpRequest +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.runtime.server.EmbeddedServer +import io.netty.handler.ssl.util.SelfSignedCertificate +import io.vertx.core.Vertx +import io.vertx.core.net.JksOptions +import io.vertx.ext.web.client.HttpResponse +import io.vertx.ext.web.client.WebClient +import io.vertx.ext.web.client.WebClientOptions +import spock.lang.Specification + +import javax.net.ssl.SSLHandshakeException +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException + +class RequestCertificateSpec2 extends Specification { + def normal() { + given: + def certificate = new SelfSignedCertificate() + + def keyStorePath = Files.createTempFile("micronaut-test-key-store", "pkcs12") + def trustStorePath = Files.createTempFile("micronaut-test-trust-store", "pkcs12") + + writeStores(certificate, keyStorePath, trustStorePath) + + def ctx = ApplicationContext.run([ + "spec.name" : "RequestCertificateSpec2", + "micronaut.http.client.read-timeout" : "15s", + 'micronaut.server.ssl.enabled' : true, + 'micronaut.server.ssl.port' : -1, + 'micronaut.server.ssl.buildSelfSigned': true, + 'micronaut.ssl.clientAuthentication' : "need", + 'micronaut.ssl.trust-store.path' : 'file://' + trustStorePath.toString(), + 'micronaut.ssl.trust-store.type' : 'JKS', + 'micronaut.ssl.trust-store.password' : '123456', + ]) + + def server = ctx.getBean(EmbeddedServer) + server.start() + + def vertx = Vertx.vertx() + def client = WebClient.create(vertx, new WebClientOptions() + .setTrustAll(true) + .setSsl(true) + .setKeyCertOptions(new JksOptions().setPath(keyStorePath.toString()).setPassword("")) + .setTrustStoreOptions(new JksOptions().setPath(trustStorePath.toString()).setPassword("123456")) + ) + + when: + def future = new CompletableFuture>() + client.get(server.port, "localhost", "/mtls").send { if (it.failed()) future.completeExceptionally(it.cause()) else future.complete(it.result()) } + def response = future.get() + then: + response.bodyAsString() == 'CN=localhost' + response.statusCode() == 200 + + cleanup: + vertx.close() + ctx.close() + Files.deleteIfExists(keyStorePath) + Files.deleteIfExists(trustStorePath) + } + + def expired() { + // this is intended behavior: an expired client cert DOES NOT lead to a handshake failure. This is JDK behavior: + // when a cert is directly in the trust store, expiry is not checked. expiry is only checked if the cert is + // signed by a CA that is in the trust store. + + given: + def certificate = new SelfSignedCertificate(Date.from(Instant.now().minus(5, ChronoUnit.HOURS)), Date.from(Instant.now().minus(1, ChronoUnit.HOURS))) + + def keyStorePath = Files.createTempFile("micronaut-test-key-store", "pkcs12") + def trustStorePath = Files.createTempFile("micronaut-test-trust-store", "pkcs12") + + writeStores(certificate, keyStorePath, trustStorePath) + + def ctx = ApplicationContext.run([ + "spec.name" : "RequestCertificateSpec2", + "micronaut.http.client.read-timeout" : "15s", + 'micronaut.server.ssl.enabled' : true, + 'micronaut.server.ssl.port' : -1, + 'micronaut.server.ssl.buildSelfSigned': true, + 'micronaut.ssl.clientAuthentication' : "need", + 'micronaut.ssl.trust-store.path' : 'file://' + trustStorePath.toString(), + 'micronaut.ssl.trust-store.type' : 'JKS', + 'micronaut.ssl.trust-store.password' : '123456', + ]) + + def server = ctx.getBean(EmbeddedServer) + server.start() + + def vertx = Vertx.vertx() + def client = WebClient.create(vertx, new WebClientOptions() + .setTrustAll(true) + .setSsl(true) + .setKeyCertOptions(new JksOptions().setPath(keyStorePath.toString()).setPassword("")) + .setTrustStoreOptions(new JksOptions().setPath(trustStorePath.toString()).setPassword("123456")) + ) + + when: + def future = new CompletableFuture>() + client.get(server.port, "localhost", "/mtls").send { if (it.failed()) future.completeExceptionally(it.cause()) else future.complete(it.result()) } + def response = future.get() + then: + response.bodyAsString() == 'CN=localhost' + response.statusCode() == 200 + + cleanup: + vertx.close() + ctx.close() + Files.deleteIfExists(keyStorePath) + Files.deleteIfExists(trustStorePath) + } + + def untrusted() { + given: + def clientCert = new SelfSignedCertificate() + // for the client to send the cert, we still need the same CN in the trust store + def serverExpectsCert = new SelfSignedCertificate() + + def keyStorePath = Files.createTempFile("micronaut-test-key-store", "pkcs12") + def trustStorePath = Files.createTempFile("micronaut-test-trust-store", "pkcs12") + + writeStores(clientCert, keyStorePath, null) + writeStores(serverExpectsCert, null, trustStorePath) + + def ctx = ApplicationContext.run([ + "spec.name" : "RequestCertificateSpec2", + "micronaut.http.client.read-timeout" : "15s", + 'micronaut.server.ssl.enabled' : true, + 'micronaut.server.ssl.port' : -1, + 'micronaut.server.ssl.buildSelfSigned': true, + 'micronaut.ssl.clientAuthentication' : "need", + 'micronaut.ssl.trust-store.path' : 'file://' + trustStorePath.toString(), + 'micronaut.ssl.trust-store.type' : 'JKS', + 'micronaut.ssl.trust-store.password' : '123456', + ]) + + def server = ctx.getBean(EmbeddedServer) + server.start() + + def vertx = Vertx.vertx() + def client = WebClient.create(vertx, new WebClientOptions() + .setTrustAll(true) + .setSsl(true) + .setKeyCertOptions(new JksOptions().setPath(keyStorePath.toString()).setPassword("")) + .setTrustStoreOptions(new JksOptions().setPath(trustStorePath.toString()).setPassword("123456")) + ) + + when: + def future = new CompletableFuture>() + client.get(server.port, "localhost", "/mtls").send { if (it.failed()) future.completeExceptionally(it.cause()) else future.complete(it.result()) } + def response = future.get() + then: + def e = thrown ExecutionException + e.cause instanceof SSLHandshakeException + + cleanup: + vertx.close() + ctx.close() + Files.deleteIfExists(keyStorePath) + Files.deleteIfExists(trustStorePath) + } + + private void writeStores(SelfSignedCertificate certificate, Path keyStorePath, Path trustStorePath) { + if (keyStorePath != null) { + KeyStore ks = KeyStore.getInstance("PKCS12") + ks.load(null, null) + ks.setKeyEntry("key", certificate.key(), "".toCharArray(), new Certificate[]{certificate.cert()}) + try (OutputStream os = Files.newOutputStream(keyStorePath)) { + ks.store(os, "".toCharArray()) + } + } + + if (trustStorePath != null) { + KeyStore ts = KeyStore.getInstance("JKS") + ts.load(null, null) + ts.setCertificateEntry("cert", certificate.cert()) + try (OutputStream os = Files.newOutputStream(trustStorePath)) { + ts.store(os, "123456".toCharArray()) + } + } + } + + @Controller + @Requires(property = "spec.name", value = "RequestCertificateSpec2") + static class TestController { + @Get('/mtls') + String name(HttpRequest request) { + def cert = request.getCertificate().get() as X509Certificate + cert.issuerX500Principal.name + } + } +} From b51b6594912e93b411e8e3e9fe71e2589175d9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20S=C3=A1nchez-Mariscal?= Date: Tue, 21 Mar 2023 15:03:20 +0100 Subject: [PATCH 05/21] Bump Jib Maven Plugin version (#8980) Fixes https://github.com/micronaut-projects/micronaut-maven-plugin/issues/675 --- parent/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parent/build.gradle b/parent/build.gradle index 6ba0d0da06c..cc551cabd8a 100644 --- a/parent/build.gradle +++ b/parent/build.gradle @@ -49,7 +49,7 @@ ext.extraPomInfo = { 'azure-functions-maven-plugin.version'('1.5.0') 'exec-maven-plugin.version'('1.6.0') 'function-maven-plugin.version'('0.9.8') - 'jib-maven-plugin.version'('3.1.4') + 'jib-maven-plugin.version'('3.3.1') 'maven-compiler-plugin.version'('3.10.1') // Override actual Maven compiler version (3.1) because some bugs cause annotation processors doesn't work well 'maven-deploy-plugin.version'('3.0.0') 'maven-failsafe-plugin.version'('2.22.2') // Override actual Maven surefire and failsafe version (2.12) to get native support for executing tests on the JUnit Platform (JUnit 5) From 85f77f250a64e98b1a91adf4affa69e7e8ae9ab9 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 22 Mar 2023 11:25:15 +0100 Subject: [PATCH 06/21] tck: static resource test (#8971) * test: static resource test see: https://github.com/micronaut-projects/micronaut-aws/issues/1361 --------- Co-authored-by: Graeme Rocher Co-authored-by: Tim Yates --- .../staticresources/StaticResourceTest.java | 52 +++++++++++++++++++ .../src/main/resources/assets/hello.txt | 1 + 2 files changed, 53 insertions(+) create mode 100644 http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/staticresources/StaticResourceTest.java create mode 100644 http-server-tck/src/main/resources/assets/hello.txt diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/staticresources/StaticResourceTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/staticresources/StaticResourceTest.java new file mode 100644 index 00000000000..853fb27ead9 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/staticresources/StaticResourceTest.java @@ -0,0 +1,52 @@ +/* + * 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.staticresources; + +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.uri.UriBuilder; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; + +import static io.micronaut.http.server.tck.TestScenario.asserts; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class StaticResourceTest { + public static final String SPEC_NAME = "StaticResourceTest"; + + @Test + public void staticResource() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + "micronaut.router.static-resources.assets.mapping", "/assets/**", + "micronaut.router.static-resources.assets.paths", "classpath:assets"), + HttpRequest.GET(UriBuilder.of("/assets").path("hello.txt").build()).accept(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "Hello World", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN))); + } +} diff --git a/http-server-tck/src/main/resources/assets/hello.txt b/http-server-tck/src/main/resources/assets/hello.txt new file mode 100644 index 00000000000..557db03de99 --- /dev/null +++ b/http-server-tck/src/main/resources/assets/hello.txt @@ -0,0 +1 @@ +Hello World From fff00e9ec0a959fb7b0c7dea4f631bfc88ee8f26 Mon Sep 17 00:00:00 2001 From: Dean Wette Date: Thu, 23 Mar 2023 04:10:57 -0500 Subject: [PATCH 07/21] add missing logback.xml (#8987) closes #8985 --- test-suite-graal/src/test/resources/logback.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 test-suite-graal/src/test/resources/logback.xml diff --git a/test-suite-graal/src/test/resources/logback.xml b/test-suite-graal/src/test/resources/logback.xml new file mode 100644 index 00000000000..8eb8c3a8170 --- /dev/null +++ b/test-suite-graal/src/test/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + From 9ca308f515f57a9b54fdb7d73e246016271c6837 Mon Sep 17 00:00:00 2001 From: micronaut-build <65172877+micronaut-build@users.noreply.github.com> Date: Mon, 27 Mar 2023 23:08:07 +0200 Subject: [PATCH 08/21] Bump micronaut-maven-plugin to 3.5.3 (#9012) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6df7eb03e0b..e29867a61b7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -43,7 +43,7 @@ developers=Graeme Rocher kapt.use.worker.api=true # Dependency Versions -micronautMavenPluginVersion=3.5.2 +micronautMavenPluginVersion=3.5.3 chromedriverVersion=79.0.3945.36 geckodriverVersion=0.26.0 webdriverBinariesVersion=1.4 From 0c1d4867f4b333ab672f204cd6307160856a7e54 Mon Sep 17 00:00:00 2001 From: Tim Yates Date: Tue, 28 Mar 2023 11:40:02 +0100 Subject: [PATCH 09/21] Logback configuration defined via JAVA_TOOL_OPTIONS is ignored (#9009) Previously, we only checked the classpath for logback configuration files, and when refreshing we ignored the `logback.configurationFile` setting. This PR checks the filesystem if the config cannot be found on the classpath It also adds `logback.configurationFile` as an optional property that points to the location of the config for when we refresh the configuration. `logback.configurationFile` has precedence over the existing `logger.config` property --- .../logging/impl/LogbackLoggingSystem.java | 32 ++++++++++- .../micronaut/logging/impl/LogbackUtils.java | 27 +++++++-- settings.gradle | 1 + .../build.gradle.kts | 18 ++++++ .../src/external/external-logback.xml | 16 ++++++ .../logback/ExternalConfigurationSpec.groovy | 56 +++++++++++++++++++ .../src/test/resources/logback.xml | 16 ++++++ 7 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 test-suite-logback-external-configuration/build.gradle.kts create mode 100644 test-suite-logback-external-configuration/src/external/external-logback.xml create mode 100644 test-suite-logback-external-configuration/src/test/groovy/io/micronaut/logback/ExternalConfigurationSpec.groovy create mode 100644 test-suite-logback-external-configuration/src/test/resources/logback.xml diff --git a/runtime/src/main/java/io/micronaut/logging/impl/LogbackLoggingSystem.java b/runtime/src/main/java/io/micronaut/logging/impl/LogbackLoggingSystem.java index bb7c0cc3ad4..f1f98b53106 100644 --- a/runtime/src/main/java/io/micronaut/logging/impl/LogbackLoggingSystem.java +++ b/runtime/src/main/java/io/micronaut/logging/impl/LogbackLoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2020 original authors + * 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. @@ -23,6 +23,7 @@ import io.micronaut.core.annotation.Nullable; import io.micronaut.logging.LogLevel; import io.micronaut.logging.LoggingSystem; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.slf4j.LoggerFactory; @@ -41,8 +42,35 @@ public final class LogbackLoggingSystem implements LoggingSystem { private final String logbackXmlLocation; + /** + * @deprecated Use {@link LogbackLoggingSystem#LogbackLoggingSystem(String, String)} instead + * @param logbackXmlLocation + */ + @Deprecated public LogbackLoggingSystem(@Nullable @Property(name = "logger.config") String logbackXmlLocation) { - this.logbackXmlLocation = logbackXmlLocation != null ? logbackXmlLocation : DEFAULT_LOGBACK_LOCATION; + this( + System.getProperty("logback.configurationFile"), + logbackXmlLocation + ); + } + + /** + * @param logbackExternalConfigLocation The location of the logback configuration file set via logback properties + * @param logbackXmlLocation The location of the logback configuration file set via micronaut properties + * @since 3.8.8 + */ + @Inject + public LogbackLoggingSystem( + @Nullable @Property(name = "logback.configurationFile") String logbackExternalConfigLocation, + @Nullable @Property(name = "logger.config") String logbackXmlLocation + ) { + if (logbackExternalConfigLocation != null) { + this.logbackXmlLocation = logbackExternalConfigLocation; + } else if (logbackXmlLocation != null) { + this.logbackXmlLocation = logbackXmlLocation; + } else { + this.logbackXmlLocation = DEFAULT_LOGBACK_LOCATION; + } } @Override diff --git a/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java b/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java index 29bb45f652f..25c914851b2 100644 --- a/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java +++ b/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java @@ -24,6 +24,8 @@ import io.micronaut.core.annotation.Nullable; import io.micronaut.logging.LoggingSystemException; +import java.io.File; +import java.net.MalformedURLException; import java.net.URL; import java.util.Iterator; import java.util.ServiceLoader; @@ -50,18 +52,35 @@ private LogbackUtils() { public static void configure(@NonNull ClassLoader classLoader, @NonNull LoggerContext context, @NonNull String logbackXmlLocation) { - configure(context, logbackXmlLocation, () -> classLoader.getResource(logbackXmlLocation)); + configure(context, logbackXmlLocation, () -> { + // Check classpath first + URL resource = classLoader.getResource(logbackXmlLocation); + if (resource != null) { + return resource; + } + // Check file system + File file = new File(logbackXmlLocation); + if (file.exists()) { + try { + resource = file.toURI().toURL(); + } catch (MalformedURLException e) { + + throw new LoggingSystemException("Error creating URL for off-classpath resource", e); + } + } + return resource; + }); } /** * Configures a Logger Context. - * + *

* Searches fpr a custom {@link Configurator} via a service loader. * If not present it configures the context with the resource. * - * @param context Logger Context + * @param context Logger Context * @param logbackXmlLocation the location of the xml logback config file - * @param resourceSupplier A resource for example logback.xml + * @param resourceSupplier A resource for example logback.xml */ private static void configure( @NonNull LoggerContext context, diff --git a/settings.gradle b/settings.gradle index 4bdc2a553f5..290ed698e71 100644 --- a/settings.gradle +++ b/settings.gradle @@ -70,6 +70,7 @@ include "test-suite-graal" include "test-suite-groovy" include "test-suite-groovy" include "test-suite-logback" +include "test-suite-logback-external-configuration" include "test-utils" // benchmarks diff --git a/test-suite-logback-external-configuration/build.gradle.kts b/test-suite-logback-external-configuration/build.gradle.kts new file mode 100644 index 00000000000..a35d4e5419d --- /dev/null +++ b/test-suite-logback-external-configuration/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("io.micronaut.build.internal.convention-test-library") +} + +dependencies { + testAnnotationProcessor(projects.injectJava) + + testImplementation(libs.managed.micronaut.test.spock) { + exclude(group="io.micronaut", module="micronaut-aop") + } + testImplementation(projects.context) + testImplementation(projects.injectGroovy) + testImplementation(libs.managed.logback) + testImplementation(projects.management) + testImplementation(projects.httpClient) + + testRuntimeOnly(projects.httpServerNetty) +} diff --git a/test-suite-logback-external-configuration/src/external/external-logback.xml b/test-suite-logback-external-configuration/src/external/external-logback.xml new file mode 100644 index 00000000000..a21b01a7a8d --- /dev/null +++ b/test-suite-logback-external-configuration/src/external/external-logback.xml @@ -0,0 +1,16 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/test-suite-logback-external-configuration/src/test/groovy/io/micronaut/logback/ExternalConfigurationSpec.groovy b/test-suite-logback-external-configuration/src/test/groovy/io/micronaut/logback/ExternalConfigurationSpec.groovy new file mode 100644 index 00000000000..3a0f4fed2dc --- /dev/null +++ b/test-suite-logback-external-configuration/src/test/groovy/io/micronaut/logback/ExternalConfigurationSpec.groovy @@ -0,0 +1,56 @@ +package io.micronaut.logback + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import io.micronaut.context.ApplicationContext +import io.micronaut.runtime.server.EmbeddedServer +import org.slf4j.LoggerFactory +import spock.lang.See +import spock.lang.Specification +import spock.util.environment.RestoreSystemProperties + +@See("https://logback.qos.ch/manual/configuration.html#auto_configuration") +class ExternalConfigurationSpec extends Specification { + + @RestoreSystemProperties + def "should use the external configuration"() { + given: + System.setProperty("logback.configurationFile", "src/external/external-logback.xml") + + when: + Logger fromXml = (Logger) LoggerFactory.getLogger("i.should.not.exist") + Logger external = (Logger) LoggerFactory.getLogger("external.logging") + + then: 'logback.xml is ignored as we have set a configurationFile' + fromXml.level == null + + and: 'external configuration is used' + external.level == Level.TRACE + } + + @RestoreSystemProperties + def "should still use the external config if custom levels are defines"() { + given: + System.setProperty("logback.configurationFile", "src/external/external-logback.xml") + + when: + def server = ApplicationContext.run(EmbeddedServer, [ + "logger.levels.app.customisation": "DEBUG" + ]) + Logger fromXml = (Logger) LoggerFactory.getLogger("i.should.not.exist") + Logger custom = (Logger) LoggerFactory.getLogger("app.customisation") + Logger external = (Logger) LoggerFactory.getLogger("external.logging") + + then: 'logback.xml is ignored as we have set a configurationFile' + fromXml.level == null + + and: 'custom levels are still respected' + custom.level == Level.DEBUG + + and: 'external configuration is used' + external.level == Level.TRACE + + cleanup: + server.stop() + } +} diff --git a/test-suite-logback-external-configuration/src/test/resources/logback.xml b/test-suite-logback-external-configuration/src/test/resources/logback.xml new file mode 100644 index 00000000000..7b053ed9a39 --- /dev/null +++ b/test-suite-logback-external-configuration/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + From 257c25c45089aeda4950d1625d8894d9b17be24f Mon Sep 17 00:00:00 2001 From: micronaut-build <65172877+micronaut-build@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:21:08 +0200 Subject: [PATCH 10/21] Bump micronaut-security to 3.9.4 (#9021) --- gradle.properties | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index e29867a61b7..eefa1985436 100644 --- a/gradle.properties +++ b/gradle.properties @@ -52,7 +52,7 @@ kotlin.stdlib.default.dependency=false # For the docs graalVersion=22.0.0.2 -micronautSecurityVersion=3.9.3 +micronautSecurityVersion=3.9.4 org.gradle.caching=true org.gradle.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aada27d0e49..67bd872381a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -112,7 +112,7 @@ managed-micronaut-rss = "3.2.0" managed-micronaut-rxjava1 = "1.0.0" managed-micronaut-rxjava2 = "1.3.0" managed-micronaut-rxjava3 = "2.4.0" -managed-micronaut-security = "3.9.3" +managed-micronaut-security = "3.9.4" managed-micronaut-serialization = "1.5.2" managed-micronaut-servlet = "3.3.5" managed-micronaut-spring = "4.4.0" From f8e5514003be47125b49955255952ec9bc743675 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 29 Mar 2023 21:21:20 +0200 Subject: [PATCH 11/21] build: netty 4.1.90-Final (#9019) * build: netty 4.1.90-Final see https://netty.io/news/2023/03/14/4-1-90-Final.html Close #8773 * refactor test --- gradle/libs.versions.toml | 2 +- .../http/server/netty/binding/HttpResponseSpec.groovy | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67bd872381a..2957f4e3d7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -126,7 +126,7 @@ managed-micronaut-views = "3.8.1" managed-micronaut-xml = "3.2.0" managed-neo4j = "3.5.35" managed-neo4j-java-driver = "4.4.9" -managed-netty = "4.1.87.Final" +managed-netty = "4.1.90.Final" managed-reactive-pg-client = "0.11.4" managed-reactive-streams = "1.0.4" # This should be kept aligned with https://github.com/micronaut-projects/micronaut-reactor/blob/master/gradle.properties from the BOM diff --git a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpResponseSpec.groovy b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpResponseSpec.groovy index df3fe92a1cc..02005fb800c 100644 --- a/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpResponseSpec.groovy +++ b/http-server-netty/src/test/groovy/io/micronaut/http/server/netty/binding/HttpResponseSpec.groovy @@ -153,11 +153,10 @@ class HttpResponseSpec extends AbstractMicronautSpec { HttpHeaders headers = response.headers then: // The content length header was replaced, not appended - !headers.names().contains("content-type") - !headers.names().contains("Content-Length") - headers.contains("content-length") response.header("Content-Type") == "text/plain" response.header("Content-Length") == "3" + response.header("content-type") == "text/plain" + response.header("content-length") == "3" } void "test server header"() { From b16c05b7445acbc9a713c9f121d62678a60ded06 Mon Sep 17 00:00:00 2001 From: micronaut-build <65172877+micronaut-build@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:23:31 +0200 Subject: [PATCH 12/21] Bump micronaut-openapi to 4.8.6 (#9020) * Bump micronaut-openapi to 4.8.6 * Update libs.versions.toml --------- Co-authored-by: Sergio del Amo --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2957f4e3d7a..7973477ae68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -100,7 +100,7 @@ managed-micronaut-neo4j = "5.2.0" managed-micronaut-nats = "3.1.0" managed-micronaut-netflix = "2.1.0" managed-micronaut-object-storage = "1.1.0" -managed-micronaut-openapi = "4.8.5" +managed-micronaut-openapi = "4.8.6" managed-micronaut-oraclecloud = "2.3.4" managed-micronaut-picocli = "4.3.0" managed-micronaut-problem = "2.6.0" From d6a69281c786e913f61b5f5c858ecd72af0dcb1b Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Thu, 30 Mar 2023 01:27:42 -0600 Subject: [PATCH 13/21] Support JDK 20 in annotation processors (#9022) --- .../processing/AbstractInjectAnnotationProcessor.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inject-java/src/main/java/io/micronaut/annotation/processing/AbstractInjectAnnotationProcessor.java b/inject-java/src/main/java/io/micronaut/annotation/processing/AbstractInjectAnnotationProcessor.java index 5e088701145..1f68fba099e 100644 --- a/inject-java/src/main/java/io/micronaut/annotation/processing/AbstractInjectAnnotationProcessor.java +++ b/inject-java/src/main/java/io/micronaut/annotation/processing/AbstractInjectAnnotationProcessor.java @@ -79,14 +79,14 @@ abstract class AbstractInjectAnnotationProcessor extends AbstractProcessor { @Override public SourceVersion getSupportedSourceVersion() { SourceVersion sourceVersion = SourceVersion.latest(); - if (sourceVersion.ordinal() <= 18) { + if (sourceVersion.ordinal() <= 20) { if (sourceVersion.ordinal() >= 8) { return sourceVersion; } else { return SourceVersion.RELEASE_8; } } else { - return (SourceVersion.values())[18]; + return (SourceVersion.values())[20]; } } From e39a1beb7d07eb894ea94a0d35cfee621d676113 Mon Sep 17 00:00:00 2001 From: micronaut-build <65172877+micronaut-build@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:35:11 +0200 Subject: [PATCH 14/21] Bump micronaut-views to 3.8.2 (#9023) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7973477ae68..e0f1a21b26a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -122,7 +122,7 @@ managed-micronaut-test-resources = "1.2.3" managed-micronaut-toml = "1.1.3" managed-micronaut-tracing = "4.4.1" managed-micronaut-tracing-legacy = "3.2.7" -managed-micronaut-views = "3.8.1" +managed-micronaut-views = "3.8.2" managed-micronaut-xml = "3.2.0" managed-neo4j = "3.5.35" managed-neo4j-java-driver = "4.4.9" From 6abd9c1cdd0a9aae251b5837ebb7ad3607d7f580 Mon Sep 17 00:00:00 2001 From: Tim Yates Date: Thu, 30 Mar 2023 08:35:24 +0100 Subject: [PATCH 15/21] Improve support for logback 1.3+ (#9018) * Improve support for logback 1.3+ Logback 1.3.x+ changed the binary format for Configurators, and introduced a default one. The issue is that when we call .configure on one, the JVM crashes as there is now an unexpected return value. I cannot find a way of detecting and logging this issue (without some sort of reflection) This change checks to see if the configurator we've detected is the default one added to logback in 1.3.x here https://github.com/qos-ch/logback/blob/5ac98f440dabec45d8ab9b3519b2aa308f05793b/logback-classic/src/main/java/ch/qos/logback/classic/util/DefaultJoranConfigurator.java And if it is, we ignore it. This will not fix it for people using a Custom Configurator that they compile under Logback 1.3+, they will still see the crash with no warning. But people upgrading to 1.3+ should see their application just run, and if they change their logback.xml emit debug with ``` ``` They will see ``` 12:55:21,222 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Skipping ch.qos.logback.classic.util.DefaultJoranConfigurator as it's assumed to be from an unsupported version of Logback ``` * Fix for Java 8 --- .../micronaut/logging/impl/LogbackUtils.java | 15 +++- settings.gradle | 1 + test-suite-logback-14/build.gradle | 26 +++++++ .../logback/LoggerConfigurationSpec.groovy | 31 ++++++++ .../logback/LoggerEndpointSpec.groovy | 71 +++++++++++++++++++ .../micronaut/logback/LoggerLevelSpec.groovy | 46 ++++++++++++ .../micronaut/logback/MemoryAppender.groovy | 17 +++++ .../io/micronaut/logback/Application.java | 15 ++++ .../controllers/HelloWorldController.java | 21 ++++++ .../src/test/resources/logback.xml | 16 +++++ 10 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 test-suite-logback-14/build.gradle create mode 100644 test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerConfigurationSpec.groovy create mode 100644 test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerEndpointSpec.groovy create mode 100644 test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerLevelSpec.groovy create mode 100644 test-suite-logback-14/src/test/groovy/io/micronaut/logback/MemoryAppender.groovy create mode 100644 test-suite-logback-14/src/test/java/io/micronaut/logback/Application.java create mode 100644 test-suite-logback-14/src/test/java/io/micronaut/logback/controllers/HelloWorldController.java create mode 100644 test-suite-logback-14/src/test/resources/logback.xml diff --git a/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java b/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java index 25c914851b2..a395313c558 100644 --- a/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java +++ b/runtime/src/main/java/io/micronaut/logging/impl/LogbackUtils.java @@ -39,6 +39,8 @@ */ public final class LogbackUtils { + private static final String DEFAULT_LOGBACK_13_PROGRAMMATIC_CONFIGURATOR = "ch.qos.logback.classic.util.DefaultJoranConfigurator"; + private LogbackUtils() { } @@ -88,7 +90,7 @@ private static void configure( Supplier resourceSupplier ) { Configurator configurator = loadFromServiceLoader(); - if (configurator != null) { + if (isSupportedConfigurator(context, configurator)) { context.getStatusManager().add(new InfoStatus("Using " + configurator.getClass().getName(), context)); programmaticConfiguration(context, configurator); } else { @@ -105,6 +107,17 @@ private static void configure( } } + private static boolean isSupportedConfigurator(LoggerContext context, Configurator configurator) { + if (configurator == null) { + return false; + } + if (DEFAULT_LOGBACK_13_PROGRAMMATIC_CONFIGURATOR.equals(configurator.getClass().getName())) { + context.getStatusManager().add(new InfoStatus("Skipping " + configurator.getClass().getName() + " as it's assumed to be from an unsupported version of Logback", context)); + return false; + } + return true; + } + /** * Taken from {@link ch.qos.logback.classic.util.ContextInitializer#autoConfig}. */ diff --git a/settings.gradle b/settings.gradle index 290ed698e71..3fae51bce05 100644 --- a/settings.gradle +++ b/settings.gradle @@ -70,6 +70,7 @@ include "test-suite-graal" include "test-suite-groovy" include "test-suite-groovy" include "test-suite-logback" +include "test-suite-logback-14" include "test-suite-logback-external-configuration" include "test-utils" diff --git a/test-suite-logback-14/build.gradle b/test-suite-logback-14/build.gradle new file mode 100644 index 00000000000..e0a46cc5dee --- /dev/null +++ b/test-suite-logback-14/build.gradle @@ -0,0 +1,26 @@ +plugins { + id("io.micronaut.build.internal.convention-test-library") +} + +description = "logback tests with a new version of logback than we support, just to check it runs. Can be removed when we upgrade as part of 4.0.0" + +// Logback 1.4.x is Java 11+ compatible +def logbackVersion = JavaVersion.current().isJava11Compatible() ? "1.4.6" : "1.3.6" + +dependencies { + testAnnotationProcessor(projects.injectJava) + + testImplementation(libs.managed.micronaut.test.spock) { + exclude(group: "io.micronaut", module: "micronaut-aop") + } + testImplementation(projects.context) + testImplementation(projects.injectGroovy) + + // Use a newer version of logback than we support + testImplementation("ch.qos.logback:logback-classic:${logbackVersion}") + + testImplementation(projects.management) + testImplementation(projects.httpClient) + + testRuntimeOnly(projects.httpServerNetty) +} diff --git a/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerConfigurationSpec.groovy b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerConfigurationSpec.groovy new file mode 100644 index 00000000000..7e79856d9ea --- /dev/null +++ b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerConfigurationSpec.groovy @@ -0,0 +1,31 @@ +package io.micronaut.logback + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import io.micronaut.context.annotation.Property +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.slf4j.LoggerFactory +import spock.lang.Specification + +@MicronautTest +// Setting a level in a property forces a refresh, so the XML configuration is ignored. Without this in 3.8.x, the test fails. +@Property(name = "logger.levels.set.by.property", value = "DEBUG") +class LoggerConfigurationSpec extends Specification { + + @Inject + @Client("/") + HttpClient client + + void "if configuration is supplied, xml should be ignored"() { + given: + Logger fromXml = (Logger) LoggerFactory.getLogger("xml.config") + Logger fromProperties = (Logger) LoggerFactory.getLogger("set.by.property") + + expect: + fromXml.level == Level.TRACE + fromProperties.level == Level.DEBUG + } +} diff --git a/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerEndpointSpec.groovy b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerEndpointSpec.groovy new file mode 100644 index 00000000000..d7aef0db31d --- /dev/null +++ b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerEndpointSpec.groovy @@ -0,0 +1,71 @@ +package io.micronaut.logback + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import io.micronaut.context.annotation.Property +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.logback.controllers.HelloWorldController +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.slf4j.LoggerFactory +import spock.lang.Issue +import spock.lang.Specification + +@MicronautTest +@Property(name = "logger.levels.io.micronaut.logback", value = "INFO") +@Property(name = "endpoints.loggers.enabled", value = "true") +@Property(name = "endpoints.loggers.sensitive", value = "false") +@Property(name = "endpoints.loggers.write-sensitive", value = "false") +@Issue("https://github.com/micronaut-projects/micronaut-core/issues/8679") +class LoggerEndpointSpec extends Specification { + + @Inject + @Client("/") + HttpClient client + + void "logback configuration from properties is as expected"() { + when: + def response = client.toBlocking().retrieve("/loggers/io.micronaut.logback") + + then: + response.contains("INFO") + } + + void "logback can be configured"() { + given: + MemoryAppender appender = new MemoryAppender() + Logger l = (Logger) LoggerFactory.getLogger("io.micronaut.logback.controllers") + l.addAppender(appender) + appender.start() + + when: + def response = client.toBlocking().retrieve("/", String) + + then: 'response is as expected' + response == HelloWorldController.RESPONSE + + and: 'no log message is emitted' + appender.events.empty + + when: 'log level is changed to TRACE' + def body = '{ "configuredLevel": "TRACE" }' + def post = HttpRequest.POST("/loggers/io.micronaut.logback.controllers", body).contentType(MediaType.APPLICATION_JSON_TYPE) + client.toBlocking().exchange(post) + + and: + response = client.toBlocking().retrieve("/", String) + + then: 'response is as expected' + response == HelloWorldController.RESPONSE + + and: 'log message is emitted' + appender.events == [HelloWorldController.LOG_MESSAGE] + + cleanup: + appender.stop() + } +} diff --git a/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerLevelSpec.groovy b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerLevelSpec.groovy new file mode 100644 index 00000000000..b953c4869e0 --- /dev/null +++ b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/LoggerLevelSpec.groovy @@ -0,0 +1,46 @@ +package io.micronaut.logback + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import io.micronaut.context.annotation.Property +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.logback.controllers.HelloWorldController +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.slf4j.LoggerFactory +import spock.lang.Issue +import spock.lang.Specification + +@MicronautTest +@Property(name = "logger.levels.io.micronaut.logback.controllers", value = "TRACE") +@Issue("https://github.com/micronaut-projects/micronaut-core/issues/8678") +class LoggerLevelSpec extends Specification { + + @Inject + @Client("/") + HttpClient client + + void "logback can be configured via properties"() { + given: + MemoryAppender appender = new MemoryAppender() + Logger l = (Logger) LoggerFactory.getLogger("io.micronaut.logback.controllers") + l.addAppender(appender) + appender.start() + + when: + def response = client.toBlocking().retrieve("/", String) + + then: 'response is as expected' + response == HelloWorldController.RESPONSE + + and: 'log message is emitted' + appender.events == [HelloWorldController.LOG_MESSAGE] + + cleanup: + appender.stop() + } +} diff --git a/test-suite-logback-14/src/test/groovy/io/micronaut/logback/MemoryAppender.groovy b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/MemoryAppender.groovy new file mode 100644 index 00000000000..3e978a6888e --- /dev/null +++ b/test-suite-logback-14/src/test/groovy/io/micronaut/logback/MemoryAppender.groovy @@ -0,0 +1,17 @@ +package io.micronaut.logback + +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import groovy.transform.CompileStatic +import groovy.transform.PackageScope + +@PackageScope +@CompileStatic +class MemoryAppender extends AppenderBase { + List events = [] + + @Override + protected void append(ILoggingEvent e) { + events << e.formattedMessage + } +} diff --git a/test-suite-logback-14/src/test/java/io/micronaut/logback/Application.java b/test-suite-logback-14/src/test/java/io/micronaut/logback/Application.java new file mode 100644 index 00000000000..8f06535b06c --- /dev/null +++ b/test-suite-logback-14/src/test/java/io/micronaut/logback/Application.java @@ -0,0 +1,15 @@ +package io.micronaut.logback; + +import io.micronaut.runtime.Micronaut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Application { + + private static final Logger LOG = LoggerFactory.getLogger(Application.class); + + public static void main(String[] args) { + LOG.trace("starting the app"); + Micronaut.run(Application.class, args); + } +} diff --git a/test-suite-logback-14/src/test/java/io/micronaut/logback/controllers/HelloWorldController.java b/test-suite-logback-14/src/test/java/io/micronaut/logback/controllers/HelloWorldController.java new file mode 100644 index 00000000000..d431f7bb72b --- /dev/null +++ b/test-suite-logback-14/src/test/java/io/micronaut/logback/controllers/HelloWorldController.java @@ -0,0 +1,21 @@ +package io.micronaut.logback.controllers; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Controller +public class HelloWorldController { + + public static final String RESPONSE = "Hello world!"; + public static final String LOG_MESSAGE = "inside hello world"; + + private static final Logger LOG = LoggerFactory.getLogger(HelloWorldController.class); + + @Get + String index() { + LOG.trace(LOG_MESSAGE); + return RESPONSE; + } +} diff --git a/test-suite-logback-14/src/test/resources/logback.xml b/test-suite-logback-14/src/test/resources/logback.xml new file mode 100644 index 00000000000..be7932b2810 --- /dev/null +++ b/test-suite-logback-14/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + From c396648a6b9bc459c4aa7913eebb8fabaa276c80 Mon Sep 17 00:00:00 2001 From: micronaut-build <65172877+micronaut-build@users.noreply.github.com> Date: Thu, 30 Mar 2023 16:10:03 +0200 Subject: [PATCH 16/21] Bump micronaut-data to 3.9.7 (#9026) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0f1a21b26a..355d46a6225 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ managed-micronaut-cache = "3.5.0" managed-micronaut-cassandra = "5.1.1" managed-micronaut-coherence = "3.7.2" managed-micronaut-crac = "1.1.2" -managed-micronaut-data = "3.9.6" +managed-micronaut-data = "3.9.7" managed-micronaut-discovery = "3.2.0" managed-micronaut-elasticsearch = "4.3.0" managed-micronaut-email = "1.5.0" From d5f0fbcf1ae3f564983c692d8261a95c491b3d9f Mon Sep 17 00:00:00 2001 From: micronaut-build Date: Thu, 30 Mar 2023 17:45:40 +0000 Subject: [PATCH 17/21] [skip ci] Release v3.8.8 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index eefa1985436..01f2cca0f4d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -projectVersion=3.8.8-SNAPSHOT +projectVersion=3.8.8 projectGroupId=io.micronaut title=Micronaut projectDesc=Natively Cloud Native From 4dd90d433521934dc7df00f1050ca2e6be45113d Mon Sep 17 00:00:00 2001 From: micronaut-build Date: Thu, 30 Mar 2023 17:58:51 +0000 Subject: [PATCH 18/21] Back to 3.8.9-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 01f2cca0f4d..7ddadd8c695 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -projectVersion=3.8.8 +projectVersion=3.8.9-SNAPSHOT projectGroupId=io.micronaut title=Micronaut projectDesc=Natively Cloud Native From fc5103c3b5a788571787e8b069c647b7d3441bf5 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Fri, 31 Mar 2023 15:05:38 +0200 Subject: [PATCH 19/21] ci: fold release-notes & sonar in gradle workflow (#9033) --- .github/workflows/corretto.yml | 38 ------------ .github/workflows/graalvm.yml | 14 ----- .github/workflows/gradle.yml | 95 +++++++++++++---------------- .github/workflows/release-notes.yml | 50 --------------- .github/workflows/sonarqube.yml | 58 ------------------ 5 files changed, 44 insertions(+), 211 deletions(-) delete mode 100644 .github/workflows/corretto.yml delete mode 100644 .github/workflows/release-notes.yml delete mode 100644 .github/workflows/sonarqube.yml diff --git a/.github/workflows/corretto.yml b/.github/workflows/corretto.yml deleted file mode 100644 index aacb77c2c95..00000000000 --- a/.github/workflows/corretto.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Corretto CI -on: - push: - branches: - - master -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - java: ['8', '11', '17'] - container: amazoncorretto:${{ matrix.java }} - steps: - - name: Display Java and Linux version - run: java -version && cat /etc/system-release - - name: Install tar && gzip - run: yum install -y tar gzip - - uses: actions/checkout@v3 - - uses: actions/cache@v3.0.2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-micronaut-core-gradle-${{ hashFiles('**/*.gradle') }} - restore-keys: | - ${{ runner.os }}-micronaut-core-gradle- - - uses: actions/cache@v3.0.2 - with: - path: ~/.gradle/wrapper - key: ${{ runner.os }}-micronaut-core-wrapper-${{ hashFiles('**/*.gradle') }} - restore-keys: | - ${{ runner.os }}-micronaut-core-wrapper- - - name: Build with Gradle - env: - GH_TOKEN_PUBLIC_REPOS_READONLY: ${{ secrets.GH_TOKEN_PUBLIC_REPOS_READONLY }} - GH_USERNAME: ${{ secrets.GH_USERNAME }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - run: unset HOSTNAME ; LANG=en_US.utf-8 LC_ALL=en_US.utf-8 ./gradlew check --no-daemon --parallel --continue diff --git a/.github/workflows/graalvm.yml b/.github/workflows/graalvm.yml index c06db472f01..490d9b0952b 100644 --- a/.github/workflows/graalvm.yml +++ b/.github/workflows/graalvm.yml @@ -7,11 +7,9 @@ name: GraalVM CE CI on: push: branches: - - master - '[1-9]+.[0-9]+.x' pull_request: branches: - - master - '[1-9]+.[0-9]+.x' jobs: build: @@ -62,18 +60,6 @@ jobs: GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} PREDICTIVE_TEST_SELECTION: "${{ github.event_name == 'pull_request' && 'true' || 'false' }}" - - name: Add build scan URL as PR comment - uses: actions/github-script@v5 - if: github.event_name == 'pull_request' && failure() - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: '❌ ${{ github.workflow }} ${{ matrix.java }} ${{ matrix.graalvm }} failed: ${{ steps.gradle.outputs.build-scan-url }}' - }) - name: Publish Test Report if: always() uses: mikepenz/action-junit-report@v3.7.1 diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 69a7471dd8d..24680e4eaa4 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -7,11 +7,9 @@ name: Java CI on: push: branches: - - master - '[1-9]+.[0-9]+.x' pull_request: branches: - - master - '[1-9]+.[0-9]+.x' jobs: build: @@ -20,85 +18,80 @@ jobs: strategy: matrix: java: ['8', '11', '17'] + env: + GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} + GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} + GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} + GH_TOKEN_PUBLIC_REPOS_READONLY: ${{ secrets.GH_TOKEN_PUBLIC_REPOS_READONLY }} + GH_USERNAME: ${{ secrets.GH_USERNAME }} + TESTCONTAINERS_RYUK_DISABLED: true + PREDICTIVE_TEST_SELECTION: "${{ github.event_name == 'pull_request' && 'true' || 'false' }}" + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - # https://github.com/actions/virtual-environments/issues/709 - - name: Free disk space + # https://github.com/actions/virtual-environments/issues/709 + - name: "🗑 Free disk space" run: | - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - sudo apt-get clean - df -h - - uses: actions/checkout@v3 - - name: Set up JDK - uses: actions/setup-java@v3 + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo apt-get clean + df -h + + - name: "📥 Checkout repository" + uses: actions/checkout@v3 with: + fetch-depth: 0 distribution: 'temurin' java-version: ${{ matrix.java }} - - name: Setup Gradle - uses: gradle/gradle-build-action@v2.3.3 - - name: Optional setup step - env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} + + - name: "🔧 Setup Gradle" + uses: gradle/gradle-build-action@v2 + + - name: "❓ Optional setup step" run: | - [ -f ./setup.sh ] && ./setup.sh || true - - name: Build with Gradle + [ -f ./setup.sh ] && ./setup.sh || [ ! -f ./setup.sh ] + + - name: "🛠 Build with Gradle" id: gradle run: | - ./gradlew check --no-daemon --parallel --continue - env: - GH_TOKEN_PUBLIC_REPOS_READONLY: ${{ secrets.GH_TOKEN_PUBLIC_REPOS_READONLY }} - GH_USERNAME: ${{ secrets.GH_USERNAME }} - TESTCONTAINERS_RYUK_DISABLED: true - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - PREDICTIVE_TEST_SELECTION: "${{ github.event_name == 'pull_request' && 'true' || 'false' }}" - - name: Add build scan URL as PR comment - uses: actions/github-script@v5 - if: github.event_name == 'pull_request' && failure() - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: '❌ ${{ github.workflow }} failed: ${{ steps.gradle.outputs.build-scan-url }}' - }) - - name: Publish Test Report + ./gradlew check --no-daemon --continue + + - name: "🔎 Run static analysis" + if: env.SONAR_TOKEN != '' + run: | + ./gradlew sonar + + - name: "📊 Publish Test Report" if: always() - uses: mikepenz/action-junit-report@v3.7.1 + uses: mikepenz/action-junit-report@v3 with: check_name: Java CI / Test Report (${{ matrix.java }}) report_paths: '**/build/test-results/test/TEST-*.xml' check_retries: 'true' + - name: "📜 Upload binary compatibility check results" if: always() uses: actions/upload-artifact@v3 with: name: binary-compatibility-reports path: "**/build/reports/binary-compatibility-*.html" - - name: Publish to Sonatype Snapshots + + - name: "📦 Publish to Sonatype Snapshots" if: success() && github.event_name == 'push' && matrix.java == '11' env: - GH_TOKEN_PUBLIC_REPOS_READONLY: ${{ secrets.GH_TOKEN_PUBLIC_REPOS_READONLY }} - GH_USERNAME: ${{ secrets.GH_USERNAME }} SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} run: ./gradlew publishToSonatype docs --no-daemon - - name: Determine docs target repository + + - name: "❓ Determine docs target repository" uses: haya14busa/action-cond@v1 id: docs_target with: cond: ${{ github.repository == 'micronaut-projects/micronaut-core' }} if_true: "micronaut-projects/micronaut-docs" if_false: ${{ github.repository }} - - name: Publish to Github Pages + + - name: "📑 Publish to Github Pages" if: success() && github.event_name == 'push' && matrix.java == '11' uses: micronaut-projects/github-pages-deploy-action@master env: diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml deleted file mode 100644 index ecb1f667c65..00000000000 --- a/.github/workflows/release-notes.yml +++ /dev/null @@ -1,50 +0,0 @@ -# WARNING: Do not edit this file directly. Instead, go to: -# -# https://github.com/micronaut-projects/micronaut-project-template/tree/master/.github/workflows -# -# and edit them there. Note that it will be sync'ed to all the Micronaut repos -name: Changelog -on: - issues: - types: [closed,reopened] - push: - branches: - - master - - '[1-9]+.[0-9]+.x' -jobs: - release_notes: - if: github.repository != 'micronaut-projects/micronaut-project-template' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Check if it has release drafter config file - id: check_release_drafter - run: | - has_release_drafter=$([ -f .github/release-drafter.yml ] && echo "true" || echo "false") - echo "has_release_drafter=${has_release_drafter}" >> $GITHUB_OUTPUT - - # If it has release drafter: - - uses: release-drafter/release-drafter@v5 - if: steps.check_release_drafter.outputs.has_release_drafter == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - - # Otherwise: - - name: Export Gradle Properties - if: steps.check_release_drafter.outputs.has_release_drafter == 'false' - uses: micronaut-projects/github-actions/export-gradle-properties@master - - uses: micronaut-projects/github-actions/release-notes@master - if: steps.check_release_drafter.outputs.has_release_drafter == 'false' - id: release_notes - with: - token: ${{ secrets.GH_TOKEN }} - - uses: ncipollo/release-action@v1 - if: steps.check_release_drafter.outputs.has_release_drafter == 'false' && steps.release_notes.outputs.generated_changelog == 'true' - with: - allowUpdates: true - commit: ${{ steps.release_notes.outputs.current_branch }} - draft: true - name: ${{ env.title }} ${{ steps.release_notes.outputs.next_version }} - tag: v${{ steps.release_notes.outputs.next_version }} - bodyFile: CHANGELOG.md - token: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index 5faafe7cbe8..00000000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,58 +0,0 @@ -# WARNING: Do not edit this file directly. Instead, go to: -# -# https://github.com/micronaut-projects/micronaut-project-template/tree/master/.github/workflows -# -# and edit them there. Note that it will be sync'ed to all the Micronaut repos -name: Static Analysis -on: - push: - branches: - - master - - '[1-9]+.[0-9]+.x' - pull_request: - branches: - - master - - '[1-9]+.[0-9]+.x' -jobs: - build: - if: github.repository != 'micronaut-projects/micronaut-project-template' - runs-on: ubuntu-latest - steps: - # https://github.com/actions/virtual-environments/issues/709 - - name: Free disk space - run: | - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - sudo apt-get clean - df -h - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/cache@v3 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Set up JDK - uses: actions/setup-java@v3 - with: - java-version: 11 - distribution: 'temurin' - - name: Optional setup step - env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - run: | - [ -f ./setup.sh ] && ./setup.sh || true - - name: Analyse with Gradle - run: | - ./gradlew check sonarqube --no-daemon --parallel --continue - env: - TESTCONTAINERS_RYUK_DISABLED: true - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }} - GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 609461769d24097b43b93ef549289ea1dd78aa13 Mon Sep 17 00:00:00 2001 From: Tim Yates Date: Fri, 31 Mar 2023 14:15:11 +0100 Subject: [PATCH 20/21] feat: Add a class for handling headers in a case-insensitive way (#9031) --- .../CaseInsensitiveMutableHttpHeaders.java | 294 ++++++++++++++++++ ...seInsensitiveMutableHttpHeadersSpec.groovy | 183 +++++++++++ 2 files changed, 477 insertions(+) create mode 100644 http/src/main/java/io/micronaut/http/CaseInsensitiveMutableHttpHeaders.java create mode 100644 http/src/test/groovy/io/micronaut/http/CaseInsensitiveMutableHttpHeadersSpec.groovy diff --git a/http/src/main/java/io/micronaut/http/CaseInsensitiveMutableHttpHeaders.java b/http/src/main/java/io/micronaut/http/CaseInsensitiveMutableHttpHeaders.java new file mode 100644 index 00000000000..2084e6e2059 --- /dev/null +++ b/http/src/main/java/io/micronaut/http/CaseInsensitiveMutableHttpHeaders.java @@ -0,0 +1,294 @@ +/* + * 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; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.convert.ConversionService; +import io.micronaut.core.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +/** + * A {@link MutableHttpHeaders} implementation that is case-insensitive. + * + * @author Tim Yates + * @since 4.0.0 + */ +@Internal +public final class CaseInsensitiveMutableHttpHeaders implements MutableHttpHeaders { + + private final boolean validate; + private final TreeMap> backing; + private final ConversionService conversionService; + + /** + * Create an empty CaseInsensitiveMutableHttpHeaders. + * + * @param conversionService The conversion service + */ + public CaseInsensitiveMutableHttpHeaders(ConversionService conversionService) { + this(true, Collections.emptyMap(), conversionService); + } + + /** + * Create an empty CaseInsensitiveMutableHttpHeaders. + * + * @param validate Whether to validate the headers + * @param conversionService The conversion service + */ + public CaseInsensitiveMutableHttpHeaders(boolean validate, ConversionService conversionService) { + this(validate, Collections.emptyMap(), conversionService); + } + + /** + * Create a CaseInsensitiveMutableHttpHeaders populated by the entries in the provided {@literal Map}. + * + * @param defaults The defaults + * @param conversionService The conversion service + */ + public CaseInsensitiveMutableHttpHeaders(Map> defaults, ConversionService conversionService) { + this(true, defaults, conversionService); + } + + /** + * Create a CaseInsensitiveMutableHttpHeaders populated by the entries in the provided {@literal Map}. + *

+ * Warning! Setting {@code validate} to {@code false} will not validate header names and values, and can leave your server implementation vulnerable to + * CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Response Splitting'). + * + * @param validate Whether to validate the headers + * @param defaults The defaults + * @param conversionService The conversion service + */ + public CaseInsensitiveMutableHttpHeaders(boolean validate, Map> defaults, ConversionService conversionService) { + this.validate = validate; + this.conversionService = conversionService; + this.backing = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + defaults.forEach((key, value) -> value.forEach(v -> this.add(key, v))); + } + + @Override + public List getAll(CharSequence name) { + if (name == null) { + return Collections.emptyList(); + } + List values = backing.get(name.toString()); + if (CollectionUtils.isEmpty(values)) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(values); + } + + @Nullable + @Override + public String get(CharSequence name) { + if (name == null) { + return null; + } + List strings = backing.get(name.toString()); + if (CollectionUtils.isEmpty(strings)) { + return null; + } + return strings.get(0); + } + + @Override + public Set names() { + return backing.keySet(); + } + + @Override + public Collection> values() { + return backing.values(); + } + + @Override + @SuppressWarnings("unchecked") + public Optional get(CharSequence name, ArgumentConversionContext conversionContext) { + String value = get(name); + if (value != null) { + if (conversionContext.getArgument().getType().isInstance(value)) { + return Optional.of((T) value); + } else { + return conversionService.convert(value, conversionContext); + } + } + return Optional.empty(); + } + + @Override + public MutableHttpHeaders add(CharSequence header, CharSequence value) { + validate(header, value); + backing.computeIfAbsent(header.toString(), s -> new ArrayList<>(2)).add(value.toString()); + return this; + } + + @Override + public MutableHttpHeaders remove(CharSequence header) { + if (header != null) { + backing.remove(header.toString()); + } + return this; + } + + /******************************************************************************************************************* + * Header validation code taken from io.netty.handler.codec.http.HttpHeaderValidationUtils. + ******************************************************************************************************************/ + + private void validate(CharSequence header, CharSequence value) { + if (header == null) { + throw new IllegalArgumentException("Header name cannot be null"); + } + if (validate) { + int index = validateCharSequenceToken(header); + if (index != -1) { + throw new IllegalArgumentException("A header name can only contain \"token\" characters, but found invalid character 0x" + Integer.toHexString(header.charAt(index)) + " at index " + index + " of header '" + header + "'."); + } + index = verifyValidHeaderValueCharSequence(value); + if (index != -1) { + throw new IllegalArgumentException("The header value for '" + header + "' contains prohibited character 0x" + Integer.toHexString(value.charAt(index)) + " at index " + index + '.'); + } + } + } + + private static int validateCharSequenceToken(CharSequence token) { + for (int i = 0, len = token.length(); i < len; i++) { + byte value = (byte) token.charAt(i); + if (!BitSet128.contains(value, TOKEN_CHARS_HIGH, TOKEN_CHARS_LOW)) { + return i; + } + } + return -1; + } + + private static int verifyValidHeaderValueCharSequence(CharSequence value) { + // Validate value to field-content rule. + // field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + // field-vchar = VCHAR / obs-text + // VCHAR = %x21-7E ; visible (printing) characters + // obs-text = %x80-FF + // SP = %x20 + // HTAB = %x09 ; horizontal tab + // See: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 + // And: https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1 + int b = value.charAt(0); + if (b < 0x21 || b == 0x7F) { + return 0; + } + int length = value.length(); + for (int i = 1; i < length; i++) { + b = value.charAt(i); + if (b < 0x20 && b != 0x09 || b == 0x7F) { + return i; + } + } + return -1; + } + + @SuppressWarnings("DeclarationOrder") + private static final long TOKEN_CHARS_HIGH; + @SuppressWarnings("DeclarationOrder") + private static final long TOKEN_CHARS_LOW; + + static { + // HEADER + // header-field = field-name ":" OWS field-value OWS + // + // field-name = token + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters. + // Delimiters are chosen + // from the set of US-ASCII visual characters not allowed in a token + // (DQUOTE and "(),/:;<=>?@[\]{}") + // + // COOKIE + // cookie-pair = cookie-name "=" cookie-value + // cookie-name = token + // token = 1* + // CTL = + // separators = "(" | ")" | "<" | ">" | "@" + // | "," | ";" | ":" | "\" | <"> + // | "/" | "[" | "]" | "?" | "=" + // | "{" | "}" | SP | HT + // + // field-name's token is equivalent to cookie-name's token, we can reuse the tchar mask for both: + BitSet128 tokenChars = new BitSet128() + .range('0', '9').range('a', 'z').range('A', 'Z') // Alphanumeric. + .bits('-', '.', '_', '~') // Unreserved characters. + .bits('!', '#', '$', '%', '&', '\'', '*', '+', '^', '`', '|'); // Token special characters. + TOKEN_CHARS_HIGH = tokenChars.high(); + TOKEN_CHARS_LOW = tokenChars.low(); + } + + private static final class BitSet128 { + private long high; + private long low; + + BitSet128 range(char fromInc, char toInc) { + for (int bit = fromInc; bit <= toInc; bit++) { + if (bit < 64) { + low |= 1L << bit; + } else { + high |= 1L << bit - 64; + } + } + return this; + } + + BitSet128 bits(char... bits) { + for (char bit : bits) { + if (bit < 64) { + low |= 1L << bit; + } else { + high |= 1L << bit - 64; + } + } + return this; + } + + long high() { + return high; + } + + long low() { + return low; + } + + static boolean contains(byte bit, long high, long low) { + if (bit < 0) { + return false; + } + if (bit < 64) { + return 0 != (low & 1L << bit); + } + return 0 != (high & 1L << bit - 64); + } + } +} diff --git a/http/src/test/groovy/io/micronaut/http/CaseInsensitiveMutableHttpHeadersSpec.groovy b/http/src/test/groovy/io/micronaut/http/CaseInsensitiveMutableHttpHeadersSpec.groovy new file mode 100644 index 00000000000..c7fa07deac9 --- /dev/null +++ b/http/src/test/groovy/io/micronaut/http/CaseInsensitiveMutableHttpHeadersSpec.groovy @@ -0,0 +1,183 @@ +package io.micronaut.http + +import io.micronaut.core.convert.ConversionService +import io.micronaut.core.type.Argument +import spock.lang.Specification + +class CaseInsensitiveMutableHttpHeadersSpec extends Specification { + + void "starts empty"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED) + + expect: + headers.isEmpty() + } + + void "can be set up with a map"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "Content-Type": ["application/json"], "Content-Length": ["123"]) + + expect: + headers.size() == 2 + headers.get("content-type") == "application/json" + headers.get("content-length") == "123" + } + + void "values can be converted"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "Content-Type": ["application/json"], "Content-Length": ["123"]) + + expect: + headers.size() == 2 + headers.get("content-type", Argument.of(MediaType)).get() == MediaType.APPLICATION_JSON_TYPE + headers.get("content-length", Argument.of(Integer)).get() == 123 + headers.get("content-type", Argument.of(String)).get() == MediaType.APPLICATION_JSON + } + + void "values can be removed"() { + when: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "Content-Type": ["application/json"]) + + then: + headers.size() == 1 + headers.get("content-type") == "application/json" + + when: + headers.remove("content-TYPE") + + then: + headers.empty + } + + void "case insensitivity"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "foo": ["123"]) + + expect: + ["foo", "FOO", "Foo", "fOo"].each { + assert headers.get(it) == "123" + } + } + + void "getAll returns an unmodifiable collection"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "foo": ["123"]) + + when: + headers.getAll("foo").add("456") + + then: + thrown(UnsupportedOperationException) + + when: + headers.getAll("missing").add("456") + + then: + thrown(UnsupportedOperationException) + + when: + headers.getAll(null).add("456") + + then: + thrown(UnsupportedOperationException) + } + + void "calling get with a null name returns null"() { + expect: + new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED).get(null) == null + } + + void "calling getAll with a null name returns an empty list"() { + expect: + new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED).getAll(null) == [] + } + + void "calling remove with a null name doesn't throw an exception"() { + when: + new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED).remove(null) + + then: + noExceptionThrown() + } + + void "getAll on a missing key results in an empty collection"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED) + + expect: + headers.getAll("foo").empty + } + + void "get on a missing key results in null"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED) + + expect: + headers.get("foo") == null + } + + void "cannot add invalid or insecure header names"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED) + + when: + headers.add("foo ", "bar") + + then: + IllegalArgumentException ex = thrown() + ex.message == '''A header name can only contain "token" characters, but found invalid character 0x20 at index 3 of header 'foo '.''' + + when: + new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "foo\nha": ["bar"]) + + then: + IllegalArgumentException cex = thrown() + cex.message == '''A header name can only contain "token" characters, but found invalid character 0xa at index 3 of header 'foo\nha'.''' + + when: + headers.add(null, "null isn't allowed") + + then: + IllegalArgumentException nex = thrown() + nex.message == "Header name cannot be null" + } + + void "cannot add invalid or insecure header values"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED) + + when: + headers.add("foo", "bar\nOrigin: localhost") + + then: + IllegalArgumentException ex = thrown() + ex.message == "The header value for 'foo' contains prohibited character 0xa at index 3." + + when: + new CaseInsensitiveMutableHttpHeaders(ConversionService.SHARED, "foo": ["bar\nOrigin: localhost"]) + + then: + IllegalArgumentException cex = thrown() + cex.message == "The header value for 'foo' contains prohibited character 0xa at index 3." + } + + void "can switch off validation"() { + given: + CaseInsensitiveMutableHttpHeaders headers = new CaseInsensitiveMutableHttpHeaders(false, ConversionService.SHARED) + + when: + headers.add("foo ", "bar") + headers.add("foo", "bar\nOrigin: localhost") + + then: + noExceptionThrown() + + when: + headers.add(null, "null isn't allowed") + + then: + IllegalArgumentException ex = thrown() + ex.message == "Header name cannot be null" + } +} From 48ca00e1fcaf0bfdba46c80831c3d038a9be04ad Mon Sep 17 00:00:00 2001 From: Tim Yates Date: Fri, 31 Mar 2023 14:16:32 +0100 Subject: [PATCH 21/21] Update CRaC to 1.2.1 for Micronaut 3.9.0 (#8992) Co-authored-by: Sergio del Amo --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae873c82885..b2bf267ec68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ managed-micronaut-azure = "3.9.0" managed-micronaut-cache = "3.5.0" managed-micronaut-cassandra = "5.1.1" managed-micronaut-coherence = "3.7.2" -managed-micronaut-crac = "1.2.0" +managed-micronaut-crac = "1.2.1" managed-micronaut-data = "3.9.7" managed-micronaut-discovery = "3.3.0" managed-micronaut-elasticsearch = "4.3.0"