コンテンツにスキップ

セキュリティ

フレームワークのセキュリティは、ハンドラを 1 行も書かないうちにかなりの部分が 決まっています。常に効いている防御もあれば、設定しなければ動かないものも、 アプリケーションが自分で持つしかないものもあります。無効だと分かっていれば手は 打てますが、有効だと思い込んでいる防御の穴はなかなか見つかりません。

そこで以下では、リクエストがどの検査を通っていくかに沿って、それぞれの担当範囲を 整理します。Popcorn Web は危険なリクエストを拒否できますが、アプリケーション固有の 操作を誰に許すかまでは決められません。

Popcorn Web は、Web アプリケーションは攻撃されるものだという前提で開発しています。 攻撃者が試すことの大半は、どのサイトに対しても同じ数種類の手口です。だから、 アプリケーションの中身を知らなくても決められる防御は、フレームワークができるかぎり 早い段階で決めてしまいます。生成時には tinybind-go がテンプレート・クエリ・リクエストバインディングをリフレクション無しの型専用 Go コードにコンパイルし、ハンドラ上位のチェインでは、アプリケーションのコードが 1 行も 走る前にリクエストの大きさを測り、解釈し、ときには拒否します。

前提のもう半分は、見えない防御は頼れない、ということです。安全な側が既定値で、 防御を切る設定は開発以外では起動を拒むか警告を出して pw doctor が報告し、拒否の レスポンスはどの検査で落ちたかを呼び出し側に教えません。このページの残りがその 詳細で、この節は地図です。

server.max_request_body がすべてのリクエストを 10 MiB で打ち切ります。自分で書いた ミドルウェアの外側に掛かるので、ボディを読むミドルウェアも縛られます。fasthttp ビルドではハンドラが走る前にサーバが 413 で拒否します。その内側ではデコーダごとに 上限があります。デコードに使うメモリは転送サイズとは別のコストだからです。JSON ドキュメント、マルチパート、CBOR(server.cbor_max_body)はそれぞれ 1 MiB、 ベアラートークンは 16 KiB、リクエスト ID は 128 バイトで、どれも無制限にはできません。 [server] と tinybind-go の サイズと形の上限を 参照してください。

遅い接続、開けたまま放置される接続

Section titled “遅い接続、開けたまま放置される接続”

ヘッダの送信に 5 秒、リクエスト全体に 30 秒、keep-alive のアイドルに 2 分。この タイムアウトが、1 バイトずつ送ってくるクライアントからゴルーチンを取り返します。 書き込みタイムアウトがゼロなのは live なレスポンスを開いたままにするためで、そちらは html.live_idle_timeout が、5 分間なにも届かなかったものを閉じます。 middleware.request_timeout を設定すれば、リクエストのコンテキストに期限が付きます。

リクエストボディを伸長することは、このフレームワークもその下の tinybind-go も しません。リクエストの Content-Encoding は無視されるので、伸長すればギガバイトに なる数キロバイトは、数キロバイトのまま上の上限の内側で届きます。圧縮は出口方向だけ で、事前圧縮アセットは pw build が作り、ID プロバイダから読むバイト列には トランスポートの伸長後に上限が掛かります。スタックで唯一伸長しうる WebSocket の permessage-deflate は既定で無効で、有効にしても ReadLimit は伸長後の大きさで 数えます。

壊れた入力と、想定外のパラメータ

Section titled “壊れた入力と、想定外のパラメータ”

pw.Parse[T] はその構造体専用に生成されたコードでバインドするので、構造体が入力面の すべてです。どのフィールドも名乗らないキーは、payload:"*" の rest マップで求めない かぎり読み飛ばされ、型どおりに解釈できない値や check に外れた値はバインド全体を 失敗させて、落ちたフィールドを挙げた 1 つの 400 の problem ドキュメントになります。 サーバ側には __proto__ が汚染するプロトタイプが存在せず、90 段より深いドキュメントは Go のスタックを守るために拒否されます。構文の違うセッションクッキーはストアに届かず、 印字可能な ASCII 128 文字に収まらないリクエスト ID は差し替えられます。 リクエストバインディングを参照してください。

