traPtitech / traPtitech/Checkin

CSRF トークンがセッションに紐付かず、クッキーとヘッダーの一致だけを検査している

Open
#74 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
TypeScript
Stars
0
Forks
0
Avg merge
3d 5h
Merged PRs (30d)
7

Description

症状

apps/web/server/utils/auth.tsisCsrfValid は、クッキー __Host-checkin_csrf の値と x-csrf-token ヘッダーの値が両方存在し、safeEqual で一致することだけを検査する。セッション ID にも user_id にも照らしておらず、値に署名も入っていない。

トークンの発行側も同じである。apps/web/server/routes/csrf.get.tsgenerateToken() が返した値をそのままクッキーとレスポンス本文の両方に入れて返す。発行の際にセッションを読まないので、発行されたトークンは特定のセッションのものではない。したがって、あるリクエストで成立するクッキーとヘッダーの組は、どのセッションからのリクエストでも成立する。トークンがそのセッションのために発行されたものかを検査する箇所が、発行側にも検証側にも無い

OWASP Cross-Site Request Forgery Prevention Cheat Sheet(https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html 、2026-09-18 に参照)は、この形を Naive Double-Submit Cookie Pattern と呼び、DISCOURAGED としている。対象ドメインにクッキーを書ける攻撃者に破られると述べ、その経路として脆弱な兄弟サブドメイン、DNS 乗っ取り、__Host- でないクッキーへの平文 HTTP 経由の注入を e.g. で挙げている。この 3 つは例示であって、閉じた列挙ではない。代わりに Signed Double-Submit Cookie を勧め、トークンをセッション固有の値へ明示的に紐付けること、署名だけでセッションへの紐付けがない場合は保護がわずかしかなくクッキー注入に対して脆弱なままであることを述べている。

現にある緩和

