grep

Frontend

브라우저 싸움에 등 터지는 개발자들을 위한 HTTP쿠키와 톰캣 쿠키 프로세서 이야기

NHN

2019년 11월 14일

원문에서 보기 ↗

무심코 프로젝트의 라이브러리를 버전 업하다가 예상치 못한 에러가 발생한 경험이 다들 있을 것입니다. 주로 이런 에러는 성가신 호환성 문제이거나 버전 별 버그가 원인이지만, 가끔은 버전과 세월을 따라 발전하는 기술 표준이 원인일 때도 있습니다. 변화하는 기술에 대한 정확한 지식이 없으면 애용하던 라이브러리 버전이 deprecate 될 때 어려움을 겪을 수밖에 없는데요, 이번 글에서는 회원개발팀에서 일어난 쿠키 버그와, 그 원인이 된 현재 진행형 HTTP 쿠키 스펙 변화에 대해 소개해 드리려고 합니다.

문제의 발단

패딩 문자 인식 문제와 따옴표가 사라지는 문제 발견

회원개발팀에서 관리하는 회원 인증 플랫폼은 상당히 오래된 시스템입니다. 회원 인증 플랫폼은 회원 관리 시스템을 전담 개발하여 NHN의 다른 서비스들에서 손쉽게 회원관리 기능을 사용할 수 있기 위해 만들어졌습니다. 회원 인증 플랫폼은 신스펙 RFC6265이 발표되기 전에 개발되었기 때문에 레거시 쿠키 표준을 사용하고 있었고, 회사의 다른 시스템들도 그랬기 때문에 문제없이 10년이 넘는 세월을 버텨 왔습니다. 하지만 새로운 프로젝트들이 최신 버전의 컨테이너를 사용하면서 문제가 생겼습니다.

회원 인증 플랫폼에서는 회원의 로그인 정보를 저장할 때 Base64인코딩된 정보를 생성하고 패딩으로 “=” 문자를 사용해 쿠키를 만들어 왔습니다. 하지만 어느 순간부터 회원 인증 플랫폼을 사용하는 특정 서버를 지나면 =가 없어지거나 쿠키를 감싸는 따옴표가 사라지며 확률적으로 로그인이 풀려버리는 문제가 발생하기 시작했습니다.

처음에는 스프링 부트의 내장 톰캣 문제가 아닌가, 특정 톰캣에 버그가 있는 것이 아닌가의 추측을 했습니다. 긴 세월 동안 잘 작동하며 안정성을 증명한 시스템 자체에 문제가 있을 것이라고는 생각을 하기 어려웠기 때문일 것입니다. 회원개발팀에서 원인을 추적하기 위해 톰캣 소스 분석과 버전별 실험을 통해 확인해본 결과, 저희가 생각한 것보다 훨씬 흥미로운 원인을 찾게 되었습니다. 이해를 돕기 위해 HTTP 쿠키의 역사부터 설명을 시작합니다.

HTTP 쿠키의 역사

1.png

넷스케이프 0.9와 초기 쿠키의 등장

넷스케이프 브라우저 0.9 버전부터 등장한 HTTP 쿠키는 웹 프로그래밍에서 빼놓을 수 없는 개념이며 동시에 가장 오래된 기술이기도 합니다. 표준이 제시되기 전에 널리 상용화된 기술들이 그러듯이 초기의 HTTP 쿠키는 넷스케이프가 제시한 쿠키 표준에 따라 개발되었습니다. 하지만 안타깝게도 넷스케이프 개발자들이 제시한 쿠키 표준 문서는 굉장히 애매모호하게 작성되어 있었고, 개발자 각각의 주관이 포함된 쿠키 구현들로 인해 큰 호환성 문제가 대두되었습니다.

RFC2109 등장과 쿠키 스펙 제한

