3P by GN⁺ | ★ favorite | 댓글 1개
  • HTTP 쿠키는 웹의 상태를 유지하는 기본 장치지만, 브라우저·서버·표준 라이브러리가 허용 문자와 오류 처리에서 엇갈려 실제 장애로 이어질 수 있음
  • RFC 6265 계열은 서버가 보내는 Set-Cookie 값과 브라우저가 받아들이는 값의 조건이 다르며, document.cookie로 만든 값이 서버 측 파서의 가정과 충돌함
  • Firefox, Chromium, Safari는 공백·따옴표·쉼표·백슬래시·Unicode 처리에서 서로 다르고, Safari는 금지 문자를 만나도 쿠키 전체가 아니라 앞부분만 저장하는 동작을 보임
  • Go는 브라우저가 허용한 JSON 쿠키를 조용히 누락할 수 있고, Python SimpleCookie는 이해하지 못하는 쿠키 이후의 로딩을 중단할 수 있으며, PHP·Ruby·Rust도 허용 범위가 제각각임
  • Unicode 쿠키 하나가 Facebook, Netflix, Okta, WhatsApp, AWS, Apple Support 등 주요 사이트의 400/500 오류나 부분 장애를 유발할 수 있어, 쿠키 규격과 라이브러리 동작을 더 명확히 맞춰야 함

브라우저는 받지만 Go는 못 읽는 쿠키

  • 쿠키는 JavaScript의 document.cookie나 HTTP 서버가 설정하는 데이터이며, 만료 전까지 범위가 맞는 HTTP 요청에 계속 포함됨
  • 예시 JavaScript는 JSON 문자열을 그대로 세션 쿠키 값으로 저장함
    • 값은 {"ginger":"snap","peanutButter":"chocolate chip","snicker":"doodle"} 형태임
    • JSON을 쿠키에 넣을 때 base64 직렬화를 하는 경우가 많지만, 브라우저는 이 값을 문제없이 설정하고 Cookie 헤더로 보냄
  • 이 쿠키가 Go 표준 라이브러리를 쓰는 코드로 전달되면서 문제가 발생함
    • Go 파서는 해당 쿠키를 해석하지 못함
    • 실패가 스택 상위로 연쇄 전파됨

RFC 안의 두 기준이 어긋남

  • 쿠키는 RFC 2109, RFC 2965, RFC 6265를 거쳐 정의됐고, 현재 갱신 중인 draft version이 있음
  • RFC는 쿠키 값을 두 영역에서 다르게 다룸
    • Section 4.1.1은 서버가 Set-Cookie로 보내는 값에서 제어 문자, 공백, 큰따옴표, 쉼표, 세미콜론, 백슬래시 등을 제외함
    • Section 5.6은 브라우저가 Set-Cookie 문자열을 파싱할 때 제어 문자를 제외하면 훨씬 더 넓게 받아들이도록 함
  • 핵심 충돌은 서버가 보내야 하는 값브라우저가 받아야 하는 값이 정렬돼 있지 않다는 점임
    • 브라우저가 서버 자신이 설정한 쿠키만 받는다면 영향이 작지만, document.cookie도 쿠키를 만들 수 있음
    • 표준은 Cookie 헤더를 처리하는 표준 라이브러리가 사용자 에이전트처럼 관대해야 하는지, 서버처럼 엄격해야 하는지 명확히 정하지 않음

