やりたかったこと
「ログイン不要で、自分の GitHub リポジトリをその場で解析できる公開ツール」を作りました。技術デューデリジェンスという名前で公開しています。ユーザー登録は求めない。GitHub と OAuth で連携し、選んだリポジトリを解析してレポートを返す。それだけです。
一見ふつうの OAuth 連携ですが、匿名(未ログイン)ユーザーが相手という一点で、認証済みユーザー向けの連携とは前提が変わります。守るべきものが増えるので、そこをどう設計したかを書きます。
落とし穴1: OAuth ログイン CSRF
OAuth の state パラメータは CSRF 対策として知られていますが、匿名フローでは通常の守り方が効きません。
認証済みユーザーなら「この OAuth を始めたのは誰か(uid)」で照合できます。コールバックで「開始したユーザーと今のユーザーが一致するか」を見れば、他人が発行した state を弾ける。ところが匿名ユーザーには uid がありません。所有者一致で守れない。
ここを突くのが OAuth ログイン CSRF です。攻撃者が自分のアカウントで OAuth を開始して state を発行し、それを被害者に踏ませる。被害者のブラウザで攻撃者のトークンが連携されてしまう。匿名だと「誰が開始したか」で区別できないので、この攻撃が通りやすい。
対策として、開始時にセッションへ単回使用の nonce を書き、コールバックで照合します。
// OAuth 開始時:セッションに nonce を書く
$nonce = /* ランダムな単回使用トークン */;
$session->set('rikka_tech_dd_oauth_nonce', $nonce);
$state = $this->oauthState->issue([
'mode' => 'public_tool',
'nonce' => $nonce, // state 側にも同じ nonce を載せる
]);
// コールバック時:state の nonce と、開始したセッションの nonce が一致するか
$expectedNonce = (string) ($data['nonce'] ?? '');
$sessionNonce = (string) ($session->get('rikka_tech_dd_oauth_nonce') ?? '');
$session->remove('rikka_tech_dd_oauth_nonce'); // 単回使用:即消す
if ($expectedNonce === '' || $expectedNonce !== $sessionNonce) {
// 開始したセッションと違う → 攻撃者が発行した state。拒否
}
束縛の鍵にセッションID(getId())を使わないのがポイントです。匿名の遅延セッションでは初回にセッションIDが空になることがあり、そこに束ねると不安定になります。開始時に自分で書いた nonce で束ねる方が確実です。
落とし穴2: state を1度しか使わせない
state は単回使用にします。コールバックで消費(consume)し、2度目は弾く。
$stateResult = $this->oauthState->consume($stateToken);
if ($stateResult['status'] === 'expired') { /* 期限切れ */ }
if ($stateResult['status'] !== 'ok') { /* 消費済み or 不明 */ }
副次的な利点として、OAuth 成功後にブラウザの「戻る」で再度コールバックが叩かれても、既に消費済みなので二重処理になりません。エラーではなく「消費済み」として穏当に扱えます。
落とし穴3: アクセストークンを DB に残さない
いちばん気を遣ったのがここです。GitHub のアクセストークンを永続化しない。
トークンは、コールバックで受け取ったらat-rest 暗号化してセッションに置き、30分で失効させます。DB のカラムにもファイルにも生のトークンを書きません。
// 生トークンを残さないよう暗号化してセッションへ。30分で失効
$session->set('rikka_tech_dd_pub_token', $this->cipher->encrypt($accessToken));
$session->set('rikka_tech_dd_pub_token_exp', $this->time->getRequestTime() + 1800);
理由は、匿名ツールのトークンは「その場の解析1回」にしか要らないからです。長期保存する必要がないものを保存すれば、漏洩したときの被害が増えるだけです。必要な期間だけ・暗号化して・揮発する場所に置く。解析が終われば用済みで、30分でセッションから消えます。
解析結果そのものはトークンとは別で、共有できるよう保存しますが、こちらも7日で失効させ、hook_cron で定期的に purge します。残す必要のないものを残し続けない、という同じ原則です。
落とし穴4: コストと乱用の多層防御
公開ツールは、認証がない分だけ乱用されやすい。外部の有料 API(解析エンジン)を叩くので、放置するとコストが青天井になります。フォーム1つに複数の防御を重ねました。
- captcha(Turnstile) ── ボットの自動投入を止める
- flood 制御 ── フォーム単位で短時間の連打を抑える
- 使用量ガード ── 日次上限・月次予算に達したら自動で全停止(kill switch)
そして fail-closed にします。たとえば captcha モジュールが無効化されていたら、ジョブを投入する前に中断する。「保護が効いていない状態では動かさない」を既定にする。通常運用では captcha は有効なので何も起きませんが、設定が external に外れたときに無防備で走らせないための保険です。
// captcha が無効なら、解析ジョブを投入せず中断(fail-closed)
if (!$this->moduleHandlerService->moduleExists('captcha')) {
// 中断
}
まとめ
匿名ユーザー向けの OAuth は、認証済み向けと前提が違います。
- OAuth ログイン CSRF を、開始時にセッションへ書いた単回 nonce で防ぐ。 uid で守れない匿名では、所有者一致の代わりにこれを使う。束縛の鍵に不安定なセッションIDを使わない
stateは単回使用。 消費済みは穏当に扱い、二重処理を防ぐ- アクセストークンを DB に残さない。 暗号化してセッションに置き、短時間(30分)で失効させる。結果も7日で purge
- コストと乱用は多層で防ぎ、fail-closed にする。 保護が効いていない状態では動かさない
「必要な期間だけ・必要な場所にだけ・暗号化して置く」を徹底すると、匿名ツールでもトークンの扱いは怖くなくなります。
この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。
技術DDは誰でも無料で試せます(GitHubリポジトリをその場で診断)。 RIKKA M&A の技術デューデリジェンスを試す