이 문제를 해결하고 새로운 표준을 제시하기 위해 나타난 문건이 RFC2109였습니다. RFC2109는 넷스케이프의 스펙에서 많은 특수문자를 제외시켰고, RFC2616 토큰을 이용해야 한다는 제안을 했습니다. 또, 특수문자에 대해 따옴표와 escaping을 강제했습니다. 강력한 스펙 제안을 두어 중구난방이었던 HTTP 쿠키를 하나의 방식으로 정리하고자 한 것입니다. 하지만 혼란을 바로잡으려는 벨 연구소의 희망과는 달리 개발자들은 이 스펙을 현실과 너무 동떨어져 있다고 여겨 거의 구현하지 않았습니다.

RFC2965의 Set-cookie2 제시

다시 쿠키에 정형화된 표준을 제시하려고 나타난 표준은 2000년의 RFC2965였습니다. RFC2965는 난장판이 이미 되어버린 Set-cookie 스펙을 포기하고 RFC2109에 기능들을 더 추가해서 새로운 Set-cookie2 스펙을 제시했습니다. 호환성을 가지기 위해 Set-cookie와 분리된 새 표준을 제시하며 점진적인 규격화를 도모한 것입니다. 하지만 업계의 반응은 싸늘했고, 이 스펙을 난센스라고 생각한 개발자들은 RFC2965 또한 무시했습니다.

RFC6265와 표준 스펙 문서화

기나긴 우여곡절 끝에 HTML5 시대가 도래했고, 새로운 스펙을 강제하는 것을 포기한 RFC6265가 2011년에 발표되었습니다. RFC6265는 이전 시도들과는 달리 새로운 기능을 제시하기보다는 상용화되어있던 브라우저와 웹 서버들의 관행을 문서화하는 데에 집중했습니다. 새로운 시스템들이 현 웹 환경에 맞게 개발될 수 있도록 가이드를 한 것입니다. RFC6265는 현실과 가장 부합하는 HTTP 표준으로 생각되고 있으며 현재 많은 애플리케이션들과 브라우저들이 RFC6265를 받아들이고 있는 상태입니다.

현재는 아직도 넷스케이프가 제시한 V0의 쿠키들이 굉장히 많이 사용되고 있지만, 점점 기술 표준은 RFC6265를 향해 변화해 가고 있습니다. 많은 개발자들의 애플리케이션을 지탱하고 있는 톰캣과 같은 소프트웨어가 RFC6265를 점차 기본값으로 채택함에 따라 레거시 스펙의 HTTP 쿠키와 RFC6265 스펙의 쿠키의 차이점에 대해 아는 것이 미래에 큰 도움이 될 것이라 생각합니다.

톰캣과 HTTP 쿠키 프로세서

위에서 알아봤던 HTTP 쿠키의 역사에서 알 수 있듯이 HTTP 쿠키의 스펙은 긴 시간 동안 엄청난 혼란과 무표준 상태를 겪었습니다. 그렇기 때문에 톰캣 8은 범용성을 보장하기 위해 두 가지 쿠키 프로세서인 LegacyCookieProcessor와 Rfc6265 CookieProcessor를 모두 내장하고 있습니다. 뿐만 아니라, 이 쿠키 프로세서를 커스터마이징 할 수 있는 여러 가지 옵션을 제공하고 있습니다. 표준이 애매모호한 쿠키 스펙에 대처하기 위해 개발자가 알맞은 쿠키 버전을 택하고 여러 가지 정책을 커스터마이징 해서 개발한 시스템에 제일 적합한 프로세싱을 할 수 있습니다.

별다른 세팅을 하지 않으면 톰캣 8은 기본값으로 LegacyCookieProcessor를 사용하도록 되어 있었으나, 톰캣 8.5를 기점으로 Rfc6265 CookieProcessor를 기본값으로 사용하도록 변경이 되었습니다. 이 때문에 Rfc6265 쿠키 스펙을 확실하게 인지하지 않는다면 신규 버전의 톰캣으로 업그레이드하거나, 회원 인증 플랫폼과 같이 사용 서버의 스펙을 알지 못할 때 예키치 못한 오류가 발생할 확률이 있습니다.

