はじめに
先日セキュリティの勉強会に参加しました。個人でもセキュリティに関する書籍を読んでいたものの、そこから学んだ知識を実務に活かすことの難しさを感じていました。システムの脆弱性に関する知識は増えたものの、その脆弱性に対して、どのような設計を行ったら、堅牢なシステムを作れるのかを学びたいと思い、参加しました。
この勉強会では脆弱性のある注文管理システムのコードをGithubから自身のPCにダウンロードして、Dockerを使って立ち上げます。勉強会の参加者で画面操作して、脆弱な箇所を挙げていきました。そして、コードを確認して、脆弱性の発生理由や改善方法を考えて、話し合いました。今回は認可の中でもアクセス制御について学習しましたため、どのような設計にすると堅牢なシステムを作れるのかについて記載します。
今回扱うアクセス制御
今回の勉強会で扱ったアクセス制御はIDOR、管理者 APIのロール確認漏れ、SSRFです。
IDOR
他のユーザー情報にアクセスできてしまう脆弱性です。今回の注文管理システムにおいては、ログインした一般ユーザーがフォームで注文番号を入力すると自身だけでなく、他のユーザーの注文情報も見えてしまう状態でした。
該当のコードは以下のようにSQLにおける抽出条件でユーザー情報を指定していないことが原因でした。
orderRoutes.get('/:id', async (c) => {
const id = c.req.param('id')
const { rows } = await pool.query('SELECT * FROM orders WHERE id = $1', [id])
if (rows.length === 0) {
return c.json({ error: 'not found' }, 404)
}
return c.json(rows[0])
})
APIのロール確認漏れ
APIにリクエストしてくるユーザーのロールチェックを行なっていないため、本来アクセス権のない画面を表示、更新などの操作が出てきしまう脆弱性です。今回のシステムにおいては、管理者ユーザーしかアクセスできないページ(全ユーザーのメールアドレスや名前などの情報が表示されているページ)に対して一般ユーザーが参照できたり、ユーザーのロールを変更できてしまいました。
こちらはバックエンドのAPIにリクエストするユーザーのロールをチェックする処理が未実装で発生していました。
以下はコードの一部です。authMiddlewareは認証をチェックする処理ですが、認可に関する実装がされていませんでした。
import { Hono } from 'hono'
import { pool } from '../db.js'
import { authMiddleware, type TokenPayload } from '../auth.js'
type Env = { Variables: { user: TokenPayload } }
export const adminRoutes = new Hono<Env>()
adminRoutes.use('*', authMiddleware)
adminRoutes.get('/orders', async (c) => {
const { rows } = await pool.query(
`SELECT o.*, u.email AS user_email, u.name AS user_name
FROM orders o JOIN users u ON u.id = o.user_id
ORDER BY o.id DESC`,
)
return c.json(rows)
})
SSRF
サーバを踏み台にして、不正なリクエストを送られてしまうことによって、秘匿情報を取得されてしまう脆弱性です。
今回のワークでは、クライアントのフォームにユーザーのアバター用URLを入力することができて、それを受け取ったサーバサイド側でURLをリクエストすることで秘匿情報を取得することができてしまいました。
フォームから送られてきたURLをaxiox.getでリクエストを送れてしまいます。それによって、例えばAWSのIAMに関するトークンを取得されてしまい、S3の画像を削除されてしまう可能性もあります。
profileRoutes.post('/avatar/preview', async (c) => {
const { url } = await c.req.json()
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 5000 })
そもそもフォームから受け取ったURLをもとにリクエストを送れてしまったり、送信情報のパラメーターに関するバリデーションチェックをしていないことが問題でした。
上記3つのアクセス制御をまとめると、ユーザーに対してリソース操作を毎回問うことを怠ると発生する脆弱性です
設計で脆弱性を防ぐ
IDOR
今回の例だとユーザー情報の抽出条件や引数として渡すことを漏らしてしまう状態でした。そのため、ユーザー情報を渡さないとSQLを発行させない仕組みにすることが大切だと思いました。
そのためには、以下の設計に変更すると良いのかと思いました。
- 生のSQLの代わりにクエリビルダやORMを使用します。生のSQLよりもクエリビルダやORMの方が抽出条件を開発者に強制できるため、ユーザー情報の実装漏れを防ぐことができます
- SQLの実行をコントローラー層で実装していたのをドメイン層に移動させます。そして、以下のコードのようにユーザー情報を渡さないとそもそもインスタンスを生成できないようにコンストラクタで制御することで、ユーザー情報を渡さないとSQLを発行できないようにします
class Orders {
constructor(private userId: number) {}
private base() { return db('orders').where('user_id', this.userId) }
findById(id) { return this.base().where('id', id).first() }
cancel(id) { return this.base().where('id', id).update({status:'cancelled'}) }
}
- 生のSQLを実装させないためにコントローラー層で生のSQLを使っていたら、検知して開発者に通知する仕組みの実装。例えばCI/CDでLintが動くようにして、PR作成時にチェックします。そして、生のSQL(pool.query(...))が記載されていたら、CI/CDをエラーにさせて検知させるようにします
自分以外の人が脆弱性のあるコードを実装しても検知できる仕組みと、ユーザー情報がないとそもそもSQLが実行できないように制御することが大切だと思いました。
APIのロール確認漏れ
今回の例だとAPIにリクエストするユーザーのロールをチェックしていないため、一般ユーザーが管理者用APIにアクセスできてしまう状態でした。そのため、ミドルウェア層(クライアントからのHTTPリクエストを受けて、チェックする門番)でユーザーのロールをチェックするキッカケとなる実装を行います。ユーザーのロールが管理者なのかを判定するロジックはユーザーのドメイン層に実装します。ドメイン層に実装することによって、同じ判定ロジックを複数箇所に記載しなくて済みます。そのため、変更が入った場合も変更が一箇所で済むようになり、変更容易性が高くなります。
以下のようにミドルウェア層を実装して、コントローラー側で呼び出します。ここでは、認証チェックと認可チェックの記載順番に依存しないように処理を記載しています。認可を先に記載した後、認証を実施してしまってもコードが正常に動くようチェック処理を入れています。
export function requireAdmin() {
return async (c, next) => {
const user = c.get('user')
if (!user) return c.json({ error: 'unauthorized' }, 401)
if (!user.isAdmin()) return c.json({ error: 'forbidden' }, 403)
return next()
}
}
以下をドメイン層に記載することで一箇所にユーザーロールを判定するようにしています。
class User {
constructor(public id: number, public email: string, public role: string) {}
isAdmin(): boolean { return this.role === 'admin' }
}
SSRF
そもそもユーザーがアバター画像を取得するのにフォームからURL入力が必要なのか?を見直した方が良いと思いました。フォームから画像をユーザーがアップロードしてそれをアバター画像として設定できるように仕様を見なせないかを考えることが大事だと思いました。
どうしてもフォームからURLを入力したいという場合は以下を実施した方が良いと思いました。
リクエストパラメーターに関するバリデーションの実装。ブラックリストよりもホワイトリストの方がセキュリティの穴が少ないと思いました。ブラックリストだと除外対象の漏れが発生する可能性が高いため、それよりも許可リストを追加するホワイトリストの方が堅牢だと思いました。今回は画像を選択したいため、ユーザーが画像をアップロードしたS3の画像を配信しているCDNのURLだけを許可する設計にすると良いと思いました。
まとめ
今回の学習をもとに以下を意識したいと思いました。
- ユーザーのリソース操作を毎回チェックしているか?
- 自分以外の人が実装する場合でも堅牢な実装になるように制約(脆弱性のある実装を検知、通知、実現させない仕組み)を設けられているか?
- 堅牢なシステムにするため、仕様から見直せないか?