요청 경로·쿼리·인코딩
HTTP 요청 대상에서 경로와 쿼리를 먼저 분리하고 퍼센트 바이트를 엄격히 디코딩해 중복 키, 빈 값, 잘못된 이스케이프를 안전한 값 객체로 만듭니다.
HTTP 요청 시작 줄의 두 번째 토큰은 단순한 파일 경로가 아니라 요청 대상입니다.
일반 웹 서버로 직접 보내는 요청은 보통 /search?q=java 같은 원점 형식을 사용합니다.
물음표 앞은 경로, 뒤는 쿼리이고 프래그먼트를 나타내는 #은 브라우저가 서버로 보내지 않습니다.
서버는 전체 요청 문자열에서 q= 위치를 검색하는 대신 이 구조를 순서대로 파싱해야 합니다.
URL 전송 형식은 오랜 중간 장비와의 호환성을 위해 제한된 ASCII 표현을 사용합니다.
한글 가를 UTF-8로 인코딩하면 세 바이트 EA B0 80이 되고, 각 바이트가 %EA%B0%80으로 표현됩니다.
서버는 퍼센트 뒤의 두 16진수를 바이트로 복원한 뒤 UTF-8 디코더를 적용합니다.
%를 문자 치환으로만 다루거나 바이트별로 곧바로 char로 바꾸면 다중 바이트 문자가 손상됩니다.
취약한 쿼리 절단
다음 코드는 헤더 값에 q=가 등장해도 잘못 잡을 수 있고, 쿼리 순서가 바뀌거나 값이 비어 있으면 인덱스 계산이 깨집니다.
& 뒤의 다음 매개변수까지 검색어에 포함시키며 퍼센트 이스케이프도 검증하지 않습니다.
기능 하나에 맞춘 문자열 위치 계산은 요청 대상 문법을 대신할 수 없습니다.
public final class SubstringQueryExtractor {
static String query(String wholeRequest) {
int start = wholeRequest.indexOf("q=") + 2;
int end = wholeRequest.indexOf(' ', start);
return wholeRequest.substring(start, end);
}
public static void main(String[] args) {
System.out.println("compile-only substring parser counterexample");
}
}파싱 순서는 시작 줄 분리, 요청 대상 추출, 첫 ? 기준 경로와 원시 쿼리 분리, & 기준 항목 분리, 첫 = 기준 키와 값 분리, 각 구성 요소 디코딩입니다.
구조 경계를 정하기 전에 전체 문자열을 디코딩하면 %26이 실제 &로 바뀌어 값의 일부가 새 매개변수처럼 보이는 문제가 생깁니다.
반드시 원시 구분자를 먼저 식별한 뒤 각 조각을 디코딩합니다.
빈 쿼리 항목과 등호가 없는 키의 의미도 정합니다.
?flag를 flag=와 같게 볼지, 잘못된 형식으로 거절할지는 애플리케이션 계약입니다.
중복 키 tag=java&tag=http는 흔하므로 단일 Map 값으로 마지막 항목만 남기지 않습니다.
Map<String, List<String>>나 순서 있는 쌍 목록이 원본 의미를 더 잘 보존합니다.
요청 대상 분해
아래 구현은 경로가 슬래시로 시작하는 원점 형식인지 확인하고 #을 거절합니다.
쿼리는 원시 상태에서 &와 첫 =을 분리한 다음 URLDecoder로 구성 요소를 복원합니다.
URLDecoder는 HTML 폼 규칙에 따라 +를 공백으로 바꾸므로 쿼리가 폼 인코딩을 따른다는 정책을 명시합니다.
일반 URI 경로에 같은 디코더를 쓰면 실제 더하기 기호가 달라지므로 경로는 별도 처리합니다.
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class RequestTargetParser {
record Target(String rawPath, Map<String, List<String>> query) {
Target {
query = Map.copyOf(query);
}
String first(String key) {
return query.getOrDefault(key, List.of()).stream()
.findFirst().orElse(null);
}
}
static Target parse(String requestTarget) {
if (requestTarget == null || requestTarget.isEmpty()) {
throw new IllegalArgumentException("target missing");
}
if (!requestTarget.startsWith("/")) {
throw new IllegalArgumentException("origin-form required");
}
if (requestTarget.indexOf('#') >= 0) {
throw new IllegalArgumentException("fragment is not sent to origin");
}
int question = requestTarget.indexOf('?');
String path = question < 0
? requestTarget
: requestTarget.substring(0, question);
String rawQuery = question < 0
? ""
: requestTarget.substring(question + 1);
return new Target(path, parseQuery(rawQuery));
}
private static Map<String, List<String>> parseQuery(String rawQuery) {
Map<String, List<String>> result = new LinkedHashMap<>();
if (rawQuery.isEmpty()) {
return result;
}
for (String field : rawQuery.split("&", -1)) {
if (field.isEmpty()) {
throw new IllegalArgumentException("empty query field");
}
String[] pair = field.split("=", 2);
String key = decodeFormComponent(pair[0]);
String value = pair.length == 2
? decodeFormComponent(pair[1])
: "";
if (key.isEmpty()) {
throw new IllegalArgumentException("query key missing");
}
result.computeIfAbsent(key, ignored -> new ArrayList<>())
.add(value);
}
result.replaceAll((key, values) -> List.copyOf(values));
return result;
}
private static String decodeFormComponent(String raw) {
try {
return URLDecoder.decode(raw, StandardCharsets.UTF_8);
} catch (IllegalArgumentException error) {
throw new IllegalArgumentException(
"invalid percent encoding",
error);
}
}
public static void main(String[] args) {
Target target = parse(
"/search?q=%EA%B0%80%EB%82%98%EB%8B%A4"
+ "&tag=java&tag=http&empty=");
System.out.println("path=" + target.rawPath());
System.out.println("q=" + target.first("q"));
System.out.println("tags=" + target.query().get("tag"));
System.out.println("empty='" + target.first("empty") + "'");
}
}Map.copyOf는 바깥 Map을 불변으로 만들지만 내부 목록까지 자동 복사하지 않습니다.
그래서 replaceAll로 각 목록을 List.copyOf한 뒤 record에 넣었습니다.
더 단순하게는 쌍을 모두 모은 뒤 스트림 수집기로 불변 구조를 만들 수 있습니다.
중복 순서가 검색 필터 의미에 중요하면 LinkedHashMap의 순서를 최종 타입에서도 보존하는 별도 불변 구현을 고려합니다.
경로는 아직 원시 형태입니다.
라우팅 전에 경로 구간별 퍼센트 디코딩과 정규화를 수행할 수 있지만 %2F를 슬래시로 바꿀지, 중복 슬래시를 합칠지, .과 ..을 처리할지는 보안에 직접 영향을 줍니다.
파일 시스템 경로에 연결한다면 URL 디코딩 뒤 기준 디렉터리 탈출 검사를 다시 해야 합니다.
퍼센트 인코딩
URLEncoder와 URLDecoder는 폼 구성 요소에 적합하고 전체 URL을 한 번에 인코딩하는 도구가 아닙니다.
전체 /search?q=가에 적용하면 슬래시와 물음표까지 이스케이프되어 요청 대상 구조를 잃습니다.
값 가만 UTF-8 폼 구성 요소로 인코딩한 뒤 쿼리 문법과 결합합니다.
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class PercentEncodingProbe {
record Observation(String text, String utf8Hex,
String encoded, int wireBytes) {
}
static Observation observe(String value) {
byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
String encoded = URLEncoder.encode(
value,
StandardCharsets.UTF_8);
return new Observation(
value,
HexFormat.ofDelimiter(" ").withUpperCase().formatHex(utf8),
encoded,
encoded.getBytes(StandardCharsets.US_ASCII).length);
}
public static void main(String[] args) {
for (String value : java.util.List.of("java", "가", "hello world", "a+b")) {
Observation observation = observe(value);
String decoded = URLDecoder.decode(
observation.encoded(),
StandardCharsets.UTF_8);
System.out.println(observation);
System.out.println("roundTrip=" + decoded.equals(value));
}
}
}ASCII 영문은 그대로 유지되지만 공백은 +, 리터럴 더하기 기호는 %2B가 됩니다.
한글 한 글자의 UTF-8 세 바이트는 퍼센트와 두 16진수 문자씩 총 아홉 ASCII 바이트가 됩니다.
이 팽창은 쿼리 길이 상한과 프록시 요청-줄 제한을 계산할 때 고려합니다.
긴 데이터나 구조화된 요청 데이터는 GET 쿼리보다 적절한 POST 본문과 Content-Type을 사용하는 편이 낫습니다.
디코더는 형식 오류 %G0, 끝에 남은 %, 올바르지 않은 UTF-8 바이트를 어떻게 처리하는지 확인해야 합니다.
URLDecoder는 잘못된 16진수 형식을 예외로 알리지만 문자 집합 디코더의 대체 정책까지 엄격히 제어하려면 CharsetDecoder에 CodingErrorAction.REPORT를 사용한 구성 요소 디코더를 작성합니다.
경로 정규화 순서
서버와 앞단 프록시가 서로 다른 순서로 디코딩하면 보안 필터가 본 경로와 애플리케이션이 본 경로가 달라질 수 있습니다.
%252e%252e 같은 이중 인코딩을 한 계층은 한 번, 다른 계층은 두 번 해제하면 우회가 생깁니다.
시스템 전체에서 디코딩 횟수와 정규화 책임을 한 번으로 정하고 이미 디코딩된 값에 디코더를 다시 적용하지 않습니다.
| 처리 대상 | 구조 분리 시점 | 디코딩 정책 | 상한 |
|---|---|---|---|
| 요청 대상 | 시작 줄에서 추출 | 아직 원시 유지 | 전체 길이 제한 |
| 경로 | 첫 ? 앞 | 구간 규칙 | 구간 수와 길이 |
| 쿼리 쌍 | &, 첫 = 분리 뒤 | 폼 구성 요소 | 키 개수와 값 길이 |
| 헤더 | 첫 콜론 분리 뒤 | 퍼센트 해제 안 함 | 줄·전체 크기 |
| 본문 | 프레이밍 완료 뒤 | Content-Type에 따름 | 본문 바이트 |
쿼리 값을 HTML에 다시 출력할 때는 URL 디코딩만으로 안전해지지 않습니다. HTML 문맥에 맞는 이스케이프가 추가로 필요합니다. SQL, 로그, 셸처럼 소비 문맥이 바뀔 때마다 해당 경계의 인코딩 또는 매개변수 바인딩을 적용합니다. 입력 정규화와 출력 이스케이프를 하나의 “정제” 함수로 합치지 않습니다.
연습 문제
tag=java&tag=%ED%95%9C%EA%B8%80&flag&empty=를 읽어 순서를 보존하는 List<Pair>를 반환합니다.
잘못된 16진수와 UTF-8 대체 문자가 필요한 바이트는 예외로 거절합니다.
+는 공백으로 처리하고 %2B는 더하기 기호로 복원합니다.
CharsetDecoder를 사용하는 풀이
구조 분리 후 각 구성 요소를 바이트 배열로 만듭니다.
ASCII 문자는 그대로 기록하고 더하기 기호는 공백 바이트로 바꾸며 퍼센트는 두 16진수를 한 바이트로 변환합니다.
마지막에 REPORT 모드 UTF-8 디코더로 문자열을 얻습니다.
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public final class StrictQueryPairsSolution {
record Pair(String key, String value) {
}
static List<Pair> parse(String rawQuery) {
List<Pair> pairs = new ArrayList<>();
if (rawQuery.isEmpty()) {
return List.of();
}
for (String rawField : rawQuery.split("&", -1)) {
if (rawField.isEmpty()) {
throw new IllegalArgumentException("empty field");
}
String[] rawPair = rawField.split("=", 2);
String key = decode(rawPair[0]);
String value = rawPair.length == 2 ? decode(rawPair[1]) : "";
if (key.isEmpty()) {
throw new IllegalArgumentException("empty key");
}
pairs.add(new Pair(key, value));
}
return List.copyOf(pairs);
}
static String decode(String raw) {
var bytes = new ByteArrayOutputStream();
for (int index = 0; index < raw.length(); index += 1) {
char current = raw.charAt(index);
if (current == '+') {
bytes.write(' ');
} else if (current == '%') {
if (index + 2 >= raw.length()) {
throw new IllegalArgumentException("truncated escape");
}
int high = Character.digit(raw.charAt(index + 1), 16);
int low = Character.digit(raw.charAt(index + 2), 16);
if (high < 0 || low < 0) {
throw new IllegalArgumentException("invalid hex escape");
}
bytes.write((high << 4) | low);
index += 2;
} else if (current <= 0x7F) {
bytes.write(current);
} else {
throw new IllegalArgumentException("raw non-ASCII character");
}
}
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes.toByteArray()))
.toString();
} catch (java.nio.charset.CharacterCodingException error) {
throw new IllegalArgumentException("invalid UTF-8", error);
}
}
public static void main(String[] args) {
List<Pair> pairs = parse(
"tag=java&tag=%ED%95%9C%EA%B8%80&flag&empty=&symbol=%2B");
pairs.forEach(System.out::println);
System.out.println("count=" + pairs.size());
}
}쌍 목록은 중복과 입력 순서를 완전히 보존합니다.
애플리케이션이 편리한 Map 조회를 원하면 이 값을 검증한 뒤 별도 인덱스로 변환합니다.
파서 자체가 마지막 값만 남기는 정책을 강제하지 않게 하면 라우트별로 단일 값 또는 복수 값 요구를 선택할 수 있습니다.
요청 대상 검증
퍼센트로 인코딩된 한글, 공백, 더하기 기호, 빈 값, 등호 없는 플래그, 중복 키를 각각 확인합니다.
%, %ZZ, 잘못된 UTF-8, 빈 키, 연속 앰퍼샌드는 명확한 오류가 되어야 합니다.
디코딩된 &가 새 쌍을 만들지 않는지와 %2F가 경로 라우팅 정책을 우회하지 않는지도 포함합니다.
서버 로그에는 원시 대상과 디코딩된 값을 무제한으로 함께 남기지 않습니다.
길이와 개인정보를 제한하고, 제어 문자를 이스케이프해 로그 주입을 막습니다.
다음 문서에서는 대상뿐 아니라 시작 줄, 헤더, Content-Length 및 chunked 본문을 하나의 바이트 판독기로 구조화하고 응답 기록기가 프레이밍을 항상 완성하도록 만듭니다.