브라우저별 쿠키 값 허용 차이

  • Firefox

    • Firefox의 쿠키 값 검사는 RFC 6265에서 금지한 문자 일부를 허용함
    • 허용되는 RFC 권장 제외 문자는 다음과 같음
      • 0x09 horizontal tab
      • 0x20 공백
      • 0x22 큰따옴표
      • 0x2C 쉼표
      • 0x5C 백슬래시
    • 이 동작은 과거 Chrome과의 호환성을 맞추기 위해 들어갔고 두 코드베이스에 남아 있음
    • network.cookie.blockUnicode 설정은 0x80 이상 값을 거부할 수 있으며, 관련 작업은 bug 1797231에서 추적됨
    • 0x7F 허용 문제는 bug 1797235에서 Firefox 108에 수정됨
  • Chromium

    • Chromium은 쿠키 값에서 제어 문자와 세미콜론만 거부함
    • Firefox보다 약간 더 엄격해 0x09 horizontal tab은 받지 않음
    • RFC와 달리 공백, 큰따옴표, 쉼표, 백슬래시, Unicode 문자는 받고 다시 보낼 수 있음
  • Safari / WebKit

    • Safari의 쿠키 저장 코드는 닫힌 소스인 CFNetwork 내부에 있어 직접 확인하기 어려움
    • JavaScript로 0x00부터 0xFF까지 쿠키 값을 설정해 확인한 결과, Safari는 다음 값을 허용함
      • 0x09 horizontal tab
      • 0x20 공백
      • 0x22 큰따옴표
      • 0x5C 백슬래시
    • Safari는 0x7F delete와 0x80-FF high ASCII / Unicode 문자를 허용하지 않음
    • RFC는 제어 문자를 만나면 쿠키 전체를 무시하라고 하지만, Safari는 금지 문자를 만난 지점 앞까지의 값을 받아들임
    • -- , -- 값을 설정하면 쉼표 주변 공백을 제거하는 Safari 버그도 관찰됨

언어와 표준 라이브러리의 파싱 차이

  • Go

    • Go의 쿠키 코드는 서버가 Set-Cookie로 보내는 값에 대한 RFC 문구에 비교적 가깝게 동작함
    • 실제 사용에서 흔한 공백과 쉼표는 허용하지만, 큰따옴표·세미콜론·백슬래시는 허용하지 않음
    • 예시 Cookie 헤더에 JSON 쿠키가 포함되면 Go의 request.Cookies() 결과에는 cookie1=foocookie3=bar만 남음
    • 브라우저가 받아들이는 cookie2는 예외나 명시적 오류 없이 조용히 빠짐
  • PHP

    • PHP는 네이티브 쿠키 파싱 함수가 없어 정확한 허용 범위를 단정하기 어렵지만, 테스트 결과 제어 문자 처리 동작이 일관적이지 않음
    • 0x00-0x090x0D carriage return 같은 값은 동작함
    • 0x10 data link escape나 0x7F delete를 쓰면 PHP는 400 Bad Request 오류를 냄
    • Unicode 쿠키도 테스트 출력에 나타남
  • Python

    • Python의 http.cookies.SimpleCookie는 JSON 쿠키를 만나면 이후 쿠키 로딩을 조용히 중단함
    • 예시 입력에서 출력은 cookie1=foo만 남음
    • 하위 도메인이 기본 도메인에 문제 쿠키를 설정할 수 있다면, 해당 쿠키 하나가 사이트 전체의 쿠키 처리를 깨뜨릴 수 있음
    • 제어 문자 처리도 불규칙함
      • 일부 제어 문자는 빈 값으로 로드됨
      • 값 앞뒤에 aa를 붙이면 제어 문자 쿠키가 로드되지 않음
  • Ruby

    • Ruby의 CGI::Cookie.parse는 파싱 시 매우 관대하게 동작하는 것으로 보임
    • 제어 문자, 탭, 큰따옴표, 쉼표, 백슬래시, 0x7F, Unicode 문자를 받아들이고, 쿠키 jar에서 꺼낼 때 percent-encoding을 적용함
    • 이 방식은 쿠키 세계에서 최적에 가까울 수 있지만, document.cookie로 설정한 코드가 percent-encoding된 반사 값을 기대하지 않을 수도 있음
  • Rust

    • Rust는 기본 쿠키 처리 기능을 제공하지 않아 인기 있는 cookie crate를 기준으로 확인함
    • 기본 설정의 cookie crate는 가장 관대한 쪽에 가까우며, 전달된 UTF-8 문자열을 받아들이는 것으로 보임