레거시 쿠키 vs RFC6265 쿠키

"레거시 쿠키"는 V0, V1, 여러 구 RFC문서에 근거하는 다양한 스펙을 가질 수 있기 때문에 레거시 쿠키는 톰캣의 Legacy cookie processor 가 인용한 스펙을 기준으로 다루겠습니다. 실제로 Legacy cookie processor는 혼란스러운 표준 문제 때문에 RFC2109, 2616, 6265를 모두 부분 부분 인용하고 있습니다.

간단 요약
쿠키버전 속성Set-cookie 헤더만료 메커니즘도메인 속성쿠키 name,value 제한사항쿠키값
레거시Version=0, 1Set-cookie, Set-cookie2max-age, expires 혼용.으로 시작name,value 모두 HTTP/1.1 token형식name=이후의 token형식 value 또는 "로 감싸진 값
RFC6265사용하지 않음Set-cookiemax-age가 있을 경우 expires 무시.으로 시작하지 않음name만 HTTP/1.1 token 형식첫 =와 첫 ;사이의 문자

쿠키 버전

Set-cookie 헤더

쿠키 만료 메커니즘

도메인 속성

RFC6265 스펙에 정의된 것과는 다르게, 톰캣의 Rfc6265CookieProcessor는 8.5.x 버전부터 첫 .을 무시하지 않고 예외가 발생하게 됩니다.

상황LegacyCookieProcessor8.0.x Rfc6265CookieProcessor8.5.x Rfc6265CookieProcessor
도메인을 .으로 시작그대로 처리.이 제거됨Exception 발생
도메인을 .으로 시작하지 않음.이 추가됨그대로 처리그대로 처리

쿠키값 요구사항과 처리

가장 차이가 많고 복잡한 부분으로, 버전마다 굉장히 다른 스펙을 제시하고 있습니다

Netscape V0

Netscape

NAME=VALUE
This string is a sequence of characters excluding semi-colon, comma and white space. If there is a need to place such data in the name or value, some encoding method such as URL style %XX encoding is recommended, though no encoding is defined or required.
This is the only required attribute on the Set-Cookie header.

NAME=VALUE 스트링은 ;, ,, <공백>을 제외한 문자로 하며, 인코딩을 권장한다는 굉장히 애매모호한 요구사항 밖에 제시하고 있지 않습니다. 하지만 많은 브라우저들은 여기에 추가로 다음 요구사항을 추가 구현했습니다

RFC2965 V1

쿠키의 name은 HTTP/1.1 스펙의 토큰 형식을 요구하며, value는 HTTP/1.1 토큰 형식 또는 quoted-string을 요구하고 있습니다.

토큰에 대한 HTTP/1.1 스펙은 다음과 같습니다.

       token          = 1*<any CHAR except CTLs or separators>
       separators     = "(" | ")" | "<" | ">" | "@"
                      | "," | ";" | ":" | "\" | <">
                      | "/" | "[" | "]" | "?" | "="
                      | "{" | "}" | SP | HT

그러므로, separators에 해당하는 문자를 사용하려면 DQUOTE로 감싼 quoted string으로 사용하는 것 만을 허용하고 있습니다.

RFC6265 cookie

쿠키의 name은 RFC2965와 같이 토큰 형식을 요구합니다. 하지만 cookie-value에 대해 차이가 있습니다.

 cookie-value      = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
 cookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
                       ; US-ASCII characters excluding CTLs,
                       ; whitespace DQUOTE, comma, semicolon,
                       ; and backslash

RFC6265에서는 cookie-value에 DQUOTE로 감싼 값이나 감싸지 않은 값 모두를 허용하며, 일괄적으로 컨트롤, 공백, ", ,, ;, \ 에 대한 제한을 두고 있습니다.

