티스토리 뷰
HTTP 메시지는 크게 세 가지로 구성된다. 시작줄, 헤더, 본문.
만약 시작줄에서 오류가 발생한다면 서버는 어떻게 반응할까? 건너건너 듣기론 다른 팀에서 이런 경우를 테스트해야할 일이 생겼다고 해서 나도 테스트를 해보았다.
우선 시작줄에 오류를 만들어내기 위하여 아래의 글을 참고하여 Burp Suite를 사용했다. QA로 일하고 있는 지인에게 물어봤는데, 이런 프록시툴을 테스트할 때 꽤 사용한다고 한다. 주로 많이 사용되고 있는 툴은 Charles(찰스)와 Fiddler(피들러)이라고 한다. Postman으로도 간단한 프록시 기능을 할 수 있다고 한다.
그리고 내가 만들어놓았던 프로젝트를 향해 요청을 보냈다.
그랬더니 브라우저에는 아래와 같이 Bad Request로 표시가 되었다.
그리고 서버 로그에는 다음과 같이 표시되었다.
2023-09-20T20:19:25.117+09:00 INFO 35822 --- [nio-9000-exec-6] o.apache.coyote.http11.Http11Processor : Error parsing HTTP request header
Note: further occurrences of HTTP request parsing errors will be logged at DEBUG level.
java.lang.IllegalArgumentException: Invalid character found in the HTTP protocol [HTTP/1.1eeeeee0x0d0x0aHost: ]
at org.apache.coyote.http11.Http11InputBuffer.parseRequestLine(Http11InputBuffer.java:558) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:264) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:894) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1740) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) ~[tomcat-embed-core-10.1.12.jar:10.1.12]
at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]
여기서 우선 봐야할 부분이 첫째줄의 Invalid character found in the HTTP protocol [HTTP/1.1eeeeee0x0d0x0aHost: ] 이다.
내가 eeeeee로 요청했는데 뒤에 이상한 문자들이 붙은 것을 확인할 수 있다.
ChatGPT에 물어보니 뒤에 "0x0d0x0a"는 시작줄의 줄 바꿈 문자열을 나타내는데, "0x0d"는 캐리지 리턴, "0x0a"는 줄 바꿈을 나타낸다. 한마디로 "0x0d0x0a"는 시작줄을 끝내고 헤더를 작성하기 위해 줄을 바꾸기 위한 "\r\n"이다.
이제 저 로그를 바탕으로 분석을 해보자.
org.apache.coyote.http11이 어떤 역할을 하는 패키지인지 ChatGPT에게 물어봤다.
여기에서 이제 시작줄에 문제가 있을 경우 그로 인한 오류가 어디까지 올 수 있는지 알 수 있다. Http11InputBuffer는 HTTP/1.1프로토콜을 준수하고, 클라이언트로부터의 HTTP 요청을 효과적으로 처리하기 위해 설계되었다고 한다. 일반적으로 개발자가 이 클래스와 직접 상호작용할 필요는 없고, 이 클래스는 서블릿 컨테이너인 Apache Tomcat내부에서 사용된다고 한다.) 아래는 Http11InputBuffer의 parseRequestLine의 일부이다.
if (parsingRequestLinePhase == 6) {
//
// Reading the protocol
// Protocol is always "HTTP/" DIGIT "." DIGIT
//
while (!parsingRequestLineEol) {
// Read new bytes if needed
if (byteBuffer.position() >= byteBuffer.limit()) {
if (!fill(false)) {
return false;
}
}
int pos = byteBuffer.position();
prevChr = chr;
chr = byteBuffer.get();
if (chr == Constants.CR) {
// Possible end of request line. Need LF next else invalid.
} else if (prevChr == Constants.CR && chr == Constants.LF) {
// CRLF is the standard line terminator
end = pos - 1;
parsingRequestLineEol = true;
} else if (chr == Constants.LF) {
// LF is an optional line terminator
end = pos;
parsingRequestLineEol = true;
} else if (prevChr == Constants.CR || !HttpParser.isHttpProtocol(chr)) {
String invalidProtocol = parseInvalid(parsingRequestLineStart, byteBuffer);
throw new IllegalArgumentException(sm.getString("iib.invalidHttpProtocol", invalidProtocol));
}
}
거의 마지막 줄쯤에서 invalidProtocol인지 확인하는 부분이 있는데, 이 부분에서 protocol이 invalid하다고 판명되었고 그래서 throw new IllegalArgumentException이 발생한다. 자, 그러면 여기에서 던져진 예외는 어디로 갈까?
아래는 Http11Processor 클래스의 service메소드 중 일부이다. 첫번째 if문에서 inputBuffer.parseRequestLine(keptAlive, protocol.getConnectionTimeout(), protocol.getKeepAliveTimeout()) 을 수행한다. 위에서 봤듯이 IllegalArgumentException가 던져질 것이고, 그러면 catch(Throwable t)에 걸리게 된다. 그리고 거의 마지막쯤에 보면 response.setStatus(400)을 한다는 걸 알 수 있다. 그래서 브라우저에서 조회했을 때 400 Bad Request가 표시된 것이 아닐까 추측해볼 수 있다.(추측인 이유는 이 케이스를 다시 재현해서 재차 확인해볼 기회가 없었기 때문.) 그리고 솔직히 이 이후로는 내가 코드 쫓아가기도 좀 힘들었다.
// Parsing the request header
try {
if (!inputBuffer.parseRequestLine(keptAlive, protocol.getConnectionTimeout(),
protocol.getKeepAliveTimeout())) {
if (inputBuffer.getParsingRequestLinePhase() == -1) {
return SocketState.UPGRADING;
} else if (handleIncompleteRequestLineRead()) {
break;
}
}
// Process the Protocol component of the request line
// Need to know if this is an HTTP 0.9 request before trying to
// parse headers.
prepareRequestProtocol();
if (protocol.isPaused()) {
// 503 - Service unavailable
response.setStatus(503);
setErrorState(ErrorState.CLOSE_CLEAN, null);
} else {
keptAlive = true;
// Set this every time in case limit has been changed via JMX
request.getMimeHeaders().setLimit(protocol.getMaxHeaderCount());
// Don't parse headers for HTTP/0.9
if (!http09 && !inputBuffer.parseHeaders()) {
// We've read part of the request, don't recycle it
// instead associate it with the socket
openSocket = true;
readComplete = false;
break;
}
if (!protocol.getDisableUploadTimeout()) {
socketWrapper.setReadTimeout(protocol.getConnectionUploadTimeout());
}
}
} catch (IOException e) {
if (log.isDebugEnabled()) {
log.debug(sm.getString("http11processor.header.parse"), e);
}
setErrorState(ErrorState.CLOSE_CONNECTION_NOW, e);
break;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
UserDataHelper.Mode logMode = userDataHelper.getNextMode();
if (logMode != null) {
String message = sm.getString("http11processor.header.parse");
switch (logMode) {
case INFO_THEN_DEBUG:
message += sm.getString("http11processor.fallToDebug");
//$FALL-THROUGH$
case INFO:
log.info(message, t);
break;
case DEBUG:
log.debug(message, t);
}
}
// 400 - Bad Request
response.setStatus(400);
setErrorState(ErrorState.CLOSE_CLEAN, t);
}
이후로는 SocketState를 계속 넘겨서 sockerState가 CLOSED이면 socket을 닫는다. 아래 코드는 NioEndPoint 클래스인데 이 클래스는 Apache Tomcat 웹 서버의 Coyote 커넥터 구현 중 하나인 NIO(New I/O) 기반의 엔드포인트 클래스라고 한다. 이 클래스는 Tomcat내에서 네트워크 통신을 관리하고 웹 클라이언트와의 연결을 처리한다고 한다. 아래 코드의 마지막 부분에 소켓을 닫는 부분이 표시되어 있다.
@Override
protected void doRun() {
/*
* Do not cache and re-use the value of socketWrapper.getSocket() in
* this method. If the socket closes the value will be updated to
* CLOSED_NIO_CHANNEL and the previous value potentially re-used for
* a new connection. That can result in a stale cached value which
* in turn can result in unintentionally closing currently active
* connections.
*/
Poller poller = NioEndpoint.this.poller;
if (poller == null) {
socketWrapper.close();
return;
}
try {
int handshake = -1;
try {
if (socketWrapper.getSocket().isHandshakeComplete()) {
// No TLS handshaking required. Let the handler
// process this socket / event combination.
handshake = 0;
} else if (event == SocketEvent.STOP || event == SocketEvent.DISCONNECT ||
event == SocketEvent.ERROR) {
// Unable to complete the TLS handshake. Treat it as
// if the handshake failed.
handshake = -1;
} else {
handshake = socketWrapper.getSocket().handshake(event == SocketEvent.OPEN_READ, event == SocketEvent.OPEN_WRITE);
// The handshake process reads/writes from/to the
// socket. status may therefore be OPEN_WRITE once
// the handshake completes. However, the handshake
// happens when the socket is opened so the status
// must always be OPEN_READ after it completes. It
// is OK to always set this as it is only used if
// the handshake completes.
event = SocketEvent.OPEN_READ;
}
} catch (IOException x) {
handshake = -1;
if (logHandshake.isDebugEnabled()) {
logHandshake.debug(sm.getString("endpoint.err.handshake",
socketWrapper.getRemoteAddr(), Integer.toString(socketWrapper.getRemotePort())), x);
}
} catch (CancelledKeyException ckx) {
handshake = -1;
}
if (handshake == 0) {
SocketState state = SocketState.OPEN;
// Process the request from this socket
if (event == null) {
state = getHandler().process(socketWrapper, SocketEvent.OPEN_READ);
} else {
state = getHandler().process(socketWrapper, event);
}
if (state == SocketState.CLOSED) {
socketWrapper.close();
}
...
그럼 결국 저 IllegalArgumentException를 처리하려면 어떻게 해야할까?
보통 톰캣 스프링에서 예외 처리를 할 때 Filter, Interceptor 두 가지 방법을 생각해볼 수 있다. 이 둘의 차이는 무엇일까?
Filter는 서블릿 컨테이너에서 동작하고, Interceptor는 스프링 프레임워크 내부에서 동작한다. 위와 같은 예외는 서블릿 컨테이너 내부에서 발생하는 것이기 때문에 Filter에서 처리를 해줘야 하지 않을까싶다. 실제로 내가 테스트한 프로젝트도 interceptor로 예외 처리하도록 되어 있었는데 interceptor에 걸리지 않았다.
Filter를 적용해서 해결 가능한지 테스트까지 한 결과를 넣어야 정확하겠지만, 이상하게도 처음에 예외가 발생한 후에 다시 똑같은 예외가 발생하지 않았다. 이후에 다시 테스트해보고 발생하면 수정해놔야겠다.
Filter로 테스트를 해보았지만 예외가 처리되지 않았다. 아래는 나와 같은 고민을 한 어떤 분이 스택오버플로우에 올리신 글인데 역시나 이 분도 어떻게 해도 해결이 불가능하다고 하셨다.
'업무 경험 및 성과' 카테고리의 다른 글
컬럼끼리 비교할 땐 서로 다른 Collation은 불가! (0) | 2023.10.13 |
---|---|
커밋 안 한 작업 날렸을 때(혹은 삭제한 파일 다시 보고 싶을 때) 마지막 희망, 로컬 기록 (0) | 2023.10.13 |
간단하게 디스크 용량이 부족할 경우의 테스트 환경 만들기 (0) | 2023.09.04 |
[회고] 시스템 개발 프로젝트 (0) | 2023.07.21 |
JSESSIONID에서 세션 클러스터링까지 (0) | 2023.07.03 |