はじめに
セキュリティ研修で、脆弱性を含むWebアプリ(Hono + TypeScript)を題材に、所有者チェックが無いAPIのバグを見つけるところから、それを設計で塞ぐところまでを考える課題に取り組んだ。
この記事では、
- 資格で覚えた知識と、実際のコードを前にしたときのギャップ
- ミドルウェア案 → クラス案 → ブランド型案と、指摘を受けながら設計が変わっていった過程
を、自分の気づきとしてまとめる。
セキュリティを設計段階から落とし込むことは、今いちばんやりたいことの一つだった。それを具体的なコードで体験できたのがうれしかった。
気づき:知っているのに、見えない
資格の知識がコード上の違和感にならない
資格取得のためにセキュリティ知識は一通り身につけたつもりだった。それでも、実際のアプリとソースコードを前にすると、「ここがおかしい」と違和感として検知するまでに時間がかかった。知識として知っていることと、コードの中で見抜けることは別のスキルだと痛感した。
用語を一言で要約できる力が要る
研修ではAIを使って調べながら進めた。そこで気づいたのは、AIの回答をスムーズに理解するにも、脆弱性の用語とその説明を一言で言える力が必要ということ。用語の理解が曖昧だと、AIの説明を読んでも頭に入ってこない。
また、恥ずかしながらSSRFは今回が初見だった。セキュリティ用語の学習はこれからも続ける。
曖昧な理解だと説明が長くなる
セキュリティインシデントを人に伝える経験は過去にもあるが、まだ未熟だと感じた。理解が曖昧な部分ほど、説明が長くなる。
所有者チェック漏れを設計で塞ぐ
Before:チェックを「書く前提」の設計
題材のアプリでは、注文APIのハンドラが注文IDだけで注文を取得・操作していた。ログインしているか(認証)は確認しているが、その注文に触れてよい人か(認可)は確認していない。
AIと現状を確認すると、user_id の条件が抜けていたのは次の2か所だった。
-
GET /:id:他人の注文が見える -
POST /:id/cancel:他人の注文をキャンセルできる
一方で、一覧取得の GET / と検索の GET /search には条件があった。条件を書いた箇所と書き忘れた箇所が混在していた。問題の本質は「チェックの書き漏れ」。
ここから設計案を3回考え直した。
案1:Honoのミドルウェアでチェックする → ✕
課題のヒントは「所有者チェックをドメインに落とし込む」だったが、最初に思いついたのはHonoのミドルウェアだった。これには次の指摘を受けた。
- ミドルウェアで検知できるのは認証したかどうかまで
- ミドルウェアを通ったユーザーが、その注文の所有者かは担保できない
- どのパス・ルーターにミドルウェアをかけるかを実装者が考慮する必要があり、実装者がそのルールを認識できない場合、漏れる可能性がある
案2:所有者専用の処理をクラスに集約する → △
次はヒント通り、所有者専用の操作処理を1ファイルにまとめてクラスにした。publicメソッドとして処理を追加し、各メソッド内で所有者チェックを行う。
export class OrderService {
async getOrder(userId: number, orderId: number) {
// ここで所有者チェック
}
async cancelOrder(userId: number, orderId: number) {
// ここでも所有者チェック
}
// 新しいメソッドを追加した人が、チェックを書き忘れたら?
}
これには次の指摘を受けた。
- 処理ファイルの集約はできたが、公開メソッドを作るときに所有者チェックを実装者が漏らす可能性がある
案3:所有者チェック済みであることを型で表す → ○
AIとの壁打ち:何の証明を、どの操作に要求させるか
案3はAIとの壁打ちで詰めた。起点はAIからのこの問いだった。
何の証明を、どの操作に要求させるか
自分の答えは「その注文の所有者であること」を証明させる、だった。
ここでAIから指摘を受けた。所有者というのはユーザーと特定の注文の関係であって、ユーザー単体の属性ではない。userId しか持たない OrderOwner のような証明にすると、
const owner = await authorizeOwner(user, 10) // 注文10で証明を取得
await cancelOrder(owner, 99) // 注文99に使い回せてしまう
と、ある注文で得た証明を別の注文に使い回せてしまう。
そこで「cancelOrder は『注文10の証明』を受け取ればよいのか?」と聞き返したところ、その通りという回答だった。注文IDを証明の中に含め、別の引数では渡さない。証明と操作対象を切り離せないようにすることで、使い回しの余地がなくなる。
型で守るといっても、「何を証明した型なのか」を正しく定義しないと穴が残る。ここは自分一人では気づけなかったポイント。
最終形
最終的には、次の形にした。
- 所有者チェックをするための関数を用意する。戻り値は所有者情報と、
unique symbolによるブランド型 - 各処理は、その所有者ブランド型の引数のみを受け付ける
// owned-order.ts
declare const ownedOrderBrand: unique symbol
export type OwnedOrder = Order & { readonly [ownedOrderBrand]: true }
// OwnedOrder を作れるのはこの関数だけ
export async function findOwnedOrder(
auth: AuthContext,
orderId: number,
): Promise<OwnedOrder | null> {
const { rows } = await pool.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[orderId, auth.userId],
)
return rows[0] ? (rows[0] as OwnedOrder) : null
}
// order-service.ts
export async function cancelOrder(order: OwnedOrder) {
// 引数が OwnedOrder である時点で、所有者確認は済んでいる
}
// ハンドラ
const order = await findOwnedOrder(auth, Number(c.req.param('id')))
if (!order) return c.json({ error: 'not found' }, 404)
await cancelOrder(order)
Before → After
Beforeは、所有者チェックを各APIハンドラで「忘れずに書く」ことを前提にした設計。チェックの有無が実装者の注意力に依存するため、一覧取得では書いたのに詳細取得では抜ける、という書き漏れが起きた。APIが増えるほど漏れのリスクも増える構造。
Afterは、所有者チェックを1か所に集約し、チェックを通った結果としてしか得られない専用型を、注文処理の引数に要求する設計。チェックを経ずに注文処理を呼ぶコードはコンパイルが通らないため、書き漏れが人の注意ではなく型によって防がれる。新しいAPIを追加する人も、注文を扱う限り自然にチェックを通ることになる。
つまり、認可の担保を「各実装者が気をつけること」から「設計の構造として外れようがないこと」へ移したのがBefore → Afterの本質。
学んだこと:ルールを「忘れさせない」のは悪手
ミドルウェア案もクラス案も、最終的には「実装者がルールを知っていて、守る」ことに依存していた。実装のルールを開発者に忘れさせないようにすることは悪手。
セキュリティを設計に落とし込むには、開発者に自由に書かせないように(記述を統一するように)、次を念頭に置く。
- ファイルの一本化
- ブランド型による偽造対策
- ESLintによる機械的ルールの実装
おわりに
資格の知識だけでは、実際のコードの中の穴に気づくのに時間がかかった。そして穴に気づけても、塞ぎ方が「気をつけて書く」のままでは、また同じ穴が生まれる。
ミドルウェア → クラス → ブランド型と設計を考え直したことで、認可を構造で担保するという考え方を実感できた。セキュリティ用語の学習を続けつつ、これからの実装に活かしていきたい。
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp
