📔

ブラウザを自作してCookieがどう処理されるかを学んだ

に公開

はじめに

JavaScriptで設定したCookieがブラウザの規制により24時間で消える一方、サーバーで設定したCookieは制限されない。この違いはどのような仕組みで起こるのでしょうか?

この仕組みを理解するために、ブラウザを自作してCookieの動作を再現してみました。その結果、見えてきた内容を詳しく解説します。

Cookieとは

Cookieは、Webサイトがブラウザに保存するデータであり、主に以下のような用途で使われます。

  • ログイン状態の維持 - ユーザーがログインしたままになる
  • カートの保存 - ECサイトで商品を記憶
  • 広告の効果測定 - どの広告から訪問したかを追跡

Cookieは2つの方法で設定できます。

  • サーバーサイド: HTTPレスポンスのSet-Cookieヘッダー
  • JavaScript: document.cookieを使用

実装の前提

ブラウザを実装するベースとして、書籍『[作って学ぶ]ブラウザのしくみ』で解説されているものを使いました。学習目的のシンプルな実装ですが、以下の機能を備えています。

  • HTTPリクエスト・レスポンス処理
  • HTML/CSSパーサーとDOM構築
  • JavaScript実行エンジン
  • ページレンダリング

https://gihyo.jp/book/2024/978-4-297-14546-0

この書籍に沿って実装したブラウザは、以下のような構成になっています。

ブラウザ全体の構成
├── core/          # ブラウザの中核機能
│   ├── http.rs         # HTTP通信処理
│   ├── url.rs          # URL解析
│   └── renderer/       # レンダリング関連
│       └── page.rs     # ページ処理
├── net/           # ネットワーク層
└── ui/            # ユーザーインターフェース

実装内容

今回の実装では、Safariのプライバシー保護機能(リンクデコレーション検出とCookie制限)を再現することで、ブラウザがCookieを処理する仕組みを深掘ります。
主にcoreモジュールに新しいファイルを追加し、HTTPの処理を拡張しました。

ブラウザ全体の構成(実装後)
├── core/          # ブラウザの中核機能
│   ├── http.rs         # HTTP通信処理
│   ├── url.rs          # URL解析
│   ├── renderer/       # レンダリング関連
│   │   └── page.rs     # ページ処理 ← ★拡張
│   ├── cookie.rs       # Cookie管理 ← ★新規追加
│   └── measurement.rs  # 計測・制限機能 ← ★新規追加
├── net/           # ネットワーク層
└── ui/            # ユーザーインターフェース

リンクデコレーション検出とCookie制限の仕組み

リンクデコレーションとは、ユーザーを追跡するためにURLに追加されるパラメータのことです。広告をクリックした際、広告プラットフォームは効果測定のためにこれらのパラメータを付与します。

Safariでは、これらを検出するとJavaScriptのCookieだけを24時間に制限します。サーバーサイドCookieを制限できない理由は、処理するタイミングが違うからです。