실제 웹사이트에서 드러난 영향

  • 테스트 사이트에서 서드파티 라이브러리 업데이트를 수동 검증하던 중 이 문제가 발견됨
    • 자동 테스트에 잡히기 어려운 변경이었음
    • 그대로 배포됐다면 이후 방문자가 깨진 쿠키를 받고, 업데이트 롤백과 쿠키 삭제 전까지 알 수 없는 오류로 잠길 수 있었음
  • 이 문제는 작은 사이트나 특정 프레임워크에만 한정되지 않음
  • 브라우저 콘솔에서 다음처럼 Unicode 쿠키를 도메인에 설정하면 여러 주요 사이트가 깨질 수 있음
    • document.cookie="unicodeCookie=🍪; domain=.grayduck.mn; Path=/; SameSite=Lax"
  • 관찰된 사례는 다음과 같음
    • Facebook: 오류 페이지가 표시되고 이미지도 깨짐
    • Instagram 및 Threads: 단순한 500 오류가 발생함
    • Netflix: NSES-500 오류를 반환하고 도움말 페이지도 깨짐
    • Okta: 모든 로그인 페이지가 400 오류를 반환함
    • WhatsApp: “whatsapp error”가 표시됨
    • Amazon: 대부분은 동작하지만 일부 기능이 무작위로 깨짐
    • AWS: 로그인 콘솔이 400 오류를 반환하고 중단됨
    • Apple Support: 기기 목록을 불러오지 못함
    • Best Buy: 내비게이션은 동작하지만 검색 기능이 동작하지 않음
    • eBay: 대부분 수정됐지만 일부가 여전히 400 오류를 냄
    • Home Depot: 수정 예정
    • Intuit: 오류 원인을 식별한 유일한 사이트
    • Outlook: 또 다른 400 오류 사례가 나타남

표준과 호환성 사이의 수정 난점

  • 30년 된 기반 명세의 문제를 고치는 일은 매우 어렵고, 이 문제에는 좋은 해결책이 없을 가능성이 큼
  • 브라우저 쪽에서 이런 쿠키를 차단하는 방안은 Mozilla와 Google 모두 검토하고 작업함
  • 일방적 차단은 호환성 문제 때문에 복잡함
    • 비 ASCII 쿠키는 전체 쿠키 중 0.01% 미만 수준으로 흔하지 않음
    • Argentina, Mexico, Finland 같은 국가에서는 훨씬 더 자주 나타난다는 telemetry가 있음
    • Mozilla는 빠르게 켤 수 있는 network.cookie.blockUnicode 설정을 구현했지만, Chromium과의 동작 호환성 문제 때문에 활성화하지 않음
  • 서버 쪽 수정도 가능할 수 있으나 수백만 개 웹사이트와 언어·프레임워크 내부 오류 처리에 걸쳐 있음
    • Facebook이나 Netflix 같은 곳은 완화할 수 있을지 몰라도, 평균적인 사이트 운영자가 해결할 시간이나 능력을 갖추기는 어려움
  • 근본적 해결은 IETF HTTP Working Group이 쿠키 명세를 내부적으로 정렬하고, 쿠키 처리 시스템이 어떻게 동작해야 하는지 엄격하게 정하는 데 있음
    • 비 ASCII 문자의 허용 여부는 서버 측과 사용자 에이전트에서 동일해야 함
    • 브라우저, 언어, 프레임워크가 쿠키를 처리하는 단계도 Content Security Policy 같은 현대 W3C 표준처럼 명시적이어야 함
    • 잘못된 쿠키 하나 때문에 다른 쿠키 처리까지 중단하는 동작은 다양한 예기치 못한 장애로 이어질 수 있어 받아들이기 어려움

제안된 쿠키 처리 절차

  • field-value에서 시작해 ;,로 나누어 raw-cookie-pair 목록을 만들되, 쉼표를 세미콜론의 동의어로 취급하지 않음
  • raw-cookie-pair는 다음 순서로 처리함
    • =가 없으면 다음 pair로 넘어감
    • 앞뒤 공백을 제거함
    • 첫 번째 = 앞은 cookie-name-octets, 뒤는 cookie-value-octets로 다룸
    • 값이 큰따옴표로 시작하면 시작 큰따옴표를 하나 제거하고, 끝 큰따옴표가 있으면 하나 제거함
    • 이름이나 값이 서버가 받아들일 수 없는 형태면 해당 pair를 건너뜀
    • 남은 [cookie-name-octets, cookie-value-octets] 튜플은 서버 정의 방식으로 처리함
  • 서버는 추가로 쿠키 이름이 token이 아닌 튜플을 거부하고, 쿠키 값이 cookie-octet에 없는 octet을 포함하면 거부하는 방향이 제안됨

