脆弱性診断で最も多く、そして最も重大なのが認可の不備です。
「URLのIDを変えたら他人のデータが見えた」という類のものです。
なぜ起きるか
認証(ログインしているか)は仕組みで守られますが、
認可(そのデータを見てよいか)は毎回書く必要があるためです。
# × ログインチェックだけ
@login_required
def order_detail(request, pk):
order = Order.objects.get(pk=pk) # 誰の注文でも取れてしまう
return render(request, "detail.html", {"order": order})
書き忘れが1か所でもあれば、そこが穴になります。
構造で防ぐ:常に絞り込んだ集合から取る
個別にチェックを書くのではなく、
そもそも自分のデータしか含まれない QuerySet から取るようにします。
def owned_orders(user):
return Order.objects.filter(user=user)
@login_required
def order_detail(request, pk):
order = get_object_or_404(owned_orders(request.user), pk=pk)
...
こうすると、チェックを書き忘れる余地がなくなります。
他人のIDを指定すれば 404 になります。
403 ではなく 404 を返す
# 403「権限がありません」→ そのIDのデータは存在すると教えている
# 404「見つかりません」 → 存在の有無すら分からない
存在を教えないほうが安全です。
一覧・更新・削除も同じ集合から
詳細画面だけ対策して、更新や削除で漏れるのがよくあるパターンです。
def order_update(request, pk):
order = get_object_or_404(owned_orders(request.user), pk=pk) # 同じ関数を使う
「取得する経路をすべて1つの関数に集約する」と、監査もしやすくなります。
推測しにくいIDにする
連番IDは、総当たりで探索されます。
公開URLに使うなら、推測困難な識別子を併用します。
public_id = models.CharField(max_length=22, unique=True, default=gen_token)
ただしこれは認可の代わりにはなりません。
「推測しにくい」だけで、漏れたら誰でもアクセスできます。必ず認可と併用します。
一括操作に注意
チェックボックスで選んだ複数件を処理する画面は、危険が集中します。
ids = request.POST.getlist("ids")
# × Order.objects.filter(pk__in=ids).delete()
# ○ 自分のものだけに絞ってから
owned_orders(request.user).filter(pk__in=ids).delete()
API も同じ
class OrderViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Order.objects.filter(user=self.request.user)
get_queryset で絞れば、詳細・更新・削除すべてに効きます。
テストで固定する
認可は必ずテストを書く領域です。
def test_cannot_access_others_order(self):
self.client.force_login(self.user_b)
for method, url in [("get", f"/orders/{self.order_a.pk}/"),
("post", f"/orders/{self.order_a.pk}/delete/")]:
with self.subTest(url=url):
res = getattr(self.client, method)(url)
self.assertIn(res.status_code, (403, 404))
まとめ
- 個別チェックではなく、絞り込んだ集合から取得する構造にする
- 取得経路を1つの関数に集約する
- 403 ではなく 404 を返す
- 一括操作は絞り込んでから実行する
- 推測しにくいIDは補助であって、認可の代わりではない
- 認可は必ずテストで固定する