diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 7fc4a0a401f..61a494b736c 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -2264,6 +2264,9 @@ Regex patterns for URIs that allow CORS requests (non-XHR). Default permits all. Regex patterns for allowed CORS origins (non-XHR). +**Note:** Origin patterns are evaluated as full-string matches (they are implicitly anchored with `^` and `$`). If you previously relied on substring matching, you must update your patterns. +For example, to safely match a domain with any subdomain and an optional port, use a pattern like: `^https?://([a-zA-Z0-9-]+\.)*example\.com(:[0-9]+)?$` + [Back to table](#cors) --- @@ -2336,6 +2339,9 @@ Regex patterns for URIs that allow XHR CORS requests. Regex patterns for allowed XHR CORS origins. +**Note:** Origin patterns are evaluated as full-string matches (they are implicitly anchored with `^` and `$`). If you previously relied on substring matching, you must update your patterns. +For example, to safely match a domain with any subdomain and an optional port, use a pattern like: `^https?://([a-zA-Z0-9-]+\.)*example\.com(:[0-9]+)?$` + [Back to table](#cors) --- diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/CorsConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/CorsConfiguration.java index 8a893d27a5c..aca18d6b8cb 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/CorsConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/CorsConfiguration.java @@ -34,14 +34,14 @@ public class CorsConfiguration { * requests. */ private List allowedOrigins = Collections.singletonList(".*"); - private final List allowedOriginPatterns = new ArrayList<>(); + private List allowedOriginPatterns = new ArrayList<>(); /** * A comma delimited list of regular expression patterns that defines which * UAA URIs allow the "X-Requested-With" header in CORS requests. */ private List allowedUris = Collections.singletonList(".*"); - private final List allowedUriPatterns = new ArrayList<>(); + private List allowedUriPatterns = new ArrayList<>(); /** * A comma delimited list of regular expression patterns that define which @@ -60,6 +60,17 @@ public boolean isAllowedCredentials() { return allowedCredentials; } + @com.fasterxml.jackson.annotation.JsonIgnore + private volatile boolean patternsCompiled = false; + + public boolean isPatternsCompiled() { + return patternsCompiled; + } + + public void setPatternsCompiled(boolean patternsCompiled) { + this.patternsCompiled = patternsCompiled; + } + public void setAllowedCredentials(boolean allowedCredentials) { this.allowedCredentials = allowedCredentials; } @@ -84,24 +95,34 @@ public List getAllowedOriginPatterns() { return allowedOriginPatterns; } + public void setAllowedOriginPatterns(List allowedOriginPatterns) { + this.allowedOriginPatterns = allowedOriginPatterns; + } + public List getAllowedOrigins() { return allowedOrigins; } public void setAllowedOrigins(List allowedOrigins) { this.allowedOrigins = allowedOrigins; + this.patternsCompiled = false; } public List getAllowedUriPatterns() { return allowedUriPatterns; } + public void setAllowedUriPatterns(List allowedUriPatterns) { + this.allowedUriPatterns = allowedUriPatterns; + } + public List getAllowedUris() { return allowedUris; } public void setAllowedUris(List allowedUris) { this.allowedUris = allowedUris; + this.patternsCompiled = false; } public int getMaxAge() { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/CorsFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/CorsFilter.java index 20d0a7246a4..b4ff7f472a0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/CorsFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/CorsFilter.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -112,8 +113,7 @@ public CorsFilter(final IdentityZoneManager identityZoneManager, public void initialize() { // initialize the configs for default zone for (CorsConfiguration configuration : Arrays.asList(xhrConfiguration, defaultConfiguration)) { - configuration.getAllowedUriPatterns().clear(); - configuration.getAllowedOriginPatterns().clear(); + configuration.setPatternsCompiled(false); compileAllowedOriginsAndUris(configuration, configuration == xhrConfiguration ? "xhr" : "default"); } @@ -324,7 +324,7 @@ protected boolean isAllowedRequestUri(final String uri, CorsConfiguration config protected boolean isAllowedOrigin(final String origin, CorsConfiguration configuration) { for (Pattern pattern : configuration.getAllowedOriginPatterns()) { // Making sure that the pattern matches - if (pattern.matcher(origin).find()) { + if (pattern.matcher(origin).matches()) { return true; } } @@ -366,26 +366,53 @@ private CorsConfiguration resolveDefaultCorsConfiguration() { return getDefaultConfiguration(); } + private String anchorPattern(String pattern) { + if (!pattern.startsWith("^")) { + pattern = "^" + pattern; + } + if (!pattern.endsWith("$")) { + pattern = pattern + "$"; + } + return pattern; + } + private void compileAllowedOriginsAndUris(CorsConfiguration configuration, String type) { - if (configuration.getAllowedUris() != null) { - for (String allowedUri : configuration.getAllowedUris()) { - try { - configuration.getAllowedUriPatterns().add(Pattern.compile(allowedUri)); - log.debug("URI '%s' is allowed for a %s CORS requests.".formatted(allowedUri, type)); - } catch (PatternSyntaxException patternSyntaxException) { - log.error("Invalid regular expression pattern in cors.{}.allowed.uris: {}", type, allowedUri, patternSyntaxException); + if (configuration.isPatternsCompiled()) { + return; + } + + synchronized (configuration) { + if (configuration.isPatternsCompiled()) { + return; + } + + List uriPatterns = new ArrayList<>(); + if (configuration.getAllowedUris() != null) { + for (String allowedUri : configuration.getAllowedUris()) { + try { + uriPatterns.add(Pattern.compile(allowedUri)); + log.debug("URI '%s' is allowed for a %s CORS requests.".formatted(allowedUri, type)); + } catch (PatternSyntaxException patternSyntaxException) { + log.error("Invalid regular expression pattern in cors.{}.allowed.uris: {}", type, allowedUri, patternSyntaxException); + } } } - } - if (configuration.getAllowedOrigins() != null) { - for (String allowedOrigin : configuration.getAllowedOrigins()) { - try { - configuration.getAllowedOriginPatterns().add(Pattern.compile(allowedOrigin)); - log.debug("Origin '%s' is allowed for a %s CORS requests.".formatted(allowedOrigin, type)); - } catch (PatternSyntaxException patternSyntaxException) { - log.error("Invalid regular expression pattern in cors.{}.allowed.origins: {}", type, allowedOrigin, patternSyntaxException); + configuration.setAllowedUriPatterns(uriPatterns); + + List originPatterns = new ArrayList<>(); + if (configuration.getAllowedOrigins() != null) { + for (String allowedOrigin : configuration.getAllowedOrigins()) { + try { + originPatterns.add(Pattern.compile(anchorPattern(allowedOrigin))); + log.debug("Origin '%s' is allowed for a %s CORS requests.".formatted(allowedOrigin, type)); + } catch (PatternSyntaxException patternSyntaxException) { + log.error("Invalid regular expression pattern in cors.{}.allowed.origins: {}", type, allowedOrigin, patternSyntaxException); + } } } + configuration.setAllowedOriginPatterns(originPatterns); + + configuration.setPatternsCompiled(true); } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/CorsFilterNonDefaultZoneTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/CorsFilterNonDefaultZoneTests.java index be69c8ed7d5..4b232896fe2 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/CorsFilterNonDefaultZoneTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/CorsFilterNonDefaultZoneTests.java @@ -92,8 +92,8 @@ void requestExpectXhrCorsResponse() throws Exception { @Test void requestWithAllowedOriginPatterns() throws Exception { - identityZone.getConfig().getCorsPolicy().getXhrConfiguration().getAllowedOriginPatterns() - .add(Pattern.compile("bunnyoutlet-shop.com$")); + identityZone.getConfig().getCorsPolicy().getXhrConfiguration().getAllowedOrigins() + .add("^.*bunnyoutlet-shop\\.com$"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/uaa/userinfo"); request.addHeader("Origin", "bunnyoutlet-shop.com"); @@ -105,8 +105,8 @@ void requestWithAllowedOriginPatterns() throws Exception { @Test void requestWithAllowedUriPatterns() throws Exception { - identityZone.getConfig().getCorsPolicy().getXhrConfiguration().getAllowedUriPatterns() - .add(Pattern.compile("/uaa/*")); + identityZone.getConfig().getCorsPolicy().getXhrConfiguration().getAllowedUris() + .add("^/uaa/.*$"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/uaa/login"); request.addHeader("Origin", "example.com"); @@ -258,8 +258,8 @@ void defaultCorsExpectStandardCorsResponse() throws Exception { @Test void defaultCorsWithAllowedOriginPatterns() throws Exception { - identityZone.getConfig().getCorsPolicy().getDefaultConfiguration().getAllowedOriginPatterns() - .add(Pattern.compile("bunnyoutlet.com$")); + identityZone.getConfig().getCorsPolicy().getDefaultConfiguration().getAllowedOrigins() + .add("^.*bunnyoutlet\\.com$"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/uaa/userinfo"); request.addHeader("Origin", "bunnyoutlet.com"); @@ -270,8 +270,8 @@ void defaultCorsWithAllowedOriginPatterns() throws Exception { @Test void defaultCorsWithAllowedUriPatterns() throws Exception { - identityZone.getConfig().getCorsPolicy().getDefaultConfiguration().getAllowedUriPatterns() - .add(Pattern.compile("/uaa/*")); + identityZone.getConfig().getCorsPolicy().getDefaultConfiguration().getAllowedUris() + .add("^/uaa/.*$"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/uaa/login"); request.addHeader("Origin", "example.com"); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java index f7aa9797685..4873bda27b8 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java @@ -2172,8 +2172,8 @@ void logOutCorsPreflight() throws Exception { */ @Test void logOutCorsPreflightForIdentityZone() throws Exception { - corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^*\\.localhost$")); - corsFilter.getFilter().setCorsXhrAllowedUris(singletonList("^/logout.do$")); + corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^.*\\.localhost$")); + corsFilter.getFilter().setCorsXhrAllowedUris(singletonList("^/logout\\.do$")); corsFilter.getFilter().initialize(); HttpHeaders httpHeaders = new HttpHeaders(); @@ -2266,8 +2266,8 @@ void logOutCorsPreflightWithUnallowedOrigin() throws Exception { @Test void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyIsNull() throws Exception { // setting the default zone CORS policy - corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^*\\.localhost$")); - corsFilter.getFilter().setCorsXhrAllowedUris(singletonList("^/logout.do$")); + corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^.*\\.localhost$")); + corsFilter.getFilter().setCorsXhrAllowedUris(singletonList("^/logout\\.do$")); corsFilter.getFilter().initialize(); // set the non default zone CORS Xhr policy to null diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java index 33207b2e7ae..7ba12312ea8 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java @@ -2622,8 +2622,8 @@ void logOutCorsPreflight(ZoneResolutionMode mode) throws Exception { void logOutCorsPreflightForIdentityZone(ZoneResolutionMode mode) throws Exception { String subdomain = "testzone1"; IdentityZone zone = MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); - corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^*\\.localhost$")); - List allowedUris = mode == ZoneResolutionMode.ZONE_PATH ? asList("^/logout.do$", "^/z/[^/]+/logout.do$") : singletonList("^/logout.do$"); + corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^.*\\.localhost$")); + List allowedUris = mode == ZoneResolutionMode.ZONE_PATH ? asList("^/logout\\.do$", "^/z/[^/]+/logout\\.do$") : singletonList("^/logout\\.do$"); corsFilter.getFilter().setCorsXhrAllowedUris(allowedUris); corsFilter.getFilter().initialize(); @@ -2734,11 +2734,11 @@ void logOutCorsPreflightWithUnallowedOrigin(ZoneResolutionMode mode) throws Exce @EnumSource(ZoneResolutionMode.class) void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyIsNull(ZoneResolutionMode mode) throws Exception { // setting the default zone CORS policy - corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^*\\.localhost$")); + corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^.*\\.localhost$")); // For ZONE_PATH mode, the request path is /z/{subdomain}/logout.do, so we need to allow that pattern List allowedUris = mode == ZoneResolutionMode.ZONE_PATH - ? asList("^/logout.do$", "^/z/[^/]+/logout.do$") - : singletonList("^/logout.do$"); + ? asList("^/logout\\.do$", "^/z/[^/]+/logout\\.do$") + : singletonList("^/logout\\.do$"); corsFilter.getFilter().setCorsXhrAllowedUris(allowedUris); corsFilter.getFilter().initialize();