サーバは自分が発行していないトークンを採用しません。知らないクッキーは消され、 リクエストは匿名のまま続きます。発行するトークンは 256 ビットの乱数で、SHA-256 ハッシュをキーに保存します。ログインはセッションをローテートします。古いレコードを 先に失効させ、新しいトークンを発行し、CSRF の秘密値を作り直すので、ログイン前に ブラウザが持っていたものはログイン後には死んでいます。step-up も同じ理由で ローテートします。クッキーは HttpOnly、Secure、SameSite=Lax で、 secure = false は開発以外では起動を拒みます。 ログインの瞬間に起きる移動を 参照してください。

パスワードの窃取と認可コードの横取り

Section titled “パスワードの窃取と認可コードの横取り”

フレームワークはパスワードを扱いません。ブラウザログインは OpenID Connect か、ID トークンを発行しないプロバイダ向けの素の OAuth 2.0 で、グラントは S256 の PKCE を 必須とする Authorization Code だけです。Password グラント、Implicit フロー、Client Credentials グラントは実装していません。state・PKCE verifier・nonce はサーバ側の トランザクションに置かれて 1 回だけ消費され、ブラウザが持つのは 10 分で切れる クッキーに載った不透明なキーだけです。ID トークンは RS256 でのみ受け付け、nonce と azp を検査します。ブラウザを持たないデバイスは Device Authorization Grant を、 サーバの指定する間隔で使います。パスキーは設定したオリジンに縛られた WebAuthn で、 ユーザー検証を必須にでき、巻き戻った署名カウンタは報告されます。pw dev は パスワードを検査しない代わりに prod を拒むローカルの OpenID Provider を立てるので、 アプリケーションは検査を飛ばすフラグの裏ではなく、本物のログイン・セッション・ ガード・CSRF 検査を通ったサインイン済みの状態でテストできます。 認証の設計と 開発用の認証プロバイダを参照してください。

CSRF、スクリプトの注入、javascript: URL、フレーム埋め込み、CSRF トークンに対する 圧縮のサイドチャネルは、このページに専用の節があります。 既定で動いているものと CSRF の仕組みを参照してください。 エスケープと URL の規則は tinybind-go の htmlbind のもので、値が何になりうるかを 着地する位置で決めます。テキストと属性はエスケープされ、URL 属性は生成時に url 型を、描画時に許可されたスキームを要求し、<script> の中身とイベントハンドラには string では何も届きません。全表は セキュリティリファレンスに あります。pw.Redirect はスクリプトを実行せずには辿れない行き先を拒否し、ログインの next はルートから始まるローカルなパスしか受け付けません。

.pw.sql の本文にある {name} はすべてプリペアドステートメントのプレースホルダ です。tinybind-go の sqlbind はテンプレート式を SQL の文字列に連結せず、手書きの $1 や ? を拒否し、識別子・演算子・ソート方向の位置に値を立てさせません。文字列を 組み立てたくなる理由も消してあります。任意条件の検索は、全条件が成り立つときの素の SQL に {if} ブロックで条件をくり抜く形で書き、宙に浮く AND やコンマの取り下げ、 空になった WHERE の除去、プレースホルダの振り直し、スライスの IN 展開は コンパイラがやるので、インジェクションが入り込みがちな句を手で組み立てる場面が ありません。どこかの分岐で WHERE が空になりうる UPDATE や DELETE は生成時に 拒否されます。条件付き SQL、 2 つの安全規則、それに sqlbind の 値は必ず parameter として渡されるを 参照してください。

静的アセットは pw build が埋め込んだツリーから配信されるので、開発以外では辿る べきディスクがありません。それでもリクエストのパスは正規形でなければならず、ドットで 始まるセグメント、..、バックスラッシュ、NUL、シンボリックリンクは、パーセント デコードを 1 回通したあとで拒否されます。 パスの扱いを参照してください。

大量リクエストとブルートフォース

Section titled “大量リクエストとブルートフォース”

