thenullpage.com
서버가 기억을 떠넘기는 방식
HTTP는 요청 하나를 처리하고 나면 그 일을 잊는다. 방금 로그인한 사람이 곧바로 글 목록을 요청해도 서버가 받은 것은 아무 사연 없는 새 요청 하나다. 그래서 서버는 기억을 자기가 붙들고 있는 대신 짧은 값 하나를 브라우저에 맡겨두고 요청마다 도로 받아 본다. 그 값을 실어 나르는 통로가 쿠키다.
응답에 Set-Cookie를 담으면 브라우저가 값을 저장하고, 이후 조건이 맞는 요청마다 Cookie 헤더를 알아서 붙여 보낸다.
HTTP/1.1 200 OK
Set-Cookie: session=a1b2c3; Path=/; Max-Age=3600
GET /me HTTP/1.1
Cookie: session=a1b2c3
브라우저가 알아서 붙인다는 점이 쿠키의 성질을 거의 다 결정한다. 사용자가 주소를 직접 치고 들어오든 남의 사이트에 걸린 링크나 이미지 태그를 타고 요청이 나가든, 조건만 맞으면 값은 따라간다. 편한 만큼 위험한 지점도 전부 여기서 시작된다.
값 뒤에 붙는 보관 규칙
Set-Cookie는 값 뒤에 세미콜론으로 속성을 이어 붙인다. 속성은 값의 일부가 아니라 브라우저에게 주는 보관 규칙이라, 다음 요청의 Cookie 헤더에는 이름과 값만 실려 돌아온다. 서버는 그 쿠키가 어떤 조건으로 저장됐는지 되돌려 받지 못한다.
쿠키가 여러 개여도 돌아오는 헤더는 한 줄이다. 브라우저가 조건에 맞는 것을 모아 세미콜론으로 이어 담기 때문이다. 그래서 이름이 같은 쿠키가 Path만 다르게 두 개 저장돼 있으면 둘 다 실려 오는데, 순서에 정해진 규칙이 없어 서버가 어느 쪽을 읽을지 장담할 수 없다.
Domain은 어느 호스트로 갈 때 붙일지, Path는 어느 경로 아래에서 붙일지를 정한다. Domain을 적지 않으면 응답을 준 그 호스트에서만 쓰이고, example.com이라고 적으면 하위 호스트까지 함께 쓴다. 넓게 잡으면 편하지만 값이 닿는 범위도 그만큼 넓어진다.
Max-Age는 초 단위 수명이고 Expires는 만료 시각을 직접 적는다. 둘 다 없으면 브라우저를 닫을 때 사라지는 쿠키가 된다. 다만 요즘 브라우저는 이전 탭을 복원하는 기능이 있어서 이 쿠키가 생각보다 오래 살아남기도 한다.
쿠키를 지우는 별도 명령은 없다. 같은 이름에 이미 지나간 시각을 적어 다시 내려보내면 브라우저가 버린다.
Set-Cookie: session=; Path=/; Max-Age=0
여기서 Domain과 Path가 처음 만들 때와 하나라도 다르면 존재하지 않는 다른 쿠키를 지우라고 시킨 셈이 되고 원본은 그대로 남는다. 로그아웃을 눌렀는데 새로고침하면 다시 로그인 상태로 보이는 증상이 대개 이것이다.
손대지 못하게 막는 속성
쿠키에 세션 식별자를 담는 이상 값을 지키는 속성이 따라와야 한다.
Set-Cookie: session=a1b2c3; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly가 붙은 쿠키는 자바스크립트에서 보이지 않는다. 페이지에 낯선 스크립트가 끼어들어 실행되더라도 값을 읽어 밖으로 빼낼 수 없다. Secure는 암호화된 연결에서만 보내라는 표시다. 이것이 없으면 평문으로 접속하는 순간 값이 중간에서 그대로 읽힌다.
SameSite는 다른 사이트에서 출발한 요청에 쿠키를 붙일지 정한다. Strict는 어떤 경우에도 붙이지 않고, Lax는 링크를 눌러 화면 자체가 넘어가는 이동에만 붙이며, None은 전부 붙이는 대신 Secure를 반드시 함께 요구한다. 값을 적지 않으면 대부분의 브라우저가 Lax로 취급한다.
이 속성 하나가 막아주는 범위가 넓다. 낚시성 페이지에 숨겨둔 폼이 우리 사이트로 몰래 제출돼도 쿠키가 따라가지 않으면 서버는 로그인하지 않은 요청으로 처리한다. 반대로 결제창처럼 다른 도메인 화면 안에 우리 페이지를 끼워 넣는 구성이라면 None을 써야 하고, 그때는 위조 요청을 걸러낼 다른 장치를 따로 마련해야 한다.
값을 누가 들고 있느냐
쿠키에 담는 값은 크게 두 갈래다. 하나는 의미 없는 무작위 문자열만 건네고 실제 정보는 서버가 보관하는 방식이다. 서버는 그 문자열로 자기 저장소를 뒤져 누구인지 찾는다. 흔히 세션이라 부르는 것이 이쪽이다. 다른 하나는 사용자 번호와 만료 시각을 값 안에 직접 적고 위조를 막는 서명을 덧붙여 통째로 넘기는 방식이다.
갈리는 지점은 취소할 수 있느냐다. 세션은 서버 저장소에서 한 줄을 지우면 그 즉시 무효가 된다. 값 안에 정보를 담는 방식은 서버가 아무것도 기억하지 않으므로 유출된 값이라도 적혀 있는 만료 시각까지는 계속 통한다. 그래서 수명을 짧게 잡고 갱신용 값을 따로 두는 구성을 쓴다.
세션 저장소를 서버 프로세스의 메모리에 두면 배포로 프로세스를 재시작하거나 서버를 두 대로 늘리는 순간 로그인이 전부 풀린다. 한 대로 운영할 때는 드러나지 않다가 규모를 키울 때 터지는 문제라, 처음부터 프로세스 밖의 저장소에 두는 편이 낫다.
수명도 두 곳에서 어긋나기 쉽다. 쿠키의 만료 시각은 브라우저가 지키고 세션 기록의 유효 기간은 서버가 지키는데, 둘을 따로 잡아두면 쿠키는 남아 있는데 서버 쪽 기록만 먼저 사라진 상태가 생긴다. 서버가 받는 것은 가리키는 데가 없는 식별자라 로그인하지 않은 요청과 똑같이 처리된다.
로그인에 성공한 순간에는 쓰던 식별자를 버리고 새 값을 발급하는 것이 정석이다. 로그인 전에 받은 식별자를 그대로 이어 쓰면 미리 심어둔 값 위에 남의 로그인 상태가 얹히는 경로가 열린다.
쿠키가 사라지는 흔한 경우
가장 잦은 것은 저장 단계에서 브라우저가 거절한 경우다. 개발용 서버를 평문으로 띄워놓고 Secure를 붙이면 브라우저는 그 쿠키를 저장하지 않는다. 응답에는 Set-Cookie가 분명히 있는데 저장소에는 아무것도 없다.
화면과 API의 주소가 다른 구성도 자주 어긋난다. 브라우저는 다른 출처로 나가는 요청에 쿠키를 기본으로 싣지 않으므로 요청하는 쪽에서 자격 증명을 포함하라고 지정해야 하고, 받는 서버도 그것을 허용한다고 응답해야 한다. 둘 중 하나만 빠져도 서버 로그에는 로그인하지 않은 요청으로 남는다.
크기도 한계가 있다. 쿠키 하나는 4킬로바이트 남짓, 도메인당 개수는 50개 안팎이 상한이고 넘으면 브라우저가 오래된 것부터 버린다. 게다가 조건이 맞는 모든 요청에 실리므로 이미지 한 장을 받을 때도 같이 나간다. 사용자 설정값을 쿠키에 쌓아두면 트래픽이 조용히 불어난다.
실제로 확인하는 방법
개발자 도구의 Application 탭에 Cookies 항목이 있다. 이름과 값뿐 아니라 Domain, Path, 만료, HttpOnly, Secure, SameSite가 열로 나오므로 의도한 대로 저장됐는지 여기서 확인한다. 저장 자체가 거부됐다면 Network 탭에서 해당 응답을 열어 Set-Cookie 옆의 경고 표시를 누르면 거절한 이유가 적혀 있다.
콘솔에서 document.cookie를 찍어 보는 방법은 절반만 맞다. HttpOnly가 걸린 쿠키는 여기 나오지 않으니 결과가 비어 있다고 쿠키가 없는 것은 아니다.
명령줄에서는 쿠키를 파일에 모아두고 다음 요청에 다시 실어 보낼 수 있다.
curl -c jar.txt -d "id=test&pw=1234" https://example.com/login
curl -b jar.txt https://example.com/me
-c는 받은 쿠키를 파일에 적고 -b는 그 파일을 읽어 보낸다. jar.txt를 열어 보면 도메인과 경로와 만료 시각이 줄마다 적혀 있어서, 서버가 무엇을 어떤 조건으로 심었는지 한눈에 대조할 수 있다.