루비를 이용해 웹 서버를 직접 구축해 본 경험이 있으신가요?
사실 우리에게는 이미 훌륭한 웹 서버들이 많이 있습니다:
- Puma
- Thin
- Unicorn
하지만 간단한 웹 서버가 어떻게 동작하는지 그 원리를 제대로 이해하고 싶다면, 직접 한번 만들어 보는 것만큼 좋은 학습 방법은 없습니다.
이 글에서는 그 과정을 단계별로 차근차근 살펴보겠습니다.
1단계: 새로운 연결 대기(Listening)
그럼 어디서부터 시작해야 할까요?
가장 먼저 해야 할 일은 TCP 80번 포트에서 새로운 연결을 수신(listen)하는 것입니다.
네트워크 프로그래밍에 대한 내용은 이미 별도의 글로 다룬 적이 있으므로, 여기서는 자세한 설명 대신 코드만 바로 보여 드리겠습니다:
require 'socket'
server = TCPServer.new('localhost', 80)
loop {
client = server.accept
request = client.readpartial(2048)
puts request
}
이 코드를 실행하면 80번 포트에서 연결을 받아들이는 서버가 시작됩니다. 아직 하는 일은 많지 않지만, 들어오는 요청이 실제로 어떤 모습인지 눈으로 확인할 수 있습니다.
참고: 리눅스나 macOS 환경에서 80번 포트를 사용하려면 루트(root) 권한이 필요합니다. 대신 1024보다 큰 포트 번호를 사용할 수도 있는데, 저는 개인적으로 8080을 즐겨 사용합니다 🙂
요청을 간편하게 생성하는 방법은 브라우저를 사용하거나 curl 같은 도구를 이용하는 것입니다.
요청을 보내면 서버 쪽에 다음과 같은 내용이 출력됩니다:
GET / HTTP/1.1
Host: localhost
User-Agent: curl/7.49.1
Accept: */*
이것이 바로 HTTP 요청입니다. HTTP는 웹 브라우저와 웹 서버 사이의 통신에 사용되는 일반 텍스트(plain-text) 프로토콜입니다.
공식 프로토콜 명세는 https://tools.ietf.org/html/rfc7230 에서 확인할 수 있습니다.
2단계: 요청 파싱(Parsing)
이제 서버가 이해할 수 있도록 요청을 더 작은 구성 요소로 분해해야 합니다.
이미 존재하는 파서를 사용할 수도 있지만, 여기서는 직접 만들어 보겠습니다. 그러려면 요청의 각 부분이 어떤 의미를 가지는지 먼저 알아야 합니다.
아래 이미지를 참고하세요:
GET 요청의 구조
헤더(header)는 브라우저 캐싱, 가상 호스팅, 데이터 압축 등에 사용되지만, 기본적인 구현에서는 무시해도 충분히 동작하는 서버를 만들 수 있습니다.
간단한 HTTP 파서를 만들려면 요청 데이터가 줄바꿈(\r\n)으로 구분된다는 점을 활용하면 됩니다. 설명을 단순하게 유지하기 위해 오류 검증이나 유효성 검사는 생략합니다.
제가 작성한 코드는 다음과 같습니다:
def parse(request)
method, path, version = request.lines[0].split
{
path: path,
method: method,
headers: parse_headers(request)
}
end
def parse_headers(request)
headers = {}
request.lines[1..-1].each do |line|
return headers if line == "\r\n"
header, value = line.split
header = normalize(header)
headers[header] = value
end
def normalize(header)
header.gsub(":", "").downcase.to_sym
end
end
이 코드는 파싱된 요청 데이터를 해시(hash) 형태로 반환합니다. 이제 요청을 사용 가능한 형태로 확보했으니, 클라이언트에게 보낼 응답(response)을 만들어 보겠습니다.
3단계: 응답 준비 및 전송
응답을 만들려면 요청된 리소스가 존재하는지, 즉 파일이 실제로 있는지 확인해야 합니다.
이를 위해 작성한 코드입니다:
SERVER_ROOT = "/tmp/web-server/"
def prepare_response(request)
if request.fetch(:path) == "/"
respond_with(SERVER_ROOT + "index.html")
else
respond_with(SERVER_ROOT + request.fetch(:path))
end
end
def respond_with(path)
if File.exists?(path)
send_ok_response(File.binread(path))
else
send_file_not_found
end
end
여기서 두 가지 일이 일어납니다:
- 첫째, 경로가
/로 지정되면 원하는 파일이index.html이라고 가정합니다. - 둘째, 요청한 파일을 찾으면 OK(200) 응답과 함께 파일 내용을 전송합니다.
파일을 찾지 못했다면 익숙한 404 Not Found 응답을 보내게 됩니다.
자주 사용되는 HTTP 응답 코드 표
참고용으로 정리했습니다.
| 코드 | 설명 |
|---|---|
| 200 | OK (성공) |
| 301 | 영구적으로 이동됨 (Moved Permanently) |
| 302 | 임시 이동 (Found) |
| 304 | 수정되지 않음 (Not Modified) |
| 400 | 잘못된 요청 (Bad Request) |
| 401 | 권한 없음 (Unauthorized) |
| 403 | 접근 금지 (Forbidden) |
| 404 | 찾을 수 없음 (Not Found) |
| 500 | 내부 서버 오류 (Internal Server Error) |
| 502 | 불량 게이트웨이 (Bad Gateway) |
Response 클래스와 메서드
앞선 예제에서 사용된 "send" 메서드들입니다:
def send_ok_response(data)
Response.new(code: 200, data: data)
end
def send_file_not_found
Response.new(code: 404)
end
그리고 Response 클래스입니다:
class Response
attr_reader :code
def initialize(code:, data: "")
@response =
"HTTP/1.1 #{code}\r\n" +
"Content-Length: #{data.size}\r\n" +
"\r\n" +
"#{data}\r\n"
@code = code
end
def send(client)
client.write(@response)
end
end
응답은 템플릿과 문자열 보간(string interpolation)을 조합해 구성됩니다.
이제 남은 일은 모든 것을 연결을 수락하는 loop 안에 엮어 넣는 것뿐입니다. 그러면 동작하는 서버가 완성됩니다.
loop {
client = server.accept
request = client.readpartial(2048)
request = RequestParser.new.parse(request)
response = ResponsePreparer.new.prepare(request)
puts "#{client.peeraddr[3]} #{request.fetch(:path)} - #{response.code}"
response.send(client)
client.close
}
SERVER_ROOT 디렉터리 아래에 HTML 파일 몇 개를 추가한 뒤 브라우저에서 접속해 보세요. 정상적으로 로드되는 것을 확인할 수 있으며, 이미지를 포함한 다른 정적 에셋(static assets)도 함께 제공할 수 있습니다.
물론 실제 상용 웹 서버에는 이 글에서 다루지 않은 훨씬 많은 기능이 있습니다.
다음은 그중 일부 누락된 기능 목록입니다. 스스로 구현해 보면서 실력을 쌓아 보세요 (연습이야말로 숙달의 어머니입니다!):
- 가상 호스팅(Virtual hosting)
- MIME 타입 처리
- 데이터 압축
- 접근 제어(Access control)
- 멀티스레딩(Multi-threading)
- 요청 유효성 검사
- 쿼리 문자열(Query string) 파싱
- POST 본문(Body) 파싱
- 브라우저 캐싱 (304 응답 코드)
- 리디렉션(Redirects)
보안에서 배우는 교훈
사용자 입력을 받아 처리하는 것은 언제나 위험합니다. 우리가 만든 작은 웹 서버에서 사용자 입력은 바로 HTTP 요청입니다.
여기서 우리는 "패스 트래버설(path traversal, 경로 탐색)"이라는 취약점을 만들어 버렸습니다. 공격자는 웹 서버 실행 계정이 접근 권한을 가진 모든 파일을, SERVER_ROOT 디렉터리 밖에 있는 파일까지도 읽을 수 있게 됩니다.
문제의 원인이 되는 코드는 바로 이 줄입니다:
File.binread(path)
직접 이 취약점을 시험해 보며 동작을 확인해 볼 수도 있습니다. 다만 대부분의 HTTP 클라이언트(curl 포함)는 URL을 사전에 처리하여 취약점을 유발하는 부분을 제거하기 때문에, "수동으로" HTTP 요청을 만들어 보내야 합니다.
이럴 때 유용한 도구 중 하나가 netcat입니다.
가능한 공격 예시는 다음과 같습니다:
$ nc localhost 8080
GET ../../etc/passwd HTTP/1.1
유닉스 계열 시스템이라면 이 요청이 /etc/passwd 파일의 내용을 반환합니다. 이렇게 되는 이유는 더블 닷(double dot, ..)이 한 단계 위 디렉터리로 이동하게 해 주므로, SERVER_ROOT 디렉터리를 "탈출"할 수 있기 때문입니다.
가능한 해결책 중 하나는 여러 개의 점을 하나로 "압축"하는 것입니다:
path.gsub!(/\.+/, ".")
보안을 고민할 때는 항상 "해커의 관점"에서 내 솔루션을 깨뜨릴 방법을 찾아야 합니다. 예를 들어 단순히 path.gsub!("..", ".")만 구현했다면, 점 세 개(...)를 사용해 쉽게 우회할 수 있습니다.
완성된 동작 코드
이 글에서 코드가 여러 곳에 흩어져 있다는 것을 압니다. 완성된 동작 코드가 필요하시다면...
링크는 다음과 같습니다:
https://gist.github.com/matugm/efe0a1c4fc53310f7ac93dcd1f041f6c#file-web-server-rb
즐거운 코딩 되세요!
마무리
이 글에서 우리는 새로운 연결을 수신하는 방법, HTTP 요청의 실제 모습, 그리고 요청을 파싱하는 방법을 배웠습니다. 또한 응답 코드와 요청된 파일의 내용(존재하는 경우)을 활용해 응답을 만드는 방법도 알아보았습니다.
마지막으로 "패스 트래버설(path traversal)" 취약점이 발생하는 원리와 그를 예방하는 방법까지 함께 살펴보았습니다.
이 글이 즐겁고 유익한 학습 경험이 되었기를 바랍니다. 새로운 글을 놓치지 않으려면 아래 폼을 통해 뉴스레터를 구독해 주세요 🙂