traPtitech / traPtitech/Checkin
CSRF トークンがセッションに紐付かず、クッキーとヘッダーの一致だけを検査している
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 0
- Forks
- 0
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 7
Description
症状
apps/web/server/utils/auth.ts の isCsrfValid は、クッキー __Host-checkin_csrf の値と x-csrf-token ヘッダーの値が両方存在し、safeEqual で一致することだけを検査する。セッション ID にも user_id にも照らしておらず、値に署名も入っていない。
トークンの発行側も同じである。apps/web/server/routes/csrf.get.ts は generateToken() が返した値をそのままクッキーとレスポンス本文の両方に入れて返す。発行の際にセッションを読まないので、発行されたトークンは特定のセッションのものではない。したがって、あるリクエストで成立するクッキーとヘッダーの組は、どのセッションからのリクエストでも成立する。トークンがそのセッションのために発行されたものかを検査する箇所が、発行側にも検証側にも無い。
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.tsのCSRF_COOKIE)。設定時の属性は同ファイルのhostCookieBaseとsetCsrfCookieによりSecure、Path=/、Domain指定なし、SameSite=Lax、httpOnly: 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 つの条件(Secure・Path=/・Domain指定なし)は、同一ホストで走るスクリプトがすべて満たせるためである。このホスト自身の名前が乗っ取られた場合も同様に塞がらない。httpOnly: falseは、クライアントがこの値を読んでx-csrf-tokenヘッダーに載せる方式のために必要な設定である。
- CORS の設定がリポジトリに 1 つも無い。
appsとpackagesの中にcorsとaccess-control-allowのどちらも 1 件も現れず(数え方:git grep -inE 'cors|access-control-allow' -- apps packages)、apps/web/nuxt.config.tsにrouteRulesが無く(数え方: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 に参照)が挙げるのはAccept・Accept-Language・Content-Language・Content-Type・Rangeの 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 では止まらない。
- 効く範囲はそのヘッダーを載せた呼び出しに限る。MDN の「オリジン間リソース共有 (CORS)」(https://developer.mozilla.org/ja/docs/Web/HTTP/Guides/CORS 、2026-09-18 に参照)の「単純リクエスト」の節は、
- oRPC の呼び出しはすべて POST である。
apps/web/server/routes/rpc/[...].tsのコメントが「all oRPC calls are POST so we cannot gate by HTTP method」と書いている。セッションクッキー__Host-checkin_sessionはhostCookieBaseを使うのでSameSite=Laxであり、サイトをまたぐ POST にはこのクッキーが付かない。SameSite=Laxが守るのは POST の側だけである。同じ OWASP のチートシートの「Limitations of SameSite」の節は、最初の限界として「Laxonly 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 apps。apps/web/server/utils/auth.ts にある定義を除いた残りが呼び出しである)。
apps/web/server/routes/logout.post.tsが直接呼び、偽であれば 403 を返す。apps/web/server/utils/auth.tsのbuildRequestContextが結果をcreateAuthHelpersに渡し、packages/api/src/auth/context.tsがそれをassertCsrf()として oRPC の文脈に載せる。
oRPC 側では、プロシージャごとに本体の中で context.assertCsrf() を明示的に呼ぶ形になっている(数え方: git grep -n 'assertCsrf()' -- packages/api/src。packages/api/src/auth/context.ts にある宣言と実装、およびテストを除いた残りが呼び出しである)。packages/api/src/orpc.ts には userProc と adminProc という認可のミドルウェアを持つビルダーがあるが、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() を書かなくても検査が掛かると読む。認可は userProc・adminProc というミドルウェアで掛かるのに対し、CSRF はプロシージャの本体で呼ぶ形なので、書き方が揃っていない。
取りうる手
- トークンをセッションに紐付ける。OWASP が勧める Signed Double-Submit Cookie の形にし、セッション ID を含む値の HMAC をクッキーとヘッダーに載せ、
isCsrfValidがそのセッションについて検証する。packages/api/src/auth/crypto.tsにcreateHmacを使う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.tsのuserProc・adminProcと同じくミドルウェアとして掛ける。これで無くなるのは、状態を変えるプロシージャの本体で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/src。auth/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 を取得して走査したところ、originCheckMiddleware と formCsrfMiddleware はどちらも 0 件で、originCheck は設定名 disableOriginCheck の一部としてしか現れない(2026-09-19 に取得。数え方: 取得先で grep -rl originCheckMiddleware、grep -rl formCsrfMiddleware、grep -ril origincheck)。一方、公開されているパッケージ better-auth 1.7.5 では、dist/api/middlewares/origin-check.d.mts がこの 3 つを宣言し、dist/api/index.d.mts がそれを再び公開しているので、better-auth/api から取り込める。ただし宣言されている型は better-call の Middleware で、その引数の型 MiddlewareInputContext には better-auth の AuthContext が含まれない。同パッケージの dist/api/middlewares/origin-check.mjs を読むと、これらは ctx.context から trustedOrigins・isTrustedOrigin・skipOriginCheck・options・logger を読む。h3 にも oRPC にもそのままは載らず、駆動するには宣言された型に無いフィールドを渡すことになる。
したがって、同じ方式をこのアプリの中に実装する。Origin ヘッダーを信頼するオリジンの一覧と照合し、Origin が null で Sec-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.ts の requestEmailVerification だけで、残りの 7 つは userProc か adminProc を使うので、識別が解決しない要求はミドルウェアが本体より前に弾く(数え方: git grep -n -F 'context.assertCsrf()' -- packages/api/src が挙げる各行について、それを含む手続きの定義行のビルダーを読む)。クッキーを持たない要求がこの検査に届く経路として確実にあるのは requestEmailVerification である。あわせて、trustForwardAuth が真のデプロイでは applyForwardedIdentity が X-Forwarded-User だけから isAdmin を決めるので、adminProc の手続きもクッキー無しで本体に届く。
スコープ外/関連
- 認証・セッションの基盤をどう作るかと、oRPC 側の CSRF をどの方式に揃えるかの結論は #72。その結論を採っても、
/rpcの CSRF は T3 を実施しない限り残る。この issue は、T3 で実施する内容と、その結論を採らない場合に個別に直す対象の両方を記録するものである。 - #16 は認証サブシステムを実装するための issue である。この issue が扱うのは、実装済みの
isCsrfValidとGET /csrfが現に行っている検査の内容なので、別の事項である。 - #15 は認可とレート制限の issue で、その「やること」に
pubとは別に認可付きの base procedure を用意することが挙がっている。上の「取りうる手」の 3 つ目はpackages/api/src/orpc.tsの同じ層に手を入れるので、実装の際は同じ箇所を触る。ただし #15 が決めるのは誰に何を許すかで、この issue が扱うのは CSRF トークンが何に紐付いているかなので、内容は重ならない。 - #73 はセッション ID をいつ入れ替えるかの issue である。別の事項だが、両方を採る場合は実装が絡む。上の「取りうる手」の 1 つ目に書いた。
- #69 は
safeEqualの型と名前が用途を制約していないという issue である。この issue はisCsrfValidがsafeEqualをどう使っているかではなく、CSRF トークンが何に紐付いているかを扱うので、別の事項である。
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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