또한, 쿠키의 파싱에 대해 명확한 알고리즘을 제시하고 있습니다:

  1. set-cookie-string 이 ;를 포함할 경우, name-value-pair는 첫 ;이전까지의 값으로 이루어져 있다.
  2. set-cookie-string 이 ;를 포함하지 않는 경우, name-value-pair는 set-cookie-string전체이다
  3. name-value-pair에 =가 없을 경우 set-cookie-string 전체를 무시한다.
  4. 쿠키의 name은 name-value-pair의 첫 =까지이고 value는 첫 = 이후이다.
  5. name, value는 양옆 공백을 제거한다
  6. name이 empty string이라면 set-cookie-string 전체를 무시한다.

톰캣 버전별 차이

항목Tomcat Legacy cookie processorTomcat 8.0.x Rfc6265 cookie processorTomcat 8.5.x Rfc6265 cookie processor
cookie-value : cookie-value에 특수문자가 있으면 자동으로 "로 감싸는가?OXX
cookie-value : " 로 감싸지지 않은 cookie-value에 특수문자가 있으면 인식이 가능한가?X (첫 HTTP/1.1 토큰의 separator 이후 값 사라짐)OO
domain : domain이 .으로 시작해도 처리가 가능한가?O (스펙 표준이다)O (스펙은 아니지만 무시하고 처리)X (에러)

주의해야 하는 브라우저 특성

IE, Microsoft Edge


    // RFC 6265 prefers Max-Age to Expires but... (see below)
    int maxAge = cookie.getMaxAge();
    if (maxAge > -1) {
        // Negative Max-Age is equivalent to no Max-Age
        header.append("; Max-Age=");
        header.append(maxAge);

        // Microsoft IE and Microsoft Edge don't understand Max-Age so send
        // expires as well. Without this, persistent cookies fail with those
        // browsers. See http://tomcat.markmail.org/thread/g6sipbofsjossacn

        // Wdy, DD-Mon-YY HH:MM:SS GMT ( Expires Netscape format )
        header.append ("; Expires=");
        // To expire immediately we need to set the time in past
        if (maxAge == 0) {
            header.append(ANCIENT_DATE);
        } else {
            COOKIE_DATE_FORMAT.get().format(
                    new Date(System.currentTimeMillis() + maxAge * 1000L),
                    header,
                    new FieldPosition(0));
        }
    }
    // Max-Age=secs ... or use old "Expires" format
    int maxAge = cookie.getMaxAge();
    if (maxAge >= 0) {
        if (version > 0) {
            buf.append ("; Max-Age=");
            buf.append (maxAge);
        }
        // IE6, IE7 and possibly other browsers don't understand Max-Age.
        // They do understand Expires, even with V1 cookies!
        if (version == 0 || getAlwaysAddExpires()) {
            // Wdy, DD-Mon-YY HH:MM:SS GMT ( Expires Netscape format )
            buf.append ("; Expires=");
            // To expire immediately we need to set the time in past
            if (maxAge == 0) {
                buf.append( ANCIENT_DATE );
            } else {
                COOKIE_DATE_FORMAT.get().format(
                        new Date(System.currentTimeMillis() + maxAge * 1000L),
                        buf,
                        new FieldPosition(0));
            }
        }
    }
Set-Cookie: TOAST_ID_NEO_CHK=; Max-Age=0; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Domain=toast.com; Path=/
Set-Cookie: TOAST_ID_NEO_SES=AAAA....; Domain=toast.com; Path=/
Set-Cookie: TOAST_ID_LAST_ID=; Max-Age=0; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Domain=toast.com; Path=/

Google chrome

Chrome Test

Chrome Test 1