レート制限は設定するまで動きません。手前のプロキシがすでにアドレス単位で制限して いるからで、こちらが足すのはエッジに計算できない、サインインした利用者ごとの予算 です。既定では利用者に毎分 600、アドレスに 300 で、超えれば 429 と Retry-After、 さらにプロセス全体の上限があります。数える相手のアドレスは server.trusted_proxies に含まれる相手からの X-Forwarded-For に限るので、ヘッダで予算は買えません。偽の kid が引き起こす鍵の再取得も、クールダウンごとに最大 1 回です。 レート制限を参照してください。

秘密値との比較——CSRF トークン、署名クッキーの MAC、パスキーのチャレンジ、HMAC の ベアラートークン、署名付きストレージ URL、OIDC の nonce——はすべて定数時間なので、 応答時間は推測がどれだけ近かったかを語りません。

ベアラートークンは、検証の前に固い上限でパースされます。3 セグメント、各 8 KiB、 ネスト 16 段、メンバ 256 個、重複メンバ無し、crit 無し、日付は整数のみです。 alg: none は拒否され、allowlist に対称アルゴリズムは書けず、鍵は issuer 自身の オリジンにある JWKS から、HTTPS でリダイレクトを辿らずに、ヘッダが名乗るアルゴリズム 用の kid で解決します。候補が曖昧なら拒否、2048 ビット未満の RSA 鍵も拒否です。 そのうえでクレーム一式を要求します。iss、aud(設定値の any か all)、 exp・nbf・iat(leeway は最大 5 分)、max_token_lifetime 以内の exp - iat、 sub、ID トークンの使い回しを防ぐ at+jwt の typ、jti、required_scopes の 全項目です。そこまで通って初めて admission と失効の判定に届き、どちらも答えられ なければ拒否側に倒れます。拒否はすべて同じ 401 と WWW-Authenticate: Bearer です。 未知の kid の再取得はクールダウンごとに 1 回、キャッシュした鍵が障害を生き延びるのは 1 時間までで、 既知の限界に 書いたとおりです。 JWT-only の API サーバーと [auth.jwt] を参照してください。

ハンドラのパニックはほかの失敗と同じ交渉済みのエラー経路で 500 になり、パニックの 内容はログに行ってレスポンスには出ません。開発以外のエラーページが載せるのは ステータスとタイトルだけです。 設定項目が無いのに環境で変わるものを 参照してください。

以下はフレームワークが実装していないものです。別の層か、アプリケーション自身が持つ べきものだからです。上の一覧は、ハンドラ自身が何を信じているかを見直すことの代わりには なりません。残りは自分で持つものに 挙げてあります。

TLS はロードバランサか CDN で終端します。フレームワークは素のポートで待ち受け、 リクエストが HTTPS だったことは自分の TLS 接続か、server.trusted_proxies の プロキシが送った X-Forwarded-Proto から知り、そうして確かめたリクエストにだけ HSTS を送ります。プロセス自身で TLS を提供しなければならない場合も、pw.Middlewares が 組み上がったチェインを普通の http.Handler として返すので可能です。 リバースプロキシを参照してください。

回線やポートを飽和させるトラフィックは、プロセスより先にそれを見る WAF かエッジが 吸収します。上に挙げたタイムアウト・上限・レート制限はアプリケーション層の流量制御 で、サインインした利用者ごとの予算だけがアプリケーションにしか計算できない部分です。

検証済みのセッションやベアラートークンが言うのは誰が要求しているかまでで、何をして いいかは言いません。パス保護はルート単位で、それより細かいものはハンドラか、ページの 中の {check …} が持ちます。

リクエストの値に何を操作させるか

Section titled “リクエストの値に何を操作させるか”

Parse の構造体にバインドされないフィールドは無いので、呼び出し側に操作させては いけない値はそこに置かず、リクエストが運んでよいものだけをバインドして残りは ハンドラで導きます。再描画可能として公開したコンポーネントのパラメータは攻撃者が 操作できる値なので、所有権の確認はコンポーネント自身の仕事です。

URL の規則が拒むのは実行されるスキームであって行き先ではありません。 //other.example/ は辿れますし、ping や srcset はパースできる行き先を何でも 受け付けます。フレームワークがローカルなパスを強制するのはログインの next だけで、 それ以外の、利用者が与えた行き先の検証は自分の仕事です。

