aegis-authz-access-control について
アプリケーションコードの認証後の認可判断を調べるセキュリティ監査スキルです。ルート単位で機能・オブジェクト・属性の権限、テナント分離、回避策を確認します。
主な機能
- 01機能レベルのアクセス制御確認
- 02オブジェクトとテナント所有権の追跡
- 03属性レベルの権限確認
- 04パスとヘッダーによる回避の検出
- 05委任となりすましの調査
活用シーン
- 01APIルートのロール確認の監査
- 02オブジェクト検索のIDORリスク検出
- 03マルチテナントのデータ分離確認
- 04APIキーとなりすましの権限範囲評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ as-foss/ aegis-skills --skill aegis-authz-access-controlインストール後、Claude Code で/aegis-authz-access-controlを入力して読み込まれたか確認します。
同梱ファイルreferences/background.mdreferences/further-reading.md
詳細
スコープと目的
認可の失敗を調査します。コードが、あるアクションを誰が呼び出せるか(BFLA、垂直権限昇格)、およびアクションがどのオブジェクトインスタンスにアクセスできるか(BOLA/IDOR、水平権限昇格)を誤って判断していないか確認します。
対象範囲:
- ルート、ハンドラー、リゾルバー、コントローラーにおけるロール/権限チェックの欠落や誤り。
- 所有者/テナントの条件を付けず、リクエストで指定された ID をキーにオブジェクトを取得している箇所。
- マルチテナントの分離不備(入力からのテナントコンテキスト、共有キャッシュ/キュー/インデックス)。
- 認可ロジックのバグ:ルールがない場合の拒否、OR 条件の誤記、HTTP メソッドの改ざん。
- パスの正規化やプロキシヘッダーを悪用した迂回(
X-Original-URLの使用、X-Forwarded-Forの信頼)。 - スコープ制限や監査を伴わない、なりすまし/API キー/サービス信頼機能。
対象外(他のモジュール):認証強度、セッション固定化、インジェクション、SSRF、暗号化。ここでは、認証後の判断ロジックのみを監査します。特権アクションに未認証でアクセスできる場合は、BFLA として報告してください。
前提条件と用語
予備知識がなくても読める入門編です。このモジュールで使われる用語をそれぞれ1行で説明します。すべての種類について、わかりやすい詳細な説明はリポジトリの GUIDE.md 用語集にあります。
- BFLA:ルートでロールがチェックされていないため、管理者専用のアクションを実行できること
- BOLA/IDOR:リクエスト内の識別子を変更して(注文番号1042 → 1043)、別のユーザーのオブジェクトにアクセスすること
- 水平権限昇格:自分の権限レベルを維持したまま、他人のデータを操作すること
- 垂直権限昇格:上位ロール向けの操作を行うこと(一般ユーザー → 管理者)
- テナント:複数の顧客が利用する共有システム内で、各顧客に分離して割り当てられた領域
- ルールがない場合の拒否:明示的な許可ルールがなければ、拒否しなければなりません。チェックの欠落は問題であり、「おそらく問題ない」ではありません。
- 汚染データの流れ/source→sink:信頼できないデータが入口から危険な関数へと流れる経路
- 報告の確度:Confirmed > Probable > Needs-Review。根拠に関するルールは templates/finding-report.md にあります。
考え方
各ルートを、相互に独立した2つの軸で評価します。両方の条件を満たす場合に限り、ルートは安全です。
| 軸 | 問い | 失敗 | 主な CWE |
|---|---|---|---|
| 機能(アクション) | この主体は、このエンドポイントを呼び出せるか? | BFLA/垂直権限昇格 | CWE-862, CWE-863 |
| オブジェクト(インスタンス) | 上記が許可されている場合、この行/ファイル/テナントにアクセスできるか? | BOLA/IDOR/テナント間の情報漏えい | CWE-639 |
| 属性 | オブジェクト上のこれらのフィールドを読み取り/書き込みできるか? | 過剰なデータ露出、マスアサインメント | CWE-285 |
ルートごとの判断手順:
- 主体の情報源を特定します。セッション、検証済み JWT の sub クレーム、API キー、mTLS の ID などです。それ以外(クエリパラメーター、
user_idのようなヘッダー、クライアント側に保存されたロール)は、それだけで危険信号です。 - ビジネス上の命名(
admin、/users、/internal、DELETE)から、必要なロール/アクションを特定します。 - オブジェクトが取得される箇所を見つけ、クエリに主体の条件が含まれているか確認します(
WHERE id = ? AND tenant_id = ?)。 - 判断箇所(ミドルウェア/デコレーター/アノテーション/ポリシー)を見つけ、状態変更やデータ返却より前に実行されることを確認します。
- 明示的な許可ルールがない場合の既定の答えは、拒否です。チェックの欠落は問題であり、「おそらく問題ない」ではありません。
2アカウントで考えてみましょう。所有者だけがデータを取得できる場合を除き、呼び出し元がオブジェクトの所有者であるかどうかにかかわらず、すべてのエンドポイントは同じように動作しなければなりません。
確認する項目
関数レベルの認可(BFLA)
- すべての変更系ルート(POST/PUT/PATCH/DELETE)と、すべての
/admin、/internal、/manageサブツリーを列挙し、それぞれに、クライアントのクレームをそのまま信頼するのではなく、サーバー側のアイデンティティを参照するガードがあることを確認します。 - 「隠蔽によるセキュリティ」に該当するものを指摘します。サーバー側のチェックがない、UI 上で非表示にされたボタン、管理者だけに表示される一方で対応するエンドポイントは公開されている SPA ルート、防御が推測困難なパス部分だけのエンドポイントです。
- HTTP メソッド間の非対称性を確認します。たとえば、GET にはガードが適用されているものの、別のハンドラーやガードのないキャッチオール(
app.all('/admin/x', ...))を通じて、同じパスで DELETE/PATCH を公開しているコントローラーです。 - API バージョンを比較します。v1 モジュールにはアノテーション/ガードが設定されているのに、複製された v2 コントローラーでは設定が漏れていないか確認します。バージョンごとのディレクトリでガード数を比較します。
- 登録/更新ペイロードで、ロールの一括割り当て(
"role":"admin"、"isAdmin":true)がユーザーレコードに受け入れられないかテストします。 - デバッグ/内部向けのサーフェスを調べます。本番環境での Spring Actuator、Django admin と DEBUG ビュー、Flask デバッガー、Go の
pprof、ASP.NET の詳細エラー、GraphQL のイントロスペクションとミューテーションを確認します。
オブジェクトレベルの認可(BOLA/IDOR)
id|uuid|guid|uid|email|slug|refという名前の各リクエストパラメーターを、最初の DB/ドキュメント検索までたどります。述語owner_id == subject.idまたはtenant_id == subject.tenant_idが、検索後ではなく、検索クエリ内に含まれていることを確認します。.all()/find()/Model.objects.all()の結果をそのままシリアライズして返す、コレクション/一覧エンドポイントを指摘します(大量の一覧表示により、他テナントの行が漏えいします)。- ネストされたリソースの場合、
/org/:orgId/doc/:docIdでは、doc.orgId == orgIdと呼び出し元がorgIdのメンバーであることの両方を確認します。親リソースだけのメンバーシップ確認では不十分です。 - エクスポート/レポート/ダウンロード/CSV/PDF/ストリーミングの各エンドポイントを確認します。通常のシリアライザーを迂回するため、ガードが抜け落ちていることがよくあります。
export|download|report|dump|backup|csv|xlsxを検索します。 - GraphQL では、トップレベルの認証ディレクティブだけでなく、リゾルバーごとのオブジェクトチェックを確認します。特にネストされたフィールド(
invoice.customer.address)や、ID 配列で取得する DataLoader/バッチローダーが対象です。 - シーケンシャル ID(
AutoField、serial、IDENTITY(1,1)、インクリメントされる数値 JSON ID)は、悪用されやすさを高める要因として記録します。UUID は緩和策にすぎず、述語の代替にはなりません。 - プロパティレベルの粒度も確認します。フィールドレベルの BOLA では、レコードへのアクセス自体は適切でも、知る必要のない呼び出し元に、機密列(
salary、role、SSN 相当のフィールド)がシリアライズされます。呼び出し元のロールごとにシリアライザーの出力を比較します。 - マスアサインメントとの重複も確認します。クライアントが書き込み可能な特権フィールド(
"role":"admin")も、プロパティレベルの認可不備に該当します。ここで見つけた場合は、API モジュールのマスアサインメントの分類と相互参照します。
垂直方向・水平方向の権限昇格
- クライアント側ストレージのロールを信頼していないか確認します。機密性の高い呼び出しで、JWT クレーム(
role、isAdmin、permissions)を権威あるストアから再導出せずに受け入れていないか、また UI の表示制御だけにlocalStorage.getItem('role')を使っていないか確認します。 - 管理パネルへの強制ブラウジングを確認します。管理者向けの静的バンドル(
express.static('admin'))が公開されていないか、フレームワークの管理画面が既定パスで弱い認証、または認証なしで公開されていないか確認します。 - バージョン間の不整合による権限昇格を確認します。v2 で強化した後も旧
/api/v1/admin/*が稼働していないか、移動済みエンドポイントの古いマイクロサービスのコピーがデプロイされたままになっていないか確認します。 - テナントをまたぐコンテキストを確認します。
tenant_idが認証済みプリンシパルのレコードではなく、リクエスト本文/クエリ/サブドメインから取得されていないか、またHostベースのテナント識別が、直接 IP アクセスや Host ヘッダーの上書きによって偽装可能でないか確認します。 - 共有インフラストラクチャのキーを確認します。テナント接頭辞のないキャッシュキー(
cache.get("invoice:"+id))、グローバル検索インデックス、テナントを再確認せずに処理されるキューメッセージ、共有ディレクトリで生の ID をファイル名に使う一時ファイルが対象です。
ロジック、ヘッダー、パスのバイパス
- 許可方式と拒否方式:ブロックリスト形式で書かれたチェック(
if (role === 'banned') return)を探し、頭の中で許可リスト方式に置き換えて考えてください。新しいロールが、気づかれないままアクセス権を継承します。 - ブール値の誤り:管理者にどこでもアクセスを許可する
role === 'admin' || userId === idは正しいものの、否定条件の中にあるrole !== 'admin' || userId !== idや、&&と||の取り違えは、意図しないアクセス許可につながります。ロールの等価性とユーザー ID の等価性を組み合わせた条件はすべて要確認です。 - パスの正規化:デコード前の生 URL に基づいてルーティングを判定すると、
%2f、//、/./、末尾のドット、;jsessionid、二重エンコード(%252f)によって、ガードが見るパスとハンドラーが見るパスが分かれる可能性があります。 - HTTP メソッドの改ざん:ガードがメソッドごとに設定されている場合は、
HEAD/OPTIONS/TRACEやメソッド上書き(X-HTTP-Method-Override: DELETE、フォームパラメーターの_method=DELETE)を試してください。 - アプリケーションコードが信頼するプロキシヘッダー:
X-Original-URL、X-Rewrite-URL、X-Forwarded-Host、X-Forwarded-Protoがルーティングやアクセス判定に使われていないか確認してください。IP 許可リストの判定でX-Forwarded-For(req.headers['x-forwarded-for'].split(',')[0])が解析される場合も要確認です。アプリケーションが、ヘッダーを削除・設定するよう構成された信頼できるプロキシの背後にない限り、これらは偽装可能です。 - フレームワーク固有の例:コントローラーのグローバルなフォールバックを上書きする ASP.NET の
[AllowAnonymous]、コンストラクターでの Laravel の$this->middleware()の設定漏れ、Rails のskip_before_action :authenticate_user!。
委任となりすまし
- サポート担当者や管理者によるなりすまし(「顧客としてログイン」):対象範囲の制限(時間制限があり、理由が記録されること)、実行者・対象者・理由を含む改変不可能な監査ログエントリー、パスワード変更時のセッション終了、元の実行者 ID を失わせるトークン発行がないことを確認してください。
- API キーや PAT が、管理者ロールを含む対話型ユーザーの全権限を継承していないか確認してください。キーのスコープが存在し、サーバー側で強制されていることも確認してください。
- サービス間通信:署名検証なしで、「何らかのゲートウェイ」が設定した転送ユーザー名・メールアドレスのヘッダー(
X-User-Email)を信頼する内部エンドポイントがないか確認してください。また、内部ホップで mTLS がないことを前提にしていないか確認してください。 - OAuth トークン交換:
scopeの縮小が無視されていないか、管理者による取り消し後も、リフレッシュトークンが元の広範なスコープを保持していないか確認してください。
調べる場所
| スタック | ルートテーブル | ガードの目印(あれば良好) | ファイル/ディレクトリ |
|---|---|---|---|
| Express/Fastify/NestJS | app.use/router.stack、Nest の @Controller メタデータ、Fastify の printRoutes | requireAuth、passport.authenticate、addHook('preHandler')、@UseGuards(...)、@Roles(...) | routes/、controllers/、*.controller.ts、main.ts |
| Django/DRF | urls.py、router.register、django-extensions の show_urls | LoginRequiredMixin、PermissionRequiredMixin、permission_classes、DRF の get_queryset フィルター | */urls.py、views.py、viewsets.py、serializers.py |
| Flask/FastAPI | デコレーター @app.route/@get/@post、OpenAPI 仕様 | before_request、@login_required、`Depends(current_user | require_roles)、ルーター単位の dependencies=[...]` |
| Spring(Java/Kotlin) | @RequestMapping ツリー、Actuator の /mappings(要保護!) | @PreAuthorize、@Secured、@RolesAllowed、SecurityFilterChain マッチャー、@EnableMethodSecurity | **/controller/**、SecurityConfig.*、application.yml |
| ASP.NET Core | コントローラーと最小 API の Map* チェーン | [Authorize]、[Authorize(Policy=...)]、.RequireAuthorization()、FallbackPolicy、[AllowAnonymous](個別に監査) | Controllers/、Program.cs、Startup.cs |
| Laravel/Symfony | php artisan route:list、bin/console debug:router | auth: ミドルウェア、can: ミドルウェア、ポリシー、Gate::authorize、Symfony の voter と access_control YAML | routes/web.php、routes/api.php、app/Policies、config/packages/security.yaml |
| Rails | rails routes | before_action :authenticate_user!、Pundit の authorize @record / after_action :verify_authorized、CanCanCan の load_and_authorize_resource | config/routes.rb、app/controllers/**、app/policies/** |
| Go(net/http/chi/gin) | mux の登録、chi walk | ミドルウェアラッパー RequireAuth/RequireRole、gin.Use(...)、グループ単位の r.Group(func(r){ r.Use(guard)}) | cmd/*/main.go、internal/http/**、middleware/ |
あわせて確認するもの:docker-compose.yml/k8s マニフェストにある内部専用ポート(状況を示すものであり、証明にはなりません)、実装済みルートと OpenAPI/Swagger 仕様との照合(仕様のずれがあれば、文書化されていない稼働中のルートが存在します)、ロール名がわかるシーダー/フィクスチャ、隠れた管理者ルートがないか確認するためのフロントエンド JS バンドル(/static/js/*.js に "/admin" という文字列が含まれている場合があります)。
パターンとシグネチャ
ルートの一覧化(最初に実行し、ガードの目印と件数を比較します):
rg -n "@(Get|Post|Put|Patch|Delete|Request)Mapping\(" --type java --type kt
rg -n "@app\.(route|get|post|put|patch|delete)|@(get|post|put|delete)\(" --type py
rg -n "\.(get|post|put|patch|delete|all)\(\s*['\"][^'\"]+" -t ts -t js
rg -n "Route::(get|post|put|patch|delete|any|resource|apiResource)" --type php
rg -n "(map|Map)(Get|Post|Put|Delete|Patch)\(\"" --type cs
rg -n "HandleFunc|\.GET\(|\.POST\(|\.DELETE\(|\.PUT\(" --type go
ガードの存在を示す目印:
@(PreAuthorize|Secured|RolesAllowed|PostAuthorize)\(
(LoginRequiredMixin|PermissionRequiredMixin|UserPassesTestMixin)
(@UseGuards|@Roles\(|SetMetadata\(['"](roles|permissions))
ガード不足の兆候(Express:2 番目の引数がハンドラーで、その間にミドルウェアがない):
\.(get|post|put|patch|delete)\(\s*['"][^'"]*['"]\s*,\s*(async\s*)?\(?[A-Za-z_$]
サブジェクトを指定せずにオブジェクトを取得している可能性を示すヒューリスティック:
objects\.(get|filter)\((pk|id)=
findById\((req|params|pathVariable)[^)]*\)
Model\.objects\.all\(\)
クライアント側で信頼されている ID のシグナル:
(decoded|payload|claims|token)\.(role|isAdmin|is_admin|admin|permissions)
localStorage\.(getItem\(['"]?(role|is_admin|isAdmin|user)['"]?\))
(req\.body|request\.body|payload)\.(role|isAdmin|is_admin|account_type)
ヘッダー/パスによるバイパスのプリミティブ:
(?i)x-(original-url|rewrite-url|forwarded-for|forwarded-host|http-method-override)
headers\[['"](x-forwarded-for|x-real-ip)['"]\]
(_method|X-HTTP-Method-Override|method_override)
なりすましと委任:
(impersonat(e|ion)|acting_as|act_as|as_user|become_user|sudo_mode)
(api[_-]?key|personal_access_token).{0,40}(scopes?|abilities?)
デバッグ/内部情報の公開:
(/debug/pprof|/actuator|/_profiler|/__profiler|/telescope|/horizon|DEBUG\s*=\s*True)
入力値から取得するテナントコンテキスト:
(tenant_id|org_id|organization_id)['"]?\s*[:=]\s*(req\.(query|params|body)|request\.(GET|data|json))
汚染追跡のガイダンス
ソース(信頼性が確認されるまでは信頼しないもの):すべてのルートパラメーター、クエリー文字列、リクエストボディ、検証済みの sub 以外の JWT ペイロードのクレーム、ヘッダー(Authorization API キーも含む X-*)、サブドメイン/Host、メッセージキューのペイロード。
シンク(認可に関連するもの):ORM/ドキュメントのクエリー(findOne/findById/get/filter/Query.first)、生の SQL、ファイルの読み書き/ダウンロード、キャッシュの get/put、キューのパブリッシュ/コンシューム、管理者向け RPC 呼び出し、他のユーザーのデータを含むメール/エクスポートの生成。
手順:
- 単一のレコードを取得する各シンクについて、引数を列挙します。シグネチャにユーザー/テナント/プリンシパルのパラメーターがない検索ヘルパーにフラグを立てます。例:
getInvoice(id)とgetInvoice(id, actor)。この引数の欠落は、後続のチェックより上流に BOLA が存在する可能性を示します。 - シンクからソースへ遡ります。パラメーターから WHERE 句までの述語チェーンに、認証済みプリンシパルの属性が含まれていない場合、BOLA 候補としてマークします。
- ロール/権限の値を追跡します。判定に使われる値が正規のユーザーストアではなく、ソース(JWT の本文、プロフィール更新で書き換え可能な DB 列、ログイン時のクライアントデータから設定される Redis セッションフィールド)に由来する場合、権限昇格の候補としてマークします。
- キャッシュ/キューについては、キーの構築とコンシューマー側のフィルタリングを追跡します。書き込み時にテナントタグが付いていても、読み取り時にフィルターがなければ脆弱です。
- 認可にはサニタイザーは存在しません。検証済みの整数 ID であっても、別テナントの有効な ID である可能性があります。検証を認可として扱ってはいけません。
フレームワーク固有の注意点:DRF の get_queryset() のオーバーライド(なければ、デフォルトのマネージャークエリーセットが漏えいにつながります);Rails の ApplicationRecord のデフォルトスコープ(デフォルトではありません);SQLAlchemy の session.query(Model).get(id) は、フィルターを追加しない限り Python レベルのテナント概念を無視します;JPA の EntityManager.find(Cls, id) は、Hibernate の @TenantId/@Filter がなければテナントでフィルターしません。
悪用と再現
2 アカウントによる BOLA テスト(標準的な手順)
- 被害者と攻撃者のアカウントを登録します:
curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"V1ctimPass!"}' curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"Att4ckerPass!"}' - 両方を認証し、トークンを保存します:
VICTIM=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"V1ctimPass!"}' | jq -r .token) ATTACKER=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"Att4ckerPass!"}' | jq -r .token) - 被害者がオブジェクトを作成し、その ID を取得します:
curl -s -X POST https://target/api/invoices -H "Authorization: Bearer $VICTIM" \ -H 'Content-Type: application/json' -d '{"amount":100,"memo":"secret-payroll"}' # -> {"id":1042,...} (sequential => enumerate 1030..1050 next) - 攻撃者が被害者のオブジェクトをリクエストします:
curl -i https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" - 結果を判定します(このトリアージが重要です):
結果 意味 200+ 被害者のフィールド("secret-payroll")BOLA 確認済み(CWE-639) 200+ 空の/マスキングされた本文。ただし、ランダムな ID とは応答時間が異なる存在確認オラクル。部分的な保護 403関数には到達可能ですが、オブジェクトへのアクセスは拒否されています。クリアする前に、他の動詞( curl -i -X PUT/PATCH/DELETE)とネストされたパスを確認してください他者の ID には 404、自分の ID には200適切な列挙防止のマスキングです。所有者が完全なレコードを受け取れることを確認してください - 書き込みパス(PATCH/DELETE で ID を入れ替え)、コレクション(
?mine=1なしのGET /api/invoices)、エクスポート(GET /api/invoices/export.csv)、ネストされた形式(1042 が org B に属するGET /org/A/invoices/1042)についても繰り返します。 - 静的解析のみで確認する場合(テストアカウントを利用できない場合):ルートのハンドラーを特定し、取得文に所有権の述語がないこと、またポリシー/ミドルウェアがルートをラップしていないことを示します。両方についてファイル名と行番号を記載してください。
BFLA 管理者エンドポイントのテスト
- 攻撃者(通常のユーザー)として、管理者向けの変更操作を呼び出します:
curl -i -X POST https://target/api/users -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"Pwn3dPass!","role":"admin"}' - 結果:
201/200= BFLA 確認済み(権限の自己付与の可能性もあります);403= 保護されています;401= ルートで認証も欠落しています(さらに深刻です)。 - 登録/更新時のロール注入パターン:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://target/api/register \ -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"Esc4late!","role":"admin","isAdmin":true}' # then re-login as [email protected] and retry step 1 - GET のみで保護されたルートに対するメソッド入れ替えパターン:
curl -i -X DELETE https://target/admin/users/42 -H "Authorization: Bearer $ATTACKER"。
パス/ヘッダー/動詞のバイパステスト
# Path confusion (use --path-as-is so curl does not normalize)
for p in '/admin/users' '/admin//users' '/admin/./users' '/admin%2fusers' '/adm%69n/users' \
'/admin;/users' '/admin/users/' '/ADMIN/users' '/api/v1/admin/stats'; do
curl -s -o /dev/null -w "%{http_code} $p\n" --path-as-is "https://target$p" -H "Authorization: Bearer $ATTACKER"
done
# Double-encoding round trip: %252f decodes once to %2f, again to '/'
# Proxy-header injection
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/public/health' -H 'X-Original-URL: /admin/users'
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/admin/stats' -H 'X-Forwarded-For: 10.0.0.1'
# Verb tampering incl. overrides
for m in HEAD OPTIONS TRACE PATCH PUT; do curl -s -o /dev/null -w "%{http_code} $m\n" -X $m https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER"; done
curl -s -X POST https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" \
-H 'X-HTTP-Method-Override: DELETE' -d '_method=DELETE'
期待される観測結果:すべてのパターンで一律に 403/404 となる場合以外のステータス(および、有効だが権限のないトークンでも同じ動作)が確認された場合、ガード回避のプリミティブが存在することを示します。回避に使われた正確なバイト列を記録してください。
修正
ポリシーレイヤーの一元化
各スタックで、すべての判断を1つの集中ポイントに集約します。ハンドラーで追加の制御を行うことはできますが、唯一のガードにしてはいけません。
| スタック | 集中ポイント |
|---|---|
| Express/Fastify | 分岐ハンドラーより前に登録するルーターレベルのミドルウェア(guardRouter.use(requireAuth, attachPolicy)) |
| NestJS | グローバルな APP_GUARD JwtAuthGuard + RolesGuard、ハンドラーごとの @Roles() メタデータ |
| FastAPI | ルーターの dependencies=[Depends(require_auth)]、操作ごとのポリシー依存関係 |
| Django | すべての CBV に適用するミックスイン + DRF DEFAULT_PERMISSION_CLASSES + カスタム get_queryset 基底クラス |
| Flask | before_request ブループリントフック + すべてのビューに適用するデコレーター |
| Spring | SecurityFilterChain のパスルール + @EnableMethodSecurity + デフォルト拒否の @PreAuthorize メタアノテーション |
| ASP.NET Core | グローバルに FallbackPolicy = RequireAuthenticatedUser を設定、エリアごとに [Authorize(Policy)] を指定 |
| Laravel | ルートミドルウェアグループ(auth:sanctum)+ Policies の自動検出、$gate->deny をデフォルトに設定 |
| Symfony | access_control YAML + 基底コントローラー/サブスクライバー内の isGranted 経由で呼び出す voters |
| Rails | ApplicationController の before_action :authenticate_user! + チェックが省略された場合に例外を発生させる Pundit の verify_authorized after_action |
| Go | 公開されていないすべてのルートグループに合成する単一の func Authz(next http.Handler) http.Handler ラッパー |
修正前/修正後のレシピ
Express:
// VULNERABLE
router.get('/invoices/:id', async (req, res) => {
const inv = await Invoice.findByPk(req.params.id);
res.json(inv);
});
// FIXED
const { requireAuth } = require('./middleware/auth');
const { authorize } = require('./policy'); // centralized decision engine
const { scopeToTenant } = require('./middleware/tenant');
router.get('/invoices/:id', requireAuth, authorize('invoice:read'),
scopeToTenant, async (req, res) => {
const inv = await Invoice.findOne({ // predicate lives IN the query
where: { id: req.params.id, tenantId: req.user.tenantId }
});
if (!inv) return res.sendStatus(404); // uniform mask, no existence oracle
res.json(inv);
});
Django/DRF:
# VULNERABLE
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
# FIXED
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
permission_classes = [IsAuthenticated, TenantMemberRequired]
def get_queryset(self): # object axis enforced centrally
return Invoice.objects.filter(
tenant_id=self.request.user.tenant_id,
)
# FIXED (classic Django CBV equivalent)
class InvoiceDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
model = Invoice
permission_required = 'invoices.view_invoice'
def get_queryset(self):
return Invoice.objects.filter(tenant=self.request.user.tenant)
Spring:
// VULNERABLE
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id) {
return invoices.findById(id).map(mapper::toDto)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
// FIXED
@PreAuthorize("hasAuthority('invoice:read')")
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id, @AuthenticationPrincipal AppUser user) {
return invoices.findByIdAndTenantId(id, user.getTenantId())
.map(mapper::toDto)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
Laravel:
// VULNERABLE
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']);
// FIXED
Route::middleware(['auth:sanctum'])
->get('/invoices/{invoice}', [InvoiceController::class, 'show'])
->middleware('can:view,invoice'); // policy = single decision point
// app/Policies/InvoicePolicy.php (registered automatically by name convention)
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id;
}
}
Rails(Pundit):
# VULNERABLE
def show
@invoice = Invoice.find(params[:id])
end
# FIXED
def show
@invoice = Invoice.find(params[:id])
authorize @invoice # raises unless policy permits
end
# with_scope in policy keeps index endpoints tenant-safe:
class ApplicationPolicy
class Scope
def resolve
scope.where(tenant_id: user.tenant_id)
end
end
end
デフォルト拒否のルーティング
// Express: public surface is an EXPLICIT allowlist; everything else falls through the guard wall
app.use(['/health', '/live', '/ready', '/login', '/register'], publicRouter);
app.use(requireAuth); // wall: nothing below resolves without identity
app.use('/admin', requireRole('admin')); // vertical wall for whole subtree
app.use('/api', apiRouter); // apiRouter assumes authenticated ctx
# Spring Security: authenticate everything, permit only listed public paths
security:
config:
http:
authorizeHttpRequests:
- "/health"
- "/login"
others: "authenticated"
admin-prefix: "/admin/**"
admin-role: "ROLE_ADMIN"
method-security: true
同等のデフォルト設定:ASP.NET の FallbackPolicy(下記を参照)、Symfony の最終手段である - { path: ^/, roles: ROLE_USER }、Laravel の api グループに対する auth、Rails の ApplicationController での認証と、使用箇所ごとに監査する skip_before_action。
// ASP.NET Core: fail closed
builder.Services.AddAuthorization(o =>
{
o.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build();
});
// minimal APIs: app.MapGet("/x", h).RequireAuthorization("TenantMember");
テナントのスコープ設定と行レベルセキュリティ
- すべてのクエリの下位にある1つのレイヤーでテナント分離を強制します。対象となるのは、リポジトリーの基底クラス、DRF の
get_queryset、EF Core のグローバルクエリフィルター(HasQueryFilter(i => i.TenantId == _tenant.Id))、Hibernate の@Filter/@TenantId、SQLAlchemy のイベント、または最後の防衛策としての Postgres RLS です。
-- Postgres row-level security as defense-in-depth under app-level checks
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- per request/connection: SET LOCAL app.tenant_id = '<uuid-from-session>';
- キャッシュキーとキューヘッダーにテナントスコープを含めます(
t:{tenant}:invoice:{id})。メッセージの受信時にテナントを再検証します。 - 外部参照には連番の整数の代わりに、不透明な UUIDv4 を使用します(緩和策であり、根本的な修正ではありません)。
- なりすまし:
act(実行者)と対象を含む専用の短い TTL のトークンタイプを使用し、理由の文字列を追記専用の監査テーブルに保存します。対象のパスワードが変更されたら自動的に期限切れにし、なりすまし権限自体の付与は管理者のみに限定します。
検証と妥当性確認
自動テスト(GIVEN/WHEN/THEN)
Scenario: Owner retains access (negative test against over-blocking)
GIVEN user A authenticated and owning invoice 1042
WHEN A requests GET /api/invoices/1042
THEN status is 200 AND body contains A's memo value
Scenario: Non-owner denied on read
GIVEN users A and B on distinct tenants
WHEN B requests GET /api/invoices/1042
THEN status is 403 or 404 AND body contains no A-owned field values
Scenario: Non-owner denied on write
WHEN B sends PATCH /api/invoices/1042 {"amount": 1}
THEN status is 403/404 AND a follow-up GET by A shows unchanged amount
Scenario: Role matrix cell denial
GIVEN role "viewer" exists with read-only rights
WHEN viewer calls DELETE /api/invoices/1042
THEN status is 403 regardless of ownership
Scenario: Unauthenticated denial
WHEN request omits credentials entirely
THEN status is 401 for every non-public route
すべてのルート × すべてのロール × {本人、自分以外、存在しない} オブジェクト ID の組み合わせでマトリックスを実行します。エラーテキストがないことだけでなく、ステータスコードを検証します。
ルートカバレッジテスト(デフォルト拒否のアサーション)
// pseudocode — port enumerator per framework (Express stack walk, rails routes, artisan route:list)
const PUBLIC_ROUTES = new Set(['/health', '/live', '/ready', '/login', '/register', '/docs', '/openapi.json']);
test('every non-public route declares >=1 authz guard; no public mutations', () => {
for (const r of enumerateRoutes(app)) {
const isPublic = PUBLIC_ROUTES.has(r.path);
const guards = r.middleware.filter(isAuthzGuard); // requireAuth/authorize/roles...
expect(guards.length >= 1 || isPublic).toBe(true); // deny-by-default
if (isPublic) expect(r.methods.some(m => ['POST','PUT','PATCH','DELETE'].includes(m))).toBe(false);
}
});
CI での補完策:ルートテーブル(rails routes、artisan route:list、Nest のルートダンプ)をスナップショットテストし、対応するガードの変更なしにルート数が増えた場合はレビューを失敗させます。
手動再テスト用チェックリスト
- Exploitation セクションの2アカウント手順を再実行し、
403/404と、所有者に対する200を確認します。 - すべてのパス/ヘッダー/HTTP メソッドのバイパスループを再実行し、一貫して拒否されることを確認します。
- 管理者が引き続き操作できることを確認します(修正によって過剰にブロックされることがよくあるため、ロールマトリックスの各セルで許可される操作もテストします)。
- エクスポート/ダウンロードと GraphQL のネストされたフィールドのパスで、所有権が適切に強制されることを確認します。
- なりすましによって監査行が書き込まれ、正しく期限切れになることを確認します。
- キャッシュキーにテナントのプレフィックスが付いており、修正前の古いエントリーが削除されていることを確認します(影響を受ける名前空間をフラッシュします)。
修正後に実行する grep
rg -n "skip_authorization|SkipAuthorization" app/
rg -n "objects\.(get|filter)\((pk|id)=" --type py
rg -n "findById\(" --type java --type kt | rg -v "AndTenant|ForTenant|Scoped"
rg -ni "x-(original-url|rewrite-url)" .
rg -n "x-forwarded-for" -i . | rg -v "trust proxy|trustedProxies|rate.?limit"
rg -n "@(Get|Post|Put|Delete|Patch)Mapping\(" --type java --type kt | wc -l # compare vs @PreAuthorize count
重大度の評価
| 検出パターン | 例 | CVSS v3.1 の基本ベクトル | スコア帯 |
|---|---|---|---|
| 認証なしの管理者操作 | POST /api/users に認証前からアクセス可能 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | クリティカル(9.8) |
| 垂直権限昇格、認証済みの書き込み | 一般ユーザーがユーザーを作成/無効化できる | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H | 高(8.8) |
| テナントをまたぐ一括読み取り | 一覧取得で全テナントのレコードが返される(スコープが変わる) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N | 高(7.7) |
| 水平権限昇格、単一オブジェクトへの書き込み | 攻撃者が別テナントの請求書を編集する | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N | 中~高(6.5) |
| 単一レコードのテナント間読み取り | 推測した ID で機密性の高いデータを含む他テナントのレコードを 1 件取得する | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N | 中(6.5) |
| 存在/タイミングのオラクルのみ | 200(空)と 404 の違いを判別できる | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N | 低~中(3.1~4.3) |
評価基準の補正要素:連番 ID を列挙できる場合、またはレート制限がない場合は、重大度を 1 段階引き上げます。シークレット/PHI/財務情報のフィールドが含まれる場合も引き上げます。実際に内部限定のサービスでネットワーク ACL による補完的な対策がある場合は引き下げますが、コード上の欠陥が残っている限り、情報レベルにはしません。管理者アカウントの乗っ取りは 9.0 以上で常にクリティカルです。テナント間の読み取りは、データが個人情報または機密情報である場合、レコードが 1 件だけでもデフォルトで高とします。
主な CWE マッピング:ガードがまったくない → CWE-862、ガードはあるが誤っている → CWE-863、ユーザーが制御するキーで他者のオブジェクトにアクセスできる → CWE-639、包括的な分類 → CWE-285、共有キャッシュ/キューの粒度 → CWE-1220。
よくある誤検出
- ゲートウェイ/サイドカーによる強制:認可を行う API ゲートウェイ、サービスメッシュの
AuthorizationPolicy、または mTLS-spiffe メッシュの背後にあるハンドラー。問題を除外する前に、強制ポイントが存在し、対象のルートをカバーしていることを確認してください。レポートにその内容を記載してください。 - 意図的に公開されているエンドポイント:料金カタログ、ステータスページ、ドキュメントなど。製品の意図と、変更メソッドが存在しないことを確認してください。
- UUID のケイパビリティ参照:推測できない ID によって実際の悪用可能性は低下しますが、所有者確認の述語が欠けていること自体は実際の欠陥です。パターン自体の誤検出ではなく、重大度の低いハードニング上の問題として報告してください。
- 意図的な 404 マスキング:他者のオブジェクトには一律に
404を返し、所有者には200を返す設計は正しく、情報漏えいではありません。存在しない ID と他者の ID で、レスポンスのタイミングや本文を区別できないことを確認してください。 - 本番ビルドではコンパイル時に除外される、開発/テスト専用のルート(例:
if (process.env.NODE_ENV !== 'production')のようなビルドフラグ/環境ゲートによって実際に除外されていることを確認してください)。 - ネットワーク層で ID が確立される、VPN + mTLS の背後にある内部ツール。同じサービスがパブリックインターフェイスにもバインドされている場合に限り、残存リスクとして注記してください。
- シリアライザーで公開フィールドのみにすでにフィルタリングされている読み取りエンドポイント。リレーションやネストしたシリアライザーから非公開フィールドが漏れないことを確認してください。
参考資料
- CWE-284:不適切なアクセス制御
- CWE-285:不適切な認可
- CWE-639:ユーザー制御キーによる認可の回避
- CWE-862:認可の欠如
- CWE-863:不適切な認可
- CWE-1220:リソース粒度の不足
- OWASP チートシートシリーズ:認可チートシート
- OWASP チートシートシリーズ:アクセス制御チートシート
- OWASP チートシートシリーズ:安全でない直接オブジェクト参照の防止チートシート
- OWASP API Security Top 10 (2023):API1 オブジェクトレベルの認可の不備
- OWASP API Security Top 10 (2023):API5 機能レベルの認可の不備
- OWASP Top 10 (2021):A01 アクセス制御の不備
- OWASP ASVS 4.0.x — セクション V4 アクセス制御