既存のWebサイトの手前にリバースプロキシを置き、レスポンスを加工してから返す構成があります。HTMLの変換、レスポンスヘッダーの追加、アクセス制御など、用途はさまざまです。この構成では、公開ドメインのDNSをプロキシへ向け、プロキシが元のサーバーへリクエストを転送します。ここでは company-a.example を公開用の名前、origin.company-a.example を取得用の名前として使用します。
変更前 訪問者 → 元のサーバー
変更後 訪問者 → プロキシ → 元のサーバー
↑ ここでレスポンスを加工してから訪問者へ返す
以降、訪問者のリクエストを最初に受けるサーバーをEdge、コンテンツを持つ元のサーバーをオリジンと呼びます。この形のサーバーがリバースプロキシです。
オリジンへのリクエストには、公開用とは別の名前を使用します。company-a.example のDNSはすでにEdgeを指しているため、同じ名前を使うとEdgeが自分自身を呼び出すためです。origin.company-a.example のような名前を用意し、オリジンを直接指すようにします。
訪問者
↓ https://company-a.example/ 公開用の名前
Edge
↓ https://origin.company-a.example/ 取得用の名前
オリジン
公開用と取得用で名前を分けると、2種類の障害が発生します。 いずれもnginxでは専用の設定で対応できる既知の問題です。以下では、障害の原因、nginxによる対応、専用機能を持たない基盤で実装する場合の要件を説明します。例に使うドメイン名はRFC 2606が文書用に予約している範囲から取っています。
内部用の名前が訪問者の画面に出る経路
この構成では、サイト内のリンクをクリックすると、ブラウザーのアドレス欄に origin.company-a.example のような内部用の名前が表示されることがあります。
内部用の名前が訪問者に公開され、その名前へのアクセスはEdgeを通りません。
原因はリダイレクトです。オリジンが 301 や 302 を返すとき、次のURLは Location ヘッダーに入ります。この値をそのまま訪問者へ返すと、次のようになります。
訪問者 → Edge https://company-a.example/blog
Edge → オリジン https://origin.company-a.example/blog
オリジン → Edge 301 Location: https://origin.company-a.example/blog/
Edge → 訪問者 301 Location: https://origin.company-a.example/blog/
↑ そのまま返している
訪問者 → オリジン Edgeを経由しない
ブラウザーは指示されたURLへ移動します。行き先の origin.company-a.example はEdgeを通らないため、書き換えは適用されず、内部用の名前も公開されます。
サイトが特別な設定をしていなくても、リダイレクトは日常的に発生します。
末尾スラッシュの補完 /blog → /blog/
www の正規化 www 付き ↔ www なし
HTTPS への誘導 http:// → https://
ログイン後の遷移 /login → /dashboard
古いURLの転送 /old-page → /new-page
このうち上の3つは、Webサーバーやフレームワークが既定で行う場合があります。サイト運営者が意識していなくても発生します。
なお Location の値は絶対URLとは限りません。RFC 9110の10.2.2節はこう定義しています。
Location = URI-reference
The field value consists of a single URI-reference. When it has the form of a relative reference ([URI], Section 4.2), the final value is computed by resolving it against the target URI ([URI], Section 5).
このフィールドの値は単一のURI参照である。相対参照 ([URI] 4.2節) の形をとる場合、最終的な値は、対象URI ([URI] 5節) に対して解決することで求められる。
Location: /blog/ のような相対参照であれば、ブラウザーは訪問者が実際にアクセスしたURLを基準に解決するため、行き先も https://company-a.example/blog/ になります。問題になるのは絶対URLの場合だけです。ただし、どちらを返すかを決めるのはオリジンなので、プロキシ側では選べません。絶対URLが返る前提で設計するのが安全です。
表示は通るのにログインだけが失敗する状態
もう1つは、ページは正しく表示される一方で、ログイン後にログイン画面へ戻される障害です。認証情報は正しく、エラーも表示されません。
原因はCookieです。オリジンが返す Set-Cookie に、取得用の名前が含まれています。
オリジン → Edge → 訪問者
Set-Cookie: session=abc123; Domain=origin.company-a.example
↑ この名前ではブラウザーが送り返さない
Cookieの仕様はRFC 6265が定めています。Domain 属性については4.1.2.3節にこうあります。
The Domain attribute specifies those hosts to which the cookie will be sent.
Domain属性は、そのCookieが送られるホストを指定する。
重要なのは方向です。指定した名前とその下位のサブドメインへは送られますが、上位のドメインへは送られません。
Domain=company-a.example
company-a.example → 送る
origin.company-a.example → 送る (下位)
Domain=origin.company-a.example
origin.company-a.example → 送る
company-a.example → 送らない (上位)
さらに、指定できる範囲自体に制限があります。同じRFCの5.3節は、ブラウザーがCookieを保存するときの手順を次のように定めています。
If the domain-attribute is non-empty:
If the canonicalized request-host does not domain-match the domain-attribute:
Ignore the cookie entirely and abort these steps.
domain属性が空でない場合:
正規化されたリクエストホストがdomain属性とドメイン一致しない場合:
そのCookieを無視し (部分的にではなく全体を破棄する)、以降の手順を中止する。
ここでいうドメイン一致は5.1.3節で定義されており、Domain の値がアクセス先の名前の接尾辞であることを求めます。company-a.example へアクセスしている訪問者に対して Domain=origin.company-a.example を送っても、この条件を満たしません。そのため、Cookie全体が破棄されます。
問題が起きるのは、オリジンが明示的に Domain を付けている場合だけです。 Domain を付けなければ、ブラウザーはアクセスした名前のCookieとして保存するため、正しく動作します。付けるかどうかはフレームワークや設定によって異なるため、一部のサイトでだけログインに失敗します。
この障害は、通常のページ表示だけでは検出しにくい点に注意が必要です。
トップページの表示 正常
記事ページの表示 正常
書き換えの適用 正常
ログイン 失敗する
カートへの追加 保持されない
言語設定の保存 保持されない
導入直後の確認をトップページの表示だけで終えると、この状態を正常と判定してしまいます。ログインなど、Cookieを使う操作も確認する必要があります。
nginxが持つ2つの専用ディレクティブ
ここまでの2つは、nginxでは専用のディレクティブで対応できます。
proxy_redirect
proxy_redirect がこう定義されています。
Sets the text that should be changed in the "Location" and "Refresh" header fields of a proxied server response.
プロキシしたサーバーの応答の "Location" および "Refresh" ヘッダーフィールドで、置き換えるべきテキストを設定する。
Refresh も対象に含まれている点に注意してください。独自実装では、Location だけを対象にして Refresh を見落とす可能性があります。
さらに、既定値の挙動があります。
location /one/ {
proxy_pass http://upstream:8000/two/;
proxy_redirect default;
}
proxy_redirect default; は、proxy_pass の設定から置換ルールを自動的に導出します。上の設定は次と等価です。
proxy_redirect http://upstream:8000/two/ /one/;
書き換えルールを手動で設定する必要はありません。 proxy_pass にあるプロキシ先の設定から、置換規則を導出する設計です。
proxy_cookie_domain
proxy_cookie_domain が対応します。
Sets a text that should be changed in the domain attribute of the "Set-Cookie" header fields of a proxied server response.
プロキシしたサーバーの応答の "Set-Cookie" ヘッダーフィールドの domain 属性で、置き換えるべきテキストを設定する。
proxy_cookie_domain origin.company-a.example company-a.example;
Path 属性についても、同じ形式の proxy_cookie_path が用意されています。
この2つは、リバースプロキシで繰り返し発生してきた問題です。 そのため、専用のディレクティブが用意されています。Node.jsのリバースプロキシライブラリにも同様の機能があります。node-http-proxy では autoRewrite と cookieDomainRewrite がこれにあたります。autoRewrite は nginx の proxy_redirect default; と同様に、要求されたホストとポートから書き換え先を自動で決定します。cookieDomainRewrite は { "old.domain": "new.domain", "*": "" } のようにオブジェクトで指定でき、"*": "" は Domain 属性の削除を意味します。
専用機能を持つ基盤の少なさ
一方、この2つに専用機能を持つ基盤は限られます。
| 基盤 |
Location の書き換え |
Set-Cookie の Domain
|
|---|---|---|
| nginx | proxy_redirect |
proxy_cookie_domain |
| node-http-proxy | autoRewrite |
cookieDomainRewrite |
Go httputil.ReverseProxy
|
無し (ModifyResponse で自作) |
無し (同左) |
| Caddy | 無し (header_down の検索置換) |
無し (同左) |
| HAProxy | 無し (http-response replace-value) |
無し (同左) |
| Envoy | 無し (response_headers_to_add は追加と削除のみ) |
無し (同左) |
専用機能を持つ基盤は限られます。 したがって、専用機能がないこと自体は不利な条件ではありません。
ただし、専用機能の設計は独自実装の指針になります。proxy_redirect default; が proxy_pass から導出する考え方は、そのまま利用できます。オリジン取得用の名前と公開名の対応を設定として持てば、書き換えルールはそこから導出できます。サイトごとに置換ルールを手動で設定する必要はありません。
独自に実装する場合の判断
Cloudflare WorkersのようなFetch APIベースの基盤では、この2つを独自に実装します。専用ディレクティブが処理していた判断を明示する必要があるため、順に決めます。
ホスト名の完全一致による判定
Location の値が内部用の名前を指していれば、公開用の名前へ置き換えます。判定は、ホスト名の完全一致で行う必要があります。
書き換える https://origin.company-a.example/...
書き換えない https://shop-b.example/... 別のサイトのドメイン
書き換えない https://payment.example.net/... 外部の決済サービス
書き換えない https://origin.company-a.example.evil.example/...
↑ 前方一致で判定すると誤って書き換える
最後の例は、部分一致で判定した場合の危険を示しています。攻撃者が自分のドメインに origin.company-a.example を含む名前を作ると、書き換えの対象に入ります。ホスト名を正規化したうえで、完全一致で判定してください。
外部ドメインへのリダイレクトは、サイト運営者が意図した動作です。決済やSSOへの遷移がこれにあたります。ここを書き換えると、サイトの機能を損ないます。
書き換えの対象範囲の決定
リダイレクトを修正しても、内部用の名前が漏れる経路は残ります。オリジンが返す内容には、絶対URLを含みうる箇所が複数あります。
Location リダイレクトの行き先
Content-Location 返した表現の場所
Link rel=canonical、rel=preload など
Refresh 指定秒後に別URLへ移動させる指示
HTML 内
<a href="https://origin.company-a.example/...">
<link rel="canonical" href="...">
<meta property="og:url" content="...">
<script src="...">
<form action="...">
その他
sitemap.xml 絶対URLの一覧
JSON-LD 構造化データ内のURL
API のレスポンス 次ページのURLなど
このうち rel=canonical と og:url は訪問者の画面に現れませんが、検索エンジンやSNSが読み取ります。ここに内部用の名前が入ると、検索結果にEdgeを通らないURLが表示される可能性があります。
すべてを書き換える設計は現実的ではありません。対象範囲をあらかじめ決めておく必要があります。
必ず書き換える Location、Content-Location、Link、Refresh
HTML の a / link / form / script の href と action
canonical、og:url
判断が要る JSON のレスポンス本文
JavaScript 内に埋め込まれた文字列
sitemap.xml
書き換えない 外部ドメインを指すすべてのURL
JavaScript内の文字列は、書き換えると壊れる可能性があります。文字列の連結で組み立てられている場合、部分的な置換が意図しない結果になります。書き換えずに検出し、サイト運営者へ報告する方法が安全です。
Cookieの削除と書き換えの選択
Domain への対処は2通りあります。
削除する方法。 Domain 属性を取り除いて訪問者へ渡します。ブラウザーは company-a.example のCookieとして保存し、以後そこへ送ります。単純で、副作用も小さい方法です。
書き換える方法。 Domain の値を公開用の名前へ置き換えます。
違いが生じるのは、サイトがサブドメインをまたいでCookieを共有している場合です。www.company-a.example と shop.company-a.example で同じセッションを使う構成では Domain=company-a.example が必要になるため、書き換える方法を選びます。ただしこの場合、Edgeがサブドメインも受けているかを確認する必要があります。受けていないサブドメインへCookieが送られると、そのサーバーが想定していない値を受け取ります。
落としてはいけない属性
Set-Cookie には Domain 以外の属性も含まれます。書き換え処理で、これらを失ってはいけません。
| 属性 | 落とすと何が起きるか |
|---|---|
Secure |
HTTPでもCookieが送られ、盗聴による漏えいの危険が生じます |
HttpOnly |
JavaScriptから読めるようになり、XSS(クロスサイトスクリプティング)でセッションを奪われる危険が生じます |
SameSite |
別サイトからのリクエストでも送られ、CSRF(クロスサイトリクエストフォージェリ)の経路が生じます |
Path |
想定より広い範囲へ送られます |
Max-Age / Expires
|
有効期間が変わり、セッションが意図せず切れる、または残り続けます |
属性を組み立て直す実装では、順序や大文字小文字の違いによって解析に失敗し、属性を失うことがあります。Domain の部分だけを置換し、他の属性を保持する方が安全です。
書き換えの対象から外す接頭辞付きのCookie
Cookie名に定められた接頭辞が付いていると、ブラウザーは追加の条件を課します。Cookie Prefixesとして定義されているものです。
__Secure- で始まる名前
Secure 属性が付いていること
HTTPS で送られたこと
__Host- で始まる名前
Secure 属性が付いていること
HTTPS で送られたこと
Path=/ であること
Domain 属性が付いていないこと ← ここが問題になる
__Host- は、Domain 属性を持たないことが条件です。属性があればブラウザーが拒否し、Cookie全体が失われます。したがって、Domain を一律に付け直す実装は __Host- のCookieを壊します。名前の接頭辞を判定し、書き換えの対象から外す必要があります。削除する方法を選べば、この条件は自然に満たされます。
複数のSet-Cookieの処理
Set-Cookie は1つの応答に複数回現れます。ログイン時にセッションとCSRFトークンを同時に発行する構成では、2つ以上が返ります。ヘッダー取得処理が最初の1つしか扱わないと、残りが失われます。
Fetch APIベースの基盤では、標準の Headers.getSetCookie() で配列として取り出せます。ヘッダーを独自に分割する処理は不要です。取り出したそれぞれについて、Domain を判定して書き換えるか削除するかを決めます。
なお逆方向、つまり訪問者からオリジンへ送られる Cookie ヘッダーには Domain が含まれません。ブラウザーは名前と値だけを送るため、そのまま転送できます。ただしEdge自身がCookieを使う場合は、名前に接頭辞を付けて区別し、転送前に取り除きます。
書き換えだけでは止まらない漏れ
Location の書き換えに加えて、漏れた場合の影響を抑える構成が必要です。
オリジン側でEdge以外からのアクセスを拒否します。 Edgeが送る共有の秘密をヘッダーで渡し、オリジンが検証します。訪問者が内部用の名前へ直接アクセスしても、拒否されます。
Edge → オリジン X-Edge-Token: <共有の秘密>
訪問者 → オリジン ヘッダーが無い → 403
この方式なら、書き換え漏れがあっても影響を抑えられます。Edgeを経由しないアクセスを拒否する有効な対策です。 ただし、オリジン側の設定が必要になります。
推測しにくい名前にする方法もあります。origin.company-a.example ではなくランダムな文字列を含む名前にすれば、推測による到達の可能性は下がります。ただし、名前が漏れた事実は変わらないため、これだけでは対策になりません。
IPアドレスによる制限だけでは十分ではありません。EdgeをCDNなどの事業者上で動かしている場合、そのIPアドレスは同じ事業者の他の利用者とも共有されることがあります。共有の秘密と組み合わせる必要があります。
実装の前に問題の名前を調べる理由
実装前に、既存の専用機能を確認する必要があります。
中継で発生する問題は、リバースプロキシで長く扱われてきた課題です。そのため、proxy_redirect のような専用ディレクティブが用意されています。今回の2項目にも既存の解決策があります。
直面している問題に既存の解決策があるかを、実装前に確認してください。 確認せずに実装を始めると、既存機能で対応できる処理を再実装することになり、Refresh ヘッダーのような境界条件を見落とす可能性があります。
まとめ
公開用と取得用の名前を分ける構成では、絶対URLの Location と Set-Cookie の Domain を確認します。前者を漏らすとプロキシを迂回され、後者を漏らすとログイン状態を維持できません。
nginxでは proxy_redirect と proxy_cookie_domain を使用します。独自実装では、ホスト名を完全一致で判定し、Cookieの他の属性と複数の Set-Cookie を保持します。あわせてオリジンへの直接アクセスを拒否すると、書き換え漏れの影響を抑えられます。
参考情報
- nginx - ngx_http_proxy_module
- node-http-proxy
- http-proxy-middleware
- Caddy - reverse_proxy
- Go - net/http/httputil.ReverseProxy
- RFC 9110 - HTTP Semantics
- RFC 6265 - HTTP State Management Mechanism
- MDN - Set-Cookie
- MDN - Cookie Prefixes
- MDN - Headers.getSetCookie()
- RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
- RFC 2606 - Reserved Top Level DNS Names