アップロードされたファイルとアーカイブ

Section titled “アップロードされたファイルとアーカイブ”

アップロードされたファイルは、バインダにとって単なるバイト列です。フレームワークは アーカイブを開かず、アップロードを描画せず、縛るのは大きさだけです。解凍、スキャン、 配信は、アプリケーションが自分の上限を決めたうえで判断することです。

規則を持たないテンプレートの位置

Section titled “規則を持たないテンプレートの位置”

htmlbind は meta refresh の URL をまだフィルタせず、style 属性には CSS ではなく HTML のエスケープを掛け、srcdoc には規則がありません。いずれも信頼できない入力から 組み立てないでください。一覧は セキュリティリファレンスが 追跡しています。

secret と印を付けた値はログと起動サマリから伏せられ、pw doctor はコミットされた ファイルの中のリテラルな秘密値を報告しますが、その値がすでに持ち出されたかどうかは 分かりません。ローテーションは自分の仕事です。

リクエストはチェインを通ってハンドラに届きます。この順序は適当に並べたものでは ありません。どの段も、その上の段がすでに走っていることを前提にしています。

段 決めること
運用エンドポイント ヘルスチェック、レディネス、フレームワークのアセットが最上位で応答する
公開アセット 静的ファイルはルートに触れずに返る
ボディ上限 大きすぎるボディは読む前に拒否する
セキュリティヘッダ ブラウザに適用させるレスポンスポリシー
リカバリ パニックが切断ではなくレスポンスになる
セッション クッキーが検証済みレコードになる。ならなければ匿名リクエスト
認証 ログイン系エンドポイントが自分のパスに応答する
CSRF 更新系リクエストが、自分のページ由来であることを示す
ガード 保護対象パスへの未認証リクエストをログインへ送る
ハンドラ —

太字の 3 段が、身元が権限に変わる場所です。その上はリクエストの形についての話で、 セッション以降が、誰が投げたのかという話になります。

CSRF が認証より下にあるのは見落としやすいのですが、理由があります。ログイン系 エンドポイントは上位で応答してしまうので、ログインの POST も OIDC のコールバックも トークンを必要としません。そもそも検査まで届かないからです。設定で除外しているのでは なく、段の位置から自然にそうなります。

何も設定していないプロジェクトを守るものが 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 = true
csrf.include = ["/**"]
csrf.exclude = []

逆にログインが無ければ、トークンを結びつけるセッションもありません。そのときの pw init は [security] セクションそのものを書きません。

パス保護も同じ形です。protection.include = [] は全パスが公開という意味なので、 プロジェクトはルートを除外していくのではなく、追加していきます。

HSTS は、証明書について確信が持てるまで無効です。一度キャッシュしたブラウザに 対して、あとから取り消すのが難しいからです。

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 なのか判別が つきません。

ヘッダに載せる値は、ページから取ることもできました。そうしなかったのはローテーションの ためです。

描画済みの HTML に埋め込んだトークンは、render した時点で固まります。ページを開いた ままセッションがローテートすると——別のタブでログインした、権限が変わった——手元には サーバが受け付けないトークンが残り、次の操作が失敗します。しかも画面には理由が何も 出ません。リクエストを出す瞬間にクッキーを読めば、この穴が塞がります。ローテーションが 新しいクッキーを書いているので、ランタイムは次のリクエストでそれを拾えます。

Django も Laravel も Spring の SPA 構成も同じ形です。代償は JavaScript から読める クッキーが 1 つ増えること。これが、フレームワークのクッキーは HttpOnly にするという 規則の、唯一の明示された例外です。読めないランタイムは送れません。

出力のたびに新しいランダムパッドが乗るので、同じページを 2 回描画するとトークンの バイト列は変わります。復元される秘密値は同じです。

理由は圧縮にあります。レスポンスは圧縮されるので、攻撃者が内容に影響を与えられる テキスト——エコーバックされた検索語、再表示されたフォームの値——と、変化しない秘密値が 同じ圧縮ボディに同居していると、レスポンスサイズを観察するだけで 1 バイトずつ 取り出せてしまいます。レスポンスごとに変わるパッドを乗せておけば、そこに蓄積するものが 残りません。

