【解決済み】【AngleSharp】.NET 9でCookieのexpiresがGMTではなくlocaltimeで解釈されてしまう

# 追記
本記事に記載している事象については、私がissueとプルリクを作成してマージされ、AngleSharp1.20としてリリースされました。よって解決済みとなります。

https://github.com/AngleSharp/AngleSharp/issues/1212

https://github.com/AngleSharp/AngleSharp/pull/1213

https://github.com/AngleSharp/AngleSharp/releases/tag/1.2.0

# はじめに
AngleSharpを使っているプロジェクトを.NET 8から.NET 9にバージョンアップしたところ、想定外の挙動(ログインが必要なサイトでログインしたことにならない)をするようになり調査していったところ原因と対処法を特定できGithubでissueを起票しよういう段階に至ったのですが、英語でissueを書く前に内容を一旦日本語で整理したいというのと同じ問題に当たった人への共有のために本記事を書きます。（私は英語は全然なのでAIの力を使ってissue書くつもりでいます）

# AngleSharpとは
本記事は主にAngleSharpをご存じの方向けのものではありますが、偶然この記事を読んだ方のために紹介しておきます。
AngleSharpは.NETでスクレイピングをするためのライブラリです。

https://anglesharp.github.io/

https://github.com/AngleSharp/AngleSharp

JavaScriptで生成されるDOMやCookieにも対応しているので、ログインが必要なサイトでもおおむね問題なくスクレイピングできます。今回の問題はそのCookieに関するものになります。

なお、AngleSharpがサポートするフレームワークは以下のようになっており
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149348/f33ecd9d-4cbe-3338-4db0-5991b146d6ea.png)
.NET 9はサポートされてないからおかしくなっても当たり前ではというようにも見えますが、.NET Standard 2.0に準拠しているので.NET 9でも問題ないと解釈できるはずです。
あと、あと仮にサポート外だからおかしくても仕方ないとしても、いずれ.NET 9対応にバージョンアップされるでしょうからそれに向けてissueを挙げておくのは意味があると思います。

# 事象
まず大前提ですがこの問題は.NET 8では発生しません。ソースコードを何も変えずTargetFrameworkを.NET 9にすると発生します。前述およびタイトルの通り、Cookieに関する問題となります。
expires（cookieの有効期限）付きのCookieは以下のように、HTTPのレスポンスヘッダで指定されます。
`Set-Cookie: name=value; Expires=Sun, 08 Sep 2024 23:59:59 GMT`
このヘッダが指定されると、クライアントはCookieを保持しその次のリクエストからそのCookieをRequestに含めるようになります。上記の例では2024/09/08 23:59:59 GMTまでがCookieの有効期限で、それを過ぎるとCookieはリクエストに含まれなくなります。Cookieのexpiresは上記フォーマットでGMTで指定されるようにRFCで決まっており、つまり日本時間JSTでは2024/09/09 08:59:59が有効期限となります。
 **GMTでの指定にもかかわらず、日本時間JSTの2024/09/08 23:59:59と解釈してしまうというのが今回発見した問題の内容です。** 
有効期限が正しくないのはもちろん問題なのですが、これの何が問題が大きいかというと、 **日本時間JSTにおいてはexpresが9時間以内の場合、最初から期限切れでそのCookieが全く使えないということです。**
具体的に例を挙げて説明します。
1. 日本時間2024/12/30 09:00:00に30分後(2024/12/30 09:30:00)が有効期限のSet-Cookieがレスポンスされる。この場合、expresの表記は`Set-Cookie: name=value; Expires=Mon, 30 Dec 2024 00:30:00 GMT`となる

2. しかし、GMTではなくlocaltime(今回はJST)`Mon, 30 Dec 2024 00:30:00`として解釈してしまう

3. この時点ですでに、日本時間では00:30:00を過ぎているためこのCookieが使われることはない（もちろんこれは正しくない、ログイン状態が保持されないなどの弊害がある）

4. GMTとJSTの時差は9時間であるので、expresが9時間以内のCookieでは必ず本事象が発生する

# 事象確認までの過程

まず最初に、.NET 9にするとあるサイトで①ログインして②ログイン後のURLのスクレイピングをしても、ログインされてない状態のレスポンスが返ってくることに気付きました。
原因がよくわからなかったのでパケットキャプチャしたところ、②ログイン後のURLのスクレイピング（リクエスト）の際に送信されるCookieに差があることことがわかりました。なお、Cookieが長かったため以下の画面キャプチャでは見えていませんが、実際には今回問題となるExpiresが後ろの方に指定されています。
**.NET 8の場合（問題ないケース）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149348/ebc1860c-54b0-bfdf-88ce-22c02257f0bd.png)


**.NET 9の場合（問題あるケース）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149348/ebca7380-a5ca-55cd-2000-a299fa165565.png)


①のレスポンスでのSet-Cookieヘッダには差がないことも確認できたので、
* Set-Cookieが処理されていない
* Set-Cookieはされているが、リクエストの際にCookieを送信していない

のどちらかであると考えることができます。

