diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java index 6c34e448daabe..c95b37cbd1302 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevConsoleProcessor.java @@ -95,6 +95,7 @@ import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.deployment.webjar.WebJarBuildItem; import io.quarkus.vertx.http.deployment.webjar.WebJarResultsBuildItem; +import io.quarkus.vertx.http.runtime.devmode.DevConsoleCORSFilter; import io.quarkus.vertx.http.runtime.devmode.DevConsoleFilter; import io.quarkus.vertx.http.runtime.devmode.DevConsoleRecorder; import io.quarkus.vertx.http.runtime.devmode.RedirectHandler; @@ -438,6 +439,7 @@ public DevConsoleTemplateInfoBuildItem config(List routes, @@ -472,6 +474,12 @@ public void setupDevConsoleRoutes( // if the handler is a proxy, then that means it's been produced by a recorder and therefore belongs in the regular runtime Vert.x instance // otherwise this is handled in the setupDeploymentSideHandling method if (!i.isDeploymentSide()) { + if (devUIConfig.cors.enabled) { + routeBuildItemBuildProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .route("dev/*") + .handler(new DevConsoleCORSFilter()) + .build()); + } NonApplicationRootPathBuildItem.Builder builder = nonApplicationRootPathBuildItem.routeBuilder() .routeFunction( "dev/" + groupAndArtifact.getKey() + "." + groupAndArtifact.getValue() + "/" + i.getPath(), diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevUIConfig.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevUIConfig.java index 634a1d9d7dd5a..828db66ef474c 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevUIConfig.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/devmode/console/DevUIConfig.java @@ -1,5 +1,6 @@ package io.quarkus.vertx.http.deployment.devmode.console; +import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigRoot; @@ -12,4 +13,19 @@ public class DevUIConfig { @ConfigItem(defaultValue = "50") public int historySize; + /** + * CORS configuration. + */ + public Cors cors = new Cors(); + + @ConfigGroup + public static class Cors { + + /** + * Enable CORS filter. + */ + @ConfigItem(defaultValue = "true") + public boolean enabled = true; + } + } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/cors/CORSHandlerTestWildcardOriginCase.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/cors/CORSHandlerTestWildcardOriginCase.java index a89ee9697275b..bea0ee1578301 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/cors/CORSHandlerTestWildcardOriginCase.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/cors/CORSHandlerTestWildcardOriginCase.java @@ -1,6 +1,7 @@ package io.quarkus.vertx.http.cors; import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.nullValue; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,6 +29,7 @@ void corsMatchingOrigin() { .when() .options("/test").then() .statusCode(200) + .header("Access-Control-Allow-Origin", origin) .header("Access-Control-Allow-Credentials", "true"); } @@ -42,7 +44,8 @@ void corsNotMatchingOrigin() { .header("Access-Control-Request-Headers", headers) .when() .options("/test").then() - .statusCode(200) + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) .header("Access-Control-Allow-Credentials", "false"); } @@ -58,6 +61,7 @@ void corsMatchingOriginWithWildcard() { .when() .options("/test").then() .statusCode(200) + .header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Credentials", "false"); } } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/devconsole/DevConsoleCorsTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/devconsole/DevConsoleCorsTest.java new file mode 100644 index 0000000000000..81d6660f6c244 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/devconsole/DevConsoleCorsTest.java @@ -0,0 +1,194 @@ +package io.quarkus.vertx.http.devconsole; + +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; +import io.restassured.RestAssured; + +public class DevConsoleCorsTest { + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .withEmptyApplication(); + + @Test + public void testPreflightHttpLocalhostOrigin() { + String origin = "http://localhost:8080"; + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", origin) + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", methods) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightHttpLocalhostIpOrigin() { + String origin = "http://127.0.0.1:8080"; + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", origin) + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", methods) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightHttpsLocalhostOrigin() { + String origin = "https://localhost:8443"; + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", origin) + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", methods) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightHttpsLocalhostIpOrigin() { + String origin = "https://127.0.0.1:8443"; + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", origin) + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", methods) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightNonLocalhostOrigin() { + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", "https://quarkus.io/http://localhost") + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) + .header("Access-Control-Allow-Methods", nullValue()) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightBadLocalhostOrigin() { + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", "http://localhost:8080/devui") + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightBadLocalhostIpOrigin() { + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", "http://127.0.0.1:8080/devui") + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) + .body(emptyOrNullString()); + } + + @Test + public void testPreflightLocalhostOriginWithoutPort() { + String methods = "GET,POST"; + RestAssured.given() + .header("Origin", "http://localhost") + .header("Access-Control-Request-Method", methods) + .when() + .options("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) + .body(emptyOrNullString()); + } + + @Test + public void testSimpleRequestHttpLocalhostOrigin() { + String origin = "http://localhost:8080"; + RestAssured.given() + .header("Origin", origin) + .when() + .get("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", nullValue()) + .body(not(emptyOrNullString())); + } + + @Test + public void testSimpleRequestHttpLocalhostIpOrigin() { + String origin = "http://127.0.0.1:8080"; + RestAssured.given() + .header("Origin", origin) + .when() + .get("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", nullValue()) + .body(not(emptyOrNullString())); + } + + @Test + public void testSimpleRequestHttpsLocalhostOrigin() { + String origin = "https://localhost:8443"; + RestAssured.given() + .header("Origin", origin) + .when() + .get("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", nullValue()) + .body(not(emptyOrNullString())); + } + + @Test + public void testSimpleRequestHttpsLocalhostIpOrigin() { + String origin = "https://127.0.0.1:8443"; + RestAssured.given() + .header("Origin", origin) + .when() + .get("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Methods", nullValue()) + .body(not(emptyOrNullString())); + } + + @Test + public void testSimpleRequestNonLocalhostOrigin() { + RestAssured.given() + .header("Origin", "https://quarkus.io/http://localhost") + .when() + .get("q/dev/io.quarkus.quarkus-vertx-http/config").then() + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) + .body(emptyOrNullString()); + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSConfig.java index 50f45d0b1f383..d98820d59c099 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSConfig.java @@ -24,7 +24,7 @@ public class CORSConfig { */ @ConfigItem @ConvertWith(TrimmedStringConverter.class) - public Optional> origins; + public Optional> origins = Optional.empty(); /** * HTTP methods allowed for CORS @@ -36,7 +36,7 @@ public class CORSConfig { */ @ConfigItem @ConvertWith(TrimmedStringConverter.class) - public Optional> methods; + public Optional> methods = Optional.empty(); /** * HTTP headers allowed for CORS @@ -48,7 +48,7 @@ public class CORSConfig { */ @ConfigItem @ConvertWith(TrimmedStringConverter.class) - public Optional> headers; + public Optional> headers = Optional.empty(); /** * HTTP headers exposed in CORS @@ -59,14 +59,14 @@ public class CORSConfig { */ @ConfigItem @ConvertWith(TrimmedStringConverter.class) - public Optional> exposedHeaders; + public Optional> exposedHeaders = Optional.empty(); /** * The `Access-Control-Max-Age` response header value indicating * how long the results of a pre-flight request can be cached. */ @ConfigItem - public Optional accessControlMaxAge; + public Optional accessControlMaxAge = Optional.empty(); /** * The `Access-Control-Allow-Credentials` header is used to tell the @@ -77,7 +77,7 @@ public class CORSConfig { * there is a match with the precise `Origin` header and that header is not '*'. */ @ConfigItem - public Optional accessControlAllowCredentials; + public Optional accessControlAllowCredentials = Optional.empty(); @Override public String toString() { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSFilter.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSFilter.java index 8bb838927594e..4a7fee1a14e4f 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSFilter.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/cors/CORSFilter.java @@ -195,7 +195,11 @@ public void handle(RoutingContext event) { String.join(",", exposedHeaders.orElse(Collections.emptyList()))); } - if (request.method().equals(HttpMethod.OPTIONS) && (requestedHeaders != null || requestedMethods != null)) { + if (!allowsOrigin) { + response.setStatusCode(403); + response.setStatusMessage("CORS Rejected - Invalid origin"); + response.end(); + } else if (request.method().equals(HttpMethod.OPTIONS) && (requestedHeaders != null || requestedMethods != null)) { if (corsConfig.accessControlMaxAge.isPresent()) { response.putHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, String.valueOf(corsConfig.accessControlMaxAge.get().getSeconds())); diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/DevConsoleCORSFilter.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/DevConsoleCORSFilter.java new file mode 100644 index 0000000000000..0ef7e7627bec5 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/devmode/DevConsoleCORSFilter.java @@ -0,0 +1,63 @@ +package io.quarkus.vertx.http.runtime.devmode; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.logging.Logger; + +import io.quarkus.vertx.http.runtime.cors.CORSConfig; +import io.quarkus.vertx.http.runtime.cors.CORSFilter; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; + +public class DevConsoleCORSFilter implements Handler { + private static final Logger LOG = Logger.getLogger(DevConsoleCORSFilter.class); + + private static final String HTTP_PORT_CONFIG_PROP = "quarkus.http.port"; + private static final String HTTPS_PORT_CONFIG_PROP = "quarkus.http.ssl-port"; + private static final String LOCAL_HOST = "localhost"; + private static final String LOCAL_HOST_IP = "127.0.0.1"; + private static final String HTTP_LOCAL_HOST = "http://" + LOCAL_HOST; + private static final String HTTPS_LOCAL_HOST = "https://" + LOCAL_HOST; + private static final String HTTP_LOCAL_HOST_IP = "http://" + LOCAL_HOST_IP; + private static final String HTTPS_LOCAL_HOST_IP = "https://" + LOCAL_HOST_IP; + + public DevConsoleCORSFilter() { + } + + private static CORSFilter corsFilter() { + int httpPort = ConfigProvider.getConfig().getValue(HTTP_PORT_CONFIG_PROP, int.class); + int httpsPort = ConfigProvider.getConfig().getValue(HTTPS_PORT_CONFIG_PROP, int.class); + CORSConfig config = new CORSConfig(); + config.origins = Optional.of(List.of( + HTTP_LOCAL_HOST + ":" + httpPort, + HTTP_LOCAL_HOST_IP + ":" + httpPort, + HTTPS_LOCAL_HOST + ":" + httpsPort, + HTTPS_LOCAL_HOST_IP + ":" + httpsPort)); + return new CORSFilter(config); + } + + @Override + public void handle(RoutingContext event) { + HttpServerRequest request = event.request(); + HttpServerResponse response = event.response(); + String origin = request.getHeader(HttpHeaders.ORIGIN); + if (origin == null) { + corsFilter().handle(event); + } else { + if (origin.startsWith(HTTP_LOCAL_HOST) || origin.startsWith(HTTPS_LOCAL_HOST) + || origin.startsWith(HTTP_LOCAL_HOST_IP) || origin.startsWith(HTTPS_LOCAL_HOST_IP)) { + corsFilter().handle(event); + } else { + LOG.errorf("Only localhost origin is allowed, but Origin header value is: %s", origin); + response.setStatusCode(403); + response.setStatusMessage("CORS Rejected - Invalid origin"); + response.end(); + } + } + } +}