リファクタリングされたNestJSアプリのセキュリティ監査:発見したこと
本番アプリケーションに50以上の新しいエンドポイントを追加すると、新しいアプリケーションだけでなく——新しい攻撃対象領域も持つことになる。Leverage OJバックエンド書き換えはシステムのほぼすべてのルートに触れ、新しいロール階層を導入し、認証レイヤー全体を置き換えた。それはまさにパーミッションバグを生み出す種類の変更だ:旧システムで機能していたアクセス制御がポートされなかった、または間違ってポートされた種類のバグ。
稼働前に体系的なセキュリティ監査を行った。この投稿は方法論と発見したものについてだ——間違ったユーザーが提出されたコードの再評価をトリガーできる回帰と、何年もアクセス制御なしで本番に放置されていたFIXMEコメントを含めて。
セットアップ:何が変わったか
オリジナルのLeverage OJバックエンドはかなりフラットなロール構造を持っていた。書き換えでよりクリーンな3層階層を導入した:
- User — 標準的な認証済みユーザー;ソリューションを提出、自分の提出を表示可能
- Supervisor — 問題を管理、すべての提出を表示、再ジャッジをトリガー可能
- Admin — 完全なシステムアクセス、ユーザー管理、システム設定
旧コードベースはロールをインラインでチェックするカスタムミドルウェアでexpress-sessionを使用していた。新しいコードベースはNestJSガードを使用する:
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Supervisor)
@Post('/rejudge/:id')
async rejudge(@Param('id') id: number) {
// ...
}インラインミドルウェアから宣言的ガードへの移行は改善であるはずだ。そしてそうだ——ロールが正しく割り当てられているとき。
監査方法論:パーミッションマトリックス
アドホックな方法でエンドポイントを1つずつ監査するのではなく、パーミッションマトリックスを構築した:すべてのエンドポイントをその期待されるアクセスレベルにマッピングするテーブル。
プロセス:
- メソッドとパス付きですべてのルートをエクスポート
- エンドポイントが何をするかに基づいて各ルートに期待されるアクセスレベルを割り当てる
- ソースから実際のガードデコレータを読む
- 不一致をフラグする
退屈だが複雑ではない。50以上の新しいエンドポイントで、マトリックスを完全に埋めるのに約3時間かかった。
発見したもの
発見1:rejudgeパーミッション回帰
rejudgeエンドポイント——提出を再キューしてジャッジする——はSupervisorアクセスを必要とすべきだ。Supervisorは日常的にジャッジが評価中にクラッシュしたときや問題のテストケースが更新されたときに再ジャッジをトリガーする必要がある。
リファクタリング中に、ガードがRole.Adminに設定されていた:
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Admin) // ← 間違い
@Post('/submissions/:id/rejudge')
async rejudge(@Param('id') id: number) {
return this.judgeService.rejudge(id)
}これは間違った方向への権限昇格だ:エンドポイントは本来あるべきより制限的になり、supervisorが必要な機能へのアクセスを事実上削除した。本番での症状は提出を再ジャッジしようとするsupervisorが403エラーを受け取ることだ——イライラするがセキュリティ脆弱性ではない。
しかしどちらの方向の不一致も、パーミッションモデルが一貫して適用されていないことを示す、それ自体が問題だ。1つのガードが間違って設定されていたら、他も間違っているかもしれない。
修正:
@Roles(Role.Supervisor) // ← 正しい:supervisor以上発見2:本番コードになったFIXME
より懸念される発見はオリジナルコードベースの/submissions/:id/inspectエンドポイントにあった。このエンドポイントは提出の完全な詳細を返す——提出されたコードとジャッジの内部評価ログを含めて。
オリジナルソースで:
// FIXME: should check permissions here
@Get('/submissions/:id/inspect')
async inspect(@Param('id') id: number) {
return this.submissionsService.getFullDetails(id)
}認証ガードなし。ロールチェックなし。提出IDを持つ任意のリクエスト——認証されているかどうかにかかわらず——がシステム内の任意の提出の完全な内容を取得できた。
// FIXMEコメントは誰かがこれが間違っていることを知っていたことを示唆する。彼らはエンドポイントを書き、欠落しているアクセス制御を記録し、時間切れになったか戻るのを忘れた。コメントはコードベースで十分長く生き残り、本番に入った。
これは意味のあるデータ露出だ:提出されたコードは知的財産であり、コンテストコンテキストでは、他のユーザーのソリューションをリアルタイムで読めることは直接的な不正行為の形式だ。提出IDが連続した整数であることは列挙を簡単にする。
新しいバックエンドでは2つの方法でこれを修正した:
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Supervisor)
@Get('/submissions/:id/inspect')
async inspect(@Param('id') id: number, @CurrentUser() user: User) {
return this.submissionsService.getFullDetails(id)
}Supervisorは任意の提出をインスペクトできる。通常ユーザーは提出をまったくインスペクトできない——自分のコードを見たい場合は、ユーザー自身の提出のみを返す標準の提出詳細エンドポイントを使用する。
新しいアーキテクチャでのセキュリティ改善
特定の発見を超えて、書き換えはパーミッションモデルを間違えにくくする構造的変更を導入した。
whitelist: true付きのValidationPipe
オリジナルバックエンドは任意のリクエストボディプロパティを受け入れ、TypeORMに渡していた。クライアントがPOSTボディに追加フィールドを送信した場合——例えば登録リクエストでrole: 'admin'——エンティティの設定方法によってはそれらのフィールドがデータベースに到達する可能性があった。
新しいバックエンドはValidationPipeをグローバルに設定する:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 未知のプロパティを除去
forbidNonWhitelisted: true, // 未知のプロパティがあれば例外をスロー
transform: true, // DTO型への自動変換
})
)whitelist: trueはDTOクラスで宣言されていないプロパティを除去する。forbidNonWhitelisted: trueはさらに進んでクライアントが未知のフィールドを送信したら400エラーを投げる。これはマス代入攻撃をフレームワークレベルで完全に防止する。
別々のレイヤーとしてのJwtAuthGuard + RolesGuard
オリジナルシステムでは、認証と認可が単一のミドルウェア関数に結合されていた。新しいシステムはそれらをきれいに分離する:
JwtAuthGuardはJWTを検証し、ユーザーをリクエストに添付する。1つの仕事がある。RolesGuardは@Roles()デコレータを読み、添付されたユーザーが必要なロールを持っているかチェックする。1つの仕事がある。
この分離は各ガードを独立してテスト可能にし、コードレビューでパーミッションバグを見つけやすくする。@UseGuards(JwtAuthGuard, RolesGuard)の後に@Roles(Role.Supervisor)があれば、意図は明確だ。
認証が必要だが特定のロールが不要なエンドポイントには、JwtAuthGuardのみが適用される。パブリックエンドポイントにはどちらのガードも適用されない。パターンは暗黙的ではなく明示的だ。
教訓
大規模リファクタリング後にパーミッション監査を実行する。 マトリックスアプローチはスケールする——部分的に自動化できるほど機械的で、個々のエンドポイントを孤立して見ている誰にも見えない不一致を表面化する。rejudge回帰は孤立しては正しく見えた(ガードがあった!ロール要件があった!)が、期待される動作と比較したときにのみ間違いとして現れた。
FIXMEコメントは発生を待っているセキュリティ脆弱性だ。 欠落しているアクセス制御を記録するコメントは何もないよりマシだが、修正ではない。本番コードベースでは、// FIXME: add authコメントは解決されるまで既知の脆弱性として扱われるべきだ。
暗黙的より明示的。 オリジナルのミドルウェアアプローチは認証チェックの適用を忘れやすかった。NestJSのデコレータアプローチはルートにガードがないことを見やすくする——@UseGuards()の不在は視覚的に明らかだ。明示的であるコストは数行の追加コードだ。利点はセキュリティ要件がソースに文書化されていることだ。
監査に数時間かかった。見つけたバグは稼働後に対処するのにそれよりずっと多くのコストがかかっただろう。
