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?

ログインできれば安全? 他人のデータを返さないための認可とテストをTypeScriptで考える

1
Posted at

この記事はこんな人におすすめ

  • ログイン機能を実装したあと、APIで何を確認すればよいか迷っている
  • 「本人のデータだけ取得する」をコードやテストで表したい
  • AIが生成したAPIのコードを、動作確認だけで終わらせたくない

この記事で得られること

認証済みの利用者が他人のデータへアクセスできてしまう原因を整理します。個人用ノートの取得処理を例に、認可のルール、検索条件、テストの期待結果をつなげて考えます。

はじめに

インプレスの公開企画をきっかけに、『Pythonで学ぶ Webアプリのセキュアコーディング』の認証・認可に関する説明を読みました。そこで扱われている「誰かを確認すること」と「何を許可するかを決めること」の違いを、APIの実装まで掘り下げます。

この記事のコードは書籍のサンプルや業務コードとは独立したTypeScriptの学習用例です。書籍全体の要約ではなく、OWASPの資料も参照しながら、一つの設計上の判断を整理してみました。

ログイン確認だけでは、データの閲覧範囲は決まらない

認証では利用者が誰かを確認し、認可ではその利用者にどの操作を許可するかを判断します。

今回は「非公開ノートは、作成した本人だけが読める」というルールにします。共有機能や管理者の例外は設けません。

リクエストする人 読もうとするノート 期待する結果
利用者A Aのノート 内容を返す
利用者A Bのノート 内容を返さない
利用者B Bのノート 内容を返す
未ログイン Aのノート 認証を求める

Aがログインできたという事実だけでは、Bのノートを読む権限まで得たことにはなりません。

認証で利用者を確定したあとも、対象ノートへの権限を確認する

例えば、次のようなAPIがあるとします。

GET /api/notes/note-demo-b

サーバーがログイン確認のあとに、指定されたIDだけでノートを取得して返していたらどうなるでしょうか。AがBのノートIDを知っていれば、Bの内容を取得できてしまいます。

こうした、識別子を通じて権限のないデータにアクセスできる問題は、IDORと呼ばれます。OWASP API Security Top 10のBOLAも、対象データに対する認可の不備を扱っています。

UUIDやボタンの非表示だけでは防げない

ランダムなUUIDはIDの推測を難しくできます。ただし、URLなどからIDを知った利用者を拒否できるかは別の問題です。今回はBのIDを知っているAでも、Bのノートを読めないことを確認します。

また、画面にBのノートへのリンクを出さなくても、APIへのリクエスト自体を防いだことにはなりません。ボタンの表示制御は操作を分かりやすくするために使い、権限の判定はサーバー側で行います。

他人のノートIDを知っていてもサーバーの認可で拒否する

「誰のデータか」を検索条件に含める

このルールなら、検索条件は次の組み合わせで表せます。

ノートID = リクエストで指定されたID
かつ
所有者ID = サーバーが認証した利用者のID

「どのノートを読みたいか」はリクエストから受け取ります。一方、「誰が読もうとしているか」はサーバー側で検証したセッションやトークンから確定します。リクエスト本文のuserIdを、そのまま本人の証明として使う設計にはしません。

ノートIDと認証済み利用者のIDを両方使って検索範囲を限定する

次のコードでは、認証処理で確定した利用者をprincipalとして受け取ります。ノートの検索時点で、所有者の条件も指定します。

export type Principal = Readonly<{ userId: string }>;

export type Note = Readonly<{
  id: string;
  ownerId: string;
  title: string;
}>;

export interface NoteRepository {
  findOwnedById(id: string, ownerId: string): Promise<Note | undefined>;
}

export type ReadNoteResult =
  | { status: 200; body: { id: string; title: string } }
  | { status: 401; body: { error: "unauthenticated" } }
  | { status: 404; body: { error: "not_found" } };

// principalは、サーバー側の認証処理で確定した値を渡す。
export async function readNote(
  principal: Principal | null,
  noteId: string,
  repository: NoteRepository,
): Promise<ReadNoteResult> {
  if (principal === null) {
    return { status: 401, body: { error: "unauthenticated" } };
  }

  const note = await repository.findOwnedById(noteId, principal.userId);
  if (!note) {
    return { status: 404, body: { error: "not_found" } };
  }

  return { status: 200, body: { id: note.id, title: note.title } };
}

これはHTTPサーバー全体のコードではありません。戻り値をHTTPレスポンスに変換する処理、認証情報の検証、URLパラメーターの実行時バリデーションは省略しています。TypeScriptでPrincipal型を付けるだけでは、値が本当に認証済みであることを保証できません。

