에러 메시지
예를 들어 검색 조건을 query string 으로 표현하면서 아래처럼 값을 조합했을 때,
/products?filter=category:electronics|brand:samsung
Tomcat 기본 설정에서는 이 요청을 파싱하는 과정에서 아래와 같은 예외가 발생할 수 있다.
java.lang.IllegalArgumentException:
Invalid character found in the request target
[/products?filter=category:electronics|brand:samsung ].
The valid characters are defined in RFC 7230 and RFC 3986
에러 메시지에서는 RFC-7230 과 RFC-3986 두 개를 가리키고 있는데 이를 살펴보자.
Why? - 왜 RFC 7230 과 RFC 3986 이 같이 나올까?
Tomcat 의 에러 메시지는 "request target 에 유효하지 않은 문자가 있다" 고 말하면서 RFC-7230 과 RFC-3986 을 함께 언급한다.
처음 보면 두 명세 중 어디를 봐야 하는지 헷갈릴 수 있는데, 두 명세가 각각 다른 역할을 한다.
- RFC 7230 - "무엇을" 검사하는지 (request-target)
- RFC 3986 - "어떤 문자가" 허용되는지 (URI 문자 규칙)
1) RFC 7230 - "무엇을" 검사하는지 / request-target 을 정의
RFC 7230 은 HTTP/1.1 메시지의 문법과 라우팅을 정의한다. HTTP 요청 라인은 아래와 같이 생겼다.
GET /products?filter=category:electronics|brand:samsung HTTP/1.1
└────────────── request-target ────────────────┘
RFC 7230 Section 5.3 Request Target 에 따르면, Request Target 은 메서드 뒤, 버전 앞의 그 토큰을 정의하고, 4가지 form 으로 나뉜다.
// RFC 7230 Section 5.3
request-target = origin-form
/ absolute-form
/ authority-form
/ asterisk-form
origin-form = absolute-path [ "?" query ] ; GET /products?filter=... ← 일반적인 요청
absolute-form = absolute-URI ; 프록시 대상: GET http://host/path
authority-form = authority ; CONNECT host:port
asterisk-form = "*" ; OPTIONS *
브라우저에서 보내는 요청은 대부분 origin-form 이고, 그 구조는 absolute-path [ "?" query ] 다.
Tomcat 이 파싱하는 대상이 바로 이 request-target 이고, 그래서 "Invalid character found in the request target" 이라고 하는 것이다.
2) RFC 3986 - "어떤 문자가" 허용되는지 / query 의 허용 문자를 정의
request-target 의 틀은 RFC-7230 이 정하지만, 그 안 query 의 문자 규칙은 RFC 3986 을 그대로 참조하기 때문에, 에러 메시자가 두 RFC 를 동시에 가리킨다.
RFC 3986 Section 2.2 Reserved Characters 에서는 예약 문자를 아래처럼 정의한다.
// RFC 3986 Section 2.2
reserved = gen-delims / sub-delims
gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
RFC 3986 Section 2.3 Unreserved Characters 에서는 비예약 문자를 정의한다.
// RFC 3986 Section 2.3
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
RFC 3986 Section 3.4 Query 에서는 query 파트의 문법을 아래처럼 정의한다.
// RFC 3986 Section 3.4
query = *( pchar / "/" / "?" )
pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
따라서, query 에 raw 로 들어갈 수 있는 문자를 정리하면 아래와 같다.
| 분류 | 문자 | query 에서 |
|---|---|---|
| unreserved | 영숫자 - . _ ~ | 그대로 OK |
| sub-delims / pchar | & = : @ ! $ ' ( ) * + , ; | 그대로 OK |
| query 확장 | / ? | 그대로 OK |
| pct-encoded | % + HEXDIG 2자리 (예: %7C) | 인코딩된 형태로만 OK |
| 그 외 | | { } [ ] ^ ` < > \ 및 비 ASCII | raw 불가 → 반드시 인코딩 |
예제에서 category:electronics 의 : 는 pchar 에 명시되어 있어 raw 로 OK 지만,
| 는 위 어느 집합에도 없기 때문에 raw 로 오면 Tomcat 이 400 을 반환한다.
Q. Tomcat 은 어디서 막는 건가요?
A.
Tomcat 은 org.apache.tomcat.util.http.parser.HttpParser 가 관리하는 크기 256짜리 boolean 배열로 각 문자를 검사한다.
기본 문자 카테고리 배열은 static 이지만, request-target 판정 배열인 IS_NOT_REQUEST_TARGET 은 커넥터마다 relaxed 설정이 다를 수 있어 instance 필드로 관리된다.
private static final int ARRAY_SIZE = 256;
private static final boolean[] IS_UNRESERVED = new boolean[ARRAY_SIZE];
private static final boolean[] IS_SUBDELIM = new boolean[ARRAY_SIZE];
private final boolean[] IS_NOT_REQUEST_TARGET = new boolean[ARRAY_SIZE];
private final boolean[] IS_QUERY_RELAXED = new boolean[ARRAY_SIZE];
생성자에서 아래 문자들을 허용하지 않게 세팅하고 있다.
if (IS_CONTROL[i] || i == ' ' || i == '\"' || i == '#' || i == '<' ||
i == '>' || i == '\\' || i == '^' || i == '`' || i == '{' ||
i == '|' || i == '}') {
IS_NOT_REQUEST_TARGET[i] = true;
}
따라서, 허용되지 않는 문자는 파싱 단계에서 요청 자체를 거절하고 400 을 반환한다.
이 검사는 헤더 파싱보다도 앞, HttpServletRequest 가 만들어지기 전 단계여서 Spring 의 어떤 필터, 인터셉터, @ControllerAdvice 도 이 400 을 가로챌 수 없다.
예시에서 query string 에서 사용한 | (0x7C) 는 처음부터 true 여서, 파싱 순간 바로 걸리게 된다.
3. 예시 - 4가지 케이스 정리
예제는 허용되지 않는 query 문자 중 | 를 대표로 사용해서 구성했다.
| 케이스 | FE 인코딩 | Tomcat 설정 | 결과 |
|---|---|---|---|
| 1 | 미인코딩 (| 그대로) | 기본 | 400 Bad Request |
| 2 | 인코딩 (%7C) | 기본 | 200 OK |
| 3 | 미인코딩 (| 그대로) | relaxed (| 허용) | 200 OK |
| 4 | 인코딩 (%7C) | relaxed (| 허용) | 200 OK |
해결 방법 A. FE 에서 인코딩
가장 표준적인 방법이고, encodeURIComponent 는 query 값에 들어간 특수 문자를 퍼센트 인코딩한다.
const filter = 'category:electronics|brand:samsung';
const encoded = encodeURIComponent(filter);
// → 'category%3Aelectronics%7Cbrand%3Asamsung'
fetch('/products?filter=' + encoded);
Tomcat 은 인코딩된 값을 받은 뒤 정상적으로 디코딩해서 어플리케이션에 전달한다.
URLSearchParams 를 쓰면 query string 조합과 인코딩을 같이 맡길 수 있어서 실수할 여지가 더 줄어든다.
const params = new URLSearchParams();
params.set('filter', 'category:electronics|brand:samsung');
fetch('/products?' + params.toString());
해결 방법 B. Tomcat relaxedQueryChars
Tomcat 이 특정 query 문자를 허용하도록 커스터마이즈하는 방법이다.
예제에서는 | 를 허용 문자로 추가한다.
@Configuration
@Profile("tomcat-relaxed")
public class TomcatCustomConfig {
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatRelaxedQueryChars() {
return (factory) -> factory.addConnectorCustomizers((connector) -> {
if (connector.getProtocolHandler() instanceof Http11NioProtocol protocol) {
protocol.setRelaxedQueryChars("|");
}
});
}
}
relaxedQueryChars 는 내부적으로 지정한 문자를 IS_NOT_REQUEST_TARGET 표에서 false 로 되돌린다.
단, 열 수 있는 문자는 IS_RELAXABLE (<>[\]^{|}) 로 제한되며, # 나 파싱 자체를 깨는 문자는 넣어도 허용되지 않는다.
: @ / ? 뿐이고, | 를 포함한 나머지와 비 ASCII 는 반드시 퍼센트 인코딩해야 한다. 📚 Reference
- RFC 7230 - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing
- RFC 7230 Section 5.3 - Request Target
- RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
- RFC 3986 Section 2.2 - Reserved Characters
- RFC 3986 Section 2.3 - Unreserved Characters
- RFC 3986 Section 3.4 - Query
- RFC 9112 - HTTP/1.1 (RFC 7230 대체)
- Apache Tomcat Configuration Reference - relaxedQueryChars
- Apache Tomcat - HttpParser.java (GitHub)