ブラウザ競争に巻き込まれる開発者へ、HTTP CookieとTomcat CookieProcessorの物語

プロジェクトのライブラリをバージョンアップしてから、予期せぬエラーに見舞われる経験をしたことがありませんか?このようなエラーは主に厄介な互換性の問題であったり、バージョン固有のバグが原因ですが、時にはバージョンと日々発展する技術仕様が原因になることもあります。変化する技術について正確な知識がなければ、愛用しているライブラリのバージョンが非推奨(deprecate)となったとき困難に直面するわけですが、今回の記事では、私が経験したCookieのバグと、その原因となったHTTP Cookieのスペック変更について紹介したいと思います。

問題の発端

パディング文字認識の問題と引用符が消える問題を発見

私が業務で管理している会員認証プラットフォームは、非常に古いシステムでした。会員認証プラットフォームは、会員管理システムを専門的に開発し、NHNの他のサービスでも簡単に会員管理機能を使用できることを目的に作成されました。会員認証プラットフォームは、新スペックのRFC6265が発表される前に開発されたため、従来のCookie標準を使用しており、同社の他のシステムもそうであったため、特に問題もなく10年以上の歳月を耐えてきました。しかし、新しいプロジェクトが最新バージョンのコンテナを使用したことで問題が生じました。

会員認証プラットフォームでは、会員のログイン情報を保存するときにBase64でエンコードされた情報を生成し、パディングに “=”文字を使ってCookieを作ってきました。しかしある瞬間から、会員認証プラットフォームを使用する特定のサーバーを通ると、=がなくなったり、Cookieを包む二重引用符が消えてログインが解除されてしまう問題が発生し始めたのです。

最初はSpringBoot内蔵のTomcatの問題ではないか、または特定のTomcatにバグがあるのではないかと推測しました。長い年月にわたって正常に機能し、安定性を証明しているシステム自体に問題があるとは考えにくかったからです。原因を追跡するため、Tomcatのソース解析とバージョン別の実験を行ったところ、私たちが考えていたよりもはるかに興味深い原因を見つけました。理解しやすいように、まずはHTTP Cookieの歴史から説明したいと思います。

HTTP Cookieの歴史

Netscape 0.9と初期Cookieの登場

Netscapeブラウザ0.9バージョンから登場したHTTP Cookieは、ウェブプログラミングに欠かせない概念であり、同時に最も古い技術でもあります。標準が提示される前に、広く商用化された技術がそうであるように、初期のHTTP Cookieは、Netscapeが提示したCookie標準に基づいて開発されました。しかし、残念ながら、Netscapeの開発者が提示したCookieの標準文書は非常に曖昧で、それぞれの開発者の主観が入ったCookieが実装されたことで、大きな互換性の問題が発生しました。

RFC2109の登場とCookieスペックの制限

この問題を解決し、新たな標準を提示するために登場した文書がRFC2109です。RFC2109はNetscapeスペックから多くの特殊文字を除外し、RFC2616トークンを使用すべきであると提案しました。また、特殊文字の二重引用符とescapingを強制しました。しかし、混乱を正そうとするベル研究所の希望とは裏腹に、開発者らはこのスペックが現実的ではないと判断し、ほとんど実装されることはありませんでした。

RFC2965のSet-Cookie2の提示

再びCookieに定型化された標準を提示しようと登場した標準が、2000年のRFC2965です。RFC2965は、すでに混乱しつつあったSet-Cookieスペックを放棄し、RFC2109に機能を追加して、新しいSet-Cookie2スペックを発表しました。互換性を持つために、Set-Cookieと分離された新しい標準を提示し、段階的な規格化を図ったのです。しかし、業界の反応は冷ややかで、このスペックをナンセンスだと感じた開発者らはRFC2965も無視しました。

RFC6265と標準スペックの文書化

長い紆余曲折の末にHTML5時代が到来し、新しいスペックの強制を放棄したRFC6265が2011年に発表されました。RFC6265は、以前の試みとは異なり、新しい機能を提示するよりは商用化されていたブラウザとウェブサーバーの慣行を文書化することに集中しました。新しいシステムが現在のウェブ環境に合わせて開発されるようにガイドしたのです。RFC6265は現実と最も合致するHTTP仕様と考えられており、現在、多くのアプリケーションとブラウザがRFC6265を受け入れています。

現在はまだ、Netscapeが提示したV0のCookieが非常に多く使用されていますが、ますます技術標準はRFC6265に向かって変化しています。多くの開発者のアプリケーションを支えているTomcatのようなソフトウェアが、RFC6265を徐々にデフォルトとして採用し始めており、従来のスペックのHTTP CookieとRFC6265スペックのCookieの違いを理解することが、将来的に大きな助けになると思われます。

