From 80969c65c39c8d4712864873dbda3dd7c6c5a019 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Tue, 29 Sep 2026 11:23:13 +0800 Subject: [PATCH] fix: apply CORS allowlist beneath servlet context path --- .../com/mydreamjob/config/SecurityConfig.java | 3 +- .../CorsContextPathIntegrationTest.java | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/config/CorsContextPathIntegrationTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index a508bf5..b94eec5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -181,7 +181,8 @@ public class SecurityConfig { config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/api/**", config); + // 匹配的是去掉 servlet context-path 后的应用路径,来源仍由白名单限定。 + source.registerCorsConfiguration("/**", config); return source; } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/config/CorsContextPathIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/config/CorsContextPathIntegrationTest.java new file mode 100644 index 0000000..e636e4c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/config/CorsContextPathIntegrationTest.java @@ -0,0 +1,76 @@ +package com.mydreamjob.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:cors_context_path;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.show-sql=false", + "app.cors.allowed-origins=http://localhost:5173,http://localhost:3000" +}) +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CorsContextPathIntegrationTest { + @Autowired MockMvc mvc; + @MockBean RedisTemplate redis; + + @ParameterizedTest + @ValueSource(strings = {"http://localhost:5173", "http://localhost:3000"}) + void configuredOriginCanPreflightIdentitySelectionWithApplicationContext(String origin) throws Exception { + mvc.perform(options("/api/v1/auth/identity/select") + .contextPath("/api/v1").servletPath("/auth/identity/select") + .header("Origin", origin) + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "authorization,content-type")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", origin)) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")) + .andExpect(header().string("Access-Control-Allow-Methods", containsString("POST"))) + .andExpect(header().string("Access-Control-Allow-Headers", containsString("authorization"))); + } + + @ParameterizedTest + @ValueSource(strings = {"http://localhost:5173", "http://localhost:3000"}) + void configuredOriginCanRead401WithoutBypassingAuthentication(String origin) throws Exception { + mvc.perform(get("/api/v1/auth/me") + .contextPath("/api/v1").servletPath("/auth/me") + .header("Origin", origin)) + .andExpect(status().isUnauthorized()) + .andExpect(header().string("Access-Control-Allow-Origin", origin)); + } + + @Test + void unconfiguredOriginRemainsRejected() throws Exception { + mvc.perform(options("/api/v1/auth/identity/select") + .contextPath("/api/v1").servletPath("/auth/identity/select") + .header("Origin", "https://untrusted.example") + .header("Access-Control-Request-Method", "POST")) + .andExpect(status().isForbidden()) + .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); + } + + @Test + void unconfiguredHeaderRemainsRejected() throws Exception { + mvc.perform(options("/api/v1/auth/identity/select") + .contextPath("/api/v1").servletPath("/auth/identity/select") + .header("Origin", "http://localhost:5173") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "x-unapproved-header")) + .andExpect(status().isForbidden()) + .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); + } +}