From 2b38db5dfaa58e708ea19d1f738505b6349af3d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:47:01 +0900 Subject: [PATCH 1/7] test(etl): reproduce HTTP payload materialization on live develop --- .../EtlHttpPayloadAdmissionTest.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java diff --git a/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java b/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java new file mode 100644 index 00000000..a28a8405 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java @@ -0,0 +1,130 @@ +package com.xtrmetl.etl.controller; + +import com.xtrmetl.etl.connector.TargetConnectorDispatcher; +import com.xtrmetl.etl.service.EtlBatchProperties; +import com.xtrmetl.etl.service.EtlService; +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Proves synchronous ETL request bytes are bounded before MVC invokes the controller service. + */ +@WebMvcTest(EtlController.class) +@EnableConfigurationProperties(EtlBatchProperties.class) +@Import(EtlHttpPayloadAdmissionTest.UnknownLengthRequestConfig.class) +class EtlHttpPayloadAdmissionTest { + + private static final String PROCESS_PATH = "/api/etl/process"; + private static final String OVERSIZED_MARKER = "oversized-private-marker"; + private static final String UNKNOWN_LENGTH_HEADER = "X-Test-Unknown-Content-Length"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private EtlService etlService; + + @MockBean + private TargetConnectorDispatcher connectorDispatcher; + + @Test + @WithMockUser + void rejectsKnownOversizedBodyBeforeControllerInvocation() throws Exception { + String request = oversizedJsonRequest(); + when(etlService.processData(anyString())).thenReturn("unexpected controller invocation"); + + mockMvc.perform(post(PROCESS_PATH) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isPayloadTooLarge()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.errorCode").value("etl_payload_too_large")) + .andExpect(jsonPath("$.instance").value(PROCESS_PATH)) + .andExpect(content().string(not(containsString(OVERSIZED_MARKER)))); + + verifyNoInteractions(etlService); + } + + @Test + @WithMockUser + void rejectsUnknownLengthOversizedBodyBeforeControllerInvocation() throws Exception { + String request = oversizedJsonRequest(); + when(etlService.processData(anyString())).thenReturn("unexpected controller invocation"); + + mockMvc.perform(post(PROCESS_PATH) + .with(csrf()) + .header(UNKNOWN_LENGTH_HEADER, "true") + .header(HttpHeaders.TRANSFER_ENCODING, "chunked") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.errorCode").value("etl_payload_too_large")); + + verifyNoInteractions(etlService); + } + + private static String oversizedJsonRequest() { + return "[{\"id\":\"" + OVERSIZED_MARKER + "" + + "x".repeat(EtlBatchProperties.DEFAULT_MAX_PAYLOAD_BYTES) + + "\"}]"; + } + + /** + * Test-only transport shim that models chunked input whose byte length is not known up front. + */ + @TestConfiguration + static class UnknownLengthRequestConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + Filter unknownLengthRequestFilter() { + return (request, response, chain) -> { + if (request instanceof HttpServletRequest httpRequest + && httpRequest.getHeader(UNKNOWN_LENGTH_HEADER) != null) { + chain.doFilter(new HttpServletRequestWrapper(httpRequest) { + @Override + public int getContentLength() { + return -1; + } + + @Override + public long getContentLengthLong() { + return -1L; + } + }, response); + return; + } + chain.doFilter(request, response); + }; + } + } +} From 44b367664f5086de32439d4645ba93b60e4a6897 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:09:28 +0900 Subject: [PATCH 2/7] fix(etl): bound HTTP payloads before MVC materialization --- .../controller/EtlPayloadAdmissionAdvice.java | 171 +++++++++++ .../EtlPayloadAdmissionAdviceTest.java | 285 ++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 etl-service/src/main/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdvice.java create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdviceTest.java diff --git a/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdvice.java b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdvice.java new file mode 100644 index 00000000..ec87e07c --- /dev/null +++ b/etl-service/src/main/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdvice.java @@ -0,0 +1,171 @@ +package com.xtrmetl.etl.controller; + +import com.xtrmetl.etl.service.EtlBatchProperties; +import com.xtrmetl.etl.service.EtlRequestError; +import com.xtrmetl.etl.service.EtlRequestException; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.Objects; + +/** + * Enforces the synchronous ETL payload byte limit before Spring MVC materializes a request body. + * + *

Known oversized bodies are rejected from their {@code Content-Length} metadata without reading + * the entity. Unknown-length or understated bodies are wrapped in a byte-counting stream that reads + * at most one byte beyond the configured limit before raising the existing typed payload error. The + * service-level admission check remains in place as defense in depth.

+ */ +@ControllerAdvice(assignableTypes = EtlController.class) +public final class EtlPayloadAdmissionAdvice extends RequestBodyAdviceAdapter { + + private final EtlBatchProperties batchProperties; + + /** + * Creates the MVC transport admission guard. + * + * @param batchProperties bounded ETL request limits shared with the service layer + */ + public EtlPayloadAdmissionAdvice(EtlBatchProperties batchProperties) { + this.batchProperties = Objects.requireNonNull( + batchProperties, + "batchProperties must not be null" + ); + } + + /** + * Applies admission control to string request bodies handled by {@link EtlController}. + * + * @param methodParameter controller method parameter receiving the request body + * @param targetType declared request-body target type + * @param converterType selected HTTP message converter type + * @return {@code true} only for the synchronous ETL string body + */ + @Override + public boolean supports( + MethodParameter methodParameter, + Type targetType, + Class> converterType + ) { + return String.class.equals(methodParameter.getParameterType()); + } + + /** + * Rejects known oversized entities and bounds streaming reads before conversion to a String. + * + * @param inputMessage request headers and body selected by Spring MVC + * @param parameter controller parameter receiving the body + * @param targetType declared request-body target type + * @param converterType selected HTTP message converter type + * @return the original headers with a byte-bounded request stream + * @throws IOException when the underlying request stream cannot be obtained + */ + @Override + public HttpInputMessage beforeBodyRead( + HttpInputMessage inputMessage, + MethodParameter parameter, + Type targetType, + Class> converterType + ) throws IOException { + int maximumBytes = batchProperties.getMaxPayloadBytes(); + long contentLength = inputMessage.getHeaders().getContentLength(); + if (contentLength > maximumBytes) { + throw payloadTooLarge(); + } + return new BoundedHttpInputMessage(inputMessage, maximumBytes); + } + + private static EtlRequestException payloadTooLarge() { + return new EtlRequestException(EtlRequestError.PAYLOAD_TOO_LARGE); + } + + private static final class BoundedHttpInputMessage implements HttpInputMessage { + + private final HttpInputMessage delegate; + private final InputStream body; + + private BoundedHttpInputMessage(HttpInputMessage delegate, int maximumBytes) throws IOException { + this.delegate = Objects.requireNonNull(delegate, "delegate must not be null"); + this.body = new BoundedInputStream(delegate.getBody(), maximumBytes); + } + + @Override + public InputStream getBody() { + return body; + } + + @Override + public HttpHeaders getHeaders() { + return delegate.getHeaders(); + } + } + + private static final class BoundedInputStream extends InputStream { + + private final InputStream delegate; + private long remaining; + + private BoundedInputStream(InputStream delegate, long maximumBytes) { + this.delegate = Objects.requireNonNull(delegate, "delegate must not be null"); + this.remaining = maximumBytes; + } + + @Override + public int read() throws IOException { + if (remaining == 0L) { + int extraByte = delegate.read(); + if (extraByte == -1) { + return -1; + } + throw payloadTooLarge(); + } + + int value = delegate.read(); + if (value != -1) { + remaining--; + } + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + Objects.checkFromIndexSize(offset, length, bytes.length); + if (length == 0) { + return 0; + } + if (remaining == 0L) { + return rejectExtraByte(); + } + + int boundedLength = (int) Math.min((long) length, remaining + 1L); + int read = delegate.read(bytes, offset, boundedLength); + if (read == -1) { + return -1; + } + if (read > remaining) { + throw payloadTooLarge(); + } + remaining -= read; + return read; + } + + private int rejectExtraByte() throws IOException { + if (delegate.read() == -1) { + return -1; + } + throw payloadTooLarge(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdviceTest.java b/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdviceTest.java new file mode 100644 index 00000000..7af909a2 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlPayloadAdmissionAdviceTest.java @@ -0,0 +1,285 @@ +package com.xtrmetl.etl.controller; + +import com.xtrmetl.etl.service.EtlBatchProperties; +import com.xtrmetl.etl.service.EtlRequestError; +import com.xtrmetl.etl.service.EtlRequestException; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.converter.StringHttpMessageConverter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.Principal; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the transport-level ETL payload guard without relying on MVC implementation details. + */ +class EtlPayloadAdmissionAdviceTest { + + private static final int MAXIMUM_BYTES = 8; + + @Test + void supportsOnlyStringRequestParameters() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + + assertTrue(advice.supports( + requestBodyParameter(), + String.class, + StringHttpMessageConverter.class + )); + assertFalse(advice.supports( + integerParameter(), + Integer.class, + StringHttpMessageConverter.class + )); + } + + @Test + void requiresBatchProperties() { + assertThrows(NullPointerException.class, () -> new EtlPayloadAdmissionAdvice(null)); + } + + @Test + void rejectsKnownOversizedBodyWithoutReadingAnyEntityByte() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + CountingInputStream body = new CountingInputStream(new byte[MAXIMUM_BYTES + 1]); + TestHttpInputMessage inputMessage = new TestHttpInputMessage(body, MAXIMUM_BYTES + 1L); + + EtlRequestException exception = assertThrows( + EtlRequestException.class, + () -> advice.beforeBodyRead( + inputMessage, + requestBodyParameter(), + String.class, + StringHttpMessageConverter.class + ) + ); + + assertSame(EtlRequestError.PAYLOAD_TOO_LARGE, exception.error()); + assertEquals(0, body.bytesRead()); + } + + @Test + void preservesHeadersAndRejectsUnknownLengthBodyAfterOnlyLimitPlusOneByte() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + CountingInputStream body = new CountingInputStream(new byte[MAXIMUM_BYTES + 32]); + TestHttpInputMessage inputMessage = new TestHttpInputMessage(body, -1L); + inputMessage.getHeaders().set("X-Test-Header", "preserved"); + HttpInputMessage boundedMessage = bounded(advice, inputMessage); + + assertSame(inputMessage.getHeaders(), boundedMessage.getHeaders()); + EtlRequestException exception = assertThrows( + EtlRequestException.class, + () -> boundedMessage.getBody().readAllBytes() + ); + + assertSame(EtlRequestError.PAYLOAD_TOO_LARGE, exception.error()); + assertEquals(MAXIMUM_BYTES + 1, body.bytesRead()); + } + + @Test + void readsExactLimitToEndWithoutFalsePositive() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + byte[] payload = "12345678".getBytes(StandardCharsets.UTF_8); + CountingInputStream body = new CountingInputStream(payload); + HttpInputMessage boundedMessage = bounded(advice, new TestHttpInputMessage(body, -1L)); + + assertArrayEquals(payload, boundedMessage.getBody().readAllBytes()); + assertEquals(MAXIMUM_BYTES, body.bytesRead()); + } + + @Test + void supportsSingleByteReadsAtAndBeyondTheLimit() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + HttpInputMessage exact = bounded( + advice, + new TestHttpInputMessage(new ByteArrayInputStream("12345678".getBytes(StandardCharsets.UTF_8)), -1L) + ); + InputStream exactBody = exact.getBody(); + for (int index = 0; index < MAXIMUM_BYTES; index++) { + assertEquals('1' + index, exactBody.read()); + } + assertEquals(-1, exactBody.read()); + + HttpInputMessage oversized = bounded( + advice, + new TestHttpInputMessage(new ByteArrayInputStream("123456789".getBytes(StandardCharsets.UTF_8)), -1L) + ); + InputStream oversizedBody = oversized.getBody(); + for (int index = 0; index < MAXIMUM_BYTES; index++) { + oversizedBody.read(); + } + assertThrows(EtlRequestException.class, oversizedBody::read); + } + + @Test + void handlesZeroLengthBulkReadAndEndOfStreamAtTheLimit() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + InputStream body = bounded( + advice, + new TestHttpInputMessage(new ByteArrayInputStream("12345678".getBytes(StandardCharsets.UTF_8)), -1L) + ).getBody(); + byte[] buffer = new byte[MAXIMUM_BYTES]; + + assertEquals(0, body.read(buffer, 0, 0)); + assertEquals(MAXIMUM_BYTES, body.read(buffer, 0, buffer.length)); + assertEquals(-1, body.read(buffer, 0, 1)); + } + + @Test + void propagatesBodyAcquisitionIOExceptionUnchanged() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + IOException expected = new IOException("test stream acquisition failure"); + HttpInputMessage failingMessage = new HttpInputMessage() { + @Override + public InputStream getBody() throws IOException { + throw expected; + } + + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } + }; + + IOException actual = assertThrows( + IOException.class, + () -> bounded(advice, failingMessage) + ); + assertSame(expected, actual); + } + + @Test + void closesTheUnderlyingRequestBody() throws Exception { + EtlPayloadAdmissionAdvice advice = advice(); + CloseTrackingInputStream delegate = new CloseTrackingInputStream(new byte[0]); + InputStream body = bounded(advice, new TestHttpInputMessage(delegate, -1L)).getBody(); + + body.close(); + + assertTrue(delegate.closed()); + } + + private static HttpInputMessage bounded( + EtlPayloadAdmissionAdvice advice, + HttpInputMessage inputMessage + ) throws Exception { + return advice.beforeBodyRead( + inputMessage, + requestBodyParameter(), + String.class, + StringHttpMessageConverter.class + ); + } + + private static EtlPayloadAdmissionAdvice advice() { + EtlBatchProperties properties = new EtlBatchProperties(); + properties.setMaxPayloadBytes(MAXIMUM_BYTES); + return new EtlPayloadAdmissionAdvice(properties); + } + + private static MethodParameter requestBodyParameter() throws NoSuchMethodException { + Method method = EtlController.class.getMethod( + "processData", + String.class, + String.class, + Principal.class + ); + return new MethodParameter(method, 0); + } + + private static MethodParameter integerParameter() throws NoSuchMethodException { + Method method = EtlPayloadAdmissionAdviceTest.class.getDeclaredMethod("integerBody", Integer.class); + return new MethodParameter(method, 0); + } + + @SuppressWarnings("unused") + private static void integerBody(Integer value) { + // Reflection target used only to prove the RequestBodyAdvice type filter. + } + + private static final class TestHttpInputMessage implements HttpInputMessage { + + private final HttpHeaders headers = new HttpHeaders(); + private final InputStream body; + + private TestHttpInputMessage(InputStream body, long contentLength) { + this.body = body; + if (contentLength >= 0L) { + headers.setContentLength(contentLength); + } + } + + @Override + public InputStream getBody() { + return body; + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + } + + private static class CountingInputStream extends ByteArrayInputStream { + + private int bytesRead; + + private CountingInputStream(byte[] bytes) { + super(bytes); + } + + @Override + public synchronized int read() { + int value = super.read(); + if (value != -1) { + bytesRead++; + } + return value; + } + + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + int read = super.read(bytes, offset, length); + if (read > 0) { + bytesRead += read; + } + return read; + } + + private int bytesRead() { + return bytesRead; + } + } + + private static final class CloseTrackingInputStream extends CountingInputStream { + + private boolean closed; + + private CloseTrackingInputStream(byte[] bytes) { + super(bytes); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + + private boolean closed() { + return closed; + } + } +} From 8c393ca38241b00dc851177734530633fcfc8130 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:46:16 +0900 Subject: [PATCH 3/7] test(security): replay Jackson LTS baseline on live develop --- .../JacksonSecurityBaselineTest.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java new file mode 100644 index 00000000..f702c1f2 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java @@ -0,0 +1,61 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the shared Maven dependency-management boundary against Jackson Databind versions that + * remain inside the currently known vulnerable 2.21.x range. + */ +class JacksonSecurityBaselineTest { + + private static final Path PROJECT_ROOT = projectRoot(); + + @Test + void jacksonSecurityBomPrecedesImportedSpringBootDependencyManagement() throws IOException { + String pom = Files.readString(PROJECT_ROOT.resolve("pom.xml"), StandardCharsets.UTF_8); + + assertTrue( + pom.contains("2.21.5"), + "Root dependency management must pin Jackson 2.21.5, the current patched 2.21 LTS baseline" + ); + + String jacksonBom = "jackson-bom"; + String springBootBom = "spring-boot-dependencies"; + int jacksonIndex = pom.indexOf(jacksonBom); + int springBootIndex = pom.indexOf(springBootBom); + + assertTrue(jacksonIndex >= 0, "Root dependencyManagement must import the Jackson BOM explicitly"); + assertTrue(springBootIndex >= 0, "Root dependencyManagement must continue importing Spring Boot dependencies"); + assertTrue( + jacksonIndex < springBootIndex, + "Without the Spring Boot parent POM, the explicit Jackson override BOM must precede spring-boot-dependencies" + ); + } + + /** Finds the repository root from root- or module-scoped Maven execution. */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} From 7e86513a7f04adef85aad109fac251e020b0251e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:51:00 +0900 Subject: [PATCH 4/7] fix(security): align Jackson with patched 2.21 LTS BOM --- pom.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pom.xml b/pom.xml index 7c0ee7c2..c5c46ae4 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,7 @@ 25 3.5.16 2025.0.3 + 2.21.5 42.7.12 3.3.16 2.0.13 @@ -33,6 +34,13 @@ + + com.fasterxml.jackson + jackson-bom + ${jackson-bom.version} + pom + import + org.springframework.boot spring-boot-dependencies From 4cef299cdb1e637a9577891c281bf3f139aab10f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:25:47 +0900 Subject: [PATCH 5/7] test(security): reject Jackson BOM decoy evidence --- .../JacksonSecurityBaselineTest.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java index f702c1f2..5d52367d 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java @@ -8,11 +8,12 @@ import java.nio.file.Path; import java.nio.file.Paths; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Guards the shared Maven dependency-management boundary against Jackson Databind versions that - * remain inside the currently known vulnerable 2.21.x range. + * Guards the shared Maven dependency-management boundary against Jackson Databind versions below + * the selected patched 2.21.5 security baseline. */ class JacksonSecurityBaselineTest { @@ -22,6 +23,31 @@ class JacksonSecurityBaselineTest { void jacksonSecurityBomPrecedesImportedSpringBootDependencyManagement() throws IOException { String pom = Files.readString(PROJECT_ROOT.resolve("pom.xml"), StandardCharsets.UTF_8); + assertJacksonBomContract(pom); + } + + @Test + void rejectsJacksonBomDecoysOutsideRootDependencyManagement() { + String pomWithDecoy = """ + + + 2.21.5 + + + + + + spring-boot-dependencies + + + + + """; + + assertThrows(AssertionError.class, () -> assertJacksonBomContract(pomWithDecoy)); + } + + private static void assertJacksonBomContract(String pom) { assertTrue( pom.contains("2.21.5"), "Root dependency management must pin Jackson 2.21.5, the current patched 2.21 LTS baseline" From a222c29c158546861ebe11c714749fea22f54303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:09:33 +0900 Subject: [PATCH 6/7] test(security): validate Jackson BOM structurally --- .../JacksonSecurityBaselineTest.java | 92 +++++++++++++++++-- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java index 5d52367d..582c3c18 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityBaselineTest.java @@ -1,13 +1,22 @@ package com.xtrmetl.etl.documentation; import org.junit.jupiter.api.Test; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; import java.io.IOException; +import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -48,17 +57,39 @@ void rejectsJacksonBomDecoysOutsideRootDependencyManagement() { } private static void assertJacksonBomContract(String pom) { - assertTrue( - pom.contains("2.21.5"), - "Root dependency management must pin Jackson 2.21.5, the current patched 2.21 LTS baseline" + Element project = parseProject(pom); + Element properties = requireDirectChild(project, "properties"); + assertEquals( + "2.21.5", + requireDirectChild(properties, "jackson-bom.version").getTextContent().trim(), + "Root properties must pin Jackson 2.21.5, the current patched 2.21 LTS baseline" ); - String jacksonBom = "jackson-bom"; - String springBootBom = "spring-boot-dependencies"; - int jacksonIndex = pom.indexOf(jacksonBom); - int springBootIndex = pom.indexOf(springBootBom); + Element dependencyManagement = requireDirectChild(project, "dependencyManagement"); + Element dependencies = requireDirectChild(dependencyManagement, "dependencies"); + List managedDependencies = directChildren(dependencies, "dependency"); + + int jacksonIndex = -1; + int springBootIndex = -1; + int jacksonCount = 0; + for (int index = 0; index < managedDependencies.size(); index++) { + Element dependency = managedDependencies.get(index); + String artifactId = directChildText(dependency, "artifactId"); + if ("jackson-bom".equals(artifactId)) { + jacksonCount++; + jacksonIndex = index; + assertEquals("com.fasterxml.jackson", directChildText(dependency, "groupId")); + assertEquals("${jackson-bom.version}", directChildText(dependency, "version")); + assertEquals("pom", directChildText(dependency, "type")); + assertEquals("import", directChildText(dependency, "scope")); + } + if ("spring-boot-dependencies".equals(artifactId)) { + assertEquals("org.springframework.boot", directChildText(dependency, "groupId")); + springBootIndex = index; + } + } - assertTrue(jacksonIndex >= 0, "Root dependencyManagement must import the Jackson BOM explicitly"); + assertEquals(1, jacksonCount, "Root dependencyManagement must import exactly one Jackson BOM"); assertTrue(springBootIndex >= 0, "Root dependencyManagement must continue importing Spring Boot dependencies"); assertTrue( jacksonIndex < springBootIndex, @@ -66,6 +97,51 @@ private static void assertJacksonBomContract(String pom) { ); } + private static Element parseProject(String pom) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder() + .parse(new InputSource(new StringReader(pom))) + .getDocumentElement(); + } catch (Exception exception) { + throw new AssertionError("Root pom.xml must be parseable XML for structural dependency validation", exception); + } + } + + private static Element requireDirectChild(Element parent, String name) { + for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element element && name.equals(elementName(element))) { + return element; + } + } + throw new AssertionError("Missing direct <" + name + "> under <" + elementName(parent) + ">"); + } + + private static List directChildren(Element parent, String name) { + List children = new ArrayList<>(); + for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element element && name.equals(elementName(element))) { + children.add(element); + } + } + return children; + } + + private static String directChildText(Element parent, String name) { + return requireDirectChild(parent, name).getTextContent().trim(); + } + + private static String elementName(Node node) { + return node.getLocalName() == null ? node.getNodeName() : node.getLocalName(); + } + /** Finds the repository root from root- or module-scoped Maven execution. */ private static Path projectRoot() { Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); From c8f30e73fcba6c692bc0ac89564766cec2e67f75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:39:49 +0900 Subject: [PATCH 7/7] test(etl): preserve payload boundary acceptance cases --- .../EtlHttpPayloadAdmissionTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java b/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java index a28a8405..c2ba0589 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/controller/EtlHttpPayloadAdmissionTest.java @@ -24,6 +24,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -92,12 +93,53 @@ void rejectsUnknownLengthOversizedBodyBeforeControllerInvocation() throws Except verifyNoInteractions(etlService); } + @Test + @WithMockUser + void acceptsKnownLengthBodyAtExactByteLimit() throws Exception { + String request = sizedJsonRequest(EtlBatchProperties.DEFAULT_MAX_PAYLOAD_BYTES); + when(etlService.processData(request)).thenReturn("processed"); + + mockMvc.perform(post(PROCESS_PATH) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().string("processed")); + + verify(etlService).processData(request); + } + + @Test + @WithMockUser + void acceptsUnknownLengthBodyImmediatelyBelowByteLimit() throws Exception { + String request = sizedJsonRequest(EtlBatchProperties.DEFAULT_MAX_PAYLOAD_BYTES - 1); + when(etlService.processData(request)).thenReturn("processed"); + + mockMvc.perform(post(PROCESS_PATH) + .with(csrf()) + .header(UNKNOWN_LENGTH_HEADER, "true") + .header(HttpHeaders.TRANSFER_ENCODING, "chunked") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().string("processed")); + + verify(etlService).processData(request); + } + private static String oversizedJsonRequest() { return "[{\"id\":\"" + OVERSIZED_MARKER + "" + "x".repeat(EtlBatchProperties.DEFAULT_MAX_PAYLOAD_BYTES) + "\"}]"; } + private static String sizedJsonRequest(int totalBytes) { + String prefix = "[{\"id\":\""; + String suffix = "\"}]"; + int fillerLength = totalBytes - prefix.length() - suffix.length(); + return prefix + "x".repeat(fillerLength) + suffix; + } + /** * Test-only transport shim that models chunked input whose byte length is not known up front. */