サーバーサイドCookie → ブラウザが制限できない

  1. ユーザーが広告をクリック(https://example.com/?gclid=ABC123)
  2. サーバーがHTTPレスポンスにCookieを含めて送信(Set-Cookie: user_id=XYZ)
  3. ブラウザがレスポンスを受信した時点で、サーバーとの通信は完了済み
  4. ブラウザは受け取ったCookieをそのまま保存するしかない
  1. ユーザーが広告をクリック(https://example.com/?gclid=ABC123)
  2. ページ内のJavaScriptが実行され、Cookieを設定しようとする(document.cookie = "js_id=ABC")
  3. ブラウザはJavaScriptの実行を管理しているため、Cookieの有効期限を変更できる
  4. ブラウザが24時間後に自動削除するよう設定する

以下では、この仕組みをブラウザに実装し、実際の動作ログでタイミングの違いを確認していきます。

Cookie処理機能の追加

ブラウザがCookieを扱うには、Set-Cookieヘッダーをパースして内部形式に変換する必要があります。まずは基本的なCookie処理の機能を実装しました。

// core/src/cookie.rs
// Cookie構造体の定義
#[derive(Debug, Clone)]
pub struct Cookie {
    pub name: String,
    pub value: String,
    pub domain: Option<String>,
    pub path: Option<String>,
    pub secure: bool,
    pub http_only: bool,
    pub same_site: Option<SameSite>,
}
// core/src/cookie.rs
pub fn from_set_cookie_header(header_value: &str, request_url: &Url) -> Option<Self> {
    let parts: Vec<&str> = header_value.split(';').collect();
    if parts.is_empty() {
        return None;
    }
    
    // 名前と値をパース(splitn(2, '=')で最初の'='でのみ分割)
    let name_value_parts: Vec<&str> = parts[0].trim().splitn(2, '=').collect();
    if name_value_parts.len() != 2 {
        return None;
    }
    
    let mut cookie = Cookie::new(
        name_value_parts[0].to_string(),
        name_value_parts[1].to_string(),
    );
    
    // ドメインが未指定の場合、リクエストURLのドメインを使用
    cookie.domain = Some(request_url.host().to_string());
    
    // 各属性をパース
    for part in parts.iter().skip(1) {
        let attr = part.trim().to_lowercase();
        
        if attr.starts_with("domain=") {
            cookie.domain = Some(attr[7..].to_string());
        } else if attr.starts_with("path=") {
            cookie.path = Some(attr[5..].to_string());
        } else if attr == "secure" {
            cookie.secure = true;
        } else if attr == "httponly" {
            cookie.http_only = true;
        }
        // SameSite等の他の属性は省略
    }
    
    Some(cookie)
}

Cookie制限機能の追加

URLパラメータからクリック識別子を検出し、条件に応じてCookieを制限する機能を実装しました。

クリック識別子の検出

まず、URLからクリック識別子(gclid、fbclid等)を抽出する機能の実装です。

// core/src/measurement.rs
// 計測タグの構造体定義
pub struct MeasurementTag {
    pub gclid: Option<String>,    // Google Ads
    pub fbclid: Option<String>,   // Facebook
    pub msclkid: Option<String>,  // Microsoft
    pub tracking_id: Option<String>,
}

impl MeasurementTag {
    pub fn from_url(url: &Url) -> Self {
        let mut tag = Self {
            gclid: None,
            fbclid: None,
            msclkid: None,
            tracking_id: None,
        };
        
        let query = url.searchpart();
        if !query.is_empty() {
            for param in query.split('&') {
                if let Some((key, value)) = param.split_once('=') {
                    match key {
                        "gclid" => tag.gclid = Some(value.to_string()),
                        "fbclid" => tag.fbclid = Some(value.to_string()),
                        "msclkid" => tag.msclkid = Some(value.to_string()),
                        "tid" => tag.tracking_id = Some(value.to_string()),
                        _ => {}
                    }
                }
            }
        }
        tag
    }
}

Cookie制限の判定ロジック

次に、検出したクリック識別子とCookieのドメインを基に制限を判定します。

// core/src/measurement.rs
pub struct ITPSimulator {
    enabled: bool,
}

impl ITPSimulator {
    pub fn apply_restrictions(
        &self,
        cookie: &Cookie,
        url: &Url,
        measurement_tag: &MeasurementTag,
    ) -> CookieRestriction {
        if !self.enabled {
            return CookieRestriction::Allowed;
        }
        
        // リンクデコレーション(トラッキングパラメータ)の検出
        if measurement_tag.has_tracking_parameters() {
            // gclidやfbclidなどが検出された場合、24時間制限を適用
            return CookieRestriction::LimitedTo24Hours;
        }
        
        // サードパーティCookieのチェック
        if let Some(cookie_domain) = &cookie.domain {
            let page_domain = url.host();
            if !self.is_same_site(cookie_domain, &page_domain) {
                // サードパーティCookieはブロック
                return CookieRestriction::Blocked;
            }
        }
        
        CookieRestriction::Allowed
    }
    
    fn is_same_site(&self, domain1: &str, domain2: &str) -> bool {
        // ドメインの比較(サブドメインも考慮)
        domain1.trim_start_matches('.').ends_with(domain2) || 
        domain2.ends_with(domain1.trim_start_matches('.'))
    }
}

HTTPレスポンス処理の実装

Cookie処理のタイミングの違いを可視化するため、以下の実装を行いました。
ブラウザが制御できるタイミングでは何ができて、何ができないかを理解したい狙いがあります。

サーバーサイドCookie処理

// core/src/renderer/page.rs
fn process_set_cookie_headers(&mut self, response: &HttpResponse, url: &Url) {
    let set_cookie_headers = response.header_values("Set-Cookie");
    
    if !set_cookie_headers.is_empty() {
        // URLから広告識別子を検出
        let measurement_tag = MeasurementTag::from_url(url);
        
        for set_cookie in set_cookie_headers {
            if let Some(cookie) = Cookie::from_set_cookie_header(&set_cookie, url) {
                if measurement_tag.gclid.is_some() {
                    console_debug(&self.browser, 
                        "[INFO] Server-side cookies cannot be restricted by browser");
                }
                // サーバーサイドCookieは制限なしで保存
                cookie_jar.borrow_mut().add_cookie(cookie);
            }
        }
        
        // JavaScript Cookieの動作を比較のため表示
        if measurement_tag.has_link_decoration() {
            self.simulate_js_cookie_with_itp(&measurement_tag);
        }
    }
}

JavaScript Cookie処理

// core/src/renderer/page.rs
fn simulate_js_cookie_with_itp(&mut self, measurement_tag: &MeasurementTag) {
    console_debug(&self.browser, 
        "Scenario: JavaScript sets document.cookie = 'js_id=JS123; max-age=2592000'");
    
    if measurement_tag.gclid.is_some() {
        console_debug(&self.browser, 
            ">>> [ITP WARNING] JavaScript Cookie would be limited to 24 hours <<<");
        console_debug(&self.browser, 
            ">>> Original: 30 days → Restricted: 24 hours");
    }
}

実装したログの意図

このログは、ブラウザが広告識別子(gclid、fbclidなど)を認識したタイミングで、それぞれのCookieに対して何ができるかを確認するために出力しています。

確認したいこと

  • ブラウザはいつ広告識別子(gclid)を認識するか
  • その時点でサーバーサイドCookieはどういう状態か
  • その時点でJavaScript Cookieはどういう状態か

実装したログの意味

  • searchpart: "gclid=ABC123" → URLパース時の広告識別子
  • Set-Cookie: measurement_id=... → HTTPレスポンスのCookieヘッダー
  • [INFO] Server-side cookies cannot be restricted → サーバーサイドCookieの処理結果
  • [ITP WARNING] JavaScript Cookie would be limited to 24 hours → JavaScript Cookieの制限表示

これらのログを順番に確認することで、Cookie設定のタイミングと制限の可否の関係を検証できるようにしました。

動作検証

実装したブラウザのデバッグログで、Cookieが設定されるタイミングとブラウザが広告識別子を認識するタイミングの関係を確認します。

今回実装したブラウザは以下のような表示になっており、検証用のHTMLページには、広告プラットフォームのクリック識別子(gclidなど)が付くリンクと、通常のリンクを用意しました。
これにより、広告経由のアクセスを再現してCookie制限の動作を検証します。

テストシナリオ

以下のシナリオで動作を検証しました。

  1. Google広告経由(gclid付き)
  2. 通常の計測(パラメータなし)

動作ログ

Google広告経由のアクセス(gclid付き)の場合

以下のログで、Google広告クリック時の処理を確認できます。

<赤色で囲った部分の説明>
①ブラウザはURLを解析し、クエリパラメータ部分(searchpart)を抽出します。ここに含まれるgclid=ABC123DEF456は、Google広告がクリックごとに生成する一意の識別子で、どの広告からの訪問かを追跡するために使用されます。

②サーバーからHTTPレスポンスが返ってきて、Set-Cookieヘッダーでサーバーサイドcookieを設定しています。

  • 名前: measurement_id(計測用Cookie)
  • 値: TRACK789
  • Max-Age: 315360000秒(約10年間)

確認できたこと

  • この時点で、ブラウザに広告識別子(gclid)が届いている
  • しかし、サーバーサイドCookieはHTTPレスポンスで既に送られてきている
  • つまり、サーバーとの通信は完了しており、ブラウザは受け取ったCookieをそのまま保存するしかない

<赤色で囲った部分の説明>
[INFO] Page has Google Click IDとログが出力されており、gclidを検出したことが分かります。

[ITP WARNING] JavaScript Cookie would be limited to 24 hoursは、デバッグ用のログです。今回は実際にはJavaScriptは実行していませんが、このタイミングでJavaScriptが「document.cookie = 'js_id=JS123; max-age=2592000'(30日間有効)」を実行したら、ブラウザがCookieを24時間に制限する、という動作を想定しています。

確認できたこと

  • このWARNINGログは広告識別子(gclid)がある場合にのみ出力されるもの
    • つまり、リンクデコレーション検出の結果よって、処理の出し分けが可能
  • JavaScript Cookieはこのタイミングで処理される
  • ブラウザはJavaScriptの実行を制御しているため、Cookieの有効期限を変更できる

ケース2:通常のアクセス(広告識別子なし)

広告識別子がない場合の処理を確認します。

<赤色で囲った部分の説明>
①URLのクエリパラメータにはtid=USER004のみで、gclidなどの広告識別子は含まれていません。これは通常の計測パラメータで、広告経由ではないアクセスを示しています。

②サーバーからのSet-Cookieヘッダーは先ほどと同様に10年間有効なCookieを設定しています。

  • 名前: measurement_id
  • 値: USER004
  • Max-Age: 315360000秒(約10年間)

<赤色で囲った部分の説明>
No link decoration detectedは、広告識別子(gclid、fbclidなど)が検出されなかったことを示しています。
この時、ITP WARNINGは出力されていないことから、リンクデコレーション検出の結果よる、処理の出し分けがうまく動いていることが分かります。そのため、JavaScriptで設定したCookieは、制限されないため30日間有効となります。

まとめ

今回の検証で、実際に動作させたのはサーバーサイドCookieの処理のみですが、HTTPレスポンス処理の中で、

  • 広告識別子(gclid、fbclidなど)を検出する
  • 検出結果に応じて処理(ログ)を出し分ける

という実装をしたことにより、サーバーサイドCookieは受け取るしかないが、JavaScript Cookieなら制御できるという違いを、コードレベルで理解することができました。

最後までご覧いただき、ありがとうございました。

Discussion