発端: skill が「ソースの外」で止めた 1 件

前回書いたとおり、Cloudflare security-audit skill のパイロットには、候補として最終検証まで到達しなかった blocked が 4 件ありました。そのうちの 1 件がこれです。

Guzzle の RedirectMiddleware がクロスホスト redirect 時に x-api-key 等のカスタムヘッダを転送するかを vendor/(本スナップショット不在)から確認できない

skill のサンドボックスには vendor/ が無く、verifier は「決定的な事実がソースの外にある」として判定を止めました。設計どおりの止まり方です。実際の vendor/ を読んだら、指摘は実在していました。

対象は guzzlehttp/guzzle 7.15.5(composer.lock)です。src/RedirectMiddleware.php の modifyRequest() に、リダイレクト先へ送るリクエストを組み立てる処理があります。

// Remove Authorization and Cookie headers if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri'])) {
    $modify['remove_headers'][] = 'Authorization';
    $modify['remove_headers'][] = 'Cookie';
}

isCrossOrigin()(guzzlehttp/psr7 の UriComparator)は、ホスト・スキーム・ポートのいずれかが違えば別オリジンと判定します。別オリジンへ飛ぶとき、剥がすのはこの 2 つだけです。x-api-key のような独自ヘッダは、Guzzle にとって秘密かどうか判別できない普通のヘッダなので、そのまま転送されます。

そして追跡は既定で有効です。src/Client.php の configureDefaults() は allow_redirects に RedirectMiddleware::$defaultSettings を入れており、その中身は max => 5、protocols => ['http', 'https']、strict => false です。何も指定しなければ、3xx を最大 5 回追いかけます。

Drupal 側も変えていません。core/lib/Drupal/Core/Http/ClientFactory.php の fromOptions() が http_client サービスに与える既定値は verify・timeout・User-Agent・proxy の 4 つで、allow_redirects には触れません。Drupal の http_client を使う限り、Guzzle の既定がそのまま効きます。

ドキュメントには書いていない

Guzzle の公式ドキュメント(Request Options の allow_redirects)には、既定値が max => 5 で「通常のリダイレクトを有効にする」とあるだけで、リダイレクト時にどのヘッダを剥がすかは書いてありません。

剥がす処理自体は、ホスト変更時の Authorization 除去が 6.1.0(2015 年)で入り、2022 年の 7.4.3〜7.4.4 で「クロスドメインの Cookie 漏洩を直す」「HTTP へのダウングレード時に Authorization を剥がし損ねていたのを直す」というセキュリティ修正として補強されたものです(CHANGELOG.md)。HTTP の意味論で「資格情報」と決まっている 2 つのヘッダを守る修正であり、アプリケーションが独自に決めた名前のヘッダまで守る設計にはなっていません。それは Guzzle の責任範囲ではなく、使う側の責任です。

当社の該当箇所

RIKKA M&A は複数の機能で Claude API を呼んでいます(請求を青天井にしないガードの記事で書いた経路です)。HTTP 呼び出しは 1 つのクライアントクラスに集約してあり、そこでは API キーを x-api-key ヘッダで送ります。Anthropic API の認証方式がそうなっているからです。

送信先は固定の 1 ドメインで、正規の動作で 3xx が返ることはありません。ですから「今すぐ漏れている」話ではありません。 成立条件は、CDN の誤設定、エッジの乗っ取り、DNS や TLS の乗っ取りといった「万一 3xx が返ってきたら」です。そのとき、Guzzle は 5 回まで律儀に追いかけ、x-api-key を付けたまま別のホストへリクエストを送ります。

一方で、リダイレクトを許可しておく理由は 1 つもありませんでした。固定の API エンドポイントに対して、追跡する 3xx はありません。理由の無い許可が、万一のときの経路になっていた、というのがこの finding の正確な形です。

是正: 1 行と、それを固定するテスト

$response = $this->httpClient->request('POST', self::ENDPOINT, [
  'headers' => [
    'x-api-key'         => $apiKey,
    'anthropic-version' => self::API_VERSION,
    'content-type'      => 'application/json',
  ],
  'json'            => $payload,
  'timeout'         => $timeout,
  'connect_timeout' => self::CONNECT_TIMEOUT,
  'allow_redirects' => FALSE,
]);

allow_redirects => FALSE にすると、Guzzle は 3xx をそのまま応答として返します(http_errors は 4xx・5xx にしか反応しません)。このクラスは本文が JSON でなければ空の応答として扱い、呼び出し側が空応答を処理します。キーはどこにも転送されません。

Unit テストでは、モックの HTTP クライアントに渡されたオプション配列に allow_redirects が存在し、値が FALSE であることを assert しています。将来この行を消すと、そのテストが落ちます。

Bearer 方式と x-api-key 方式で挙動が分かれる

同じ Guzzle でも、API の認証ヘッダの名前で安全側に倒れるかどうかが変わります。

認証ヘッダ別オリジンへの redirect 時
Authorization: Bearer ...Guzzle が剥がす
x-api-key: ...(独自名)そのまま転送される

Authorization を使う API なら、allow_redirects を放置していても Guzzle が守ってくれます。x-api-key のような独自名のヘッダで認証する API では守ってくれません。どのヘッダで秘密を送っているかで、既定値の安全性が違います。 クライアントを書くときに確認する項目です。

なぜ気付きにくいのか

  • リダイレクト追跡は「便利な既定値」として有効で、無効化する動機が普段は生まれません
  • ドキュメントには剥がすヘッダの一覧が無く、ソースを読まないと分かりません
  • Drupal の http_client のようなラッパーを経由すると、Guzzle の既定値がさらに見えなくなります
  • 監査ツールは vendor/ を見ません。今回の skill は「確認できない」と正直に止めましたが、止まった項目を放置すれば「見ていない」まま終わります。 判定に至らなかった項目を実ソースで潰したから見つかりました

まとめ

  • Guzzle 7.15.5 は既定で 3xx を最大 5 回追跡し、別オリジンへ飛ぶときに剥がすのは Authorization と Cookie だけです。x-api-key のような独自ヘッダは転送されます
  • Drupal の http_client は allow_redirects に触れないので、Guzzle の既定がそのまま効きます
  • 固定の API エンドポイントにリダイレクト追跡は要りません。allow_redirects => FALSE を明示し、テストで固定してください
  • 独自名のヘッダで秘密を送る API ほど、この既定値が効きます。Authorization なら Guzzle が守り、独自名なら守りません
  • 監査ツールが「ソースの外」と止めた項目は、実ソースで潰すまで未確認です。今回はそこに実在の指摘がありました