findOwnedByIdの名前や型だけでも、所有者による絞り込みは保証されません。DBを使う実装では、IDと所有者の両方を検索条件に含め、実際の検索結果まで検証する必要があります。

403と404は、何を知らせるかで選ぶ

この例では、他人のノートと存在しないノートのどちらにも、同じ404の結果を返しています。閲覧できない利用者にノートの存在を知らせない方針です。

権限不足を明示してよいサービスなら、403を返す設計もあります。RFC 9110でも、禁止されたリソースの存在を隠すために404を返す選択が認められています。どちらか一方を常に正解とせず、存在自体を公開してよいかで決めます。

対象の存在を知らせる方針に合わせて403と404を選ぶ

なお、同じステータスと本文にしても、応答時間などを通じた存在の推測まで防いだとは言えません。また、DBの障害は「見つからない」とは別です。この例では検索時の例外をそのまま呼び出し元へ伝え、HTTP層での障害処理を別途設計する前提です。

テストでは「本人が読める」と「他人は読めない」を両方確認する

本人の取得だけを確認しても、認可が働いているかは分かりません。拒否だけを確認すると、誰にもデータを返さない実装でも通ってしまいます。

許可と拒否を両方検証し関数テストと統合テストの範囲を分ける

今回はテスト用のメモリ内データを使い、先ほどの表を検証します。BのノートIDはテストに明示し、推測の難しさに頼らず拒否できるかを確かめます。

先ほどの関数をauthorization.ts、次のコードをauthorization.test.mjsとして同じフォルダへ保存すると、この拒否ケースを実行できます。

import test from "node:test";
import assert from "node:assert/strict";
import { readNote } from "./authorization.ts";

function fixture() {
  const notes = [
    { id: "note-demo-a", ownerId: "user-demo-a", title: "Aのノート" },
    { id: "note-demo-b", ownerId: "user-demo-b", title: "Bのノート" },
  ];
  return {
    repository: {
      async findOwnedById(id, ownerId) {
        return notes.find(note => note.id === id && note.ownerId === ownerId);
      },
    },
  };
}

test("AがBの既知のIDを指定してもBのノートを返さない", async () => {
  const { repository } = fixture();
  assert.deepEqual(
    await readNote({ userId: "user-demo-a" }, "note-demo-b", repository),
    { status: 404, body: { error: "not_found" } },
  );
});

確認した実行環境はNode.js v26.13.0です。TypeScriptの型除去が有効な環境で、次のコマンドを実行します。Node.jsでの実行は型チェックを行わないため、型チェックは別途必要です。

node --test authorization.test.mjs

検証では、上のケースに加えて、本人による取得、未ログイン時の拒否、検索条件、検索障害の伝播も確認しました。

ただし、ここで確認できるのは関数とメモリ内の検索処理です。実際の認証ミドルウェアやDBへの接続は検証していません。アプリへ組み込んだら、AとBで別々にログインし、実際のAPIを通す統合テストが必要です。

取得APIの検証だけで、更新・削除・一覧・エクスポートまで安全だとは言えません。例えば、単件取得で拒否していても、一覧APIで他人のノートを返していれば情報は漏れます。保護したい操作ごとに対象範囲を確認します。

AIに実装を頼む前に、人間が決めること

「安全なノートAPIを作って」だけでは、誰にどこまで許可するかが曖昧です。今回なら、実装前に次の条件を渡せます。

非公開ノートは作成者本人だけが閲覧できる。
共有と管理者の例外は設けない。
利用者のIDはサーバー側の認証結果から取得する。
他人のノートと存在しないノートは、同じ404の結果にする。
本人の取得成功と、別の利用者からの取得拒否をテストする。

AIが生成したコードを見るときも、この条件から確認します。「ログイン確認がある」「テストが通った」という報告だけでは、対象データへの権限まで検証されたか判断できません。

後から共有機能を追加すれば、本人以外にも読める条件が必要になります。検索条件もテストもそのルールに合わせて見直します。先に所有者チェックを固定し、仕様の変化を無理に当てはめないことも設計上の判断です。

まとめ

認証が成功しても、すべてのデータを扱ってよいことにはなりません。今回は「本人の非公開ノートだけ読める」というルールを、検索条件と成功・拒否のテストに落とし込みました。

AIを使って実装するときも、誰に何を許可し、何を拒否するかは先に決める必要があります。その条件と、テストで実際に確認した範囲を結び付けることが、認可の実装をレビューする手がかりになります。

参考資料

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?