セキュリティ
フレームワークのセキュリティは、ハンドラを 1 行も書かないうちにかなりの部分が 決まっています。常に効いている防御もあれば、設定しなければ動かないものも、 アプリケーションが自分で持つしかないものもあります。無効だと分かっていれば手は 打てますが、有効だと思い込んでいる防御の穴はなかなか見つかりません。
そこで以下では、リクエストがどの検査を通っていくかに沿って、それぞれの担当範囲を 整理します。Popcorn Web は危険なリクエストを拒否できますが、アプリケーション固有の 操作を誰に許すかまでは決められません。
リクエストが通る順序
Section titled “リクエストが通る順序”リクエストはチェインを通ってハンドラに届きます。この順序は適当に並べたものでは ありません。どの段も、その上の段がすでに走っていることを前提にしています。
| 段 | 決めること |
|---|---|
| 運用エンドポイント | ヘルスチェック、レディネス、フレームワークのアセットが最上位で応答する |
| 公開アセット | 静的ファイルはルートに触れずに返る |
| ボディ上限 | 大きすぎるボディは読む前に拒否する |
| セキュリティヘッダ | ブラウザに適用させるレスポンスポリシー |
| リカバリ | パニックが切断ではなくレスポンスになる |
| セッション | クッキーが検証済みレコードになる。ならなければ匿名リクエスト |
| 認証 | ログイン系エンドポイントが自分のパスに応答する |
| CSRF | 更新系リクエストが、自分のページ由来であることを示す |
| ガード | 保護対象パスへの未認証リクエストをログインへ送る |
| ハンドラ | — |
太字の 3 段が、身元が権限に変わる場所です。その上はリクエストの形についての話で、 セッション以降が、誰が投げたのかという話になります。
CSRF が認証より下にあるのは見落としやすいのですが、理由があります。ログイン系 エンドポイントは上位で応答してしまうので、ログインの POST も OIDC のコールバックも トークンを必要としません。そもそも検査まで届かないからです。設定で除外しているのでは なく、段の位置から自然にそうなります。
既定で動いているもの
Section titled “既定で動いているもの”何も設定していないプロジェクトを守るものが 5 つあります。
レスポンスヘッダ。 X-Content-Type-Options、X-Frame-Options、リファラポリシー、
それに範囲を狭く取った Content-Security-Policy が、すべてのレスポンスに乗ります。
このポリシーが縛るのはスクリプト、プラグイン、ドキュメントのベース URL、フレーム
埋め込みだけです。画像・フォント・スタイル・通信先には触れません。普通のページが
そのまま動くようにしてあります。中身と、自分で書くべき場面については
セキュリティヘッダを参照してください。
エスケープ。 テンプレートは型の付いた穴に値を差し込み、その値が着地する位置に 合わせてエスケープします。生のマークアップを出すには専用のイントリンシックを明示的に 呼ぶ必要があるので、ヘルパの内側に隠れることがなく、レビューで目に入ります。
URL スキーム。 URL に対しては、エスケープは正しい道具ではありません。危ないのは
ブラウザが読む文字ではなく実行するスキームのほうで、javascript:alert(1) はどんな
エスケープもすり抜けます。そこで URL を持つ属性はスキームの allowlist と照合し、
外れたものは #tb-blocked-url として出力します。
URL 属性を参照してください。
不透明なセッショントークン。 ブラウザが持つのは 256 ビットのランダム値です。 ストアの鍵はその SHA-256 ハッシュなので、データベースのダンプが漏れても、その値を そのままクッキーとして使い回すことはできません。
名乗ったとおりの静的アセット。 pw build は、public/ の中に中身と拡張子が
食い違うファイルがあると拒否します。スクリプト入りの SVG が .png を名乗っていても、
バイナリまで届きません。image/svg+xml のレスポンスには sandbox も付きます。配信する
画像のうち実行されうるのは SVG だけで、放っておけば自分のオリジンで動いてしまうから
です。静的アセットを
参照してください。
設定を1行書くまで動かないもの
Section titled “設定を1行書くまで動かないもの”CSRF は、対象のパスを指定するまで動きません。これは意図的です。何も覆っていない
検査は「保護がある」と読めてしまいます。csrf.include = [] は、csrf.enabled = false
よりたちの悪い嘘です。
ブラウザログインは、入ってきた時点でその対象を 1 つ持ち込みます。だから pw init は
形を書いたうえで有効にします。雛形のサインアウト操作はフォームですし、生成された
フォームはトークンが無ければ描画されずに弾かれるからです。
[security]csrf.enabled = truecsrf.include = ["/**"]csrf.exclude = []逆にログインが無ければ、トークンを結びつけるセッションもありません。そのときの
pw init は [security] セクションそのものを書きません。
パス保護も同じ形です。protection.include = [] は全パスが公開という意味なので、
プロジェクトはルートを除外していくのではなく、追加していきます。
HSTS は、証明書について確信が持てるまで無効です。一度キャッシュしたブラウザに 対して、あとから取り消すのが難しいからです。
CSRF の仕組み
Section titled “CSRF の仕組み”CSRF トークンは、そのリクエストがサーバの描画したページから来たことを示すものです。 仕組みとしては、サーバは知っていて攻撃者のページからは読めない秘密値です。
秘密値はセッションごとに 1 つで、セッションを作るときに生成してセッションレコードに 入れます。ローテーションは自動です。ログインするとセッションが置き換わり、秘密値も 一緒に変わるので、ログイン前に作ったトークンはログイン後には通りません。
秘密値はハンドラまで届きません。コードが読むセッションビューからは意図的に外して ありますし、テンプレートのスコープにも入りません。
トークンがブラウザに届く経路は 3 つあり、どれも同じ 1 つの秘密値に対して検証します。
| 経路 | 書くもの | 読むもの |
|---|---|---|
| hidden フィールド | テンプレートコンパイラが、全 unsafe form に | 素のフォーム送信 |
pw_csrf クッキー |
セッションマネージャが、セッションクッキーと並べて | ブラウザランタイム |
X-CSRF-Token ヘッダ |
ランタイムが、クッキーから読んで | サーバ |
hidden フィールドは、自分で書かなくていい部分です。<form method="post">(put、
patch、delete も同じ)には、生成時に第一子として自動で入ります。GET フォームには
入りません。フィールドがそのままクエリ文字列になり、URL に載ったトークンは履歴・
ログ・リファラに残ってしまうからです。
同じページでも、2 種類のフォームは実行時ではなく生成時に弾かれます。1 つはクロス オリジンに POST するフォームで、セッションの秘密値を第三者に渡すことになります。 もう 1 つは method が計算値のフォームで、これは safe なのか unsafe なのか判別が つきません。
クッキーがある理由
Section titled “クッキーがある理由”ヘッダに載せる値は、ページから取ることもできました。そうしなかったのはローテーションの ためです。
描画済みの HTML に埋め込んだトークンは、render した時点で固まります。ページを開いた ままセッションがローテートすると——別のタブでログインした、権限が変わった——手元には サーバが受け付けないトークンが残り、次の操作が失敗します。しかも画面には理由が何も 出ません。リクエストを出す瞬間にクッキーを読めば、この穴が塞がります。ローテーションが 新しいクッキーを書いているので、ランタイムは次のリクエストでそれを拾えます。
Django も Laravel も Spring の SPA 構成も同じ形です。代償は JavaScript から読める
クッキーが 1 つ増えること。これが、フレームワークのクッキーは HttpOnly にするという
規則の、唯一の明示された例外です。読めないランタイムは送れません。
毎回バイトが変わる理由
Section titled “毎回バイトが変わる理由”出力のたびに新しいランダムパッドが乗るので、同じページを 2 回描画するとトークンの バイト列は変わります。復元される秘密値は同じです。
理由は圧縮にあります。レスポンスは圧縮されるので、攻撃者が内容に影響を与えられる テキスト——エコーバックされた検索語、再表示されたフォームの値——と、変化しない秘密値が 同じ圧縮ボディに同居していると、レスポンスサイズを観察するだけで 1 バイトずつ 取り出せてしまいます。レスポンスごとに変わるパッドを乗せておけば、そこに蓄積するものが 残りません。
検証のときは、届いたリクエストが運んできたパッドから期待値を組み直し、定数時間で 比較します。Rails も Django も同じ理由でマスクしています。
匿名の訪問者
Section titled “匿名の訪問者”トークンには秘密値が要ります。ところが公開ページに自前の unsafe form がある場合—— 問い合わせフォーム、POST 検索——秘密値を持ってきてくれるログインがありません。
これに専用の仕組みは要りませんでした。秘密値は
登録されたセッションスロットで、
配置は session.Private です。private なスロットは、セッションが匿名のあいだは封を
したクッキーに載ります。だから匿名の訪問者もちゃんと秘密値を持てて、しかもストアに
行は増えません。ページを読んだクローラが払うのはクッキー 1 つで、レコードではないと
いうことです。
サインインすると、ログイン時のローテーションが同じスロットを設定済みのバックエンドへ 移し、新しい秘密値を作ります。匿名側が残ることはありません。そもそも 2 つ目が存在 しないからです。1 つのスロットが両方の母集団を賄っていて、ここに固有の設定は 1 つも ありません。
一時期は分かれていました。認証済みはレコードのフィールド、匿名は署名クッキーで、 専用の鍵と専用の opt-in が付いていました。分けていた理由は「匿名トラフィックに ストアの行数を決めさせない」ことでしたが、private なスロットをログインまでクッキーに 置いておけば同じ問いに答えられます。それで 2 つ目の仕組みは消えました。
拒否されたとき
Section titled “拒否されたとき”拒否されたリクエストは、ほかと同じエラー経路を通って 403 になります。ブラウザには 自分の HTML エラーページ、API クライアントには problem ドキュメントです。ハンドラは 呼ばれません。
レスポンスは、どの検査で落ちたかを言いません。理由はログのほうに出ます。レスポンスで 名指ししてしまうと、攻撃者にどちらを詰めればいいかを教えることになるからです。
自分で持つもの
Section titled “自分で持つもの”以下はフレームワークが決めません。
認可。 検証済みのセッションが言うのは、誰が要求しているかまでです。何をしていいか
は言いません。パス保護もルート単位なので、それより細かいものは自分で書きます。
ハンドラの中でも書けますし、
{check …} を使ってページ自身の
中でも書けます。後者は Go の関数をエラーのためだけに呼び、拒否が 1 バイトも書かれる
前にレスポンスを選びます。
どのパスが unsafe か。 csrf.include を狭めるのは自分の判断です。csrf.exclude
も同じで、webhook はここに入ります。セッションを持たず、自前の認証を運んでくるから
です。
登録したコンポーネントの入力。 コンポーネントを再描画可能として公開すると、その
パラメータは攻撃者が操作できる値になります。渡された値を整形するだけのコンポーネントは
安全ですが、識別子でレコードを読むものは、ハンドラと同じように所有権を自分で確かめる
必要があります。{check …} はそのためにあります。レビューすべき地点は、登録している
場所です。
設定の中の秘密値。 secret として分類した値はログと起動サマリから伏せられますし、
pw doctor はコミットされたファイルの中のリテラルな秘密値を報告します。ただし、
その値がすでに漏れていたかどうかまでは分かりません。
開発だけに許されていること
Section titled “開発だけに許されていること”APP_ENV によって意味が変わる設定がいくつかあります。どれもリクエストから読むことは
ありませんし、ホスト名から推測することもありません。環境は配備が与えるトークンで、
知らない値は開発ではなく配備として扱います。
以下の 3 つの層は、分けて把握しておく価値があります。落ちるタイミングが違いますし、 そのうち 1 つはそもそも落ちません。
開発以外では起動しない
Section titled “開発以外では起動しない”プロセスが起動しません。どれも防御を弱めるのではなく切ってしまうものなので、警告では 済ませていません。
session.cookie.secure = false は、セッションクッキーが平文の http を通ることを
許します。pw init は開発用の設定ファイルに意図してこれを書きますが、dev 以外では
設定名を挙げて起動に失敗します。例外は secure を伴わない same_site = none で、
こちらはどの環境でも失敗します。そもそもその組み合わせをクロスサイトクッキーとして
受け入れるブラウザがありません。
auth.jwt.dev.trust_unverified_tokens は、署名の無い手書きのベアラートークンを通します。
curl を持った開発者が認可サーバ無しで作業できるようにするためのものです。鍵は 4 つ
あって、全部が開いている必要があります。pwdev ビルドモード、stg でも prod でも
ない APP_ENV、設定そのもの、そしてループバックからのリクエスト(opt-out はありません)
です。タグ無しでビルドしたバイナリは、この設定を見つけただけで起動に失敗します。
無視はしません。そして、緩まないもののほうも同じくらい重要です。admission、失効、
パーサの上限はいずれも効いたままなので、開発者が相手にするのは迂回路ではなく本物の
ルールです。
開発用 ID プロバイダは、誰の認証もしません。
サインインは一覧から利用者を選ぶだけです。だから prod では起動を拒否しますし、
ループバックにしかバインドしません(opt-out はありません)。pw build も、対象の
アプリケーションがこれを import していると失敗するので、事故でリリース成果物に
混ざることはありません。pw dev がどう繋いでいるかは
認証の組み込みにあります。
開発以外でも通るが、警告が出る
Section titled “開発以外でも通るが、警告が出る”こちらは起動します。起動時に警告が出て、pw doctor も報告します。認証を切るのでは
なく、可視性・永続性・多層防御を削る種類のものなので、判断は配備の側に残してあります。
| 設定 | dev 以外での代償 |
|---|---|
security.csrf.enabled = false |
unsafe なリクエストの origin もトークンも検査されない |
security.headers.enabled = false, hsts.enabled = false |
ブラウザが適用するレスポンスポリシーが弱くなる |
observability.query.enabled |
SQL 文が診断レコードに載る |
observability.query.bind_values |
行の値も載る。アプリのデータがフレームワークの SQL レコードに入る唯一の経路 |
observability.minimum_level = trace / debug |
配備されたプロセスで冗長なログが出る |
sqlite のインメモリ DSN |
スキーマも全行も再起動で消える |
server.public.read_local = true |
ビルド済みツリーではなくディスクから配信される |
auth.enabled かつ session.backend = cookie |
発行済みのセッションをログアウトで終わらせられない(次節) |
まず見るべきは csrf.enabled です。フレームワークの既定が false なので、ログイン
無しで雛形を作って一度も有効にしていないプロジェクトは、選んだ覚えのないままこの行に
入っています。
設定項目が無いのに環境で変わるもの
Section titled “設定項目が無いのに環境で変わるもの”エラーページは、dev では問題をそのまま載せます。メッセージ、原因、Problem に
持たせた detail まで出ます。それ以外の環境ではステータスとタイトルだけです。これに
対応する設定項目はありませんし、テンプレートも両者で同じものです。変わるのは、
そこに渡されるものだけです。開発中に読むのはその失敗を起こした本人で、これから直す
人でもあります。それ以外の環境では、同じページが公開の場に出ます。
1 つだけ、意図的に環境で切っていない緩和があります。auth.oidc.allow_loopback_http
と JWT 側の対応物は http の issuer を許しますが、縛っているのは APP_ENV ではなく
「ホストが実際にループバックであること」です。環境トークンは配備が与えるもので
間違えられる以上、こちらのほうが強い縛りになります。
ここに挙げる 4 つの防御には、絶対ではなく境界があります。しかもその理由は、どれも 現実の側にあります。ブラウザは一度渡したものを手放しませんし、ランタイムはソケットの read を途中でやめられません。設定を強めれば消えるという種類のものではないので、 知ったうえで設計を組む対象として書いておきます。
クッキーバックエンドはセッションを終わらせられない
Section titled “クッキーバックエンドはセッションを終わらせられない”session.backend = cookie は、セッションレコードをサーバに置かず、封をしてブラウザに
持たせます。運用コストが最も低く、インフラを何も必要としない唯一のバックエンドですが、
代償が失効です。すでにブラウザに書いたレコードは取り返せません。ログアウトが期限切れに
できるのはクライアント側の写しだけで、それ以前に取られた写しは封の期限まで動き続けます。
アカウント停止も同じ形になります。
これをログインと組み合わせること自体は、開発では正しい判断です。データベース無しで
ログインが動くことがこのバックエンドの存在理由ですし、露出させるには他人が握っている
ブラウザが必要になります。なので dev では何も言いません。それ以外の環境では起動時に
警告が出て、pw doctor も設定に対して PW0506 を報告します。
the login session is stored where it cannot be revokedどちらもプロセスは止めません。セッションをオンデマンドで終わらせられなくても構わないか
どうかを知っているのは配備だけなので、判断はそちらに残します。ただし、その警告を
staging や本番のログで読んでいるなら、答えはほぼ確実に「構う」でしょう。rdb、
redis、dynamo、firestore のいずれかに移してください。インシデント発生後もセッションを安全に管理できる
かどうかを決めるのはログアウトと停止の 2 つで、どちらもサーバが消せるレコードを
必要とします。
カート、ロケール、ウィザードの途中経過のように、ログインを前提としない状態を置く だけなら、クッキーバックエンドは今でも良い選択です。
アカウント停止が効くまで最大 30 秒かかる
Section titled “アカウント停止が効くまで最大 30 秒かかる”停止したアカウントは、ログインの時点で拒否されます。すでにセッションを持っている リクエストも拒否します。生きているセッションの背後にあるアカウントを、リクエストの 流れの中で読み直しているからです。ただし読み直すのは 1 アカウントにつき 30 秒に 1 回までです。毎リクエスト読むと、認証済みのページすべての手前にデータベースの往復が 1 回入ってしまいます。
この間隔が、「すでに握られているセッションに停止が届くまでの時間」に対する正直な
答えです。停止を実行する場所から auth.ForgetAccount(accountID) を呼べば、次の
リクエストですぐ読み直されます。ただしプロセスローカルなので、複数インスタンスで
動かしている配備では、ほかのインスタンスは結局この間隔を待つことになります。
アカウントストアにまったく到達できないときは、通過させるのでもサインアウトさせるのでも なく、503 で拒否します。資格情報はまだ判定できていませんし、リトライで成功する余地も あります。それに障害中に通過させてしまうと、すべての停止がアカウントデータベースの 稼働を前提にしたものになってしまいます。
TinyGo ビルドではタイムアウトを自前で効かせている
Section titled “TinyGo ビルドではタイムアウトを自前で効かせている”TinyGo の net/http は、接続したあと期限なしで read します。これは推測ではなく、
実装自身が TINYGO: TODO handle timeouts というコメントでそう書いています。つまり
OIDC ディスカバリ、トークン交換、JWKS 取得を context.WithTimeout で囲んでも何も
縛れず、遅い IdP がリクエストハンドラを相手が切るまで掴んだままにできてしまいます。
そこで OIDC と JWT のクライアントは、TinyGo ビルドでは transport を包み、期限が来たら 必ず呼び出し側に戻るようにしています。包んでも解決できないのは、その下で走っている ラウンドトリップ自体のキャンセルです。あのランタイムにキャンセルの口が無いことが、 そもそもの理由だからです。したがって、固まった IdP が払わせるコストは、止まった ハンドラではなく、ソケットが自分で失敗するまでのゴルーチン 1 本とコネクション 1 本に なります。リクエストは処理され続けますが、負荷が続く状況で永遠に固まる IdP は、 やはりアラートに値します。
これはホスト Go のビルドでは再現しません。force_tinygo_logic を付けても同じです。
あのタグが選ぶのは TinyGo 側のコード経路で、リンクされる net/http はホストのもの
だからです。タイムアウトの挙動は、実際のツールチェーンで試すしかない数少ない項目の
1 つです。
キャッシュした JWKS の鍵は障害中でも期限切れになる
Section titled “キャッシュした JWKS の鍵は障害中でも期限切れになる”検証鍵は issuer から取得してキャッシュします。更新に失敗したときは、キャッシュ済みの 鍵をそのまま使い続けます。issuer に到達できないことは、その鍵が間違っていることを 意味しないからです。ただしそれはキャッシュ TTL を過ぎてから 1 時間までで、その先は 検証が失敗する側に倒れます。
ここに上限を設けているのは、公開されている文書から取り下げられた鍵は、その文書を
このプロセスが読めるかどうかとは無関係に取り下げられているからです。stale の窓に
上限が無いと、侵害された鍵は障害が
続くかぎり使えることになり、それは攻撃者にとって都合よく選べる窓になります。既定は
1 時間で、MaxStaleAge で 24 時間まで動かせます。「無期限」にする設定は意図的に
用意していません。
何が有効かを確認する
Section titled “何が有効かを確認する”pw doctor は、指定した環境のマージ済み設定を読んで、無効になっているものを報告します。
pw doctor --env prod無効な CSRF 検査、secure の無いセッションクッキー、弱められたレスポンスヘッダ、
開発以外で有効なままのクエリ診断を指摘します。開発マシンから本番の設定に対して走らせる
ことに意味があります。findings が対象にしているのは、動いているプロセスではなく
ファイルだからです。