ここまでは普段スクレイピングしているとある会員サイトで確認していましたが、issueを起票することを視野に入れこの事象が再現する最小構成を確認するためにあらゆる条件を試すことができるように、またほかの人がissueをみて実験ができるサイトが必要であろうと思えたので、自分でサイトを立てて確認していくことにしました。
そこでまず、もっとも単純な`Set-Cookie: name=value`の形式（expiresの指定なし）で検証したところ２回目のリクエストで問題なくCookieが送信されることがわかりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149348/5c20510d-9faf-6950-274a-ecd4efb76dbd.png)


元々のCookieは `expres` 以外にも`Path` `Secure` `HttpOnly`も指定されていたのですが、`expires`の可能性が高いだろうと読んで `Set-Cookie: name=value; Expires=Mon, 30 Dec 2024 00:30:00 GMT`の形で実験したところやはり見事に再現しました。ここまでくると、Expiresの解釈に問題があるだろうということまではわかるのですが、その先もTimezoneの扱いで何か起こってるのだろうというのは何となく想像できますよね。
そこで、ExpiresのGMTをJSTで解釈しても期限切れになっていない日時（9時間3分後）をExpresに指定したSet-Cookieをレスポンスしたところ、見事に次のリクエストにCookieは含まれました。

**有効期限が9時間以内だと最初から期限切れになってしまう**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149348/00bbac87-3791-a2f8-b2d0-2e1d0008b1b6.png)

**有効期限が9時間より大きいと期限切れにはならない**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149348/d858a770-e2c2-4b8c-5075-6c760acc4710.png)

よって、GMTの日時をlocaltimeで解釈してしまっているということが確定しました。

# 原因
ではなぜこのようなことが起きているのか根本原因をさらに探ってみましょう。
幸いにもAngleSharpはOSSですし、.NET自身もOSSです。ソースコードを見て原因を探ることができます。
`Set-Cookie`のキーワードでAngleSharpのソースコードを見ていくと、以下にたどり着きます。

https://github.com/AngleSharp/AngleSharp/blob/5dc976bb0c4b30799e28a0ea43eff180f4259964/src/AngleSharp/Io/MemoryCookieProvider.cs#L44

```cs
public void SetCookie(Url url, String value)
{
    var cookies = Sanatize(url.HostName, value);

    try
    {
        _container.SetCookies(url, cookies);
    }
    catch (CookieException ex)
    {
        Debug.WriteLine("Cookie exception, see {0} for details.", ex);
    }
}
```

Cookieの文字列をなにやら編集して、コンテナにSetCookiesしています。この`_container`は宣言部を見ればわかりますが、.NET標準のクラスである`System.Net.CookieContainer`です。
ということは、以下のどちらかが原因と考えられます
* `System.Net.CookieContainer`側のExpiresの解釈に問題がある
* その前に何やらAngleSharp側でCookieの文字列を処理している部分に問題がある

Cookieの文字列を処理している部分（Sanatizeメソッド）を見ると、以下のような部分があります。

https://github.com/AngleSharp/AngleSharp/blob/5dc976bb0c4b30799e28a0ea43eff180f4259964/src/AngleSharp/Io/MemoryCookieProvider.cs#L84

```cs
if (DateTime.TryParse(middle.Replace("UTC", "GMT"), out var utc))
{
    var time = utc.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture);
    cookie = $"{front}{time}{back}";
}
```

最初は`GMT`という文字列が含まれれていたexpiresの文字列が、ToStringでGMTの文字がない状態になっていることがわかります。このメソッドで編集されたCookieの文字列が`System.Net.CookieContainer`の`SetCookies`でどう処理されるかですが、たどっていくと最終的には`System.Net.CookieParser`というクラスでDateTime.TryParseされていることがわかりました。
つまり、**`29 Dec 2024 13:19:22`(GMTのついていない)のような文字列をParseしたとき、.NET 8ではGMT(UTC)として解釈されていたものが、.NET 9ではlocaltime(現地時間)で解釈されるようになったということです。**

# 解決策
.NET側でなぜこのような仕様の変更があったのかはわかりませんが、.NET側の仕様を元に戻すというのは難しいと思われますので、AngleSharp側でCookieの文字列を編集している部分を修正するのが妥当と考えられます。元々Cookieは`GMT`という文字列が含まれるのが正しい姿ですので、
```cs
if (DateTime.TryParse(middle.Replace("UTC", "GMT"), out var utc))
{
    var time = utc.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture);
    cookie = $"{front}{time}{back}";
}
```
としてやればよいのではないかと思います。
そもそもAngleSharpで意図的にGMTを消してるソースじゃないの？という疑問があるかと思いますが、断言できませんがこのメソッドの目的はCookieのdomain部分をいじりたいがためにあるようで、GMTを消すのが目的ではないと思われます。

# おわりに
OSSのソースコードからさらに.NETのソースコードまで追いかけたのは初めてでしたが、普段仕事でソースコードを追う以上に頭を使い、いい勉強になりました。
この情報を元に、OSSへの初めてのissue起票とプルリクを進めていく所存です。