댓글과 토론

Hacker News 의견들
  • 쿠키는 이상한 함정과 불편한 동작으로 가득하지만, 99.95%는 잘 돌아감. 제일 좋아하는 쿠키 지뢰밭은 쿠키 섀도잉(cookie shadowing) 인데, 같은 이름으로 도메인·경로 같은 주요 속성만 다르게 쿠키를 설정하면 거의 같은 쿠키가 여러 개 동시에 생기고, 백엔드나 JS에서는 어느 것이 어느 것인지 구분할 방법이 없음
    https://example.com/somepath에 가서 브라우저 콘솔에 아래를 입력해보면 됨
    document.cookie = "foo=a";
    document.cookie = "foo=b; domain=.example.com";
    document.cookie = "foo=c; path=/somepath";
    document.cookie
    내 경우 결과는 'foo=c; foo=a; foo=b'였음

    • 회사에서 누가 설계했는지 모르겠지만 스테이징과 개발 환경을 같은 도메인에 두었고, 거대한 회사 전체가 이 패턴을 따라가고 있음
      정말 엄청난 실수임
    • 같은 브라우저에서 한 웹사이트에 여러 계정을 쓰면 생기는 이상한 동작 중 상당수가 이걸로 설명될 수도 있겠다는 생각이 듦
    • /somepath에 있다면 세 값 중 가장 구체적인 값인 C를 받는 게 꽤 합리적으로 보임. 모든 값이 순서대로 반환되니, 경로별 값과 전역 값을 둘 다 알 수 있어서 가장 나은 절충처럼 느껴짐
      다만 마법 같은 document.cookie 세터는 마음에 안 들지만, 이미 거의 30년 된 물건이라 어쩔 수 없음
    • 참고로 기술적으로는 도메인 앞의 점은 허용되지 않으며 무시됨: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3
      최근 jshttp/cookie에서 검증을 강화하면서 이 문제가 다시 나왔음: https://github.com/jshttp/cookie/pull/167
      그 PR 이후 검증은 글에서 언급된 브라우저 코드와 비슷하게 다시 조금 완화됨
      원래 변경은 우리 코드에서, 인코딩 없이 문자열을 이어 붙여 쿠키 헤더를 만들던 버그를 찾으면서 시작됐음. 가끔 값에 공백이 들어가 요청이 깨졌고, 이를 피하려고 개발자들에게 jshttp/cookie의 serialize() 사용을 제안하려 했지만, 그 함수의 검증으로는 우리가 본 버그를 잡기에 충분하지 않다는 걸 알게 됨
      수정안을 제안하자 다른 사람이 검증이 너무 느슨해서 쿠키의 이름 필드에 JS를 끼워 넣고, 다른 곳에서 그게 처럼 해석되게 만들 수 있다는 점을 발견함. 꽤 특이한 코드 주입 경로가 됨
    • 맞음, 정말 위험 요소가 많음. https://www.usenix.org/conference/usenixsecurity15/technical-sessions/presentation/zheng에서 이 문제와 관련 골칫거리를 자세히 다룸
  • 글에서 Rust 접근을 언급하지만, 다른 언어들과 달리 Rust 표준 라이브러리에는 쿠키 처리 기능이 들어 있지 않음. 실제로는 서드파티 cookie 크레이트의 동작을 보고 있는 셈이고, Ruby처럼 퍼센트 인코딩하는 옵션도 포함되어 있음: https://docs.rs/cookie/0.18.1/cookie/

    • 좋은 이름을 일찍 차지해서 사실상 표준화되는 방식임
  • HTTP 프로토콜 안에는 사실상 서로 다른 프로토콜이 만 개쯤 박혀 있는 것 같음. 브라우저와 웹 서버가 온갖 기능을 덧붙였고, 각각 명세와 사실상 명세가 있으며, 이 모든 게 거의 하나의 범용 HTTP라는 우산 아래 전달됨
    클라이언트가 이 만 개의 비명세 중 어떤 버전과 호환되는지 지정할 수도 없고, 서버도 마찬가지임. 명세를 업그레이드할 수 없는 이유는 나머지 클라이언트가 이해하지 못하고, 하위 호환성도 없기 때문임
    그래서 누구도 합의하지 못하고 고칠 수도 없는 무작위 혼돈이 남았음. 계획된 폐기도 없으니 과거의 나쁜 결정을 계속 끌고 가야 함

    • 이해하지 못하는 프로토콜은 막아버리는 형편없는 미들웨어 장비도 원인임. “기본적으로 실패하게 막는 게 더 안전하다”는 식이라서, 앞으로 영원히 모든 새 애플리케이션 트래픽은 실제 인터넷에서 동작하려면 HTTP 위로 터널링해야 함
    • 솔직히 이제는 이런 세상과 화해했고, 계획된 폐기가 있는 세상보다 더 마음에 들지도 모름
    • 독점 기업이 깔끔한 명세를 정하고 마음대로 강제 폐기하지 못하게 하려면, 그 대가로 무정부 상태를 감수해야 함
  • 약 10년 전 프로젝트에서 쿠키 기반 세션을 구현했는데, 인증이 Safari에서는 되고 Chrome에서는 안 되는 이유를 디버깅하느라 정말 고생했음. 정확히 어느 쪽이었는지는 기억나지 않지만, 한 브라우저는 형식이 맞지 않으면 쿠키를 아예 설정하지 않았음
    특별히 이상한 일을 한 것도 아니었고, 기억상으로는 -_ 차이였던 것 같음

    • Safari와 Chrome 사이에 대소문자 구분 차이가 있었던 것 같음. 아마 Set-Cookie 헤더였을 수도 있음
      예전에 이 문제 때문에 쿠키 키에 camelCase를 쓰지 못했던 적이 있음
      검색해도 정확한 이슈는 잘 못 찾겠음
  • 쿠키가 도입된 직후부터 합리적인 사용법은 불투명 토큰만 넣어서 서버가 다음에 같은 클라이언트임을 알아보고, 나머지는 전부 서버 쪽에 저장하는 것이라고 여겨졌던 것 같음
    클라이언트가 원칙적으로 서버가 절대 보내지 않을 값을 처리할 수 있다는 게 왜 문제인지 모르겠음. 그냥 그런 값을 보내지 않으면 되고, “그걸 보내면 무슨 일이 생길까?” 같은 수수께끼를 걱정할 필요도 없음

    • 쿠키는 오래된 기술임. 웹이 아직 어리던 90년대에 가장 먼저 도입된 것 중 하나이고, 나쁜 아이디어가 몇 차례 반복돼 왔음
      그래도 불투명 토큰을 저장할 유일한 장소라서 인증에는 써야 함
  • 쿠키 헤더 파싱은 엉망임. “표준”은 실제 현장에서 존재하는 동작을 반영하지 못하고, 백엔드 서버·라이브러리·프레임워크마다 받아들이는 형식이 다르며, 브라우저는 또 다른 일을 함
    프론트엔드와 백엔드를 완전히 통제한다면 큰 문제는 아니지만, 서로 다른 것들을 연동해야 하는 순간 아주 빠르게 어리석은 상황이 됨

  • 쿠키는 크고 복잡한 난장판처럼 보이고, 동시에 하위 호환성 때문에 거의 바꾸기도 불가능함. 이럴 때는 완전히 별도의 새 메커니즘을 만드는 게 맞지 않을까 싶음
    예를 들어 NewCookie 같은 메커니즘을 새로 명세하고, 처음부터 일관되게 동작하도록 다시 설계할 수 있음. 현대적인 보안 조치를 내장하고, 더 엄격한 명세와 제대로 된 유니코드 지원도 넣을 수 있음

    • NewCookie를 언급한 게 재미있는데, 실제로는 이미 폐기된 Set-Cookie2 헤더가 있음: https://stackoverflow.com/q/9462180/3474615
    • NewCookie는 대략 브라우저 Local Storage에 해당함
      적어도 일부 사용 사례에서는 그렇고, 물론 헤더와 직접 통합되지는 않음
    • 핵심 문제는 쿠키가 추적과 너무 깊게 엮여 있다는 점인 것 같음. 지금 더 나은 쿠키를 만들려 하면, 그런 개념 자체가 존재하길 원하지 않는 프라이버시 옹호자들에게 막힐 가능성이 큼
      쿠키가 이미 존재하기 때문에 우리는 쿠키에 묶여 있음
    • 클라이언트 쪽 상태를 저장하기에 가장 안전한 곳은 DOM과 URL임. 모든 사용 사례를 덮지는 못하지만, 이메일의 사전 승인 링크를 클릭하는 영역 같은 것은 커버함
      iOS Safari가 고객들이 제어하는 도메인의 쿠키를 임의로 먹어치우는 문제를 쫓느라 꼬박 한 달을 보냈음. Google, Twitter, Facebook 같은 도메인에서 세션 상태가 이렇게 사라지는 건 본 적이 없음
    • 이름은 NewCookie보다 나아야 함. SuperCookie, UltraCookie, BetterCookie 같은 제안도 가능함
      조금 더 진지하게는, cookie라는 단어를 피하고 아예 다른 이름을 붙이는 게 좋음. cookie라는 말에는 너무 많은 짐이 붙어 있음
  • 글쓴이가 JSON.stringify 결과를 쿠키에 넣는 데서 시작했는데, 누군가 문자열화되는 JSON 안에 세미콜론을 넣었기 때문이 아닌 게 오히려 놀라웠음
    쿠키 주변 골칫거리 대부분은 임의의 사용자 입력을 쿠키에 넣으려 할 때 생기는 것 같음. 그렇게 하지 말아야 함. 인증 토큰에 쓰는 것처럼 고정 길이 영숫자 ASCII 문자열만 쓰면 괜찮음

  • 꽤 지뢰밭이라는 데 동의함
    개발자로서 우회하는 방법은 값을 URL 안전 Base64로 인코딩하는 것임. 그러면 바이트 원시값을 얻고 내부 표현은 원하는 대로 쓸 수 있음. 다만 글에서도 말하듯 100% 통제할 수 있는 건 아님. 사용자 에이전트이니 그래야 하기도 하고
    더 많은 사용자 에이전트가 “선 위의 바이트와 기도”보다 표준 준수를 택했으면 좋겠음. 스크린샷의 400 응답들은 명세에 맞는 응답임. 헤더가 처음부터 UTF-8이었거나, 처음에는 ASCII였다가 나중에 UTF-8을 허용하는 쪽이 더 나았을 것 같음. 다만 전자는 인과관계상 어렵고, 후자도 원래 불법이던 값을 합법으로 만들기 때문에 여전히 문제를 일으킬 수 있음

    • URL 안전 Base64라고 할 때 정확히 무엇을 뜻하는지 반드시 명시해야 함. base64url 인코딩은 base64에 URL 인코딩을 더한 것과 약 3% 경우에서 호환되지 않는데, 개발 중에는 쉽게 놓치지만 운영에서는 반드시 터짐
    • 쿠키 값에는 =, /, + 문자를 넣을 수 있으니 표준 Base64 인코딩도 쓸 수 있음 :)
  • 글은 포스텔의 법칙을 조롱하지만, 쿠키를 설정하는 쪽이 보낼 때 보수적이었다면 애초에 이런 글이 필요 없었을 것임

    • 조롱받아 마땅함. 포스텔의 법칙은 끔찍한 아이디어였고 곳곳에 지뢰밭을 만들었음
      때로 그 지뢰는 단순한 버그가 아니라 커다란 보안 구멍이 되기도 함
      클라이언트가 명세에 맞지 않는 데이터를 보낸다면 그건 버그이고 고쳐야 함. 서버가 의도를 추측해서 받아들이는 일이 절대 당연해져서는 안 됨
    • 포스텔의 법칙의 문제는 바로 송신자가 절대 보수적이지 않다는 데 있음. 대부분의 수신자가 받아들이는 세부 동작은 결국 송신자가 쓰게 됨