この弱点の及ぶ範囲を測るために、既に効いている条件を並べる。

  • __Host- 接頭辞。CSRF クッキーの名前は __Host-checkin_csrf である(apps/web/server/utils/auth.tsCSRF_COOKIE)。設定時の属性は同ファイルの hostCookieBasesetCsrfCookie により SecurePath=/Domain 指定なし、SameSite=LaxhttpOnly: false である。draft-ietf-httpbis-rfc6265bis-22(https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-22 、2026-09-18 に参照)の「The "__Host-" Prefix」の節は、この接頭辞で始まる名前のクッキーは Secure 属性と値が /Path 属性を持ち Domain 属性を持たない形でしか設定されないこと、Domain 属性が無いことで host-only-flag が真になりクッキーが特定のホストに固定されてサブドメインをまたがないことを述べている。
    • 塞がる範囲: 兄弟サブドメインからの上書きと、平文 HTTP 経由の注入。この接頭辞をブラウザーが実装している限り、OWASP が例示した 3 経路のうちこの 2 つはこのクッキー名については成立しない。
    • 塞がらない範囲: このクッキーは httpOnly: false なので、このホストで走るスクリプトからは document.cookie で上書きできる__Host- が課す 3 つの条件(SecurePath=/Domain 指定なし)は、同一ホストで走るスクリプトがすべて満たせるためである。このホスト自身の名前が乗っ取られた場合も同様に塞がらない。httpOnly: false は、クライアントがこの値を読んで x-csrf-token ヘッダーに載せる方式のために必要な設定である。
  • CORS の設定がリポジトリに 1 つも無いappspackages の中に corsaccess-control-allow のどちらも 1 件も現れず(数え方: git grep -inE 'cors|access-control-allow' -- apps packages)、apps/web/nuxt.config.tsrouteRules が無く(数え方: grep -c 'routeRules' apps/web/nuxt.config.ts)、apps/web/server/middleware というディレクトリが存在しない(数え方: ls apps/web/server)。apps/web/app/plugins/orpc.ts はクライアントで x-csrf-token ヘッダーを付けて /rpc を呼ぶ。MDN の「CORS セーフリストリクエストヘッダー」(https://developer.mozilla.org/ja/docs/Glossary/CORS-safelisted_request_header 、2026-09-18 に参照)が挙げるのは AcceptAccept-LanguageContent-LanguageContent-TypeRange の 5 つで、x-csrf-token はこれに含まれない。したがって、このヘッダーを載せてオリジンをまたいで /rpc を呼ぶと、プリフライトリクエストが起き、Access-Control-Allow-Origin が返らないのでブラウザーが遮断する。
    • 効く範囲はそのヘッダーを載せた呼び出しに限る。MDN の「オリジン間リソース共有 (CORS)」(https://developer.mozilla.org/ja/docs/Web/HTTP/Guides/CORS 、2026-09-18 に参照)の「単純リクエスト」の節は、<form> 要素はどのオリジンにでも単純なリクエストを送信できるので、サーバーを書く人はすでにクロスサイトリクエストフォージェリー (CSRF) から保護していなければならない、と書いている。サイトをまたぐフォーム送信は単純リクエストなのでプリフライトを起こさず、CORS では止まらない。
  • oRPC の呼び出しはすべて POST であるapps/web/server/routes/rpc/[...].ts のコメントが「all oRPC calls are POST so we cannot gate by HTTP method」と書いている。セッションクッキー __Host-checkin_sessionhostCookieBase を使うので SameSite=Lax であり、サイトをまたぐ POST にはこのクッキーが付かない。
    • SameSite=Lax が守るのは POST の側だけである。同じ OWASP のチートシートの「Limitations of SameSite」の節は、最初の限界として「Lax only blocks unsafe methods.」を挙げ、状態を変える操作が GET で到達できるなら SameSite=Lax は止めないと書いている。このアプリには状態を変える GET が 2 つある。GET /verify-email/confirm(マジックリンクのトークンを消費し、セッションを作るか既存のセッションに user_id を付ける)と GET /login/callback(traQ OAuth のコードを交換してセッションを作る)である。どちらも isCsrfValid の検査の外にある(数え方: git grep -n 'isCsrfValid' -- packages apps にこの 2 ファイルが現れない)。この 2 つが偽造されないことは、SameSite でも CSRF トークンでもなく、ワンタイムトークンと OAuth の state に依っている。
検査が掛かる範囲

isCsrfValid を呼ぶのは 2 箇所である(数え方: git grep -n 'isCsrfValid' -- packages appsapps/web/server/utils/auth.ts にある定義を除いた残りが呼び出しである)。

  • apps/web/server/routes/logout.post.ts が直接呼び、偽であれば 403 を返す。
  • apps/web/server/utils/auth.tsbuildRequestContext が結果を createAuthHelpers に渡し、packages/api/src/auth/context.ts がそれを assertCsrf() として oRPC の文脈に載せる。

oRPC 側では、プロシージャごとに本体の中で context.assertCsrf() を明示的に呼ぶ形になっている(数え方: git grep -n 'assertCsrf()' -- packages/api/srcpackages/api/src/auth/context.ts にある宣言と実装、およびテストを除いた残りが呼び出しである)。packages/api/src/orpc.ts には userProcadminProc という認可のミドルウェアを持つビルダーがあるが、CSRF の検査をミドルウェアとして掛けるビルダーは無い。

ここまでに挙げたコードの事実は、2026-09-18 に main、HEAD=74ee63c の作業ツリーで確かめた。

なお DEPLOY-NEOSHOWCASE.md は、このアプリのデプロイ先の例として https://checkin-dev.trap.show を挙げ、環境変数 NUXT_APP_ORIGIN の値にも同じ URL を挙げている。同じ OWASP のチートシートは、SameSite の適用範囲がオリジンではなく登録可能なドメインであり、自分で管理していないホストと登録可能なドメインを共有しているデプロイでは SameSite を単独の防御として頼れないと述べている。

この形のデプロイ先では、兄弟ホストからのリクエストは登録可能なドメインが同じなのでサイトをまたがず、SameSite=Lax はセッションクッキーを送る。兄弟ホストからのフォーム送信は単純リクエストなので CORS も止めない。この経路を止めているのは、フォームからは x-csrf-token ヘッダーを設定できず isCsrfValid が偽になることだけである。CSRF クッキーは __Host- 接頭辞のため兄弟ホストから設定できず、オリジンが違うので兄弟ホストのスクリプトからは読めないので、値を合わせることもできない。つまりこの経路については、トークンがセッションに紐付いていないことは成否を変えない。

誰が何を誤るか

isCsrfValid という名前と double-submit という語から、この関数を読む人は、トークンがセッションに紐付いていて、他のセッション向けに発行されたトークンは弾かれると読む。実際に照合しているのはクッキーとヘッダーの一致だけである。

いまクロスサイトのリクエストを止めているものを誤る。経路によって効いているものが違う。オリジンをまたぐスクリプトからの呼び出しは CORS が、サイトをまたぐ POST はセッションクッキーの SameSite=Lax が止めている。兄弟ホストからのフォーム送信は、上に書いたとおりそのどちらでも止まらず、double-submit の検査そのものが止めている。トークンがセッションに紐付いていないことが効いてくるのは、攻撃者が有効なクッキーとヘッダーの組を用意できた場合で、そのときにそれを検出するサーバー側の検査が無い。ここに挙げた 3 つが、効いている条件を網羅していることは確かめていない。

状態を変えるプロシージャを新しく足す人が、assertCsrf() を書かなくても検査が掛かると読む。認可は userProcadminProc というミドルウェアで掛かるのに対し、CSRF はプロシージャの本体で呼ぶ形なので、書き方が揃っていない。

取りうる手

  • トークンをセッションに紐付ける。OWASP が勧める Signed Double-Submit Cookie の形にし、セッション ID を含む値の HMAC をクッキーとヘッダーに載せ、isCsrfValid がそのセッションについて検証する。packages/api/src/auth/crypto.tscreateHmac を使う deriveMailHash があるので、鍵の受け渡しの形はそこに揃えられる。#73 と併せて採る場合の注意: #73 はセッション ID を権限が広がるたびに入れ替える案である。この案を採ったあとにそちらを足す場合、入れ替える側が CSRF トークンの発行し直しも行わないと、入れ替えの直後は古いセッション ID で作られたトークンが送られるので検査が通らなくなる
  • Sec-Fetch-Site を主たる信号にし、Origin の検証をその代替経路として併せて実装する。同じチートシートの Fetch Metadata headers の節は、Sec-Fetch-* を送らないブラウザーがあるため、標準ヘッダーによるオリジン検証への代替経路は Fetch Metadata を実装する際の必須要件であると書いている。どちらか一方を選ぶ形にはできない。この案は、技術選定の見直し(#72)の T3 が採った方式である。T3 は better-auth を入れれば掛かるものではなく、この方式をアプリの中に実装するものである。実施する形は下の「直さずに残した理由」の「T3 の手段」に書いた。
  • assertCsrf() をプロシージャの本体で呼ぶ形をやめ、packages/api/src/orpc.tsuserProcadminProc と同じくミドルウェアとして掛ける。これで無くなるのは、状態を変えるプロシージャの本体で assertCsrf() を書き忘れる誤りである。どちらのビルダーを使うかは書き手の選択であり、契約の側に状態を変えるかどうかの区別が無い以上、誤ったビルダーを選んだことは型チェックでは検出されない。区別を型で表すには、契約の側にその情報を持たせる変更が別に要る。

直さずに残した理由

技術選定の見直しで認証・セッションの基盤の作り直しを決めており、その結論は #72 にある。トークンをセッションに紐付ける案は、CSRF トークンの発行にセッションを読ませることになるので、セッションの表現と、リクエストごとにセッションを解決する順序に手を入れる。その作り直しと同じ範囲に入るため、単独では直さずに残した。基盤が入れ替わる前に発行と検証だけを書き換えると、入れ替えの時点で同じ箇所をもう一度書き換えることになる。

ただし、/rpc については基盤を better-auth に差し替えるだけでは解消しないbetter-auth の CSRF 対策は、単純リクエストを避けること・Origin 検証・SameSite=Lax のクッキー・Fetch Metadata による検査から成り、CSRF トークンを使わない。公式ドキュメントの「Security」(https://better-auth.com/docs/reference/security 、2026-09-19 に参照)は、この仕組みについて「without requiring CSRF tokens or client-side JavaScript」と書いている。したがって「トークンがセッションに紐付かない」という症状は、better-auth のハンドラが処理する要求については消える。

しかし掛かる範囲はそのハンドラが処理する要求だけであるcontext.assertCsrf() を呼ぶ 8 箇所(数え方: git grep -n -F 'context.assertCsrf()' -- packages/api/srcauth/router.ts に 1、membership/router.ts に 3、payouts/router.ts に 4)は、メール確認の要求・請求書の発行・払い戻しの実行といったドメインの操作で、better-auth には移らない。この issue が対象にしている isCsrfValid は、#72 の T3 を実施しない限り残る

T3 の手段

#72 の T3 は「better-auth と同じ Origin 検証+Fetch Metadata に揃える。ただし T2 の移行設計の中で確定する」と決めている。その手段を次のとおりとする。

better-auth は、この検査を自分のハンドラの外で使う手段を文書化していない。1.7 系のドキュメントの一覧 https://better-auth.com/docs/llms.txt が挙げる 175 ページの .md を取得して走査したところ、originCheckMiddlewareformCsrfMiddleware はどちらも 0 件で、originCheck は設定名 disableOriginCheck の一部としてしか現れない(2026-09-19 に取得。数え方: 取得先で grep -rl originCheckMiddlewaregrep -rl formCsrfMiddlewaregrep -ril origincheck)。一方、公開されているパッケージ better-auth 1.7.5 では、dist/api/middlewares/origin-check.d.mts がこの 3 つを宣言し、dist/api/index.d.mts がそれを再び公開しているので、better-auth/api から取り込める。ただし宣言されている型は better-callMiddleware で、その引数の型 MiddlewareInputContext には better-authAuthContext が含まれない。同パッケージの dist/api/middlewares/origin-check.mjs を読むと、これらは ctx.context から trustedOriginsisTrustedOriginskipOriginCheckoptionslogger を読む。h3 にも oRPC にもそのままは載らず、駆動するには宣言された型に無いフィールドを渡すことになる。

したがって、同じ方式をこのアプリの中に実装するOrigin ヘッダーを信頼するオリジンの一覧と照合し、OriginnullSec-Fetch-Site が同一オリジンを裏付ける場合は要求の URL のオリジンで照合し、どちらの裏付けも得られない要求は拒否する。未文書の API に繋ぐ案を退けたのは、better-call が内部の扱いを変えても、型チェックもビルドも通ったまま検査が効かなくなるためである。文書化されていない以上、その変更が破壊的変更として扱われる保証がない。

挙動が変わる範囲better-auth の Origin 検証は、同じ「Security」のページの disableCSRFCheck の節が無効にする対象として「Origin header validation when cookies are present」を挙げているとおり、クッキーが付いた要求に対して働く。同パッケージの dist/api/middlewares/origin-check.mjs でも、originCheckMiddleware が呼ぶ照合は Cookie ヘッダーの無い要求では何もせずに戻り、クッキーの無い要求を扱うのはサインインとサインアップの経路に付く formCsrfMiddleware の側である。ここで実装する検査はクッキーの有無に関わらず働く。context.assertCsrf() を呼ぶ 8 つの手続きのうち、識別を要求しない pub を使うのは packages/api/src/auth/router.tsrequestEmailVerification だけで、残りの 7 つは userProcadminProc を使うので、識別が解決しない要求はミドルウェアが本体より前に弾く(数え方: git grep -n -F 'context.assertCsrf()' -- packages/api/src が挙げる各行について、それを含む手続きの定義行のビルダーを読む)。クッキーを持たない要求がこの検査に届く経路として確実にあるのは requestEmailVerification である。あわせて、trustForwardAuth が真のデプロイでは applyForwardedIdentityX-Forwarded-User だけから isAdmin を決めるので、adminProc の手続きもクッキー無しで本体に届く。

スコープ外/関連

  • 認証・セッションの基盤をどう作るかと、oRPC 側の CSRF をどの方式に揃えるかの結論は #72。その結論を採っても、/rpc の CSRF は T3 を実施しない限り残る。この issue は、T3 で実施する内容と、その結論を採らない場合に個別に直す対象の両方を記録するものである。
  • #16 は認証サブシステムを実装するための issue である。この issue が扱うのは、実装済みの isCsrfValidGET /csrf が現に行っている検査の内容なので、別の事項である。
  • #15 は認可とレート制限の issue で、その「やること」に pub とは別に認可付きの base procedure を用意することが挙がっている。上の「取りうる手」の 3 つ目は packages/api/src/orpc.ts の同じ層に手を入れるので、実装の際は同じ箇所を触る。ただし #15 が決めるのは誰に何を許すかで、この issue が扱うのは CSRF トークンが何に紐付いているかなので、内容は重ならない。
  • #73 はセッション ID をいつ入れ替えるかの issue である。別の事項だが、両方を採る場合は実装が絡む。上の「取りうる手」の 1 つ目に書いた。
  • #69 は safeEqual の型と名前が用途を制約していないという issue である。この issue は isCsrfValidsafeEqual をどう使っているかではなく、CSRF トークンが何に紐付いているかを扱うので、別の事項である。

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read #72 and its T3 decision first, then inspect apps/web/server/utils/auth.ts, apps/web/server/routes/csrf.get.ts, apps/web/server/routes/logout.post.ts, packages/api/src/auth/context.ts, and packages/api/src/orpc.ts. Trace the existing CSRF checks and the eight assertCsrf() call sites before choosing the implementation. Done means the selected protection is applied consistently to the affected requests and its behavior is covered by relevant auth and oRPC tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
authentication, backend, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.