サイトは Bun + Hono で書かれていて、コンテナの中で動き、外部へは Cloudflare Tunnel 経由で出している。5日前に立て続けに2つのバグ(be15f5a、2ea4f53、間隔は8分)が出たが、どちらも「ローカル開発時の前提をこのトポロジに持ち込んでしまった」ものだった。

ひとつめ:origin が http

トンネルが公網からリクエストを送り込むとき、コンテナに届くこの一跳は http になる。そのため request.url で origin を取得している箇所では、どこでも http://... が返ってくる。

大半の箇所では問題ないが、2か所だけまずい:

  • cookie の Secure 属性、および OAuth コールバック URL の組み立て——組み上がるのは http のコールバックで、リダイレクトバック時にはブラウザが https として扱うので食い違う。
  • 外部向けに絶対 URL を生成する場合(共有リンク、メール内のリンク)——送り出されるのは http で、ユーザーがクリックするとリダイレクトされるか、そもそも繋がらない。

Cloudflare は x-forwarded-host と x-forwarded-proto を付けてくれる。ならそれに従って復元すればいい?

ダメだ。x-forwarded-host はクライアントが偽装できるヘッダであり、コンテナのポートに直結する場合にはなおさら、完全にリクエスト側の言い値になる。無条件に信用すれば、origin を書き換えられる穴がひとつ増えるだけだ。

publicRequest(request, configuredUrl) を書いた(src/server/public-request.ts、全部で10行)。ルールはこうだ:

  1. 公開ドメインが設定されていなければ、そのまま返す。
  2. リクエストの host が設定された host と一致しない → 何もしない。別の入口(ループバックでのプレビュー、イントラネットからの直結)から来ているということなので、その origin は利用者自身のものであり、書き換える方がむしろ誤りだ。
  3. host が一致し、プロトコルもすでに同じ → 何もしない。無駄にリクエストを複製しない。
  4. 残る唯一のケース:host が一致し、プロトコルが異なる → protocol だけを変え、new Request() で作り直す。
const configured = new URL(configuredUrl);
const incoming = new URL(request.url);
if (incoming.host !== configured.host || incoming.protocol === configured.protocol)
  return request;
incoming.protocol = configured.protocol;
return new Request(incoming, request);

new Request(url, request) は method、body、headers およびその他の属性をすべて保持し、origin だけを置き換える。src/server/index.ts では fetch: app.fetch を、この処理を通してから転送するように変える:

fetch: (...[request, bindings, executionCtx]: Parameters<typeof app.fetch>) =>
  app.fetch(publicRequest(request, process.env.APP_URL), bindings, executionCtx),

こうして「https への復元」は、確かにこの入口だと分かったときにだけ起きる。偽装された x-forwarded-host は host が一致しないので無視される。

tests/public-request.test.ts の3つがカバーしているのはまさにこれだ:https を復元しつつ POST 本文 / Cookie / クエリ文字列がひとつも欠けないこと、x-forwarded-host: af.saop.cc を伴っていても実際に繋がっているのが 127.0.0.1:31270 の場合は信用されないこと、プロトコルが同じか公開ドメイン未設定のときは(複製ではなく)同一オブジェクトを返すこと。

3番目は厳密にはセキュリティ要件ではなく、予測可能性の話だ——ローカルの curl 127.0.0.1:3000 でデバッグ中に突然 https 形式の origin を見たら、半日悩むことになる。

ふたつめ:コンテナの 127.0.0.1 はホストのものではない

コンテナが外網にアクセスするには、ホストマシン上のプロキシ 127.0.0.1:11090 を経由する必要がある。

compose.yaml にはこう書いた:

environment:
  HTTP_PROXY: ${OUTBOUND_PROXY:-}
  HTTPS_PROXY: ${OUTBOUND_PROXY:-}
  NO_PROXY: ${OUTBOUND_NO_PROXY:-}
  http_proxy: ${OUTBOUND_PROXY:-}
  https_proxy: ${OUTBOUND_PROXY:-}
  no_proxy: ${OUTBOUND_NO_PROXY:-}

大文字と小文字の両方を書いているのは、Bun/undici は小文字しか見ず、多くのシェルツールは大文字しか見ないからだ。

ハマりどころを .env.production.example に直接はっきり書いておく必要がある:

# 宿主机上的代理,不是容器自己的 127.0.0.1
OUTBOUND_PROXY=http://127.0.0.1:11090
OUTBOUND_NO_PROXY=localhost,127.0.0.1,postgres,redis

OUTBOUND_PROXY に 127.0.0.1 を入れると、指すのはホストマシンのループバックになる。 一方コンテナ内の 127.0.0.1 はコンテナ自身なので、食い違えばネットワーク全体が通らなくなる。同時に NO_PROXY には postgres や redis といったサービス名を必ず入れておく——これらは同じ compose ネットワーク内にあり、直結した方がプロキシ経由より速いし、プロキシがこれらの名前を知らないという事態も避けられる。

イメージに書き込まないこと

プロキシは環境依存だ:ビルドマシンにはあっても、ノート PC にはないかもしれないし、本番ではポートが違うかもしれない。だから実行時の environment での注入だけを使い、Dockerfile には ENV HTTP_PROXY をひとつも書かない。

イメージに書き込んでしまえば、イメージはビルド時のネットワーク設定に縛り付けられる。しかも Dockerfile の ENV は実行時の注入を上書きしてしまう——変えたいならイメージを再ビルドするしかなく、明らかに設定の問題なのに。

2つの共通点

2つのバグの形は同じだ:コードは「リクエストは直結、ネットワークも直結」という前提で書かれていて、デプロイのトポロジはそうなっていない。

トンネルもコンテナもそれぞれは問題なく動いていて、両者の境界に翻訳の層がひとつ欠けていた——一方はインバウンドの origin を http から https へ翻訳し(しかも翻訳すべきときにだけ)、もう一方はアウトバウンドの宛先をコンテナのループバックからホストのループバックへ翻訳する。

こういう境界ものには特徴がある:ローカルではすべて正常に動く。ローカルでは「公網の入口」であり同時に「ホストマシン」でもあるので、2つの前提が同時に成立してしまうからだ。デプロイして初めて壊れ、しかも壊れる場所は変更箇所から遠く離れている。

deploy/README.md には今、この2つの説明が並べて置いてある。次に誰かが新しい環境変数を追加するとき、少なくとも「コンテナとホストは2つのループバックだ」という一文は目に入るはずだ。