diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy index 31e403e646..161836f3e1 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy @@ -332,22 +332,6 @@ class GateConfig extends RedisHttpSessionConfiguration { ) } - @Bean - OriginValidator gateOriginValidator( - @Value('${services.deck.base-url:}') String deckBaseUrl, - @Value('${services.deck.redirect-host-pattern:#{null}}') String redirectHostPattern, - @Value('${cors.allowed-origins-pattern:#{null}}') String allowedOriginsPattern, - @Value('${cors.expect-localhost:false}') boolean expectLocalhost) { - return new GateOriginValidator(deckBaseUrl, redirectHostPattern, allowedOriginsPattern, expectLocalhost) - } - - @Bean - FilterRegistrationBean simpleCORSFilter(OriginValidator gateOriginValidator) { - def frb = new FilterRegistrationBean(new CorsFilter(gateOriginValidator)) - frb.setOrder(Ordered.HIGHEST_PRECEDENCE) - return frb - } - /** * This AuthenticatedRequestFilter pulls the email and accounts out of the Spring * security context in order to enabling forwarding them to downstream components. @@ -372,7 +356,7 @@ class GateConfig extends RedisHttpSessionConfiguration { def frb = new FilterRegistrationBean(securityFilter) frb.order = 0 frb.name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME - return frb; + return frb } @Bean diff --git a/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java new file mode 100644 index 0000000000..8d7b60c198 --- /dev/null +++ b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java @@ -0,0 +1,82 @@ +/* + * Copyright 2019 Netflix, Inc. + * + * 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 + * + * http://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 com.netflix.spinnaker.gate.config; + +import com.netflix.spinnaker.gate.filters.CorsFilter; +import com.netflix.spinnaker.gate.filters.GateOriginValidator; +import com.netflix.spinnaker.gate.filters.OriginValidator; +import java.util.Arrays; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class GateCorsConfig { + + private static final List ALLOWED_HEADERS = + Arrays.asList( + "x-requested-with", + "content-type", + "authorization", + "X-ratelimit-app", + "X-spinnaker-priority"); + + private static final Long MAX_AGE_IN_SECONDS = 3600L; + + @Bean + OriginValidator gateOriginValidator( + @Value("${services.deck.base-url:}") String deckBaseUrl, + @Value("${services.deck.redirect-host-pattern:#{null}}") String redirectHostPattern, + @Value("${cors.allowed-origins-pattern:#{null}}") String allowedOriginsPattern, + @Value("${cors.expect-localhost:false}") boolean expectLocalhost) { + return new GateOriginValidator( + deckBaseUrl, redirectHostPattern, allowedOriginsPattern, expectLocalhost); + } + + @Bean + @ConditionalOnProperty(name = "cors.allow-mode", havingValue = "regex", matchIfMissing = true) + FilterRegistrationBean regExCorsFilter(OriginValidator gateOriginValidator) { + FilterRegistrationBean filterRegBean = + new FilterRegistrationBean<>(new CorsFilter(gateOriginValidator)); + filterRegBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return filterRegBean; + } + + @Bean + @ConditionalOnProperty(name = "cors.allow-mode", havingValue = "list") + FilterRegistrationBean allowedOriginCorsFilter( + @Value("${cors.allowed-origins:*}") List allowedOriginList) { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(allowedOriginList); + config.setAllowedHeaders(ALLOWED_HEADERS); + config.setMaxAge(MAX_AGE_IN_SECONDS); + config.addAllowedMethod("*"); // Enable CORS for all methods. + source.registerCorsConfiguration("/**", config); // Enable CORS for all paths + FilterRegistrationBean filterRegBean = + new FilterRegistrationBean<>(new org.springframework.web.filter.CorsFilter(source)); + filterRegBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return filterRegBean; + } +} diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/GateCorsAllowedOriginConfigSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/GateCorsAllowedOriginConfigSpec.groovy new file mode 100644 index 0000000000..2ce7fc2bbf --- /dev/null +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/GateCorsAllowedOriginConfigSpec.groovy @@ -0,0 +1,84 @@ +/* + * Copyright 2019 Netflix, Inc. + * + * 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 + * + * http://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 com.netflix.spinnaker.gate.config + +import com.netflix.spinnaker.gate.Main +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import spock.lang.Specification + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@AutoConfigureMockMvc +@SpringBootTest(classes = Main) +@ActiveProfiles('alloworigincors') +@TestPropertySource(properties = ["spring.config.location=classpath:gate-test.yml"]) +class GateCorsAllowedOriginConfigSpec extends Specification { + + @Autowired + private MockMvc mvc + + def "cors filter should send 200 back if no origin header exists"() { + expect: + mvc.perform(get("/version")) + .andExpect(status().is(200)) + .andExpect(header().exists('X-SPINNAKER-REQUEST-ID')) + .andExpect(header().doesNotExist('Access-Control-Allow-Origin')) + .andReturn() + .response + .contentAsString.length() > 1 + } + + def "cors filter should send 403 back for localhost"() { + expect: + mvc.perform(get("/version").header('Origin', 'https://localhost')) + .andExpect(status().is(403)) + .andExpect(header().stringValues('Vary', 'Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers')) + .andExpect(header().doesNotExist('Access-Control-Allow-Origin')) + .andReturn() + .response + .contentAsString == 'Invalid CORS request' + } + + def "cors filter should send 403 back for unknown origin"() { + expect: + mvc.perform(get("/version").header('Origin', 'https://test.blah.com')) + .andExpect(status().is(403)) + .andExpect(header().stringValues('Vary', 'Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers')) + .andExpect(header().doesNotExist('Access-Control-Allow-Origin')) + .andReturn() + .response + .contentAsString == 'Invalid CORS request' + } + + def "cors filter should set the allowed origin header to testblah.somewhere.net(allowed origin)"() { + expect: + mvc.perform(get("/version").header('Origin', 'https://testblah.somewhere.net')) + .andExpect(status().isOk()) + .andExpect(header().stringValues('Access-Control-Allow-Origin', 'https://testblah.somewhere.net')) + .andReturn() + .response + .contentAsString.length() > 1 + } + +} diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/GateCorsRegexConfigSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/GateCorsRegexConfigSpec.groovy new file mode 100644 index 0000000000..985a8f97d4 --- /dev/null +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/GateCorsRegexConfigSpec.groovy @@ -0,0 +1,71 @@ +/* + * Copyright 2019 Netflix, Inc. + * + * 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 + * + * http://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 com.netflix.spinnaker.gate.config + +import com.netflix.spinnaker.gate.Main +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import spock.lang.Specification + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@SpringBootTest(classes = Main) +@ActiveProfiles('regexcors') +@TestPropertySource(properties = ["spring.config.location=classpath:gate-test.yml"]) +class GateCorsRegexConfigSpec extends Specification { + + @Autowired + private MockMvc mvc + + def "cors filter should set the allowed origin header to localhost"() { + expect: + mvc.perform(get("/version").header('Origin', 'https://localhost')) + .andExpect(status().isOk()) + .andExpect(header().stringValues('Access-Control-Allow-Origin', 'https://localhost')) + .andReturn() + .response + .contentAsString.length() > 0 // Got some content. + } + + def "cors filter should set the allowed origin header to *(allow all)"() { + expect: + mvc.perform(get("/version").header('Origin', 'https://test.blah.com')) + .andExpect(status().isOk()) + .andExpect(header().stringValues('Access-Control-Allow-Origin', '*')) + .andReturn() + .response + .contentAsString.length() > 0 // Got some content. + } + + def "cors filter should set the allowed origin header to testblah.somewhere.net"() { + expect: + mvc.perform(get("/version").header('Origin', 'https://testblah.somewhere.net')) + .andExpect(status().isOk()) + .andExpect(header().stringValues('Access-Control-Allow-Origin', 'https://testblah.somewhere.net')) + .andReturn() + .response + .contentAsString.length() > 0 // Got some content. + } + +} diff --git a/gate-web/src/test/resources/gate-test.yml b/gate-web/src/test/resources/gate-test.yml index b111a5672a..75a54894c9 100644 --- a/gate-web/src/test/resources/gate-test.yml +++ b/gate-web/src/test/resources/gate-test.yml @@ -20,3 +20,23 @@ services: mine.enabled: false swabbie.enabled: false + +--- + +spring: + profiles: alloworigincors + +cors: + allow-mode: "list" + allowed-origins: > + https://testblah.domain.net, + https://testblah.somewhere.net + +--- + +spring: + profiles: regexcors + +cors: + allowedOriginsPattern: '^https?://(?:localhost|[^/]+\.somewhere\.net)(?::[1-9]\d*)?/?$' + expectLocalhost: true