서블릿 요청 입력
실제 embedded Tomcat에서 쿼리·폼·JSON·multipart 입력의 소유권, 본문 스트림의 일회성, 문자 인코딩, 미디어 타입, 전달 헤더와 계층별 크기 제한을 검증합니다.
HTTP 요청 하나에는 URI의 쿼리, 헤더, 본문 바이트와 직접 연결한 피어 정보가 함께 들어옵니다.
그러나 Servlet API가 이 입력을 모두 같은 자료구조로 바꾸지는 않습니다.
컨테이너가 소유하는 파라미터 파서와 애플리케이션이 소유하는 본문 파서를 구분하지 않으면, JSON을 getParameter()로 찾거나 이미 소비한 폼 본문을 다시 읽는 오류가 생깁니다.
REQUEST SOURCE · CONTENT-TYPE PARAMETERS · DECODE · VALIDATE
입력 위치와 media type이 Servlet 읽기 API를 결정한다
field 이름이 같아도 먼저 어디에 실렸는지 구분합니다.
query와 form의 charset 계약은 서로 다르고,
getParameter*는 form body를 파싱하며 소비할 수
있습니다. JSON body는 getReader()와
getInputStream() 중 하나만 골라 한 번만 decode합니다.
01 · DECLARED CONTRACT
field 이름보다 source와 Content-Type 매개변수를 먼저 본다
URI query인지 body인지 구분하고 media type뿐 아니라
charset과 multipart boundary도
보존합니다. 선언과 지원 정책이 맞지 않으면 decode 전에 거절합니다.
02 · PARAMETER ROUTES
query charset과 urlencoded form charset은 같은 설정이 아니다
Query decode는 connector의 URI 정책을 따릅니다. Form은
charset 또는 setCharacterEncoding을
getParameter* 전에 확정하며, 이 접근이 body를
파싱하고 소비할 수 있습니다.
03 · PARTS OR ONE BODY PARSER
multipart는 parts로, JSON은 reader와 stream 중 하나로 읽는다
multipart/form-data는 boundary와 설정을 확인한 뒤
getParts()를 사용합니다. JSON은
application/json과 명시적으로 지원하는
application/*+json만 받아 한 parser로 한 번
decode합니다.
04 · VALIDATE ONCE
모든 경로의 typed draft를 하나의 command 경계로 모은다
source별 decode가 끝난 값에 required, range, enum, cross-field 규칙을 한 번 적용합니다. 검증된 command만 실행 계층으로 전달하고 decode 오류와 validation 오류를 경계에서 구분합니다.
Content-Type 비교는 parameters를 제거한 media type만 보지
말고, decode에 필요한 charset과 boundary를
함께 보존해야 합니다. application/*+json 허용 여부도
추측하지 않고 애플리케이션 정책으로 명시합니다.
입력 형식마다 읽는 주체가 다릅니다
ServletRequest.getParameter 계열은 HTTP 쿼리와 POST application/x-www-form-urlencoded 폼을 제공합니다.
같은 이름에 값이 여러 개면 getParameter()는 첫 값만 반환하므로, 다중 값 계약에는 getParameterValues() 또는 getParameterMap()을 사용합니다.
파라미터 접근은 아직 파싱하지 않은 쿼리와 폼 본문을 컨테이너가 파싱하게 만들 수 있습니다. 따라서 폼 파라미터를 읽은 뒤 원시 본문도 온전히 남아 있으리라 가정하면 안 됩니다.
multipart/form-data는 Servlet 등록에 MultipartConfig가 있을 때 컨테이너가 part를 제공합니다. 텍스트 part는 파라미터 API에서 보일 수 있고 파일은 getPart와 getParts로 소유권을 명확히 드러냅니다.
반면 application/json은 자동으로 파라미터 맵이 되지 않습니다. 애플리케이션이나 Spring MVC HttpMessageConverter가 요청 본문을 한 번 읽어 JSON 객체로 변환합니다.
| 입력 위치·형식 | 일차 소유자 | 읽는 API |
|---|---|---|
| URI 쿼리 | Servlet 컨테이너 | getParameter*, getQueryString |
POST application/x-www-form-urlencoded | Servlet 컨테이너 | getParameter* |
구성된 multipart/form-data | Servlet 컨테이너 | getParameter*, getPart, getParts |
| JSON·XML·임의 바이너리 본문 | 애플리케이션 또는 변환기 | getInputStream 또는 getReader 뒤 전용 파서 |
Spring의 MockHttpServletRequest.addParameter()는 이 컨테이너 파싱을 재현하지 않습니다. 테스트 작성자가 이미 파싱됐다고 가정한 값을 mock의 파라미터 맵에 직접 넣는 설정 API입니다.
실제 퍼센트 디코딩, 파라미터 결합, 소비 시점과 크기 제한은 loopback HTTP로 컨테이너를 통과시켜 별도로 검증해야 합니다.
본문은 한 경로로 한 번 읽습니다
getInputStream()은 본문을 바이트로 읽고 getReader()는 선택된 문자 인코딩으로 디코딩합니다.
한 요청에서는 둘 중 하나만 호출할 수 있습니다. 리더를 얻은 뒤 스트림을 얻거나 스트림을 얻은 뒤 리더를 얻으면 IllegalStateException입니다.
이 규칙과 별개로 파라미터 파싱도 폼 본문을 소비할 수 있습니다. 이 fixture의 Tomcat에서는 getParameter() 호출 뒤 getInputStream()에 0바이트가 남습니다. Servlet 계약은 두 접근이 간섭할 수 있다고 규정하므로, 다른 컨테이너에서도 오류나 빈 스트림의 구체 형태에 의존하지 않고 원문이 재생되지 않는다고 설계합니다.
필터가 로깅을 위해 먼저 readAllBytes()를 호출하면 downstream Servlet이나 메시지 컨버터가 읽을 데이터도 사라집니다. 본문을 여러 소비자에게 전달해야 한다면 소유권과 재생 정책을 명시한 별도 래퍼가 필요합니다.
문자 인코딩은 파싱 전에 결정합니다
setCharacterEncoding은 파라미터를 읽거나 getReader()를 얻기 전에 호출해야 합니다. 그 뒤의 변경은 효력이 없습니다.
이 fixture의 폼 경로는 UTF-8을 먼저 설정한 뒤 %EC%9A%94%EC%B2%AD을 요청으로 디코딩합니다.
쿼리 문자열의 디코딩 설정과 본문 문자 인코딩 설정은 컨테이너에서 서로 다른 옵션일 수 있습니다. 둘을 하나의 setCharacterEncoding 호출이 모두 해결한다고 일반화하지 않습니다.
JSON은 기본적으로 바이트 스트림을 파서에 넘깁니다. 미디어 타입에 charset이 있더라도 지원 문자 집합과 디코딩 정책을 파서·프레임워크 계약으로 고정해야 합니다.
미디어 타입은 문자열이 아니라 구조로 판정합니다
Content-Type은 application/json;charset=UTF-8처럼 파라미터를 포함할 수 있으므로 "application/json".equals(request.getContentType())는 정상 요청을 거부합니다.
Spring MediaType으로 타입과 서브타입을 파싱한 뒤 지원 정책을 적용합니다.
이 단원의 정책은 다음과 같습니다.
application/json과 그 파라미터 조합을 허용합니다.application/problem+json,application/vnd.board+json처럼 서브타입 접미사가+json인application/*도 허용합니다.text/json, 깨진 미디어 타입, 타입이 없는 본문은 거부합니다.- 허용된 타입이어도 malformed JSON은 별도의
400 Bad Request입니다.
Spring MVC에서는 consumes 조건과 HttpMessageConverter가 같은 종류의 선택과 역직렬화를 담당합니다. 원시 Servlet에서는 이 경계를 직접 구현해야 합니다.
TRUST → GATES → ONE READ → BOUNDED OBSERVATION
신뢰를 정규화한 뒤 한 parser만 본문을 읽고, cache는 chain 뒤에 본다
본문 소비자와 관찰자를 분리합니다. 원시
X-Forwarded-* 값은 edge가 다시 쓴 뒤에만 신뢰하고,
downstream parser 하나가 원본 스트림을 소비하게 둡니다. 캐싱 래퍼는
그 읽기를 따라 제한된 바이트만 모으며, 로깅은
doFilter가 돌아온 뒤 cache를 읽습니다.
01 · TRUSTED EDGE
forwarded 메타데이터는 신뢰 edge가 정규화한 뒤에만 믿는다
신뢰할 프록시의 직접 연결만 허용하고, 외부가 보낸
X-Forwarded-* 값을 제거한 뒤 edge가 다시 씁니다.
정규화 전의 사용자 헤더를 IP 제한이나 감사 주체로 사용하지
않습니다.
02 · TRANSPORT & MEDIA GATES
크기·인코딩·미디어 타입 실패를 서로 다른 응답으로 좁힌다
proxy와 컨테이너 한도를 넘으면 413, 지원하지 않는
문자 집합이나 잘못된 인코딩이면 400, 선택할 parser가
없는 미디어 타입이면 415로 구분합니다.
03 · ONE BODY READER
선택된 parser 하나가 원본 stream을 읽고 wrapper는 그 읽기만 cache한다
ContentCachingRequestWrapper는 생성 시 본문을 미리
복제하지 않습니다. downstream parser가
getInputStream()을 한 번 소비하는 동안 읽힌 바이트만
상한까지 저장하며, JSON 문법·필드 타입 실패는
400입니다.
04 · VALIDATE & OBSERVE
command 검증 뒤 chain이 돌아오면 제한된 cache만 기록한다
업무 constraint 실패는 별도의 400으로 남깁니다.
로깅은 chain 이후 cache를 읽고 최대 바이트 수와 민감 필드 마스킹을
적용합니다. 잘림 여부는 wrapper가 자동으로 알려 준다고 가정하지
않고 별도 정책으로 추적하며, 소비되지 않은 원본 전체를 자동으로
얻는다고 가정하지 않습니다.
실패 응답은 예시 정책입니다. 실제 배포에서는 proxy·컨테이너·메시지 converter의 상한과 예외 매핑을 같은 계약으로 맞추고, 인증 헤더·쿠키· 비밀번호·전체 JSON 본문은 기본 로그에서 제외합니다.
실제 embedded Tomcat에서 경계를 검증합니다
다음 한 파일은 production 규칙의 작은 예제와 검증 fixture를 함께 둔 독립 compilation unit입니다.
Boot 4.1.1이 임의 포트의 Tomcat 11.0.24를 열고 JDK HttpClient가 127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다. Spring mock은 addParameter()가 네트워크 파서가 아니라는 부정 증명에만 사용합니다.
package board.servlet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.server.servlet.context
.ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.util.ContentCachingRequestWrapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
class EmbeddedServletRequestInputTest {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.version(HttpClient.Version.HTTP_1_1)
.build();
private static final String JSON_BODY =
"{\"title\":\"HTTP\",\"content\":\"HTTP 본문\"}";
@Test
void addParameter는_mock_설정이지_본문_파싱이_아니다()
throws Exception {
var request = new MockHttpServletRequest(
"POST",
"/raw/input/form");
request.setContentType("application/x-www-form-urlencoded");
request.setContent("title=wire".getBytes(UTF_8));
assertNull(request.getParameter("title"));
request.addParameter("title", "preset");
assertAll(
() -> assertEquals(
"preset",
request.getParameter("title")),
() -> assertEquals(
"title=wire",
new String(
request.getInputStream().readAllBytes(),
UTF_8)));
}
@Test
void 실제_query_form_JSON은_서로_다른_입력_소유권을_보인다()
throws Exception {
assertEquals(40, JSON_BODY.getBytes(UTF_8).length);
try (var server = RunningServer.start()) {
var query = send(
server,
"GET",
"/raw/input/query?title=HTTP&tag=network&tag=spring",
null,
null,
Map.of());
var form = send(
server,
"POST",
"/raw/input/form",
"application/x-www-form-urlencoded",
"title=%EC%9A%94%EC%B2%AD&tag=one&tag=two"
.getBytes(UTF_8),
Map.of());
var json = send(
server,
"POST",
"/raw/input/json?source=query",
"application/json;charset=UTF-8",
JSON_BODY.getBytes(UTF_8),
Map.of());
assertAll(
() -> assertEquals(200, query.statusCode()),
() -> assertEquals(
"title=HTTP;tags=network,spring",
query.body()),
() -> assertEquals(200, form.statusCode()),
() -> assertEquals(
"encoding=UTF-8;title=요청;tags=one,two;remaining=0",
form.body()),
() -> assertEquals(201, json.statusCode()),
() -> assertEquals(
"query=query;parameterTitle=<null>;bytes=40;"
+ "title=HTTP;content=HTTP 본문",
json.body()));
}
}
@Test
void reader와_inputStream은_양방향으로_상호_배타적이다()
throws Exception {
try (var server = RunningServer.start()) {
var readerFirst = send(
server,
"POST",
"/raw/input/reader-first",
"text/plain;charset=UTF-8",
"body".getBytes(UTF_8),
Map.of());
var streamFirst = send(
server,
"POST",
"/raw/input/stream-first",
"text/plain;charset=UTF-8",
"body".getBytes(UTF_8),
Map.of());
assertAll(
() -> assertEquals(200, readerFirst.statusCode()),
() -> assertEquals(
"reader->stream:IllegalStateException",
readerFirst.body()),
() -> assertEquals(200, streamFirst.statusCode()),
() -> assertEquals(
"stream->reader:IllegalStateException",
streamFirst.body()));
}
}
@Test
void JSON_정책은_파라미터와_application_plus_json을_구조적으로_판정한다()
throws Exception {
try (var server = RunningServer.start()) {
var parameterized = send(
server,
"POST",
"/raw/input/json",
"application/json;charset=UTF-8",
JSON_BODY.getBytes(UTF_8),
Map.of());
var suffix = send(
server,
"POST",
"/raw/input/json",
"application/problem+json;profile=error",
JSON_BODY.getBytes(UTF_8),
Map.of());
var rejected = send(
server,
"POST",
"/raw/input/json",
"text/json",
JSON_BODY.getBytes(UTF_8),
Map.of());
var malformed = send(
server,
"POST",
"/raw/input/json",
"application/json",
"{".getBytes(UTF_8),
Map.of());
assertAll(
() -> assertEquals(201, parameterized.statusCode()),
() -> assertEquals(201, suffix.statusCode()),
() -> assertEquals(415, rejected.statusCode()),
() -> assertEquals(400, malformed.statusCode()));
}
}
@Test
void multipart는_구성된_part_파서와_파일_크기_게이트를_사용한다()
throws Exception {
try (var server = RunningServer.start()) {
var accepted = multipart(
server,
"board-boundary-ok",
"12345678");
var rejected = multipart(
server,
"board-boundary-large",
"123456789");
assertAll(
() -> assertEquals(200, accepted.statusCode()),
() -> assertEquals(
"title=upload;file=a.txt;bytes=8;content=12345678",
accepted.body()),
() -> assertEquals(413, rejected.statusCode()));
}
}
@Test
void 직접_연결_피어와_사용자_전달_헤더는_같은_신뢰값이_아니다()
throws Exception {
try (var server = RunningServer.start()) {
var response = send(
server,
"GET",
"/raw/input/peer",
null,
null,
Map.of("X-Forwarded-For", "203.0.113.9"));
assertAll(
() -> assertEquals(200, response.statusCode()),
() -> assertEquals(
"remote=127.0.0.1;forwarded=203.0.113.9",
response.body()));
}
}
@Test
void 애플리케이션_게이트는_선언_길이와_chunked_스트림을_모두_제한한다()
throws Exception {
try (var server = RunningServer.start()) {
var exact = send(
server,
"POST",
"/raw/input/sized",
"application/octet-stream",
new byte[64],
Map.of());
var declaredTooLarge = send(
server,
"POST",
"/raw/input/sized",
"application/octet-stream",
new byte[65],
Map.of());
var chunkedTooLarge = sendUnknownLength(
server,
"/raw/input/sized",
new byte[65]);
assertAll(
() -> assertEquals(204, exact.statusCode()),
() -> assertEquals(413, declaredTooLarge.statusCode()),
() -> assertEquals(413, chunkedTooLarge.statusCode()));
}
}
@Test
void contentCachingWrapper는_downstream_소비_뒤_상한까지만_기록한다()
throws Exception {
assertEquals(40, JSON_BODY.getBytes(UTF_8).length);
try (var server = RunningServer.start()) {
var response = send(
server,
"POST",
"/raw/input/cache",
"application/json",
JSON_BODY.getBytes(UTF_8),
Map.of());
var sample = server.probe().last.get();
assertAll(
() -> assertEquals(200, response.statusCode()),
() -> assertEquals("downstreamBytes=40", response.body()),
() -> assertEquals(0, sample.beforeBytes()),
() -> assertEquals(16, sample.afterBytes()),
() -> assertEquals(40, sample.declaredBytes()));
}
}
private static HttpResponse<String> multipart(
RunningServer server,
String boundary,
String fileContent)
throws Exception {
var body = ("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"title\"\r\n"
+ "\r\n"
+ "upload\r\n"
+ "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"a.txt\"\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ fileContent + "\r\n"
+ "--" + boundary + "--\r\n")
.getBytes(UTF_8);
return send(
server,
"POST",
"/raw/input/multipart",
"multipart/form-data; boundary=" + boundary,
body,
Map.of());
}
private static HttpResponse<String> send(
RunningServer server,
String method,
String path,
String contentType,
byte[] body,
Map<String, String> headers)
throws IOException, InterruptedException {
var builder = HttpRequest.newBuilder(
URI.create(server.origin() + path))
.timeout(Duration.ofSeconds(5));
if (contentType != null) builder.header("Content-Type", contentType);
headers.forEach(builder::header);
var publisher = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body);
return CLIENT.send(
builder.method(method, publisher).build(),
HttpResponse.BodyHandlers.ofString(UTF_8));
}
private static HttpResponse<String> sendUnknownLength(
RunningServer server,
String path,
byte[] body)
throws IOException, InterruptedException {
var request = HttpRequest.newBuilder(
URI.create(server.origin() + path))
.timeout(Duration.ofSeconds(5))
.header("Content-Type", "application/octet-stream")
.POST(HttpRequest.BodyPublishers.ofInputStream(
() -> new ByteArrayInputStream(body)))
.build();
return CLIENT.send(
request,
HttpResponse.BodyHandlers.ofString(UTF_8));
}
private record RunningServer(
ServletWebServerApplicationContext context,
String origin,
CacheProbe probe)
implements AutoCloseable {
static RunningServer start() {
var probe = new CacheProbe();
var application = new SpringApplication(TestApplication.class);
application.setWebApplicationType(WebApplicationType.SERVLET);
application.setRegisterShutdownHook(false);
application.setDefaultProperties(Map.of(
"server.address", "127.0.0.1",
"server.port", "0",
"server.forward-headers-strategy", "none",
"spring.main.banner-mode", "off",
"logging.level.root", "OFF"));
application.addInitializers(context -> context
.getBeanFactory()
.registerSingleton("cacheProbe", probe));
var context = (ServletWebServerApplicationContext)
application.run();
return new RunningServer(
context,
"http://127.0.0.1:"
+ context.getWebServer().getPort(),
probe);
}
@Override
public void close() {
context.close();
}
}
@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
static class TestApplication {
@Bean
ServletRegistrationBean<RequestInputServlet> requestInputServlet() {
var registration = new ServletRegistrationBean<>(
new RequestInputServlet(),
"/raw/input/*");
registration.setName("requestInputServlet");
registration.setMultipartConfig(
new MultipartConfigElement("", 8, 512, 0));
return registration;
}
@Bean
FilterRegistrationBean<BoundedCacheFilter> boundedCacheFilter(
CacheProbe probe) {
var registration = new FilterRegistrationBean<>(
new BoundedCacheFilter(probe));
registration.setName("boundedCacheFilter");
registration.addUrlPatterns("/raw/input/cache");
registration.setOrder(1);
return registration;
}
}
static final class RequestInputServlet extends HttpServlet {
private static final JsonMapper JSON = JsonMapper.builder().build();
private static final int MAX_BODY_BYTES = 64;
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
switch (request.getRequestURI()) {
case "/raw/input/query" -> text(
response,
200,
"title=" + request.getParameter("title")
+ ";tags="
+ String.join(
",",
request.getParameterValues("tag")));
case "/raw/input/peer" -> text(
response,
200,
"remote=" + request.getRemoteAddr()
+ ";forwarded="
+ request.getHeader("X-Forwarded-For"));
default -> response.sendError(404);
}
}
@Override
protected void doPost(
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
switch (request.getRequestURI()) {
case "/raw/input/form" -> readForm(request, response);
case "/raw/input/json" -> readJson(request, response);
case "/raw/input/reader-first" -> readerFirst(
request,
response);
case "/raw/input/stream-first" -> streamFirst(
request,
response);
case "/raw/input/multipart" -> readMultipart(
request,
response);
case "/raw/input/sized" -> readSized(request, response);
case "/raw/input/cache" -> text(
response,
200,
"downstreamBytes="
+ request.getInputStream()
.readAllBytes()
.length);
default -> response.sendError(404);
}
}
private static void readForm(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
request.setCharacterEncoding(UTF_8);
var title = request.getParameter("title");
var tags = request.getParameterValues("tag");
var remaining = request.getInputStream().readAllBytes().length;
text(
response,
200,
"encoding=" + request.getCharacterEncoding()
+ ";title=" + title
+ ";tags=" + String.join(",", tags)
+ ";remaining=" + remaining);
}
private static void readJson(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
if (!supportsJson(request.getContentType())) {
response.sendError(415, "unsupported content type");
return;
}
var parameterTitle = request.getParameter("title");
var querySource = request.getParameter("source");
var body = request.getInputStream().readAllBytes();
final CreatePostInput input;
try {
input = JSON.readValue(body, CreatePostInput.class);
} catch (JacksonException exception) {
response.sendError(400, "invalid JSON");
return;
}
text(
response,
201,
"query=" + printable(querySource)
+ ";parameterTitle="
+ printable(parameterTitle)
+ ";bytes=" + body.length
+ ";title=" + input.title()
+ ";content=" + input.content());
}
private static boolean supportsJson(String rawContentType) {
if (rawContentType == null) return false;
final MediaType mediaType;
try {
mediaType = MediaType.parseMediaType(rawContentType);
} catch (InvalidMediaTypeException exception) {
return false;
}
return "application".equals(mediaType.getType())
&& ("json".equals(mediaType.getSubtype())
|| "json".equals(
mediaType.getSubtypeSuffix()));
}
private static void readerFirst(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
request.getReader();
try {
request.getInputStream();
text(response, 500, "reader->stream:allowed");
} catch (IllegalStateException expected) {
text(
response,
200,
"reader->stream:IllegalStateException");
}
}
private static void streamFirst(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
request.getInputStream();
try {
request.getReader();
text(response, 500, "stream->reader:allowed");
} catch (IllegalStateException expected) {
text(
response,
200,
"stream->reader:IllegalStateException");
}
}
private static void readMultipart(
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
try {
var title = request.getParameter("title");
var file = request.getPart("file");
var bytes = file.getInputStream().readAllBytes();
text(
response,
200,
"title=" + title
+ ";file=" + file.getSubmittedFileName()
+ ";bytes=" + bytes.length
+ ";content=" + new String(bytes, UTF_8));
} catch (IllegalStateException tooLarge) {
response.reset();
response.sendError(413, "multipart limit exceeded");
}
}
private static void readSized(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
var declared = request.getContentLengthLong();
if (declared > MAX_BODY_BYTES) {
response.sendError(413, "declared body is too large");
return;
}
var bytes = request.getInputStream()
.readNBytes(MAX_BODY_BYTES + 1);
if (bytes.length > MAX_BODY_BYTES) {
response.sendError(413, "streamed body is too large");
return;
}
response.setStatus(204);
}
private static String printable(String value) {
return value == null ? "<null>" : value;
}
private static void text(
HttpServletResponse response,
int status,
String value)
throws IOException {
response.setStatus(status);
response.setContentType("text/plain");
response.setCharacterEncoding(UTF_8);
response.getWriter().write(value);
}
}
static final class BoundedCacheFilter implements Filter {
private static final int CACHE_LIMIT = 16;
private final CacheProbe probe;
BoundedCacheFilter(CacheProbe probe) {
this.probe = probe;
}
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
var wrapped = new ContentCachingRequestWrapper(
(HttpServletRequest) request,
CACHE_LIMIT);
var before = wrapped.getContentAsByteArray().length;
try {
chain.doFilter(wrapped, response);
} finally {
probe.last.set(new CacheSample(
before,
wrapped.getContentAsByteArray().length,
request.getContentLengthLong()));
}
}
}
static final class CacheProbe {
final AtomicReference<CacheSample> last = new AtomicReference<>();
}
record CacheSample(
int beforeBytes,
int afterBytes,
long declaredBytes) {}
public record CreatePostInput(String title, String content) {}
}여덟 테스트가 확인하는 범위는 다음과 같습니다.
- mock의
addParameter()는 원시 폼 본문을 파싱하지 않고 미리 정한 값만 추가합니다. - 실제 Tomcat은 중복 쿼리 값과 UTF-8 폼을 파라미터로 만들고, 40바이트 JSON은 쿼리와 분리된 본문으로 남깁니다.
getReader()와getInputStream()은 어느 쪽을 먼저 호출해도 다른 쪽 호출을 막습니다.- 미디어 타입 파라미터와
application/*+json은 허용하되text/json은415, malformed JSON은400으로 거부합니다. - 구성된 multipart 파서는 텍스트 필드와 파일 part를 제공하고 8바이트 파일 상한을 집행합니다.
- 직접 보낸
X-Forwarded-For는 loopback 피어 주소를 자동으로 신뢰 주소로 바꾸지 않습니다. - 64바이트 애플리케이션 게이트는 알려진
Content-Length와 길이를 모르는 chunked 입력을 모두 제한합니다. - 캐싱 래퍼는 downstream이 40바이트를 소비하기 전에는 0바이트이고, 소비 뒤에도 설정한 16바이트만 보관합니다.
이 테스트는 정상적인 embedded Tomcat 요청 경로를 증명합니다. 외부 프록시의 실제 제한이나 신뢰 헤더 재작성은 이 JVM 안에서 실행하지 않았으므로 배포 환경의 통합 테스트 대상입니다.
전달 헤더는 신뢰 경계에서 다시 써야 합니다
getRemoteAddr()는 요청 객체가 나타내는 원격 끝점입니다. 기본적으로 직접 연결한 클라이언트 또는 마지막 프록시이고, 컨테이너의 전달 헤더 처리 설정에 따라 달라질 수 있습니다.
사용자는 Forwarded와 X-Forwarded-* 헤더를 직접 보낼 수 있습니다. Spring Framework의 forwarded header 보안 지침처럼, 신뢰 경계의 프록시는 외부에서 들어온 두 헤더 계열을 모두 제거하고 자신이 관찰한 값으로 다시 써야 합니다.
그 전제가 성립한 뒤에만 Boot의 server.forward-headers-strategy로 컨테이너의 native 처리 또는 Spring Framework 처리를 선택합니다.
애플리케이션 코드가 원시 X-Forwarded-For 문자열의 첫 토큰을 곧바로 인증, 감사, 요청률 제한의 클라이언트 주소로 쓰면 안 됩니다.
이 단원의 loopback 테스트는 전략을 none으로 고정해 127.0.0.1 피어와 사용자가 넣은 203.0.113.9 헤더가 서로 다른 값임을 확인합니다. 실제 프록시 체인의 홉 수와 신뢰 CIDR은 배포 구성에서 검증해야 합니다.
크기 제한은 서로 다른 계층에 둡니다
하나의 제한이 모든 입력 경로를 보호하지 않습니다.
| 계층 | 먼저 막는 대상 | 예시 정책 |
|---|---|---|
| edge proxy | 연결·전체 요청·헤더 | 최대 요청 크기, 헤더 크기, 전송 시간 |
| Servlet connector | form POST·파라미터 파싱 | form 크기, 파라미터 수, swallow 정책 |
| multipart parser | 전체 multipart·개별 part·메모리/디스크 전환 | maxRequestSize, maxFileSize, threshold |
| endpoint parser | JSON·XML·임의 스트림 | 선언 길이 선검사 + 상한을 둔 실제 스트림 읽기 |
| 관측 로깅 | 메모리에 남기는 복사본 | 작은 cache limit, 타입 allowlist, 민감값 마스킹 |
MultipartConfigElement의 파일 상한과 요청 상한은 multipart에만 적용됩니다. JSON 상한을 대신하지 않습니다.
Boot 4.1.1의 MultipartAutoConfiguration도 MultipartProperties를 MultipartConfigElement로 연결합니다. spring.servlet.multipart.max-file-size와 max-request-size는 업로드 계층의 제한이며 connector와 JSON endpoint 제한을 대체하지 않습니다.
Content-Length는 빠른 거절에 유용하지만 없거나 거짓일 수 있습니다. 따라서 endpoint는 선언 길이를 확인한 뒤에도 limit + 1바이트까지만 읽어 초과를 판정해야 합니다.
반대로 로깅 cache limit은 요청 거절 정책이 아닙니다. downstream은 전체 40바이트를 읽는 동안 래퍼가 앞 16바이트만 복사할 수 있습니다.
본문 로깅은 체인 뒤에 제한된 결과만 봅니다
Spring ContentCachingRequestWrapper는 스트림이나 리더를 통해 실제로 읽힌 내용만 가로채 저장합니다.
래퍼 생성 자체는 본문을 읽지 않습니다. downstream이 본문을 소비하지 않으면 getContentAsByteArray()도 빈 배열입니다.
따라서 필터는 다음 순서를 지킵니다.
- 구체적인 작은 cache limit으로 요청을 감쌉니다.
- 본문을 미리 읽지 않고 필터 체인을 호출합니다.
- 체인이 반환된 뒤 실제 소비된 cache만 확인합니다.
- 콘텐츠 타입 allowlist, 길이 절단, 비밀번호·토큰·개인정보 마스킹을 거친 값만 기록합니다.
cache limit을 넘긴 원문이 자동으로 거부되거나 전체 본문이 안전하게 재생되는 것은 아닙니다. 업로드와 대용량 JSON을 통째로 로깅하지 않고 요청 ID, 타입, 선언 길이, 처리 결과 같은 메타데이터를 우선합니다.
연습 문제
fixture의 JSON 정책에 application/vnd.board+json과 콘텐츠 타입이 없는 본문을 추가해 각각 201과 415를 검증하세요.
그 다음 cache limit을 8바이트로 낮추되 downstream이 여전히 40바이트를 읽는지 확인하고, 기록용 preview에서는 content 필드를 남기지 않도록 바꾸세요.
마지막으로 실제 배포 프록시가 외부 Forwarded와 X-Forwarded-*를 모두 제거한 뒤 새 값을 넣는지 통합 테스트를 작성하세요. 프록시가 없는 현재 fixture에서 전달 헤더를 신뢰하도록 바꾸는 것은 해답이 아닙니다.
다음 문서에서는 입력 검증이 끝난 뒤 상태·헤더·본문을 어떤 순서로 HttpServletResponse에 쓰고, 응답 커밋 뒤에는 무엇을 바꿀 수 없는지 살펴봅니다.