thenullpage.com

이름과 값이 한 줄씩

헤더는 요청이나 응답의 시작줄 바로 아래에 놓이는 부가 정보다. 한 줄에 하나씩, 이름과 콜론과 값 순서로 적는다. 헤더가 다 끝나면 아무것도 없는 빈 줄이 하나 오고 그 뒤부터가 본문이다. 이 빈 줄이 유일한 경계라서, 헤더 값에 줄바꿈 문자가 섞여 들어가면 서버는 그 지점부터를 본문으로 읽어버린다.


GET /articles/42 HTTP/1.1
Host: example.com
Accept: application/json
Accept-Language: ko,en;q=0.8


이름은 대소문자를 구분하지 않는다. Content-Type과 content-type은 같은 헤더이고, HTTP/2부터는 아예 소문자로만 전송한다. 값 쪽은 반대라서 적은 그대로 구분된다. 헤더끼리의 순서에는 의미가 없고, 같은 이름을 여러 줄에 나눠 써도 쉼표로 이어붙인 하나의 값과 같게 취급된다. Set-Cookie는 그 규칙에서 빠지는 예외라 쿠키 하나마다 줄을 따로 잡아야 한다.


본문의 정체를 알리는 값

본문은 그냥 바이트 덩어리라서 그것이 무엇인지 알려주는 헤더가 없으면 받는 쪽이 해석할 방법이 없다. 그 역할이 Content-Type이다. JSON 문자열을 보내면서 이 헤더를 빠뜨리면 서버 프레임워크는 본문을 해석하지 못하고 빈 값으로 넘긴다. 요청은 200으로 끝났는데 저장된 데이터만 비어 있는 상황이 여기서 나온다.


뒤에 붙는 charset도 같이 봐야 한다. 한글이 물음표나 깨진 기호로 저장되는 사고는 대부분 이 값이 없거나 utf-8이 아닐 때 생긴다. 파일 업로드는 형식이 조금 다르다. 여러 조각을 한 본문에 담아야 해서 조각을 나누는 구분 문자열을 boundary로 정해두고, 조각마다 다시 자기 헤더를 붙인다.


POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----X39d

------X39d
Content-Disposition: form-data; name="file"; filename="photo.png"
Content-Type: image/png


Content-Length는 본문이 몇 바이트인지 알린다. 값이 실제 길이보다 작으면 나머지가 잘리고, 크면 받는 쪽이 오지 않는 바이트를 기다린다. 길이를 미리 알 수 없는 응답은 이 헤더 대신 Transfer-Encoding에 chunked를 적어 조각 단위로 흘려보낸다.


요청이 자신을 밝히는 헤더

Host는 HTTP/1.1에서 반드시 있어야 하는 헤더다. 서버 한 대의 IP 하나에 여러 도메인이 얹혀 있는 구성이 흔한데, 요청이 도착한 시점에 서버가 아는 것은 포트 번호뿐이라 어느 사이트를 달라는 것인지 판단할 근거가 Host밖에 없다. IP 주소로 직접 접속했을 때 엉뚱한 사이트가 뜨거나 400이 돌아오는 이유가 이것이다.


User-Agent는 브라우저와 운영체제 정보를 담는다. 값이 길고 지저분한 것은 옛 서버들이 특정 문자열만 보고 화면을 갈라주던 시절의 흔적이 쌓인 탓이다. 이 값으로 기기를 판별하면 새 브라우저가 나올 때마다 어긋나므로 화면 분기는 화면 너비로 하는 편이 안전하다.


Referer는 이 요청이 어느 페이지에서 출발했는지 알린다. 표준을 만들 때 Referrer의 철자를 틀렸고 그대로 굳었다. 여기에는 이전 페이지의 전체 주소가 담기므로, 주소에 초대 토큰 같은 값이 들어 있으면 링크를 타고 넘어간 외부 사이트의 로그에 그 값이 남는다.


중간 서버가 덧붙이는 것들

요청이 CDN이나 리버스 프록시를 거쳐 들어오면 애플리케이션이 보는 접속 IP는 사용자가 아니라 앞단 서버의 것이다. 그래서 프록시가 원래 IP를 적어 넘기는데, 그 자리가 X-Forwarded-For다. 단계를 여러 번 거치면 값이 쉼표로 이어 붙는다.


