🐿️

miseのバグを直そうとしたらGiteaのバグを直した件

に公開

Tl;dr

AWS S3の署名付きURLはHTTPメソッドを署名に含むので、GET用の署名付きURLに対してHEADリクエストを送ると403 Forbiddenになります。HEADリクエストにはHEAD用の署名付きURLを返しましょう。
Giteaがこれを踏んでいました。

はじめに

私は大学生なのですが、趣味でmiseにコントリビュートしています。
この話はaqua:gitea.com/gitea/tea@0.10.1がインストールできないというバグ報告から始まりました。

https://github.com/jdx/mise/discussions/5643

miseにはbackendという機能があり、miseのコアでサポートされているツール(Node.js, Javaなど)以外にも、npm, cargo, pipxなどいろいろなパッケージマネージャのglobal installをまとめて管理することができます。(正確には、ツールをmiseが管理するディレクトリにnpm install -gでインストールするという感じです。)

Aqua backendは、Aquaという他のバージョンマネージャー(Go製)をRustで一部再実装したもので、aqua-registryという2000ほどのツールのインストールURLなどのメタデータがまとめられたものを利用しています。
主にGitHub/GitLabのRelase Assetをサポートしていますが、Giteaは直接はサポートされていないので、Giteaでホストされているgitea.com/gitea/tea(GiteaのCLI)は、バイナリなどのURLを直接指定するtype: httpになっています。

https://github.com/aquaproj/aqua-registry/blob/main/pkgs/gitea.com/gitea/tea/registry.yaml

結局は、miseがhttps://gitea.com/gitea/tea/releases/download/v0.10.1/tea-0.10.1-linux-amd64.xzをダウンロードして展開するだけなのですが、これが403 Forbiddenになるというのがバグです。

S3署名付きURLの問題

今回の主な原因はこれで、GiteaはリリースアセットなどのホストにS3(正確にはS3互換なオブジェクトストレージ)を使えるのですが、そのアクセスに署名付きURL(Presigned URL)を利用します。

署名付きURLとは、パブリックからはアクセスできないリソースに一時的に誰でもアクセスできるURLを発行するS3の機能です。便利ですね。

https://gitea.com/gitea/tea/releases/download/v0.10.1/tea-0.10.1-linux-amd64.xz303 See Otherを返し、Locationヘッダーには次の長いURLが含まれます。

https://4d3e0f26919f429c2b0092fb846c818a.r2.cloudflarestorage.com/gitea-com-prod/attachments/5/9/59bce4a7-e69c-4293-8581-cd98e0e29048?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=90744cd4dfc7c318a594a56d0ebe8323%2F20250715%2Fauto%2Fs3%2Faws4_request&X-Amz-Date=20250715T050622Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&response-content-disposition=attachment%3B%20filename%3D%22tea-0.10.1-linux-amd64.xz%22&X-Amz-Signature=12426a82a805627055a460e20e2098e732b49470ccc1a6054346eb58bc19c8db

これが署名付きURLで、X-Amz-Expires=300とある通り、5分で無効になる一時的なURLです。

ただし、これは基本的にAWSのIAMアカウントに付与する権限を一時的にURLに付与する機能です。URLのクエリパラメータに含まれる署名(X-Amz-Signature)は、HTTPメソッド(GET, HEADなど)、バケット名、オブジェクトキー、有効期限などの情報から生成されます。

例えば、GetObject(ファイルの取得)のみを許可した場合、PostObjectファイルのアップロード)はもちろんできません。しかし、ここで問題になるのがGetObjectHeadObject (メタデータの取得)も別権限だということです。

つまり、GETのみが許可された署名付きURLにHEADリクエストを行うと、署名が無効とみなされ 403 Forbiddenになるわけです。逆(HEAD用のURLにGET)がダメなのは分かりますが、これ(GET用のURLにHEAD)は許可しても問題なさそうに思えます。しかし、S3の仕様上は別の権限なのでダメなようです。

https://stackoverflow.com/questions/15717230/pre-signing-amazon-s3-urls-for-both-head-and-get-verbs

ここで、バグの原因はHEADリクエストがGET用の署名付きURLに送られているか、あるいはその逆と見当がつきました。

miseとHEADリクエスト

そもそもなぜHEADでアクセスしているかというと、URLの存在チェックのためです。

miseの仕様として、バージョンは基本的にsemverになっていたほうが都合がよいです。これはfuzzyにバージョンを指定(majorだけ、major.minorだけとか)できるので、バージョン解決がsemver前提でないとうまくいかないからです。
miseのAqua backendもこれに従って基本的にsemverのみを書くことが推奨されています。

GitHubのタグは特にmonorepoなど、長くなっていることがあるのですが(例:biomejs/biome)、prefixを単に消すと間違ったバイナリをインストールしてしまうことがあるので、aqua-registryのversion_prefixに頼っています。

しかし、Aquaはこの曖昧さを避けるためなのか、GitHubのタグそのまま(@biomejs/biome@2.3.3など)でしか指定できません。
miseがいい感じに使えるようにするためには、aqua-registryにさらにメタデータを追加する必要がありますが、そうするとわざわざフォークが必要になってしまいます。

なので、miseでは「ぽい」バージョンをいくつか試すようになっています。
例えば、1.0.2 なら v1.0.2 も試してみますし、version_prefix: cli/ がついていれば、cli/1.0.2cli/v1.0.2 も試してみます。(もう少し複雑なロジックになっています)

とにかく、それらのうちどれが正しいURLかの判定に、HEADリクエストを使っているわけです。
GETを使うこともできますが、ダウンロードの前に他の処理もあるので、先にバージョンを確定させたほうが好都合です。

調査:リダイレクトとR2

303リダイレクトとreqwest