検証のときは、届いたリクエストが運んできたパッドから期待値を組み直し、定数時間で 比較します。Rails も Django も同じ理由でマスクしています。

トークンには秘密値が要ります。ところが公開ページに自前の unsafe form がある場合—— 問い合わせフォーム、POST 検索——秘密値を持ってきてくれるログインがありません。

これに専用の仕組みは要りませんでした。秘密値は 登録されたセッションスロットで、 配置は session.Private です。private なスロットは、セッションが匿名のあいだは封を したクッキーに載ります。だから匿名の訪問者もちゃんと秘密値を持てて、しかもストアに 行は増えません。ページを読んだクローラが払うのはクッキー 1 つで、レコードではないと いうことです。

サインインすると、ログイン時のローテーションが同じスロットを設定済みのバックエンドへ 移し、新しい秘密値を作ります。匿名側が残ることはありません。そもそも 2 つ目が存在 しないからです。1 つのスロットが両方の母集団を賄っていて、ここに固有の設定は 1 つも ありません。

一時期は分かれていました。認証済みはレコードのフィールド、匿名は署名クッキーで、 専用の鍵と専用の opt-in が付いていました。分けていた理由は「匿名トラフィックに ストアの行数を決めさせない」ことでしたが、private なスロットをログインまでクッキーに 置いておけば同じ問いに答えられます。それで 2 つ目の仕組みは消えました。

拒否されたリクエストは、ほかと同じエラー経路を通って 403 になります。ブラウザには 自分の HTML エラーページ、API クライアントには problem ドキュメントです。ハンドラは 呼ばれません。

レスポンスは、どの検査で落ちたかを言いません。理由はログのほうに出ます。レスポンスで 名指ししてしまうと、攻撃者にどちらを詰めればいいかを教えることになるからです。

以下はフレームワークが決めません。

認可。 検証済みのセッションが言うのは、誰が要求しているかまでです。何をしていいか は言いません。パス保護もルート単位なので、それより細かいものは自分で書きます。 ハンドラの中でも書けますし、 {check …} を使ってページ自身の 中でも書けます。後者は Go の関数をエラーのためだけに呼び、拒否が 1 バイトも書かれる 前にレスポンスを選びます。

どのパスが unsafe か。 csrf.include を狭めるのは自分の判断です。csrf.exclude も同じで、webhook はここに入ります。セッションを持たず、自前の認証を運んでくるから です。

登録したコンポーネントの入力。 コンポーネントを再描画可能として公開すると、その パラメータは攻撃者が操作できる値になります。渡された値を整形するだけのコンポーネントは 安全ですが、識別子でレコードを読むものは、ハンドラと同じように所有権を自分で確かめる 必要があります。{check …} はそのためにあります。レビューすべき地点は、登録している 場所です。

設定の中の秘密値。 secret として分類した値はログと起動サマリから伏せられますし、 pw doctor はコミットされたファイルの中のリテラルな秘密値を報告します。ただし、 その値がすでに漏れていたかどうかまでは分かりません。

APP_ENV によって意味が変わる設定がいくつかあります。どれもリクエストから読むことは ありませんし、ホスト名から推測することもありません。環境は配備が与えるトークンで、 知らない値は開発ではなく配備として扱います。

以下の 3 つの層は、分けて把握しておく価値があります。落ちるタイミングが違いますし、 そのうち 1 つはそもそも落ちません。

プロセスが起動しません。どれも防御を弱めるのではなく切ってしまうものなので、警告では 済ませていません。

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 時間まで動かせます。「無期限」にする設定は意図的に 用意していません。

pw doctor は、指定した環境のマージ済み設定を読んで、無効になっているものを報告します。

ターミナルウィンドウ
pw doctor --env prod

無効な CSRF 検査、secure の無いセッションクッキー、弱められたレスポンスヘッダ、 開発以外で有効なままのクエリ診断を指摘します。開発マシンから本番の設定に対して走らせる ことに意味があります。findings が対象にしているのは、動いているプロセスではなく ファイルだからです。