URL 쿼리스트링에 허용되지 않는 문자를 넣으면 어떻게 될까 (RFC 7230, RFC 3986)

#Spring#Tomcat#Java
URL 쿼리스트링에 RFC 3986 에서 허용하지 않는 문자를 인코딩 없이 그대로 보내면 Tomcat 이 400 Bad Request 를 반환한다.
검색 조건이나 필터 문법처럼 query 값에 특수 문자가 섞이는 경우를 기준으로 허용 문자 규칙과 해결 방법을 정리한다.
FE 인코딩과 Tomcat relaxedQueryChars 설정이 각각 어떤 의미인지도 함께 살펴본다.

에러 메시지

예를 들어 검색 조건을 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" 이라고 하는 것이다.

참고로, RFC-7230 은 2022년 6월 RFC 9112 로 obsolete 되었다.
Tomcat 에러 메시지 문구는 여전히 "RFC-7230 and RFC-3986" 으로 남아 있는 경우가 많아서, Tomcat 버전의 실제 메시지를 확인하는 게 정확하다.

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
그 외| { } [ ] ^ ` < > \ 및 비 ASCIIraw 불가 → 반드시 인코딩

예제에서 category:electronics: 는 pchar 에 명시되어 있어 raw 로 OK 지만, | 는 위 어느 집합에도 없기 때문에 raw 로 오면 Tomcat 이 400 을 반환한다.

RFC-2396 에서는 | 를 unwise 문자로 분류했었다.
unwise = | \ ^ [ ] `
URL 에 넣을 수는 있지만 쓰지 않는 게 좋다는 뜻으로, 금지가 아닌 비권장이었다.
RFC-3986 에서 unwise 분류가 사라지고 대신 허용 목록 자체에서 제외됐다. 즉 반드시 인코딩해야 하는 문자가 됐다.

Q. Tomcat 은 어디서 막는 건가요?

A. Tomcat 은 org.apache.tomcat.util.http.parser.HttpParser 가 관리하는 크기 256짜리 boolean 배열로 각 문자를 검사한다.

비트마스크는 여러 개의 true/false 값을 비트 단위로 저장해두고 빠르게 확인하는 방식이다.
여기서는 특정 문자가 request-target 에 들어와도 되는지 미리 표시해둔 표에 가깝다고 보면 된다.

기본 문자 카테고리 배열은 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());
URLSearchParams 는 공백을 + 로, encodeURIComponent 는 %20 으로 인코딩한다.
서버가 + 를 공백으로 디코딩하는지에 따라 미묘한 차이가 있을 수 있으니 알아두면 좋다.

해결 방법 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 (<>[\]^{|}) 로 제한되며, # 나 파싱 자체를 깨는 문자는 넣어도 허용되지 않는다.

relaxedQueryChars 는 RFC 표준을 우회하는 설정이기 때문에, 필요한 문자만 최소한으로 허용해야 한다.

정리
✔ RFC 7230 은 "무엇을" 검사하는지(request-target)를, RFC 3986 은 "어떤 문자가" 허용되는지(URI 문자 규칙)를 각각 정의한다. 에러가 두 RFC 를 동시에 가리키는 이유다.
✔ query raw 허용 문자는 unreserved + sub-delims + : @ / ? 뿐이고, | 를 포함한 나머지와 비 ASCII 는 반드시 퍼센트 인코딩해야 한다.
✔ Tomcat 은 HttpParser 의 boolean 배열로 각 바이트를 검사하며, 커넥터 레벨에서 400 을 반환하므로 Spring 레이어로 올라오지 않는다.
✔ 가장 표준적인 해결은 클라이언트에서 query 값을 인코딩하는 것이고, 서버에서 열어야 한다면 relaxedQueryChars 로 필요한 문자만 최소한으로 허용한다.

📚 Reference