diff --git a/src/main/java/com/proovy/global/security/CorsProperties.java b/src/main/java/com/proovy/global/security/CorsProperties.java new file mode 100644 index 0000000..58f5453 --- /dev/null +++ b/src/main/java/com/proovy/global/security/CorsProperties.java @@ -0,0 +1,39 @@ +package com.proovy.global.security; + +import java.util.List; +import java.util.regex.Pattern; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * {@code proovy.cors.*} — 브라우저 CORS 허용 origin. + * + * @param allowedOriginPatterns 허용 origin pattern({@link org.springframework.web.cors.CorsConfiguration#setAllowedOriginPatterns} + * 문법, 예 {@code http://localhost:[*]}). 기본·운영은 {@code https://proovy.ai.kr}만이고, + * local profile만 개발 Front(Vite dev server) origin을 더한다. credentials를 허용하므로 + * 항목(Spring이 쉼표로 나누는 조각 포함)마다 {@code scheme://host[:port|:[*]]}만 받는다 — + * wildcard는 포트({@code :[*]})에만 쓸 수 있고 {@code *}·{@code https://*} 같은 host wildcard는 거부한다 + */ +@ConfigurationProperties(prefix = "proovy.cors") +public record CorsProperties(@DefaultValue("https://proovy.ai.kr") List allowedOriginPatterns) { + + private static final Pattern EXPLICIT_ORIGIN = + Pattern.compile("https?://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:([0-9]{1,5}|\\[\\*]))?"); + + public CorsProperties { + if (allowedOriginPatterns == null || allowedOriginPatterns.isEmpty()) { + throw new IllegalArgumentException("proovy.cors.allowed-origin-patterns must not be empty"); + } + for (String pattern : allowedOriginPatterns) { + // CorsConfiguration은 한 항목을 쉼표로 다시 나눈다 — 조각마다 검사한다 + for (String origin : (pattern == null ? "" : pattern).split(",", -1)) { + if (!EXPLICIT_ORIGIN.matcher(origin.trim()).matches()) { + throw new IllegalArgumentException( + "proovy.cors.allowed-origin-patterns must list explicit origins (scheme://host[:port|:[*]]): " + + origin.trim()); + } + } + } + allowedOriginPatterns = List.copyOf(allowedOriginPatterns); + } +} diff --git a/src/main/java/com/proovy/global/security/SecurityConfig.java b/src/main/java/com/proovy/global/security/SecurityConfig.java index 848f608..e4ca3e3 100644 --- a/src/main/java/com/proovy/global/security/SecurityConfig.java +++ b/src/main/java/com/proovy/global/security/SecurityConfig.java @@ -3,6 +3,7 @@ import jakarta.servlet.DispatcherType; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -22,6 +23,7 @@ @Configuration @EnableWebSecurity +@EnableConfigurationProperties(CorsProperties.class) @RequiredArgsConstructor public class SecurityConfig { @@ -31,6 +33,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final JwtAuthenticationEntryPoint jwtAuthEntryPoint; + private final CorsProperties corsProperties; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -73,8 +76,13 @@ static boolean isRunStreamAsyncDispatch(HttpServletRequest request) { @Bean public CorsConfigurationSource corsConfigurationSource() { + return corsConfigurationSource(corsProperties); + } + + /** 허용 origin은 {@code proovy.cors.allowed-origin-patterns}(기본·운영 https://proovy.ai.kr, local profile은 localhost 추가). */ + static CorsConfigurationSource corsConfigurationSource(CorsProperties properties) { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("https://proovy.ai.kr")); + configuration.setAllowedOriginPatterns(properties.allowedOriginPatterns()); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4cca942..50d48f3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -112,6 +112,9 @@ jwt: proovy: api: public-base-url: ${PROOVY_API_BASE_URL:} + # 브라우저 CORS 허용 origin. 기본·운영은 서비스 도메인만 허용한다(local profile만 개발 Front origin 추가). + cors: + allowed-origin-patterns: https://proovy.ai.kr # Brain 내부 실행 API client (task 1.76). base-url이 비어 있으면 client bean을 만들지 않는다. # 인증은 한 모드만: production은 id-token(고정 audience), local/test는 shared-token. brain: @@ -156,6 +159,13 @@ spring: hibernate: format_sql: true +# 로컬 Front(Vite dev server, 포트 무관)가 로컬 Server에 POST할 수 있게 localhost origin을 더한다. +proovy: + cors: + allowed-origin-patterns: + - https://proovy.ai.kr + - http://localhost:[*] + --- # =============================== # Production 환경 diff --git a/src/test/java/com/proovy/global/security/CorsConfigurationTest.java b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java new file mode 100644 index 0000000..126f268 --- /dev/null +++ b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java @@ -0,0 +1,114 @@ +package com.proovy.global.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 실제 application.yaml을 profile별로 읽어 CORS 허용 origin을 확인한다. local profile만 개발 Front(localhost, 포트 무관)를 + * 허용하고, 운영(prod)·CI(test) profile은 https://proovy.ai.kr만 허용한다. 실행 환경의 OS 환경변수 + * (SPRING_PROFILES_ACTIVE·PROOVY_CORS_ALLOWED_ORIGIN_PATTERNS 등)는 결과에 섞이지 않게 뺀다. + */ +class CorsConfigurationTest { + + private static final String SERVICE_ORIGIN = "https://proovy.ai.kr"; + private static final String DEV_FRONT_ORIGIN = "http://localhost:5196"; + + @Configuration + @EnableConfigurationProperties(CorsProperties.class) + static class Properties { + } + + private final ApplicationContextRunner context = new ApplicationContextRunner(() -> { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().remove(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME); + ctx.setEnvironment(environment); + return ctx; + }) + .withInitializer(new ConfigDataApplicationContextInitializer()) + .withUserConfiguration(Properties.class); + + @Test + @DisplayName("local profile은 서비스 도메인과 localhost의 모든 포트를 허용한다") + void localProfileAllowsLocalhostOrigins() { + context.withPropertyValues("spring.profiles.active=local").run(ctx -> { + assertThat(ctx.getEnvironment().getActiveProfiles()).containsExactly("local"); + CorsConfigurationSource source = SecurityConfig.corsConfigurationSource(ctx.getBean(CorsProperties.class)); + + assertThat(post(source, DEV_FRONT_ORIGIN).getStatus()).isEqualTo(200); + assertThat(post(source, "http://localhost:5173").getStatus()).isEqualTo(200); + assertThat(post(source, SERVICE_ORIGIN).getStatus()).isEqualTo(200); + assertThat(post(source, "http://localhost.attacker.example").getStatus()).isEqualTo(403); + assertThat(post(source, "https://evil.example").getStatus()).isEqualTo(403); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"prod", "test"}) + @DisplayName("운영·CI profile은 서비스 도메인만 허용하고 localhost POST를 거부한다") + void otherProfilesAllowServiceOriginOnly(String profile) { + context.withPropertyValues("spring.profiles.active=" + profile).run(ctx -> { + assertThat(ctx.getEnvironment().getActiveProfiles()).containsExactly(profile); + CorsProperties properties = ctx.getBean(CorsProperties.class); + assertThat(properties.allowedOriginPatterns()).containsExactly(SERVICE_ORIGIN); + CorsConfigurationSource source = SecurityConfig.corsConfigurationSource(properties); + + assertThat(post(source, SERVICE_ORIGIN).getStatus()).isEqualTo(200); + assertThat(post(source, DEV_FRONT_ORIGIN).getStatus()).isEqualTo(403); + }); + } + + @Test + @DisplayName("credentials 허용이라 빈 목록·host wildcard(쉼표로 숨긴 항목 포함)는 기동에서 거부한다") + void rejectsEmptyOrWildcardOrigins() { + assertThatThrownBy(() -> new CorsProperties(List.of())).isInstanceOf(IllegalArgumentException.class); + for (String unsafe : List.of("*", "https://*", "http://*:[*]", "https://*.example.com", SERVICE_ORIGIN + ", *", + "", "proovy.ai.kr", "https://proovy.ai.kr/")) { + assertThatThrownBy(() -> new CorsProperties(List.of(SERVICE_ORIGIN, unsafe))) + .as(unsafe) + .isInstanceOf(IllegalArgumentException.class); + } + context.withPropertyValues("spring.profiles.active=prod", "proovy.cors.allowed-origin-patterns=*") + .run(ctx -> assertThat(ctx).hasFailed()); + context.withPropertyValues("spring.profiles.active=prod", "proovy.cors.allowed-origin-patterns[0]=https://*") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + @Test + @DisplayName("명시 origin과 포트 wildcard, 쉼표로 나열한 명시 origin은 받는다") + void acceptsExplicitOrigins() { + CorsProperties properties = new CorsProperties(List.of(SERVICE_ORIGIN, "http://localhost:[*]", + "http://localhost:5173, http://127.0.0.1:8080")); + assertThat(properties.allowedOriginPatterns()).hasSize(3); + } + + /** Origin을 단 POST가 CORS filter를 통과하면(chain 진행) 200, 거부되면 403. */ + private static MockHttpServletResponse post(CorsConfigurationSource source, String origin) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/conversations"); + request.addHeader("Origin", origin); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + new CorsFilter(source).doFilter(request, response, chain); + if (chain.getRequest() == null) { + assertThat(response.getStatus()).isEqualTo(403); + } + return response; + } +}