domain 설정Response HeaderChrome Cookie
sub domain제외2.png Set-Cookie: test-cookie=cookie-value===boot; Domain=testcookie.com; Path=/2 (1).png * domain : .testcookie.com * name : test-cookie * value : cookie-value===boot
sub domain포함3.png Set-Cookie: test-cookie=cookie-value===boot; Domain=local.testcookie.com; Path=/3(1).png * domain : .local.testcookie.com * name : test-cookie * value : cookie-value===boot

Chrome Test 2

domain 설정Response HeaderChrome Cookie
domain 설정 없음4.png * Set-Cookie: test-cookie=cookie-value===boot; Path=/4(1).png * domain : local.testcookie.com * name : test-cookie * value : cookie-value===boot

쿠키 프로세서 혼용에서 발생하는 문제

위와 같은 차이점 때문에 쿠키 프로세서를 혼용하게 되면 예상치 못한 문제가 발생할 수 있습니다. 일반적인 프로젝트에서는 일괄적으로 톰캣 버전 관리와 설정을 하기 때문에 문제가 되지 않지만, 회원 인증 플랫폼의 인증 모듈 등의 쿠키를 사용하는 dependency는 충분히 아래와 같은 상황이 일어날 수 있습니다.

쿠키 특수문자 파싱 에러

5main.png

  1. LegacyCookieProcessor를 사용하는 서버에서 session="hexstring=="과 같이 특수문자를 포함하는 쿠키를 만들어 냅니다. LegacyCookieProcessor는 RFC2965에 따라 Set-Cookie: session="hexstring==" 의 요청을 하게 됩니다. 이는, =가 포함된 문자열은 HTTP/1.1의 토큰 형식이 아니기 때문에 Quoted string타입의 데이터로 쿠키를 표현하기 위함입니다.
  2. 해당 쿠키를 보유한 UserAgent가 Rfc6265CookieProcessor를 사용하는 서비스로 접속합니다.
  3. Set-Cookie: session="hexstring==" 는 RFC6265와 호환되는 값이므로 정상적으로 처리를 하게 됩니다. 하지만 Rfc6265CookieProcessor의 서버에서 읽은 값 그대로 Set-Cookie를 요청한다고 가정합니다.
  4. Rfc6265CookieProcessor에서 session="hexstring=="의 cookie value로 요청을 하게 되면 Set-Cookie: session=hexstring== 과 같이 따옴표가 없는 방식으로 Set-Cookie를 요청하게 됩니다. RFC6265에서 쿠키의 값은 첫 =이후의 값으로 정의하고 있기 때문에 따옴표를 붙이지 않아도 해석의 어려움이 없기 때문입니다.
  5. UserAgent가 다시 LegacyCookieProcessor를 사용하는 서비스로 쿠키(session=hexstring==)로 접속하면, legacyCookieProcessor는 따옴표가 없는 HTTP/1.1 토큰에서 =라는 허용되지 않은 문자를 무시하고 쿠키(session=hexstring)로 인식합니다. 실제 톰캣 LegacyCookieProcessor에서는 해당 상황에서 session=hexstring으로 첫 특수문자와 이후의 값을 모두 버려 버리기 때문입니다.
  6. 해당 유저는 세션 쿠키가 손상되었기 때문에 오류를 발생시키고 로그인이 풀려 버립니다.

해결책

RFC6265 cookie processor 지향

조건

불가능한 경우

Legacy cookie processor 지향

조건

장점

단점

근본적인 해결책

회원개발팀에서 회원 인증 플랫폼 쿠키에 대한 해결책

오래된 브라우저를 사용하는 서비스를 지원해야 하는 회원 인증 플랫폼의 특성상 일괄적으로 Rfc6265CookieProcessor로의 업그레이드는 불가능합니다. 또, 쿠키의 형식과 생성 로직을 바꾸게 되면 많은 유저가 로그인되어있는 모든 서비스에 직접적인 영향이 가므로 쿠키의 형식을 변경하는 것은 어렵습니다. 그러므로 저희 팀은 가장 합당한 해결책이 다음과 같다는 결론을 내렸습니다.

Reference