TomcatとHTTP CookieProcessor

上記で紹介したHTTP Cookieの歴史からも分かるように、HTTP Cookieのスペックは、長い年月にわたり多大な混乱とデファクトスタンダードのない状態を経験してきました。そのためTomcat 8は、汎用性を確保するために2つのCookieProcessor、つまりLegacyCookieProcessorとRFC6265 CookieProcessorの両方を内蔵しています。さらに、これらのCookieProcessorをカスタマイズできるさまざまなオプションも提供しています。標準の曖昧なCookieスペックに対処するため、開発者が適切なCookieバージョンを選択し、さまざまなポリシーをカスタマイズして開発してきたシステムに対して、最適な処理を行うことができます。

特別なセッティングがなければ、Tomcat 8はデフォルトでLegacyCookieProcessorを使用するようになっていましたが、Tomcat 8.5からはRFC6265 CookieProcessorをデフォルトとして使用するように変更されました。このため、RFC6265 Cookieスペックを確実に認識しなければ、新規バージョンのTomcatにアップグレードしたり、会員認証プラットフォームのように使用サーバーのスペックが分からない場合は、エラーが発生する可能性があります。

「LegacyCookie」はV0、V1、複数のRFC文書に基づきさまざまなスペックを持つため、ここではTomcatのLegacyCookieProcessorが引用したスペックに基づいて説明します。実際のところ、LegacyCookieProcessorは混乱した標準問題のため、RFC2109、2616、6265のすべてを部分的に引用しています。

簡単なまとめ
Cookie バージョン属性 Set-Cookieヘッダー 有効期限のメカニズム ドメイン属性 Cookie name、value制限  Cookieの値
Legacy Version=0、1 Set-Cookie、Set-Cookie2 max-age、expires混用 .で始まる name、valueともHTTP/1.1トークン形式 name=以降のトークン形式、valueまたは"で囲まれた値
RFC6265 使用しない Set-Cookie max-ageがある場合、expires無視 .で始まらない nameがHTTP/1.1トークン形式 最初の=と最初の;の間の文字

Cookieのバージョン

  • LegacyCookie:バージョンヘッダーを使用してCookie-versionを指定します。NetscapeスペックのCookieは、Version=0を使用するか、バージョンヘッダーを使用しません。RFC2965 CookieはVersion=1を使用します。TomcatのLegacyCookieProcessorはバージョン0のCookieの作成を試み、バージョン1のスペックを引用する場合のみ、バージョン1でCookieを作成します。
  • RFC6265 Cookie:新しいスペックを提示するよりも、現行スペックの整理が目的のため、$Versionを使用しません。

Set-Cookieヘッダー

  • LegacyCookie:Set-Cookieから一般的なCookie処理を行います。Set-Cookie2スペックを使ってRFC2965専用スペックを使用しますが、TomcatのLegacyCookieProcessorは、Set-Cookie2を使用しません。
  • RFC6265 Cookie:Set-Cookieのみを使用しています。

Cookieの有効期限のメカニズム

  • LegacyCookie:RFC2965はexpires属性を許可しておらず、HTTP/1.1スペックで計算したmax-ageのみを許可しています。しかし、IE6、7ブラウザはmax-ageを実装していないので、TomcatのLegacyCookieProcessorはmax-ageと同じexpiresを計算して、両方を追加しています。即時に満了する場合は、max-age=0を使用します。
  • RFC6265 Cookie:同一Cookieでexpiresとmax-ageの両方を使用できますが、max-ageがある場合、expiresを完全無視するようになっています。即時に満了する場合は、過去の時間をexpires属性に使用します。

ドメイン属性

  • LegacyCookieスペックのブラウザ:V0スペックは、ドメイン文法について詳しく記述していません。RFC2965のV1スペックでは、ドメイン名.で開始すると明示しており、abc.comは自動的に.abc.comに切り替えて保存するようになっています。
  • RFC6265 Cookie:LegacyCookieと真っ向から対抗しているスペックで、ドメイン名の最初の文字が.であることを強制しません。スペックには違反しますが、最初の.を確認すると、その文字を無視して処理します。

RFC6265スペックに定義されたものとは異なり、TomcatのRFC6265CookieProcessorは、8.5.xバージョンから最初の.を無視せずに、例外を発生させます。

