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
39 changes: 39 additions & 0 deletions src/main/java/com/proovy/global/security/CorsProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
10 changes: 9 additions & 1 deletion src/main/java/com/proovy/global/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,7 @@

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(CorsProperties.class)
@RequiredArgsConstructor
public class SecurityConfig {

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 환경
Expand Down
114 changes: 114 additions & 0 deletions src/test/java/com/proovy/global/security/CorsConfigurationTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading