1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

認可のバグを「ドメインに集約」して根本から防ぐ

1
Last updated at Posted at 2026-10-13

きっかけ

注文管理アプリの実装で、認可まわりに3つのバグを見つけました。

  1. 管理者用APIなのに、一般ユーザーでも叩けてしまう(ロールチェック漏れ)
  2. URLの id を変えるだけで、他人の注文を見られてしまう(user_id の絞り込み忘れ / IDOR)
  3. 画面でロールによる表示の出し分けができていない

バラバラのバグに見えて、原因は1つでした。
認可の判断が、あちこちのコードに手書きでコピペされていたこと。 手書きなので、どこかで必ず忘れてしまいます。

一番刺さった学び

今日学んだ中で一番刺さったのは 「アクセス制御の不備(IDOR)」 です。

理由は、攻撃者が特別なことを何もしていないからです。URLの id を 1つずらすだけで他人のデータに届いてしまいます。しかもコードは一見ちゃんと書けています。「正しく書いたつもり」なのに、1箇所の書き忘れで穴が空く。 だから「気をつける」では防げません。構造で防ぐしかない、と痛感しました。

自分が立てた仮説

バグを直すにあたって、まず自分はこう考えました。

  • user_id の付け忘れ → 検索条件(Criteria)をドメインとして作り、検索では必ずそれを経由させる。userId と id を持つ Criteria を定義すれば、絞り込みの付け忘れを防げるのでは。
  • ロールチェック → enum で役割を型安全に扱えばいいのでは。

方向性としては悪くないと思っていました。

AIと壁打ちして考えた設計方針

壁打ちしてみて、自分の仮説は方向性は合っているけれど「もう一歩」足りない、と気づきました。最終的に次の3つを柱に設計することにしました。それぞれ「なぜセキュリティ的に効くのか」も自分なりに整理します。

判断は1箇所(ドメイン)に集約する

最初は「Criteria や Actor を作ればいい」くらいに考えていましたが、本質は 認可の判断を書く場所を1つに絞ることでした。判断が各ハンドラに散っていたのが、そもそもの穴の原因だったからです。
入口を1つにすれば、守るべき場所が「全API」から「その1箇所」に減ります。セキュリティ的には、チェックの抜け漏れ(=攻撃者の入口)が構造的に生まれなくなる、ということです。

不正な状態をそもそも作らせない(Always-Valid)

仮説では「Criteria に user_id を持たせる」でしたが、"持てる"だけだと結局セットし忘れます。なので 「所有者なしでは生成できない」ところまで寄せることにしました。
中途半端な状態を最初から存在させなければ、そこを突く攻撃も成立しません。**「チェックで弾く」のではなく「そもそも作らせない」**という発想です。

信用できない入力は入口で安全な型に変える(Parse, don't validate)

URLの id は生の文字列で、信用できない外部入力です。これを奥まで持ち回って使う直前に検証していると、検証を忘れる経路ができてしまいます。なので 入口で一度だけ検証して安全な型(OrderId など)に変換し、以降はその型だけを扱うことにしました。
どこまでが危険な生データで、どこからが安全かが型で見えるので、信頼の境界がはっきりします。

まとめると、入口で安全な型に変え(Parse, don't validate)、その型は常に正しく(Always-Valid)、使う入口は1つに絞る(ドメインに集約)。
こうすると「不正な状態を・変な場所で・うっかり作る」ことがどこでもできなくなります。
仮説にあった Criteria と enum は、この3つに沿って 「持てる→持たないと作れない」「enum は名前、判断はふるまいへ」 と一歩進めた形になりました。

実際にどう書いたか

1. Actor ― 「誰が」と「何ができるか」をまとめる

ログインユーザーを表すオブジェクトです。enum は役割の名前として使い、if (role !== 'admin') のような判断はこのクラスのメソッドに集約します。

enum Role {
  Admin = 'admin',
  Member = 'member',
}

class Actor {
  constructor(
    readonly userId: number,
    readonly role: Role,
  ) {}

  // 判断はハンドラに書かず、ここに集約する
  canManageUsers() {
    return this.role === Role.Admin
  }
}

呼ぶ側は if (role !== 'admin') ではなく actor.canManageUsers() と意図の名前で尋ねます。ルールが変わってもこのメソッドだけ直せば済みます。

2. OrderCriteria ― 所有者なしでは作れない検索条件

class OrderCriteria {
  private constructor(readonly ownerId: number, readonly orderId?: number) {}

  // actor からしか作れない = user_id が必ず入る
  static forOwner(actor: Actor, orderId?: number) {
    return new OrderCriteria(actor.userId, orderId)
  }
}

new を外から呼べないので、forOwner(actor) 経由でしか作れません。
= user_id の絞り込みを忘れようがありません。

3. OrderRepository ― DB アクセスを 1 箇所にまとめる

find は条件に合う注文を複数、findOne は1件だけ(なければ null)返します。
WHERE user_id = ... はここで必ず criteria.ownerId から付くので、呼び出し側が絞り込みを書くことはありません。

class OrderRepository {
  constructor(private readonly pool: Pool) {}

  async find(criteria: OrderCriteria) {
    // user_id は criteria.ownerId から必ず付く。生SQLを外に出さない
    const { rows } = await this.pool.query(
      'SELECT * FROM orders WHERE user_id = $1 ORDER BY id DESC',
      [criteria.ownerId],
    )
    return rows
  }

  // find の結果から 1 件だけ取り出すヘルパー
  async findOne(criteria: OrderCriteria) {
    const rows = await this.find(criteria)
    return rows[0] ?? null
  }
}

4. 使う側(ハンドラ)はこれだけです

orderRoutes.get('/:id', async (c) => {
  const actor = c.get('actor')
  const criteria = OrderCriteria.forOwner(actor, Number(c.req.param('id')))
  const order = await orderRepo.findOne(criteria) // user_id は内部で必ず付く
  return order ? c.json(order) : c.json({ error: 'not found' }, 404)
})

ロールチェックも同じ発想で、if (role !== 'admin') を各所に書くのではなく、requireRole('admin') を管理者ルートにまとめて適用すれば、書き忘れが消えます。

なお今回のトークンは署名なしで role を改ざんできる状態でした。
どれだけ設計を整えても、role の出所が信用できなければ意味がありません(これも「信頼の境界」の話)。

これから意識する一行のチェック

「この生の string/number は、入口で“安全な型”に変換したか? そのまま奥へ流していないか?」

まとめ

  • 認可バグの根っこは「判断があちこちに手書きでコピペされていた」こと
  • 一番怖いのは IDOR。正しく書いたつもりでも、書き忘れ1つで穴が空いてしまいます
  • 直す鍵は ドメインに集約 / Always-Valid / Parse, don't validate
  • 「気をつける」ではなく「忘れても安全」な構造にします
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?