状況 LegacyCookieProcessor 8.0.x RFC6265CookieProcessor 8.5.x RFC6265CookieProcessor
ドメインが.で始まる そのまま処理 .が削除される 例外発生
ドメインが.で始まらない .が追加される そのまま処理 そのまま処理

Cookieの値の要件と処理

最も違いが多く複雑な部分では、バージョン別に異なるスペックを提示しています。

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文字列は、;,<空白>を除く文字とし、エンコードを推奨するという非常に曖昧な要件しか提示していません。しかし、多くのブラウザは、次のような要件を追加実装しています。

  • NAMEまたはVALUEは、空文字列(empty string)になることができる
  • NAME=VALUE文字列に=がない場合、NAMEは空文字列になり、残りの値をVALUEに処理する
  • Set-Cookie: =abcのようにNAMEのないCookieはSet-Cookie: abcで出力する
  • Netscapeの要件とは異なり,<空白>を許容するが、値の前後にある空白はstripする
  • コントロール文字である00、1F、7Fを許可しない
  • Unicode文字列の処理は、それぞれのブラウザが自由に実装し、混沌としたスペックの差がある
  • OperaとGoogle Chromeは、UTF-8でエンコードされる
  • IEはデフォルトのエンコーディングを使用するが、UTF-8は絶対に使用しない
  • FirefoxはUTF-16のローバイトのみを使用する(ISO-8859-1でなければ値が破棄される)
  • SafariはASCII文字を除き、転送を拒否する
RFC2965 V1

Cookie 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で使用することを許可しています。

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で囲まれた値や囲まれていない値の両方を可能にし、一括してコントロール、スペース、,;\に対する制限を設けています。

また、Cookieの解析について明確なアルゴリズムを提示しています。

  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. Cookie nameはname-value-pairの最初の=までで、valueは最初の=以降である
  5. name、valueは両側のスペースを削除する
  6. nameが空文字列ならset-cookie-string全体を無視する

Tomcatバージョン別の違い

項目 TomcatLegacyCookieProcessor Tomcat 8.0.x RFC6265 CookieProcessor Tomcat 8.5.x RFC6265 CookieProcessor
cookie-value:cookie-valueに特殊文字があると、自動的にで囲まれるか O X X
cookie-value:に囲まれていないcookie-valueに特殊文字がある場合、認識可能か X(最初のHTTP/1.1トークンの区切り文字以降の値を消す) O O
domain:domainが.で始まっても処理可能か O(スペック標準) O(スペックではないが無視して処理) X(エラー)

注意が必要なブラウザの特性

IE、Microsoft Edge

  • max-age属性を実装していない
  • max-age属性と同一時間で計算されたexpires属性を設定して転送する必要がある
  • RFC6265 CookieProcessorは同一時間のmax-ageとexpiresをすべて追加する
  • LegacyCookieProcessorはV0 Cookieと「常に有効期限を追加」(always add expires)設定を有効にした状態のexpiresを追加する(V1 Cookieを使用するには、「常に有効期限を追加」を有効にすることを推奨)

  • RFC6265CookieProcessorで該当の問題に対して補正:
// 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));
    }
}
  • LegacyCookieProcessorでの補正:
// 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));
        }
    }
}
  • Rfc6265CookieProcessorに変更時、max-ageとexpiresが二重に追加されるSet-Cookieフィールド:
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

  • Cookie domain
    • 前に.が含まれる場合(Legacyスペックで処理)
      • Set-Cookieにdomainを指定した場合、HeaderのSet-Cookieのdomain前に.が存在するかどうかに関わらず、.を含める
        • Chromeテスト1参照
    • 前に.が含まれていない場合(RFC6265スペックで処理)
      • Set-Cookieにdomainを指定しない場合.を含めない
        • Chromeテスト2を参照

Chromeテスト

  • テスト環境
    • バージョン:77.0.3865.90(公式ビルド)(64ビット)
    • 検証日:2019.10.01

Chromeテスト1

ドメイン設定 Response Header Chrome Cookie
サブドメインを除く Set-Cookie: test-cookie=cookie-value===boot;
Domain=testcookie.com; Path=/

domain : .testcookie.com
name : test-cookie
value : cookie-value===boot

サブドメインを含む Set-Cookie: test-cookie=cookie-value===boot;
Domain=local.testcookie.com; Path=/

domain : .local.testcookie.com
name : test-cookie
value : cookie-value===boot

Chromeテスト2

ドメイン設定 Response Header Chrome Cookie
ドメイン設定なし

Set-Cookie: test-cookie=cookie-value===boot; Path=/

