JSON 및 파일 처리 라이브러리 상향 회귀 테스트 추가
- commons-lang3 3.20.0 업그레이드로 문자열 처리 유지 검증 - 파트 헤더 설정 증가(CVE-2025-48976) 회귀 확인 테스트 추가 - jackson-bom 2.18.10 업그레이드 후 날짜 직렬화 검증
This commit is contained in:
+14
-9
@@ -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 /
|
||||
|
||||
+106
@@ -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("<p>이용약관 & 안내</p>",
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 직렬화 회귀 테스트.
|
||||
* <p>
|
||||
* Spring Boot 의 {@link JacksonAutoConfiguration} 이 만들어 주는 ObjectMapper(= MVC 응답에 실제로 쓰이는 것)로
|
||||
* 화면/AJAX 응답의 날짜 표현이 그대로인지 고정한다. 직접 new 한 ObjectMapper 나 Jackson2ObjectMapperBuilder 는
|
||||
* WRITE_DATES_AS_TIMESTAMPS 가 켜져 있어 실제 응답과 다르므로 쓰지 않는다.
|
||||
* </p>
|
||||
*/
|
||||
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());
|
||||
});
|
||||
}
|
||||
}
|
||||
+56
@@ -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) 상향 회귀 테스트.
|
||||
* <p>
|
||||
* 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 enum 의 declaredClass 를 통해
|
||||
* ClassLoader 로 내려가는 경로가 막힌다. 검증기들이 쓰는 일반 프로퍼티 접근은 그대로 동작해야 한다.
|
||||
* </p>
|
||||
*/
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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) 상향 회귀 테스트.
|
||||
* <p>
|
||||
* application.yml 로딩 경로가 정상 동작하는지, 그리고 임의 타입을 지정하는 글로벌 태그(!!java...)가
|
||||
* 거부되는지 확인한다.
|
||||
* </p>
|
||||
*/
|
||||
class YamlLoaderTagTest {
|
||||
|
||||
private final YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
|
||||
private List<PropertySource<?>> load(String yaml) throws IOException {
|
||||
return loader.load("test", new ByteArrayResource(yaml.getBytes("UTF-8")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 yml 은 그대로 로딩된다")
|
||||
void plainYamlLoads() throws Exception {
|
||||
List<PropertySource<?>> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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) 상향에 따른 파트 헤더 상한 회귀 테스트.
|
||||
* <p>
|
||||
* 1.6 부터 파트 헤더 총량 기본 상한이 512 바이트라 한글 파일명(UTF-8 3바이트/자)이 길면 업로드가 깨진다.
|
||||
* {@link MultipartConfig} 가 상한을 2048 로 올려 두는지 파싱 결과로 확인한다.
|
||||
* </p>
|
||||
*/
|
||||
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))));
|
||||
}
|
||||
}
|
||||
@@ -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 우회책 회귀 테스트.
|
||||
* <p>
|
||||
* {@code PortalConfigSecurity} 는 HeaderWriterFilter 를 eager 모드로 후처리한다. eager 모드에서는
|
||||
* 컨트롤러가 실행되기 전에 보안 헤더가 이미 기록돼 있어야 하며, 응답이 먼저 커밋되더라도 헤더가 누락되지 않는다.
|
||||
* </p>
|
||||
*/
|
||||
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<String> 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<String> 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user