オリジン保護と trusted proxy#
Cloudflare やリバースプロキシの裏で動くアプリ向けに、CDN を迂回したリクエストを弾く OriginGuard と 実クライアント IP を取り戻す TrustedProxy を同梱しています。
環境変数で有効にする#
Relayer::boot() が次の変数を読み、結果をルーターの guard として設定します。guard は /_defer/{name}、dev プロファイラ、middleware.php の すべての前に 走ります。何も設定しなければ何も入りません。
ORIGIN_SECRET=<長いランダム値> # OriginGuard を有効化
ORIGIN_SECRET_PREVIOUS=<旧値> # ローテーション中はこれも受け付ける
ORIGIN_GUARD_HEADER=X-Origin-Secret # 既定値
ORIGIN_GUARD_EXCEPT=/healthz,/_health/* # 完全一致、または `/prefix/*`
ORIGIN_GUARD_MODE=enforce # または report
TRUSTED_PROXIES=fdaa::/16 # TrustedProxy を有効化(CIDR のカンマ区切り)
TRUSTED_PROXY_PRESET=fly # fly | cloudflare | xff(既定)
CLOUDFLARE_IP_RANGES=173.245.48.0/20,… # 前段の後ろに Cloudflare レイヤーを足すオリジン保護を middleware.php に置いてはいけません。そこが包むのはルートディスパッチだけなので、/_defer/{name} を直接叩けば迂回できます。自分で合成したい場合は AppRouter::setGuard() が同じ fn(Request, $next) を受け取り、環境変数から組まれたものを置き換えます。
<?php
// public/index.php
use Polidog\Relayer\Http\OriginGuard;
use Polidog\Relayer\Relayer;
use Psr\Log\LoggerInterface;
Relayer::boot(__DIR__ . '/..')
->setGuard(OriginGuard::middleware([
'header' => 'X-Origin-Secret', // 既定値
'secretsFromEnv' => ['ORIGIN_SECRET', 'ORIGIN_SECRET_PREVIOUS'],
'except' => ['/healthz'], // 完全一致、または `/prefix/*`
'mode' => 'enforce', // または 'report'
'logger' => Relayer::container()->get(LoggerInterface::class), // 任意
]))
->run();OriginGuard#
CDN が転送時に付与するシークレットヘッダーを検証し、無い・一致しないリクエストに 403 を返します。
- 既定は無効。 空でないシークレットがなければ素通しなので、ローカルや CI が 403 だらけになりません。
secretsFromEnvには環境変数名を書きます。値は trim されるので、末尾の改行で全リクエストが 403 になることはありません。値を直接secretsに渡すこともできます。- ローテーション中は新旧両方が通ります。比較は
hash_equalsです。 exceptはプラットフォームのヘルスチェックなどを対象から外します。mode: 'report'は拒否せずログに出して通すので、enforceの前の様子見に使えます。- ログに出るのはメソッド / パス / クライアント IP だけで、シークレットもクライアントが送った値も出しません。
- シークレットは
fly secrets setや gitignore 済みの.env.localで渡してください。
TrustedProxy#
転送ヘッダーからクライアント IP を解決しますが、直前のホップ(REMOTE_ADDR)が信頼するプロキシのときだけです。結果は $request->clientIp() で取れます。$request->ip() は REMOTE_ADDR のままで、clientIp() は解決できなければ ip() にフォールバックします。
use Polidog\Relayer\Http\TrustedProxy;
// 前段が Cloudflare: CF-Connecting-IP。レンジは同梱しないので
// https://www.cloudflare.com/ips-v4 と /ips-v6 を取得して渡す
TrustedProxy::middleware(['preset' => 'cloudflare', 'proxies' => $cloudflareRanges]);
// Fly.io: Fly-Client-IP。Fly のプライベートネットワークから来たときだけ信頼
TrustedProxy::middleware(['preset' => 'fly', 'proxies' => ['fdaa::/16']]);
// 自前のプロキシ: X-Forwarded-For を右から辿り、自分のプロキシを読み飛ばす
TrustedProxy::middleware(['preset' => 'xff', 'proxies' => ['10.0.0.0/8']]);
// 任意の単一 IP ヘッダー(`preset` と同時指定不可)
TrustedProxy::middleware(['header' => 'X-Real-IP', 'proxies' => ['192.0.2.1']]);proxiesは必須で、単一アドレスか CIDR(IPv4 / IPv6)です。不正なレンジはマッチしません。- 信頼しないホップから来たヘッダーや、IP として不正な値は無視します。
- ホップ検証を省略できるのは明示的な
'trustAnyHop' => trueだけで、プロセスへの経路がそのプロキシ 1 本だと証明できる場合に限ります。
多段プロキシ(chain)#
Cloudflare → Fly 構成では、Fly-Client-IP は Cloudflare の接続元 IP で、利用者は CF-Connecting-IP にいます。レイヤーを外側ほど後に並べると、各レイヤーは「ここまでに解決したアドレスがそのレイヤーのプロキシである」ときだけ適用されます。
TrustedProxy::middleware(['chain' => [
['preset' => 'fly', 'proxies' => ['fdaa::/16']],
['preset' => 'cloudflare', 'proxies' => $cloudflareRanges],
]]);レシピ: Cloudflare + Fly.io#
- Cloudflare → Rules → Transform Rules → Modify Request Header で、ゾーンに対して
X-Origin-Secretに長いランダム値を設定します。 - シークレットと信頼チェーンをアプリに渡します。PHP は書きません。
fly secrets set \
ORIGIN_SECRET=<その値> \
ORIGIN_GUARD_EXCEPT=/healthz \
TRUSTED_PROXIES=fdaa::/16 \
TRUSTED_PROXY_PRESET=fly \
CLOUDFLARE_IP_RANGES="$(curl -s https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6 | paste -sd,)"- 確認します。
curl -I https://<app>.fly.dev/が403、独自ドメインが200、/healthzはどちらからでも200になれば完了です。
ローテーションは新しい ORIGIN_SECRET に加えて ORIGIN_SECRET_PREVIOUS=<旧値> を設定し、後で後者を外します。CDN のキャッシュについては CDN キャッシュ を参照してください。
改訂履歴 (1)
- v0.31.0: 新規ページ