Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ The same authorization can be required with the `@PermissionsAllowed(value = { "
* xref:security-openid-connect-multitenancy.adoc#programmatic-startup[Programmatic OIDC start-up for multitenant application]
* xref:security-authentication-mechanisms.adoc#form-based-auth-programmatic-set-up[Set up Form-based authentication programmatically]
* xref:security-authentication-mechanisms.adoc#mtls-programmatic-set-up[Set up the mutual TLS client authentication programmatically]
* xref:security-cors.adoc#cors-filter-programmatic-set-up[Configuring the CORS filter programmatically]

[[standard-security-annotations]]
== Authorization using annotations
Expand Down
39 changes: 39 additions & 0 deletions docs/src/main/asciidoc/security-cors.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,45 @@ Allowing unrestricted origins in production environments poses severe security r
For production, define explicit origins in the `quarkus.http.cors.origins` property.
====

[[cors-filter-programmatic-set-up]]
== Configuring the CORS filter programmatically

To enforce CORS policies in your application, enable the Quarkus CORS filter with the `io.quarkus.vertx.http.security.HttpSecurity` CDI event:

[source,java]
----
package org.acme.http.security;

import io.quarkus.vertx.http.security.HttpSecurity;
import jakarta.enterprise.event.Observes;

public class CorsProgrammaticConfig {
void configure(@Observes HttpSecurity httpSecurity) {
httpSecurity.cors("https://example.com");
}
}
----

The `io.quarkus.vertx.http.security.CORS` builder allows you to create a complete CORS configuration:

[source,java]
----
package org.acme.http.security;

import io.quarkus.vertx.http.security.CORS;
import io.quarkus.vertx.http.security.HttpSecurity;
import jakarta.enterprise.event.Observes;

public class CorsProgrammaticConfig {
void configure(@Observes HttpSecurity httpSecurity) {
httpSecurity.cors(CORS.builder()
.origin("https://example.com")
.method("POST")
.build());
}
}
----

== References

* xref:security-overview.adoc[Quarkus Security overview]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package io.quarkus.devui.runtime;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand All @@ -12,6 +10,7 @@

import io.quarkus.vertx.http.runtime.cors.CORSConfig;
import io.quarkus.vertx.http.runtime.cors.CORSFilter;
import io.quarkus.vertx.http.security.CORS;
import io.vertx.core.Handler;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.HttpServerRequest;
Expand Down Expand Up @@ -44,46 +43,15 @@ public DevUICORSFilter(List<String> hosts) {
private static CORSFilter corsFilter(String allowedHost) {
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() {
@Override
public Optional<List<String>> origins() {
List<String> validOrigins = new ArrayList<>();
validOrigins.add(HTTP_LOCAL_HOST + ":" + httpPort);
validOrigins.add(HTTPS_LOCAL_HOST + ":" + httpsPort);
validOrigins.add(HTTP_LOCAL_HOST_IP + ":" + httpPort);
validOrigins.add(HTTPS_LOCAL_HOST_IP + ":" + httpsPort);

if (allowedHost != null) {
validOrigins.add(allowedHost);
}
return Optional.of(validOrigins);
}

@Override
public Optional<List<String>> methods() {
return Optional.empty();
}

@Override
public Optional<List<String>> headers() {
return Optional.empty();
}

@Override
public Optional<List<String>> exposedHeaders() {
return Optional.empty();
}

@Override
public Optional<Duration> accessControlMaxAge() {
return Optional.empty();
}

@Override
public Optional<Boolean> accessControlAllowCredentials() {
return Optional.empty();
}
};
final var corsBuilder = CORS.origins(Set.of(
HTTP_LOCAL_HOST + ":" + httpPort,
HTTPS_LOCAL_HOST + ":" + httpsPort,
HTTP_LOCAL_HOST_IP + ":" + httpPort,
HTTPS_LOCAL_HOST_IP + ":" + httpsPort));
if (allowedHost != null) {
corsBuilder.origin(allowedHost);
}
CORSConfig config = (CORSConfig) corsBuilder.build();
return new CORSFilter(config);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public OpenApiRecorder(
}

public Consumer<Route> corsFilter(Filter filter) {
if (httpConfig.getValue().corsEnabled() && filter.getHandler() != null) {
if (httpConfig.getValue().cors().enabled() && filter.getHandler() != null) {
return new Consumer<Route>() {
@Override
public void accept(Route route) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
import io.quarkus.arc.deployment.UnremovableBeanBuildItem;
import io.quarkus.arc.processor.BeanInfo;
import io.quarkus.builder.item.EmptyBuildItem;
import io.quarkus.builder.item.SimpleBuildItem;
import io.quarkus.deployment.Capabilities;
import io.quarkus.deployment.Capability;
Expand Down Expand Up @@ -80,6 +79,7 @@
import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem;
import io.quarkus.vertx.core.deployment.IgnoredContextLocalDataKeysBuildItem;
import io.quarkus.vertx.http.runtime.VertxHttpBuildTimeConfig;
import io.quarkus.vertx.http.runtime.cors.CORSConfig;
import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig;
import io.quarkus.vertx.http.runtime.security.AuthorizationPolicyStorage;
import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism;
Expand Down Expand Up @@ -294,17 +294,18 @@ void createHttpAuthenticationHandler(HttpSecurityRecorder recorder, Capabilities
@Consume(TlsRegistryBuildItem.class) // we may need to register a TLS configuration for the mTLS
@Consume(RuntimeConfigSetupCompleteBuildItem.class)
@Produce(PreRouterFinalizationBuildItem.class)
@Produce(HttpSecurityConfigSetupCompleteBuildItem.class)
@Record(ExecutionTime.RUNTIME_INIT)
@BuildStep
void initializeHttpSecurity(Optional<HttpAuthenticationHandlerBuildItem> authenticationHandler,
HttpSecurityConfigSetupCompleteBuildItem initializeHttpSecurity(
Optional<HttpAuthenticationHandlerBuildItem> authenticationHandler,
HttpSecurityRecorder recorder, BeanContainerBuildItem beanContainerBuildItem,
ShutdownContextBuildItem shutdown) {
if (authenticationHandler.isPresent()) {
recorder.prepareHttpSecurityConfiguration(shutdown);
recorder.initializeHttpAuthenticatorHandler(authenticationHandler.get().handler,
beanContainerBuildItem.getValue());
RuntimeValue<CORSConfig> programmaticCorsConfig = recorder.prepareHttpSecurityConfiguration(shutdown);
recorder.initializeHttpAuthenticatorHandler(authenticationHandler.get().handler, beanContainerBuildItem.getValue());
return new HttpSecurityConfigSetupCompleteBuildItem(programmaticCorsConfig);
}
return new HttpSecurityConfigSetupCompleteBuildItem(null);
}

@BuildStep
Expand Down Expand Up @@ -897,7 +898,12 @@ public boolean getAsBoolean() {
}
}

static final class HttpSecurityConfigSetupCompleteBuildItem extends EmptyBuildItem {
static final class HttpSecurityConfigSetupCompleteBuildItem extends SimpleBuildItem {

final RuntimeValue<CORSConfig> programmaticCorsConfig;

private HttpSecurityConfigSetupCompleteBuildItem(RuntimeValue<CORSConfig> programmaticCorsConfig) {
this.programmaticCorsConfig = programmaticCorsConfig;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
import io.quarkus.vertx.core.deployment.CoreVertxBuildItem;
import io.quarkus.vertx.core.deployment.EventLoopCountBuildItem;
import io.quarkus.vertx.http.HttpServerOptionsCustomizer;
import io.quarkus.vertx.http.deployment.HttpSecurityProcessor.HttpSecurityConfigSetupCompleteBuildItem;
import io.quarkus.vertx.http.deployment.devmode.NotFoundPageDisplayableEndpointBuildItem;
import io.quarkus.vertx.http.deployment.spi.FrameworkEndpointsBuildItem;
import io.quarkus.vertx.http.deployment.spi.UseManagementInterfaceBuildItem;
Expand All @@ -72,6 +73,7 @@
import io.quarkus.vertx.http.runtime.VertxHttpConfig.InsecureRequests;
import io.quarkus.vertx.http.runtime.VertxHttpRecorder;
import io.quarkus.vertx.http.runtime.attribute.ExchangeAttributeBuilder;
import io.quarkus.vertx.http.runtime.cors.CORSConfig;
import io.quarkus.vertx.http.runtime.cors.CORSRecorder;
import io.quarkus.vertx.http.runtime.filters.Filter;
import io.quarkus.vertx.http.runtime.filters.GracefulShutdownFilter;
Expand Down Expand Up @@ -148,8 +150,11 @@ FrameworkEndpointsBuildItem frameworkEndpoints(NonApplicationRootPathBuildItem n

@BuildStep
@Record(ExecutionTime.RUNTIME_INIT)
FilterBuildItem cors(CORSRecorder recorder) {
return new FilterBuildItem(recorder.corsHandler(), SecurityHandlerPriorities.CORS);
FilterBuildItem cors(CORSRecorder recorder,
Optional<HttpSecurityConfigSetupCompleteBuildItem> httpSecurityConfigSetupCompleteBuildItem) {
RuntimeValue<CORSConfig> programmaticCorsConfig = httpSecurityConfigSetupCompleteBuildItem
.map(i -> i.programmaticCorsConfig).orElse(null);
return new FilterBuildItem(recorder.corsHandler(programmaticCorsConfig), SecurityHandlerPriorities.CORS);
}

@BuildStep
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package io.quarkus.vertx.http.cors;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;

import java.time.Duration;
import java.util.Set;

import jakarta.enterprise.event.Observes;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.vertx.http.security.CORS;
import io.quarkus.vertx.http.security.HttpSecurity;

public class CORSFluentApiFullConfigHandlerTest {

@RegisterExtension
static QuarkusUnitTest runner = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addClasses(BeanRegisteringRoute.class, CorsProgrammaticConfig.class));

@Test
@DisplayName("Handles a detailed CORS config request correctly")
public void corsFullConfigTestServlet() {
given().header("Origin", "http://custom.origin.quarkus")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "X-Custom")
.when()
.options("/test").then()
.statusCode(200)
.header("Access-Control-Allow-Origin", "http://custom.origin.quarkus")
.header("Access-Control-Allow-Methods", containsString("GET"))
.header("Access-Control-Allow-Methods", containsString("PUT"))
.header("Access-Control-Allow-Methods", containsString("POST"))
.header("Access-Control-Allow-Methods", not(containsString("DELETE")))
.header("Access-Control-Expose-Headers", "Content-Disposition")
.header("Access-Control-Allow-Headers", "X-Custom")
.header("Access-Control-Allow-Credentials", "false")
.header("Access-Control-Max-Age", "86400");

given().header("Origin", "http://www.quarkus.io")
.header("Access-Control-Request-Method", "PUT")
.when()
.options("/test").then()
.statusCode(200)
.header("Access-Control-Allow-Origin", "http://www.quarkus.io")
.header("Access-Control-Allow-Methods", containsString("PUT"))
.header("Access-Control-Allow-Methods", containsString("GET"))
.header("Access-Control-Allow-Methods", containsString("POST"))
.header("Access-Control-Allow-Methods", not(containsString("DELETE")))
.header("Access-Control-Allow-Credentials", "false")
.header("Access-Control-Expose-Headers", "Content-Disposition");
}

@Test
@DisplayName("Returns only allowed headers and methods")
public void corsPartialMethodsTestServlet() {
given().header("Origin", "http://custom.origin.quarkus")
.header("Access-Control-Request-Method", "DELETE")
.header("Access-Control-Request-Headers", "X-Custom, X-Custom2")
.when()
.options("/test").then()
.statusCode(200)
.header("Access-Control-Allow-Origin", "http://custom.origin.quarkus")
.header("Access-Control-Allow-Methods", containsString("GET"))
.header("Access-Control-Allow-Methods", containsString("PUT"))
.header("Access-Control-Allow-Methods", containsString("POST"))
.header("Access-Control-Allow-Methods", not(containsString("DELETE")))
.header("Access-Control-Expose-Headers", "Content-Disposition")
.header("Access-Control-Allow-Credentials", "false")
.header("Access-Control-Allow-Headers", "X-Custom");// Should not return X-Custom2
}

public static class CorsProgrammaticConfig {
void configure(@Observes HttpSecurity httpSecurity) {
httpSecurity.cors(CORS.builder()
.origins(Set.of("http://custom.origin.quarkus", "http://www.quarkus.io"))
.methods(Set.of("GET", "PUT", "POST"))
.header("X-Custom")
.exposedHeader("Content-Disposition")
.accessControlMaxAge(Duration.ofDays(1))
.accessControlAllowCredentials(false)
.build());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.quarkus.vertx.http.cors;

import static io.restassured.RestAssured.given;
import static org.hamcrest.core.IsNull.nullValue;

import java.util.Set;

import jakarta.enterprise.event.Observes;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.vertx.http.security.HttpSecurity;

public class CORSFluentApiOriginRegexTest {

@RegisterExtension
static QuarkusUnitTest runner = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addClasses(BeanRegisteringRoute.class, CorsProgrammaticConfig.class));

@Test
public void corsRegexValidOriginTest() {
given().header("Origin", "https://asdf.domain.com")
.when()
.get("/test").then()
.statusCode(200)
.header("Access-Control-Allow-Origin", "https://asdf.domain.com")
.header("Access-Control-Allow-Credentials", "true");
}

@Test
public void corsRegexValidOrigin2Test() {
given().header("Origin", "https://abc-123.app.mydomain.com")
.when()
.get("/test").then()
.statusCode(200)
.header("Access-Control-Allow-Origin", "https://abc-123.app.mydomain.com")
.header("Access-Control-Allow-Credentials", "true");
}

@Test
public void corsRegexInvalidOriginTest() {
given().header("Origin", "https://asdfdomain.com")
.when()
.get("/test").then()
.statusCode(403)
.header("Access-Control-Allow-Origin", nullValue())
.header("Access-Control-Allow-Credentials", nullValue());
}

@Test
public void corsRegexInvalidOrigin2Test() {
given().header("Origin", "https://abc-123app.mydomain.com")
.when()
.get("/test").then()
.statusCode(403)
.header("Access-Control-Allow-Origin", nullValue())
.header("Access-Control-Allow-Credentials", nullValue());
}

public static class CorsProgrammaticConfig {
void configure(@Observes HttpSecurity httpSecurity) {
httpSecurity.cors(Set.of(
"/https:\\/\\/(?:[a-z0-9\\-]+\\.)*domain\\.com/",
"/https://([a-z0-9\\-_]+)\\.app\\.mydomain\\.com/"));
}
}
}
Loading
Loading