303 See Otherは、302 Found, 307 Temporary Redirectと同様、一時的なリダイレクトを示すステータスです。

これらの違いは、307がリクエストメソッドを変えない(MUST NOT)のに対して、302は変えるかもしれない(MAY)、303GETに変えることができる(can)という違いがあります。

つまり、HEADリクエストでも、リダイレクト先にはGETでリクエストするのが一般的、ということでしょうか?
結論としては違うのですが、私はこれが(miseで)きちんと実装されていないのではないかと疑い、miseを読みました。

miseはHTTPアクセスに reqwestというライブラリを使っています。
HEADリクエストは単にこれを呼んでいるので、mise自体ではなく、reqwestに問題があるかもしれません。
しかし、Issueを見ると、303でもGETHEADはメソッドをそのままにするのが一般的なようです。
xh, curlで試してもそのままHEADメソッドを使っていますね。

つまり、GET用のURLにHEADでアクセスしているのが原因ということです。元のバグ報告に「GETではリダイレクト先にアクセスするとできる」と書かれていたので、早く試せば気づけましたね。

R2の検証

Cloudflare R2はAWS S3互換のオブジェクトストレージです。
HEADの署名付きURLもサポートしていると書かれていましたが、本当かなと思い自分でR2を作成して試してみたところ、きちんとサポートされていました。
R2が原因の可能性は(元より薄いですが)潰せたことになります。

(aws-cliのpresignはなんとGET以外サポートしていないのでBunで書きました、Auto-install便利)

この時点で、Gitea側が常にGET用の署名付きURLを生成しているのだろうという推測がつきました。

Gitea側の原因

よく考えたら、Giteaはオープンソースでした。読みます。

そうすると、やはり常にm.client.PresignedGetObjectを使ってGET用のURLを生成していますね。(最近話題(?)のMinIOを使っています)

https://github.com/go-gitea/gitea/blob/990ae2bfa8ae951f7c566552a6da0bfba70c0334/modules/storage/minio.go#L282-L292

ここを直せばよいのかと思いましたが、よくRFC9110を読むと、303レスポンスのLocationヘッダーに対しては、GETHEADもしていいと書いてあります。

A user agent can perform a retrieval request targeting that URI (a GET or HEAD request if using HTTP), which might also be redirected, and present the eventual result as an answer to the original request.

つまり、リダイレクト先のURL(=署名付きURL)がGETHEADの両方をサポートしなければならない、とも読めます。
しかし、S3の署名付きURLは、1つのURLで両方のメソッドをサポートすることはできません。

では、302は(曖昧なので)なしとして、307に返すよう変えるべきでしょうか?
しかし、変えても特に便利なことはありません。曖昧なRFCの一解釈に準拠するようになるだけです。

何が一番便利かというと、やはりHEADリクエストにはそのままHEADができる署名付きURLを返すことでしょう。
具体的には、GiteaがリダイレクトURLを生成する際に、元のリクエストがHEADならHeadObject用の署名付きURLを、それ以外(GETなど)ならGetObject用の署名を生成するよう修正します。

おそらくユースケースが少ない機能ですが、一応バグではあるのでIssueに書いてPRを送り付けることにしました。

https://github.com/go-gitea/gitea/issues/35086
https://github.com/go-gitea/gitea/pull/35088

ただ、Goを書くのが初めてなのでおそらく設計的に良くなさそうです…すみません。テストなしで出したのに爆速でレビュー、修正してもらえてありがたい限りです。

Giteaの公式インスタンス(gitea.com)はdev版を使っているので、だいぶ前(8月とか)に報告されたmiseのバグ自体は治っていたんですが、先週1.25.0でリリースもされました。

ちなみに、blameを見ると、これ最初の実装からずっとこうだったみたいです。

そもそもなんで303なんですか?と思ったら、307にしようとしたら破壊的変更なので…と303になった歴史的背景があるっぽいですね。まあ 302 よりはマシかもしれない。
https://github.com/go-gitea/gitea/pull/18063

mise側の対応(見送り)

ここで話をmiseに戻します。そもそもmiseがリリースアセットにHEADを送っているのが(Gitea側から見れば珍しい挙動なわけです。
Gitea側で修正する前に、これをどうすべきか考えました。HEADが失敗したらGETにフォールバックする、という案です。
そうすれば、今回のようにHEADのみ拒否されることはほとんどないはずなので、ほぼうまくいきます。

しかし、GETにフォールバックするということは、つまりダウンロードするということです。ファイルサイズが大きいと時間がかかってしまうため、HEADを使っている意味がなくなってしまいます。わざわざ対応するほどでもないかなと思ったので、mise側への修正PRはクローズしました。

おわりに

バグ報告に書いてある通り、HEADリクエストでGETしかできないURLが返されていると早く気づけばよかったのですが、少し遠回りをしてしまいました。
ただ、こうして関係ないことまで含めて調べることも楽しいし、いろいろ知れるので良いです。趣味ですし。

おもしろいバグ修正があったのでとても久しぶりにZennに書いてみました。(下書きのまま忘れて数か月かかりましたが)
文章力が落ちているのでまた定期的に書かなくては。

miseにコントリビュートし始めたきっかけはあまり覚えてないのですが、何かバグを直すためだった気がします。
ほとんどバグ修正だけずっとしていますが、バグの調査が楽しいので良いです。
そもそもmiseがすごくすごいツールなので貢献できているという満足感もあります。

Rustもmiseで初めて触りましたが楽しく、大学最初のコースでCを学んでRustの解決したかった問題を理解できておもしろかったりしました。

あと、コードを読んでいるとなんとなくバグの理由に推測がつくようにもなってきて楽しいです。


長く乱雑な文を読んでくださりありがとうございました。

冬は日本に帰るのでインターンなど探しています。おすすめがあったら教えてください。

Discussion