X-Forwarded-For: 203.0.113.9, 70.41.3.18
X-Forwarded-Proto: https
X-Forwarded-Host: example.com


여기서 흔히 어긋난다. 이 헤더는 사용자가 직접 넣어 보낼 수도 있어서, 맨 앞 값을 믿고 차단이나 요청 횟수 제한을 걸면 값을 지어내는 것만으로 우회된다. 신뢰하는 프록시가 붙인 값을 기준으로 삼아야 한다.


X-Forwarded-Proto를 무시해서 생기는 사고도 잦다. 사용자는 HTTPS로 접속했는데 프록시가 뒷단에는 평문 HTTP로 넘기는 구성에서, 애플리케이션이 자기 연결만 보고 HTTPS로 가라는 리다이렉트를 돌려주면 같은 왕복이 끝없이 반복된다.


브라우저를 단속하는 응답 헤더

응답 헤더 중에는 데이터를 설명하는 대신 브라우저의 행동을 제한하는 것들이 있다. 브라우저 안에서 벌어지는 일은 서버가 직접 막을 수 없으니 지시를 헤더에 실어 보내는 방식이다.


Strict-Transport-Security: max-age=63072000; includeSubDomains
Content-Security-Policy: default-src 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin


첫 줄은 이 도메인에는 앞으로 평문 HTTP로 접속을 시도하지도 말라는 지시다. 브라우저가 지정된 기간 동안 이 사실을 기억해서, 사용자가 주소를 http로 쳐도 요청이 나가기 전에 https로 바뀐다. 기간을 길게 걸면 되돌리는 데도 그만큼 걸리므로 짧게 시작하는 편이 낫다.


둘째 줄은 이 문서가 어디서 온 자원을 실행해도 되는지 정한다. 자기 출처만 허용해두면 댓글 같은 입력창으로 외부 스크립트 태그가 삽입되더라도 브라우저가 실행을 거부한다. nosniff는 본문 내용을 보고 형식을 추측하지 말고 Content-Type에 적힌 대로만 다루라는 뜻이라, 사용자가 올린 파일을 브라우저가 HTML로 오인해 실행하는 경로를 막는다.


직접 만든 헤더와 그 비용

표준에 없는 값을 주고받으려고 이름을 새로 만드는 일은 흔하다. 오래된 관행은 X-로 시작하는 이름이었는데, 나중에 표준이 되면 접두사를 떼기가 애매해진다는 이유로 지금은 권장되지 않는다.


커스텀 헤더에는 대가가 따른다. 다른 출처로 보내는 요청에 표준이 아닌 헤더를 얹으면 브라우저가 본 요청 전에 허가를 묻는 요청을 한 번 더 보낸다. 응답에 담은 커스텀 헤더도 서버가 노출을 따로 허용하지 않으면 자바스크립트에서 읽히지 않는다.


크기 제한도 있다. 서버 대부분이 헤더 전체를 8킬로바이트 안팎으로 자르고, 넘으면 431을 돌려주거나 연결을 끊는다. 이 한도를 넘기는 주범은 대개 커스텀 헤더가 아니라 도메인에 쌓인 쿠키라, 특정 사용자만 요청이 실패한다면 그 브라우저의 쿠키 총량을 확인해 볼 만하다.


실제로 오간 헤더를 보는 법

코드에 적어둔 헤더와 실제로 나간 헤더는 같지 않다. Host나 Content-Length는 브라우저가 알아서 채우고 fetch로 지정해도 무시하므로, 확인은 전송된 내용으로 해야 한다.


curl -v https://example.com/api/posts
> GET /api/posts HTTP/1.1 보낸 헤더
> Host: example.com
< HTTP/1.1 200 OK 받은 헤더
< Content-Type: application/json


부등호 방향이 보낸 것과 받은 것을 가른다. 응답 헤더만 필요하면 -I를 붙여 본문 없이 받는다. 브라우저에서는 개발자 도구 Network 탭에서 요청을 클릭해 Headers 항목을 보면 되는데, Request Headers 자리에 임시 헤더라는 경고가 뜨면 전송 전 값을 보여주는 중이라 새로고침 후 다시 봐야 한다.