domain : local.testcookie.com
name : test-cookie
value : cookie-value===boot

CookieProcessorの混在から生じる問題

上記のような違いがあるため、CookieのProcessorを混用すると、予期せぬ問題が発生する場合があります。一般的なプロジェクトでは、一括してTomcatのバージョン管理と設定を行うため問題はありませんが、会員認証プラットフォームの認証モジュールなどのCookieを使用する依存性は、下記のような状況が十分に起こり得ます。

Cookie特殊文字の解析エラー

  1. LegacyCookieProcessorを使用するサーバーで、session=”hexstring==”のように特殊文字を含むCookieを作成します。LegacyCookieProcessorはRFC2965に基づいてSet-Cookie: session=”hexstring==”をリクエストします。=が含まれる文字列は、HTTP/1.1のトークン形式ではないため、quoted string型のデータでCookieを表現するためです。
  2. このCookieを保有しているユーザーエージェント(UserAgent)が、RFC6265CookieProcessorを使用するサービスに接続します。
  3. Set-Cookie: session=”hexstring==”はRFC6265と互換性のある値であるため、通常の処理を行います。しかし、RFC6265CookieProcessorのサーバーから読み取った値のまま、Set-Cookieをリクエストします。
  4. RFC6265CookieProcessorでsession=”hexstring ==”のcookie-valueにリクエストすると、Set-Cookie: session=hexstring==のように、二重引用符がない方式でSet-Cookieをリクエストします。RFC6265でCookieの値は、最初の=以降の値として定義しているので、二重引用符を付けなくても解釈の問題がないからです。
  5. ユーザーエージェントが再びLegacyCookieProcessorを使用するサービスでCookie(session=hexstring==)に接続すると、legacyCookieProcessorは二重引用符がないHTTP/1.1トークンで=という許容されていない文字を無視して、Cookie(session=hexstring)として認識します。実際のTomcat LegacyCookieProcessorでは、この状況でsession=hexstringで最初の特殊文字とその後の値をすべて捨ててしまうからです。
  6. 当該ユーザーは、セッションCookieが破損しているため、エラーが発生し、ログインが解除されてしまいます。

解決方法

RFC6265 CookieProcessor指向

条件

  • Tomcatのバージョン統一
    • 8.0.x以降のバージョンに統一し、8.0.xではRFC6265 CookieProcessorを設定する
  • Tomcatのバージョンが統一できない場合
    • TomcatのバージョンによってRFC6265 CookieProcessorで統一ができない場合は、URL-Safe Base64のようにRFC6265 CookieProcessorとLegacyCookieProcessorの両方で使用可能なトークンを生成して使用する

不可能な場合

  • domain
    • Tomcat 8.5を基準に、ドメイン処理のメカニズムが異なるため、ブラウザでCookieを書き込むときdomainをどのように処理するか、また権限問題をどのように処理するか、必ず確認する必要がある
  • cookie-value
    • すでに使用しているCookieにHTTP/1.1トークンの区切り文字が含まれる場合、既存のトークンが異常動作する可能性がある

LegacyCookieProcessor指向

条件

  • 現在のTomcatのバージョンは、LegacyCookieProcessorをサポートするため、すべての場合で可能である
    • Tomcat 8.5.x以上はLegacyCookieProcessorに設定する必要がある

利点

  • 一括でCookieProcessorを統一しやすい

欠点

  • スペックの下降平準化である点

根本的な解決方法

どのようなCookieProcessorでも、HTTP/1.1スペックのトークン形式を遵守して、LegacyCookieProcessorも引用符なしで認識できるようにする。

  • 特殊文字を可能な限り使用しない
  • 特殊文字が含まれる場合、URL-Safeのエンコードを行う

会員認証プラットフォームでのCookieの解決方法

古いブラウザを使用してサービスをサポートしなければならない会員認証プラットフォームの特性上、一括してRFC6265CookieProcessorへのアップグレードは不可能です。また、Cookieの形式と生成ロジックを変えると、多くのユーザーがログインしているすべてのサービスに直接影響するため、Cookieの形式を変更することも難しい状態です。このようなことから、最も適切な解決方法を次のように結論付けました。

  • Tomcat 8.5+を使用する他のプロジェクトに対して、LegacyCookieProcessorを使用するようにガイドし、\, ALLOW_EQUALS_IN_VALUEオプションをtrueに設定するように案内する
  • 漸進的に導入を進めている新バージョンの会員認証プラットフォームは、後方互換性と将来的に主流となるRFC6265 Processorに対して備えるため、URL-Safeエンコードを確認する

Reference

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop