diff --git a/build.gradle b/build.gradle index 36acfa1..3f9d872 100644 --- a/build.gradle +++ b/build.gradle @@ -111,7 +111,8 @@ dependencies { implementation 'org.jasypt:jasypt:1.9.3' implementation 'xerces:xercesImpl:2.12.2' - implementation 'org.apache.commons:commons-lang3:3.12.0' + // Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정. + implementation 'org.apache.commons:commons-lang3:3.20.0' implementation 'org.apache.commons:commons-collections4:4.4' implementation 'commons-net:commons-net:3.9.0' @@ -124,10 +125,9 @@ dependencies { // exclude group: 'commons-collections', module: 'commons-collections' } implementation 'org.mapstruct:mapstruct:1.5.5.Final' - // WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환. - implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6' - implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6' + // jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다. + // 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가 + // 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다). implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3' @@ -172,10 +172,15 @@ ext { // 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소. set('snakeyaml.version', '2.6') - // 위 상향의 전제. snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고, - // jackson-dataformat-yaml 은 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다. - // BOM 을 통째로 올려 jackson 모듈 버전도 통일한다(기존엔 core/databind 만 2.18.6, 나머지는 2.13.5). - set('jackson-bom.version', '2.18.6') + // jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다. + // 이유 3가지 + // 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은 + // 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제. + // 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. + // 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정. + // 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다. + // 2.18.x 마지막 패치를 쓴다. + set('jackson-bom.version', '2.18.10') // Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라 // 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 / diff --git a/src/test/java/com/eactive/apim/portal/common/compatibility/CommonsLangUpgradeCompatibilityTest.java b/src/test/java/com/eactive/apim/portal/common/compatibility/CommonsLangUpgradeCompatibilityTest.java new file mode 100644 index 0000000..1aa69fb --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/compatibility/CommonsLangUpgradeCompatibilityTest.java @@ -0,0 +1,106 @@ +package com.eactive.apim.portal.common.compatibility; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import com.eactive.apim.portal.common.util.UserTypeUtil; +import com.eactive.apim.portal.common.validator.CellPhoneValidator; +import org.apache.commons.lang3.StringEscapeUtils; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Commons Lang 업그레이드 시 포털에서 사용하는 문자열 처리 결과가 유지되는지 검증한다. + */ +class CommonsLangUpgradeCompatibilityTest { + + @AfterEach + void resetUserTypeConfiguration() { + UserTypeUtil.setInternalEmailDomains(null); + } + + @Test + @DisplayName("빈 문자열과 공백 판정이 기존 분기 조건을 유지한다") + void keepsEmptyAndBlankBranchingBehavior() { + assertAll( + () -> assertTrue(StringUtils.isEmpty(null)), + () -> assertTrue(StringUtils.isEmpty("")), + () -> assertFalse(StringUtils.isEmpty(" ")), + () -> assertTrue(StringUtils.isBlank(" \t")), + () -> assertFalse(StringUtils.isNotBlank(" \t")), + () -> assertTrue(StringUtils.isNotEmpty(" ")) + ); + } + + @Test + @DisplayName("폼 입력과 상태 설정의 trim 및 기본값 처리가 유지된다") + void keepsTrimmingAndDefaultingBehavior() { + assertAll( + () -> assertEquals("서비스명", StringUtils.trimToEmpty(" 서비스명 ")), + () -> assertEquals("", StringUtils.trimToEmpty(null)), + () -> assertEquals("api-001", StringUtils.defaultIfBlank(" ", "api-001")), + () -> assertEquals("API 이름", StringUtils.defaultIfBlank("API 이름", "api-001")), + () -> assertTrue(StringUtils.equalsAnyIgnoreCase(" Y ".trim(), "true", "y", "1")) + ); + } + + @Test + @DisplayName("API 경로와 메시지 리소스명 문자열 처리가 유지된다") + void keepsPathAndResourceNameBehavior() { + String fullPath = StringUtils.join("/openapi/", "/", StringUtils.removeStart("/users", "/")); + String normalizedPath = StringUtils.replacePattern(fullPath, "//+", "/"); + String resource = "/WEB-INF/messages/messages_ko.properties"; + String baseName = StringUtils.substringBeforeLast(resource, ".properties"); + + assertAll( + () -> assertEquals("/openapi/users", normalizedPath), + () -> assertEquals("/openapi", StringUtils.removeEnd("/openapi/", "/")), + () -> assertEquals("/WEB-INF/messages", StringUtils.substringBeforeLast(baseName, "/")), + () -> assertEquals("messages_ko", StringUtils.substringAfterLast(baseName, "/")), + () -> assertEquals("messages", StringUtils.substringBeforeLast("messages_ko", "_")) + ); + } + + @SuppressWarnings("deprecation") + @Test + @DisplayName("API와 약관 본문의 HTML 엔티티 디코딩 결과가 유지된다") + void keepsHtmlUnescapeBehavior() { + assertEquals("

이용약관 & 안내

", + StringEscapeUtils.unescapeHtml4("<p>이용약관 & 안내</p>")); + } + + @Test + @DisplayName("휴대전화 검증의 기존 허용 및 거부 결과가 유지된다") + void keepsCellPhoneValidationBehavior() { + CellPhoneValidator validator = new CellPhoneValidator(); + + assertAll( + () -> assertTrue(validator.isValid("010-1234-5678", null)), + () -> assertTrue(validator.isValid("821012345678", null)), + () -> assertFalse(validator.isValid("", null)), + () -> assertFalse(validator.isValid("02-1234-5678", null)), + () -> assertFalse(validator.isValid("010-12-5678", null)) + ); + } + + @Test + @DisplayName("내부 사용자 이메일 도메인 정규화와 판별 결과가 유지된다") + void keepsInternalUserDomainBehavior() { + UserTypeUtil.setInternalEmailDomains(Arrays.asList(" DJBANK.com ", "@Partner.COM", " ")); + + assertAll( + () -> assertEquals(Arrays.asList("@djbank.com", "@partner.com"), + UserTypeUtil.getInternalEmailDomains()), + () -> assertTrue(UserTypeUtil.isInternalEmail(" USER@DJBANK.COM ")), + () -> assertTrue(UserTypeUtil.isInternalEmail("user@partner.com")), + () -> assertFalse(UserTypeUtil.isInternalEmail("user@example.com")), + () -> assertFalse(UserTypeUtil.isInternalEmail(null)) + ); + } +} diff --git a/src/test/java/com/eactive/apim/portal/common/json/JacksonDateSerializationTest.java b/src/test/java/com/eactive/apim/portal/common/json/JacksonDateSerializationTest.java new file mode 100644 index 0000000..7155697 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/json/JacksonDateSerializationTest.java @@ -0,0 +1,128 @@ +package com.eactive.apim.portal.common.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * jackson 2.13.5 → 2.18.x 상향에 따른 JSON 직렬화 회귀 테스트. + *

+ * Spring Boot 의 {@link JacksonAutoConfiguration} 이 만들어 주는 ObjectMapper(= MVC 응답에 실제로 쓰이는 것)로 + * 화면/AJAX 응답의 날짜 표현이 그대로인지 고정한다. 직접 new 한 ObjectMapper 나 Jackson2ObjectMapperBuilder 는 + * WRITE_DATES_AS_TIMESTAMPS 가 켜져 있어 실제 응답과 다르므로 쓰지 않는다. + *

+ */ +class JacksonDateSerializationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)); + + /** Boot 가 구성한 ObjectMapper 로 검증 로직을 실행한다. */ + private void withBootObjectMapper(MapperAssertion assertion) { + contextRunner.run(context -> assertion.accept(context.getBean(ObjectMapper.class))); + } + + @FunctionalInterface + interface MapperAssertion { + void accept(ObjectMapper objectMapper) throws Exception; + } + + /** 테스트 소스에는 lombok annotation processor 가 걸려 있지 않아 접근자를 직접 둔다. */ + public static class SampleDTO { + private LocalDateTime createdAt; + private LocalDate baseDate; + private String name; + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDate getBaseDate() { + return baseDate; + } + + public void setBaseDate(LocalDate baseDate) { + this.baseDate = baseDate; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Test + @DisplayName("@JsonFormat 지정 필드는 지정 패턴 문자열로 직렬화된다") + void jsonFormatPatternIsHonored() { + withBootObjectMapper(objectMapper -> { + DailyStatDTO dto = new DailyStatDTO(); + dto.setStatDate(LocalDate.of(2026, 8, 18)); + dto.setUptimeRatio(0.9987); + dto.setStatus("NORMAL"); + + String json = objectMapper.writeValueAsString(dto); + + assertTrue(json.contains("\"statDate\":\"2026-08-18\""), json); + assertTrue(json.contains("\"status\":\"NORMAL\""), json); + }); + } + + @Test + @DisplayName("어노테이션 없는 날짜 필드는 타임스탬프 숫자가 아니라 ISO-8601 문자열이다") + void plainDatesAreIsoStrings() { + withBootObjectMapper(objectMapper -> { + SampleDTO dto = new SampleDTO(); + dto.setCreatedAt(LocalDateTime.of(2026, 8, 18, 9, 30, 0)); + dto.setBaseDate(LocalDate.of(2026, 8, 18)); + dto.setName("포털"); + + String json = objectMapper.writeValueAsString(dto); + + assertTrue(json.contains("\"createdAt\":\"2026-08-18T09:30:00\""), json); + assertTrue(json.contains("\"baseDate\":\"2026-08-18\""), json); + assertTrue(json.contains("\"name\":\"포털\""), json); + }); + } + + @Test + @DisplayName("ISO-8601 문자열은 다시 날짜 타입으로 역직렬화된다") + void isoStringsDeserializeBack() { + withBootObjectMapper(objectMapper -> { + String json = "{\"createdAt\":\"2026-08-18T09:30:00\",\"baseDate\":\"2026-08-18\",\"name\":\"포털\"}"; + + SampleDTO dto = objectMapper.readValue(json, SampleDTO.class); + + assertEquals(LocalDateTime.of(2026, 8, 18, 9, 30, 0), dto.getCreatedAt()); + assertEquals(LocalDate.of(2026, 8, 18), dto.getBaseDate()); + }); + } + + @Test + @DisplayName("모르는 필드가 있어도 역직렬화가 실패하지 않는다 - Boot 기본 동작 유지") + void unknownPropertiesAreIgnored() { + withBootObjectMapper(objectMapper -> { + String json = "{\"name\":\"포털\",\"unknownField\":123}"; + + SampleDTO dto = objectMapper.readValue(json, SampleDTO.class); + + assertEquals("포털", dto.getName()); + }); + } +} diff --git a/src/test/java/com/eactive/apim/portal/common/security/BeanUtilsPropertyAccessTest.java b/src/test/java/com/eactive/apim/portal/common/security/BeanUtilsPropertyAccessTest.java new file mode 100644 index 0000000..2cac70f --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/security/BeanUtilsPropertyAccessTest.java @@ -0,0 +1,56 @@ +package com.eactive.apim.portal.common.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.commons.beanutils.PropertyUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * commons-beanutils 1.11.0(CVE-2025-48734) 상향 회귀 테스트. + *

+ * 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 enum 의 declaredClass 를 통해 + * ClassLoader 로 내려가는 경로가 막힌다. 검증기들이 쓰는 일반 프로퍼티 접근은 그대로 동작해야 한다. + *

+ */ +class BeanUtilsPropertyAccessTest { + + enum Kind { + A, B + } + + public static class Form { + private String password = "pw1234"; + private Kind kind = Kind.A; + + public String getPassword() { + return password; + } + + public Kind getKind() { + return kind; + } + } + + @Test + @DisplayName("일반 프로퍼티 접근은 정상 - PasswordRuleValidator 등이 쓰는 경로") + void plainPropertyAccessWorks() throws Exception { + assertEquals("pw1234", PropertyUtils.getProperty(new Form(), "password")); + assertEquals(Kind.A, PropertyUtils.getProperty(new Form(), "kind")); + } + + @Test + @DisplayName("enum 의 declaredClass 접근은 차단된다") + void declaredClassIsSuppressed() { + assertThrows(NoSuchMethodException.class, + () -> PropertyUtils.getProperty(new Form(), "kind.declaredClass")); + } + + @Test + @DisplayName("declaredClass 를 거쳐 ClassLoader 로 내려가는 경로도 차단된다") + void classLoaderIsUnreachable() { + assertThrows(NoSuchMethodException.class, + () -> PropertyUtils.getNestedProperty(new Form(), "kind.declaredClass.classLoader")); + } +} diff --git a/src/test/java/com/eactive/apim/portal/common/security/YamlLoaderTagTest.java b/src/test/java/com/eactive/apim/portal/common/security/YamlLoaderTagTest.java new file mode 100644 index 0000000..6c85b95 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/security/YamlLoaderTagTest.java @@ -0,0 +1,51 @@ +package com.eactive.apim.portal.common.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ByteArrayResource; + +/** + * snakeyaml 2.6(CVE-2022-1471) 상향 회귀 테스트. + *

+ * application.yml 로딩 경로가 정상 동작하는지, 그리고 임의 타입을 지정하는 글로벌 태그(!!java...)가 + * 거부되는지 확인한다. + *

+ */ +class YamlLoaderTagTest { + + private final YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + + private List> load(String yaml) throws IOException { + return loader.load("test", new ByteArrayResource(yaml.getBytes("UTF-8"))); + } + + @Test + @DisplayName("일반 yml 은 그대로 로딩된다") + void plainYamlLoads() throws Exception { + List> sources = load("server:\n port: 39130\nportal:\n auth-ttl: 300\n"); + + assertEquals(1, sources.size()); + assertEquals(39130, sources.get(0).getProperty("server.port")); + assertEquals(300, sources.get(0).getProperty("portal.auth-ttl")); + } + + @Test + @DisplayName("임의 타입을 지정하는 글로벌 태그는 거부된다") + void globalTagIsRejected() { + String payload = "key: !!javax.script.ScriptEngineManager [!!java.net.URL [\"http://127.0.0.1/\"]]\n"; + + Exception thrown = assertThrows(Exception.class, () -> load(payload)); + + assertNotNull(thrown.getMessage()); + assertEquals(true, thrown.getMessage().contains("Global tag is not allowed")); + } +} diff --git a/src/test/java/com/eactive/apim/portal/config/MultipartConfigTest.java b/src/test/java/com/eactive/apim/portal/config/MultipartConfigTest.java new file mode 100644 index 0000000..5fb45de --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/config/MultipartConfigTest.java @@ -0,0 +1,100 @@ +package com.eactive.apim.portal.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.multipart.commons.CommonsMultipartResolver; + +/** + * commons-fileupload 1.6.0(CVE-2025-48976) 상향에 따른 파트 헤더 상한 회귀 테스트. + *

+ * 1.6 부터 파트 헤더 총량 기본 상한이 512 바이트라 한글 파일명(UTF-8 3바이트/자)이 길면 업로드가 깨진다. + * {@link MultipartConfig} 가 상한을 2048 로 올려 두는지 파싱 결과로 확인한다. + *

+ */ +class MultipartConfigTest { + + private static final String BOUNDARY = "----portalTestBoundary"; + + private CommonsMultipartResolver resolver() { + PortalProperties properties = new PortalProperties(); + properties.getFile().setMaxSize("8MB"); + return new MultipartConfig(properties).filterMultipartResolver(); + } + + private MockHttpServletRequest multipartRequest(String filename) throws IOException { + String head = "--" + BOUNDARY + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n"; + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(head.getBytes("UTF-8")); + body.write("hello".getBytes("UTF-8")); + body.write(("\r\n--" + BOUNDARY + "--\r\n").getBytes("UTF-8")); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/file/upload"); + request.setContentType("multipart/form-data; boundary=" + BOUNDARY); + request.setCharacterEncoding("UTF-8"); + request.setContent(body.toByteArray()); + return request; + } + + private String korean(int length) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + sb.append('한'); + } + return sb.append(".pdf").toString(); + } + + @Test + @DisplayName("영문 파일명 업로드는 그대로 파싱된다") + void asciiFilename() throws Exception { + MultipartHttpServletRequest parsed = resolver().resolveMultipart(multipartRequest("report.pdf")); + MultipartFile file = parsed.getFile("file"); + + assertNotNull(file); + assertEquals("report.pdf", file.getOriginalFilename()); + assertEquals(5L, file.getSize()); + } + + @Test + @DisplayName("한글 파일명 137자 이상도 파싱된다 - fileupload 1.6 기본 상한 512 였다면 실패할 길이") + void longKoreanFilenameOverDefaultLimit() throws Exception { + String filename = korean(150); + + MultipartHttpServletRequest parsed = resolver().resolveMultipart(multipartRequest(filename)); + MultipartFile file = parsed.getFile("file"); + + assertNotNull(file); + assertEquals(filename, file.getOriginalFilename()); + } + + @Test + @DisplayName("OS 파일명 상한(255자)이 전부 한글이어도 파싱된다") + void koreanFilenameAtOsLimit() throws Exception { + String filename = korean(255); + + MultipartHttpServletRequest parsed = resolver().resolveMultipart(multipartRequest(filename)); + MultipartFile file = parsed.getFile("file"); + + assertNotNull(file); + assertEquals(filename, file.getOriginalFilename()); + } + + @Test + @DisplayName("파트 헤더가 상한(2048바이트)을 넘으면 거부한다 - DoS 방어가 살아 있음") + void partHeaderOverConfiguredLimitIsRejected() { + assertThrows(MultipartException.class, + () -> resolver().resolveMultipart(multipartRequest(korean(700)))); + } +} diff --git a/src/test/java/com/eactive/apim/portal/config/SecurityHeaderEagerWriteTest.java b/src/test/java/com/eactive/apim/portal/config/SecurityHeaderEagerWriteTest.java new file mode 100644 index 0000000..4832d9d --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/config/SecurityHeaderEagerWriteTest.java @@ -0,0 +1,70 @@ +package com.eactive.apim.portal.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; + +import javax.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.web.header.HeaderWriterFilter; +import org.springframework.security.web.header.writers.CacheControlHeadersWriter; +import org.springframework.security.web.header.writers.XContentTypeOptionsHeaderWriter; + +/** + * CVE-2026-22732 우회책 회귀 테스트. + *

+ * {@code PortalConfigSecurity} 는 HeaderWriterFilter 를 eager 모드로 후처리한다. eager 모드에서는 + * 컨트롤러가 실행되기 전에 보안 헤더가 이미 기록돼 있어야 하며, 응답이 먼저 커밋되더라도 헤더가 누락되지 않는다. + *

+ */ +class SecurityHeaderEagerWriteTest { + + private HeaderWriterFilter filter(boolean eager) { + HeaderWriterFilter filter = new HeaderWriterFilter(Arrays.asList( + new XContentTypeOptionsHeaderWriter(), + new CacheControlHeadersWriter())); + filter.setShouldWriteHeadersEagerly(eager); + return filter; + } + + @Test + @DisplayName("eager 모드에서는 체인(컨트롤러) 실행 시점에 이미 보안 헤더가 기록돼 있다") + void eagerWritesHeadersBeforeChain() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/file/download"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference seenInChain = new AtomicReference<>(); + + filter(true).doFilter(request, response, (req, res) -> { + seenInChain.set(((HttpServletResponse) res).getHeader("X-Content-Type-Options")); + new MockFilterChain().doFilter(req, res); + }); + + assertEquals("nosniff", seenInChain.get(), "체인 진입 시점에 헤더가 있어야 한다"); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + assertNotNull(response.getHeader("Cache-Control")); + } + + @Test + @DisplayName("기본(lazy) 모드에서는 체인 실행 시점에 헤더가 아직 없다 - eager 설정이 실제로 의미 있음을 고정") + void lazyDoesNotWriteHeadersBeforeChain() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/file/download"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference seenInChain = new AtomicReference<>(); + + filter(false).doFilter(request, response, (req, res) -> { + seenInChain.set(((HttpServletResponse) res).getHeader("X-Content-Type-Options")); + new MockFilterChain().doFilter(req, res); + }); + + assertNull(seenInChain.get(), "lazy 모드는 체인 이후에 헤더를 쓴다"); + assertEquals("nosniff", response.getHeader("X-Content-Type-Options")); + } +}