From 2de52ec2b882abbc4c5a821060dc9260a30c5705 Mon Sep 17 00:00:00 2001 From: chowon442 Date: Tue, 20 Oct 2026 19:14:02 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20CORS=20=ED=97=88=EC=9A=A9=20origin?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=ED=99=94=20=E2=80=94=20local=20profile?= =?UTF-8?q?=EB=A7=8C=20localhost=20=EA=B0=9C=EB=B0=9C=20Front=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9,=20=EA=B8=B0=EB=B3=B8=C2=B7=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EC=9D=80=20proovy.ai.kr=20=EC=9C=A0=EC=A7=80=20[1.96]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proovy.cors.allowed-origin-patterns(CorsProperties)로 허용 origin을 읽는다. 빈 목록·전체 허용(*)은 기동에서 거부한다(credentials 허용). - application.yaml 기본은 https://proovy.ai.kr, local profile만 http://localhost:[*]를 더한다. prod·test profile은 기본값 그대로다. - 로컬 Front(Vite dev server proxy)의 브라우저 POST(전송·정지)가 Origin: http://localhost:로 403이던 문제를 해결한다. - CorsConfigurationTest: 실제 application.yaml을 profile별(local·prod·test)로 binding해 CorsFilter POST 판정을 확인한다. Refs Team-Proovy/Proovy-front#306 Co-Authored-By: Claude Opus 5.5 --- .../global/security/CorsProperties.java | 29 ++++++ .../global/security/SecurityConfig.java | 10 +- src/main/resources/application.yaml | 10 ++ .../security/CorsConfigurationTest.java | 91 +++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/proovy/global/security/CorsProperties.java create mode 100644 src/test/java/com/proovy/global/security/CorsConfigurationTest.java 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..7d002e9 --- /dev/null +++ b/src/main/java/com/proovy/global/security/CorsProperties.java @@ -0,0 +1,29 @@ +package com.proovy.global.security; + +import java.util.List; +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를 허용하므로 + * 모든 origin({@code *})은 받지 않는다 + */ +@ConfigurationProperties(prefix = "proovy.cors") +public record CorsProperties(@DefaultValue("https://proovy.ai.kr") List allowedOriginPatterns) { + + public CorsProperties { + if (allowedOriginPatterns == null || allowedOriginPatterns.isEmpty()) { + throw new IllegalArgumentException("proovy.cors.allowed-origin-patterns must not be empty"); + } + for (String pattern : allowedOriginPatterns) { + if (pattern == null || pattern.isBlank() || pattern.trim().equals("*")) { + throw new IllegalArgumentException("proovy.cors.allowed-origin-patterns must list explicit origins"); + } + } + 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..7b57a0e --- /dev/null +++ b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java @@ -0,0 +1,91 @@ +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.Configuration; +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만 허용한다. + */ +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() + .withInitializer(new ConfigDataApplicationContextInitializer()) + .withUserConfiguration(Properties.class); + + @Test + @DisplayName("local profile(application.yaml 기본 활성)은 서비스 도메인과 localhost의 모든 포트를 허용한다") + void localProfileAllowsLocalhostOrigins() { + context.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 허용이라 빈 목록·전체 허용(*)은 기동에서 거부한다") + void rejectsEmptyOrWildcardOrigins() { + assertThatThrownBy(() -> new CorsProperties(List.of())).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CorsProperties(List.of(SERVICE_ORIGIN, "*"))) + .isInstanceOf(IllegalArgumentException.class); + context.withPropertyValues("spring.profiles.active=prod", "proovy.cors.allowed-origin-patterns=*") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + /** 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; + } +} From 33117708d3ecd8f25f7f4b0d68e814010f4fffaf Mon Sep 17 00:00:00 2001 From: chowon442 Date: Tue, 20 Oct 2026 19:21:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20CorsConfigurationTest=20local=20prof?= =?UTF-8?q?ile=20=EB=AA=85=EC=8B=9C=20=E2=80=94=20CI=20SPRING=5FPROFILES?= =?UTF-8?q?=5FACTIVE=3Dtest=EA=B0=80=20application.yaml=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EC=9D=84=20=EB=8D=AE=EB=8A=94=EB=8B=A4=20[1.96]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs Team-Proovy/Proovy-front#306 Co-Authored-By: Claude Opus 5.5 --- .../com/proovy/global/security/CorsConfigurationTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/proovy/global/security/CorsConfigurationTest.java b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java index 7b57a0e..0c52b85 100644 --- a/src/test/java/com/proovy/global/security/CorsConfigurationTest.java +++ b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java @@ -37,9 +37,10 @@ static class Properties { .withUserConfiguration(Properties.class); @Test - @DisplayName("local profile(application.yaml 기본 활성)은 서비스 도메인과 localhost의 모든 포트를 허용한다") + @DisplayName("local profile은 서비스 도메인과 localhost의 모든 포트를 허용한다") void localProfileAllowsLocalhostOrigins() { - context.run(ctx -> { + // 활성 profile은 명시한다 — CI는 SPRING_PROFILES_ACTIVE=test 환경변수로 application.yaml 기본(local)을 덮는다 + context.withPropertyValues("spring.profiles.active=local").run(ctx -> { assertThat(ctx.getEnvironment().getActiveProfiles()).containsExactly("local"); CorsConfigurationSource source = SecurityConfig.corsConfigurationSource(ctx.getBean(CorsProperties.class)); From 77c56046098a6bb5b219035677da1682ad1aee90 Mon Sep 17 00:00:00 2001 From: chowon442 Date: Tue, 20 Oct 2026 19:32:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20r196=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20CORS=20origin=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=EC=9D=84=20=EC=89=BC=ED=91=9C=20=EC=A1=B0=EA=B0=81?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20scheme://host[:port|:[*]]=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=B4=20host=20wildcard=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20OS?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EA=B2=A9=EB=A6=AC=20?= =?UTF-8?q?[1.96]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'https://*'·'http://*:[*]'·'a, *'처럼 credentialed 전체 허용이 되는 값을 기동에서 거부한다(포트 wildcard :[*]만 허용). - CorsConfigurationTest는 SYSTEM_ENVIRONMENT property source를 뺀 environment로 실행해 SPRING_PROFILES_ACTIVE·PROOVY_CORS_ALLOWED_ORIGIN_PATTERNS에 영향받지 않는다. Refs Team-Proovy/Proovy-front#306 Co-Authored-By: Claude Opus 5.5 --- .../global/security/CorsProperties.java | 16 +++++++-- .../security/CorsConfigurationTest.java | 34 +++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/proovy/global/security/CorsProperties.java b/src/main/java/com/proovy/global/security/CorsProperties.java index 7d002e9..58f5453 100644 --- a/src/main/java/com/proovy/global/security/CorsProperties.java +++ b/src/main/java/com/proovy/global/security/CorsProperties.java @@ -1,6 +1,7 @@ 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; @@ -10,18 +11,27 @@ * @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를 허용하므로 - * 모든 origin({@code *})은 받지 않는다 + * 항목(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) { - if (pattern == null || pattern.isBlank() || pattern.trim().equals("*")) { - throw new IllegalArgumentException("proovy.cors.allowed-origin-patterns must list explicit origins"); + // 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/test/java/com/proovy/global/security/CorsConfigurationTest.java b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java index 0c52b85..126f268 100644 --- a/src/test/java/com/proovy/global/security/CorsConfigurationTest.java +++ b/src/test/java/com/proovy/global/security/CorsConfigurationTest.java @@ -11,7 +11,9 @@ 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; @@ -20,7 +22,8 @@ /** * 실제 application.yaml을 profile별로 읽어 CORS 허용 origin을 확인한다. local profile만 개발 Front(localhost, 포트 무관)를 - * 허용하고, 운영(prod)·CI(test) profile은 https://proovy.ai.kr만 허용한다. + * 허용하고, 운영(prod)·CI(test) profile은 https://proovy.ai.kr만 허용한다. 실행 환경의 OS 환경변수 + * (SPRING_PROFILES_ACTIVE·PROOVY_CORS_ALLOWED_ORIGIN_PATTERNS 등)는 결과에 섞이지 않게 뺀다. */ class CorsConfigurationTest { @@ -32,14 +35,19 @@ class CorsConfigurationTest { static class Properties { } - private final ApplicationContextRunner context = new ApplicationContextRunner() + 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() { - // 활성 profile은 명시한다 — CI는 SPRING_PROFILES_ACTIVE=test 환경변수로 application.yaml 기본(local)을 덮는다 context.withPropertyValues("spring.profiles.active=local").run(ctx -> { assertThat(ctx.getEnvironment().getActiveProfiles()).containsExactly("local"); CorsConfigurationSource source = SecurityConfig.corsConfigurationSource(ctx.getBean(CorsProperties.class)); @@ -68,13 +76,27 @@ void otherProfilesAllowServiceOriginOnly(String profile) { } @Test - @DisplayName("credentials 허용이라 빈 목록·전체 허용(*)은 기동에서 거부한다") + @DisplayName("credentials 허용이라 빈 목록·host wildcard(쉼표로 숨긴 항목 포함)는 기동에서 거부한다") void rejectsEmptyOrWildcardOrigins() { assertThatThrownBy(() -> new CorsProperties(List.of())).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> new CorsProperties(List.of(SERVICE_ORIGIN, "*"))) - .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. */