採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

FastAPI セキュリティ実践

テクノロジ系 / L16 security — セキュリティ実践

FastAPI セキュリティ実践

作った Task API を「公開しても困らない」状態にする

15 ステップで「攻撃」→「回避」を体験する

このスライドの読み方 (大事)

マーク意味
🔴 攻撃 STEPわざと脆弱なまま動かして、 攻撃が成立する様子を見る
🟢 回避 STEP直前 STEP の弱点を最小差分で直す
🟡 概念 STEPコードは変えず、 仕組みを確認する

⚠️ 倫理ルール: 攻撃手順は 自分のローカル環境にだけ 試すこと。

他人のサーバ / 別組織のサービスに対して試すと不正アクセス禁止法違反になります。

作るもの: 守りに入った Task API

前回 (Task API SQLite版) は 誰でも全部見えてしまう 状態。

これを 「自分のタスクは自分にしか触れない」 形に育てる。

                        Task API (前回作ったもの)
ユーザー A (alice)     ┌─────────────────────────────────┐
   📋 一覧             │ GET    /tasks                    │
   ✏️ 追加              │ POST   /tasks                    │  ← 認証なし
   ✓ 完了              │ PATCH  /tasks/{id}               │  ← 認可なし
   🗑 削除              │ DELETE /tasks/{id}               │  ← 平文 PW
                       └─────────────────────────────────┘
ユーザー B (bob)        ← A のタスクも丸見え
ブラウザ拡張            ← トークンが盗まれる

完成イメージ: 守りが入った後

                        Task API (本教材の最終形)
ユーザー A              ┌─────────────────────────────────┐
   🔑 まずログイン      │ POST  /login (Bearer Token)      │
   🍪 cookie に保存      │ Set-Cookie HttpOnly Secure       │
   📋 一覧 (自分のみ)    │ GET   /tasks  → owner_id で絞る  │
   ✏️ 追加              │ POST  /tasks                     │
   ✓ 完了 (title/done)   │ PATCH /tasks/{id} (allowlist)    │
                       └─────────────────────────────────┘
ユーザー B              ← A のタスクは 404
ブラウザ拡張            ← JS から token に触れない
攻撃者 (SQL注入)        ← `?` プレースホルダで弾く

機能マップ: STEP × 学ぶ道具

STEP種別内容学ぶ道具
1🟡認証なし状態の認識(curl)
2🟢Basic 認証を 1 行でHTTPBasic / Depends
3🔴タイミング攻撃を体験== の罠
4🟢固定時間比較に直すsecrets.compare_digest
5🔴SQL インジェクション体験' OR '1'='1' --
6🟢プレースホルダで塞ぐconn.execute(sql, (値,))
7🔴DB ファイルを覗くsqlite3 tasks.db
8🟢bcrypt でハッシュ保存passlib[bcrypt]

機能マップ (続き)

STEP種別内容学ぶ道具
9🔴IDOR (他人のタスクが見える)/tasks/{id} 直叩き
10🟢owner_id で絞るDepends(current_user)
11🔴Mass Assignment{"owner_id": 999}
12🟢allowlist で限定Pydantic extra='forbid'
13🟢Bearer TokenOAuth2PasswordBearer
14🔴localStorage 盗難localStorage.getItem('token')
15🟢HttpOnly CookieSet-Cookie httponly secure

→ 攻撃 6 / 回避 8 / 概念 1 = 15 STEP

進め方

  1. まず STEP1 を起動して 「現状は丸見え」 を確認 (5 分)
  2. 以降は STEP ごとに cd stepNN/answer && uvicorn main:app --reload
  3. 攻撃 STEP では demo_attack_*.py を別ターミナルで走らせて 「攻撃が成立する」 体験
  4. 回避 STEP では starter から answer への差分を読む → 自分で書き換え → curl/Swagger UI で確認

各 STEP の starter前 STEP の answer と完全一致

詰まったら次 STEP の starter から再開できる。

> 📌 スライド上のコードは 要点を抜粋した説明用 です。

> 完全に動くコードは必ず step**/answer/main.py を直接見てください。

> (import 文や型ヒントなどはスライドから省略されています)

環境セットアップ (1 回だけ / 5 分)

# サンプルコードのディレクトリで
python -m venv .venv

# Mac/Linux
source .venv/bin/activate
# Windows
.venv\Scripts\activate.bat

# ライブラリ一括インストール
pip install -r requirements.txt

うまく動かない時:

  • python -m venv が無い → python3 -m venv .venv
  • bcrypt のビルドが Windows で失敗 → pip install --only-binary :all: -r requirements.txt

🟡 STEP1: 認証がない状態を見る

「公開した瞬間に何が起きるか」 をまず体感する。

cd step01/answer
uvicorn main:app --reload

別ターミナルで:

curl http://localhost:8000/tasks       # → [] (誰でも見える)
curl -X POST -H "Content-Type: application/json" \
  -d '{"title":"テスト"}' http://localhost:8000/tasks
curl http://localhost:8000/tasks       # → [{"id":1, ...}]

認証なし = 公開した瞬間、誰でも書き込める / 全件取れる

STEP1: 実機の Swagger UI (/docs)

!w:1100

→ 5 つの API すべてに鍵マークが付いていない = 認証なしで誰でも叩ける

STEP1: 嬉しいこと表 (現状を整理)

機能APIいまの状態
📋 一覧GET /tasks🚨 誰でも見える
✏️ 追加POST /tasks🚨 誰でも書ける
✓ 完了PATCH /tasks/{id}🚨 誰でも変えられる
🗑 削除DELETE /tasks/{id}🚨 誰でも消せる

→ このまま 「URL を友達に教える」 = 「家の鍵を渡す」 と同じ。

🛡️ 次の STEP で 「入口に鍵を付ける」 ところから始める

🟢 STEP2: 入口に Basic 認証を 1 行で付ける

FastAPI なら Depends(HTTPBasic()) を 1 行足すだけ。

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials

security = HTTPBasic()

def require_auth(c: HTTPBasicCredentials = Depends(security)) -> str:
    if c.username != "admin" or c.password != "secret":
        raise HTTPException(status_code=401, detail="invalid",
                            headers={"WWW-Authenticate": "Basic"})
    return c.username

@app.get("/tasks")
def list_tasks(user: str = Depends(require_auth)):   # ← ここ
    ...

STEP2: 動作確認

curl http://localhost:8000/tasks
# {"detail":"Not authenticated"}      ← 401

curl -u admin:secret http://localhost:8000/tasks
# []                                  ← 200
状態レスポンス
🚨 認証なし401 Unauthorized
✅ admin:secret200 OK
🚨 admin:wrong401 Unauthorized

→ 「鍵を持っている人だけ通す」 が 5 行で実現

STEP2: 実機の Swagger UI

!w:1100

→ 認証なしで Execute すると 401。 右上に Authorize ボタンが現れる

STEP2: Authorize ダイアログ

!w:1100

→ Authorize を押すと Basic 認証の入力フォームが出る。 Swagger UI 上で admin/secret を入れると以降の Execute は認証ヘッダ付きで送られる

STEP2: でも実は…

if c.password != "secret"!= が、 実は 新しい弱点を生んでいる

文字列比較は 「前から見て一致しない文字が出た瞬間」 に False を返す。

つまり、 正解と前から N 文字一致しているとごく僅かに比較が長くなる

ネット越しでも理屈は同じ。 攻撃者が大量に試行すれば、 平均時間の差で 1 文字ずつ秘密を当てていける (タイミング攻撃)。

→ 次の STEP3 で 実機で時間差を計測 してみる

🔴 STEP3: タイミング攻撃を体験する

step03/demo_attack_timing.py を実行:

cd step03
python demo_attack_timing.py

出力 (抜粋):

  guess                     time(ns)
  --------------------------------------
  'sxxxxxxxxxxxxxxxxxx'             10
  'suxxxxxxxxxxxxxxxxx'             13
  'supxxxxxxxxxxxxxxxx'             16
  ...
  'supersecretpasswxxx'             27
  'supersecretpassword'             29

→ 一致する文字が増えるほど時間が伸びている

STEP3: なぜ時間が伸びるか

def compare_eq(a, b):
    return a == b      # ← ここで CPU は前から 1 文字ずつ比べる
  • "sxxxx..." == "super..." → 2 文字目で不一致 → すぐ終わる
  • "supersecretpasswxxx" == "supersecretpassword" → 16 文字目で不一致 → 16 ステップ分の時間

ネット越しでは雑音が大きいので 1 回だけでは見抜けない。

しかし 何万回試行して中央値を取れば差は浮かぶ

→ 次の STEP4 で 「常に最後まで比べる」 関数に置き換える

🟢 STEP4: 一定時間で比較するように直す

Python の secrets.compare_digest長さに関係なく必ず最後まで比べる

import secrets

def require_auth(c: HTTPBasicCredentials = Depends(security)) -> str:
    user_ok = secrets.compare_digest(
        c.username.encode("utf-8"), ADMIN_USERNAME.encode("utf-8")
    )
    pass_ok = secrets.compare_digest(
        c.password.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")
    )
    if not (user_ok and pass_ok):
        raise HTTPException(
            status_code=401,
            detail="invalid credentials",
            headers={"WWW-Authenticate": "Basic"},
        )
    return c.username

bytes 同士で渡す必要があるので .encode() 必須。

STEP4: ポイントは「ユーザ名も同じ関数で」

user_ok = secrets.compare_digest(...)
pass_ok = secrets.compare_digest(...)
if not (user_ok and pass_ok):
    ...

❌ NG例: if c.username != "admin" or compare_digest(...)

→ username が違うと pass の処理が走らない = ユーザ名の有無が時間で漏れる

✅ OK: 両方とも compare_digest を必ず呼んでから判定

→ 「秘密を扱う比較は常に compare_digest」 を癖にする

STEP4: 動作確認

curl -u admin:secret http://localhost:8000/tasks   # 200
curl -u admin:wrong  http://localhost:8000/tasks   # 401

挙動は同じ。 違うのは 「攻撃者から見える時間差が消えた」 こと。

→ Basic 認証としては及第点。 ただし admin/secret が 1 人ハードコードのまま。

次の STEP5 で 「DB に複数ユーザを持つ」 ことを試みる が、 そこでまた 新しい罠 にハマる。

STEP5: いま DB に誰がいるかを確認する

users テーブルに何が入っているかは GET /users で見られる (STEP5 で追加)。

password は出さない。 id と username だけ:

curl -u alice:alice-pw http://localhost:8000/users
# [{"id":1,"username":"alice"},{"id":2,"username":"bob"}]

→ 「攻撃が成立した時、 誰のアカウントを乗っ取ったことになるのか」 を

確認するための参照用エンドポイント。 攻撃 / 防御の答え合わせに使う。

> ⚠️ 実務では /users のような一覧 API も 認可設計 が要る (管理者だけ見える等)。

> 本教材は デバッグ用に常時オン。 認可については STEP9-10 で別途扱う。

🔴 STEP5: SQL を文字列で組むとどうなるか

DB の users テーブルから 「username/password が一致する行があれば OK」 を作りたい。

素直に書くとこうなる:

sql = (
    f"SELECT * FROM users "
    f"WHERE username = '{credentials.username}' "
    f"AND password = '{credentials.password}'"
)
row = conn.execute(sql).fetchone()

これは 動く。 alice/alice-pw で 200 が返る。

でも、 とても危険

STEP5: 攻撃を試してみる

step05/demo_attack_sqli.py:

cd step05
python demo_attack_sqli.py

出力:

=== ③ SQL インジェクションを username に仕込む ===
  username = "' OR '1'='1' --"
  password = 'anything-totally-wrong'
  status=200  body[:80]='[]'
  ⚠️ password を知らないのに突破できてしまった

STEP5: 何が起きたか

username' OR '1'='1' -- を入れると、 組み立てられる SQL は:

SELECT * FROM users
WHERE username = '' OR '1'='1' --' AND password = 'x'
  • OR '1'='1' → WHERE が 必ず真
  • -- 以降は SQL のコメント → password 条件が 無効化

結果: password を一切知らなくても 1 行返ってくる = ログイン突破

→ 入力値が 「データ」 でなく 「SQL の構文」 として解釈された のが原因

🟢 STEP6: 入力を「データ」として扱う

修正は 1 行: 値をタプルで別に渡す

# ❌ Before (STEP5)
sql = f"SELECT * FROM users WHERE username = '{c.username}'..."
conn.execute(sql)

# ✅ After (STEP6)
conn.execute(
    "SELECT * FROM users WHERE username = ?",
    (c.username,),
)

? のところは値の入る穴。 DB ドライバが 「これはデータ」 と扱うので

' OR '1'='1' -- を入れても 「そういう変な名前のユーザを探す」 だけになり、

SQL として解釈されない。

STEP6: 動作確認

curl -u "' OR '1'='1' --:x" http://localhost:8000/tasks
# 401                                  ← 攻撃失敗
curl -u alice:alice-pw http://localhost:8000/tasks
# []                                   ← 正規ユーザは通る
入力STEP5STEP6
alice:alice-pw200200
alice:wrong401401
' OR '1'='1' --:x🚨 200✅ 401

ルール: 「SQL を文字列連結で組まない / 必ず ? プレースホルダ」 を癖にする

STEP6: 関連トピック (覚えておく)

  • ORM (SQLAlchemy 等) を使うと、 そもそも生 SQL を書かないので安全側に倒れる
  • ただし ORM でも .raw_sql(f"... {user_input}") のような穴は作れる → 結局 入力を構文に混ぜない
  • 表示時の XSS (テキストを HTML にそのまま貼る) も同じ構図 = 「データ」 と「言語」 を分ける

→ 「ユーザ入力が言語の構文に混ざる」 系の脆弱性は全て同じ防ぎ方

🔴 STEP7: DB ファイルを開けばパスワードが見える

password を平文で保存していると、 DB ファイルさえ手に入れば全部見える

cd step07/answer
sqlite3 tasks.db
sqlite> SELECT username, password FROM users;
alice|alice-pw
bob|bob-pw

Python からも:

cd step07
python demo_attack_dbfile.py

→ alice-pw / bob-pw がそのまま表示される

STEP7: 実機 (sqlite3 で覗く)

!w:1100

→ DB ファイルさえ手に入れば、 平文 PW が並んでいるのが SQL 一発で見える

STEP7: 実務での示唆

シーン起きること
サーバから DB ファイルが流出全ユーザの ID/PW がそのまま漏れる
開発機のバックアップ DB が共有過去パスワードまで全部見える
別サービスでも同じ PW 使い回し被害がそのサービスに留まらない

→ 「鍵を盗まれても 開けられない」 仕組みにする = ハッシュ化

ハッシュ = 「変換した結果から元のパスワードに戻せない」 一方向の関数

🟢 STEP8: bcrypt で 「戻せないハッシュ」 にする

passlib[bcrypt] を入れて 2 行だけ変える:

from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# 保存時
pwd_context.hash("alice-pw")
# → "$2b$12$xVj..." (毎回違う)

# 照合時
pwd_context.verify("alice-pw", "$2b$12$xVj...")
# → True

requirements.txtpasslib[bcrypt]bcrypt<4.1 を入れておく。

STEP8: 列名も変える (役割の自己ドキュメント化)

CREATE TABLE users (
  id            INTEGER PRIMARY KEY,
  username      TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,    -- ← 旧 password から改名
  created_at    TEXT NOT NULL
)

DB を開いた人が一目で 「これは平文じゃない」 と分かる。

sqlite> SELECT password_hash FROM users LIMIT 1;
$2b$12$KixOu5hI/CKQq2j9rGUJxOJ5fNJSrPq.6QJ9YxX7eMb8sxbV3tnHO

→ DB が流出しても、 これを 「総当たり」 する以外に元の PW を得る手がない

STEP8: salt が入っているとは何か

>>> pwd_context.hash("alice-pw")
'$2b$12$KixOu5hI/...'           # 1 回目
>>> pwd_context.hash("alice-pw")
'$2b$12$rXfP9aE7m/...'           # 2 回目 (違う!)

bcrypt は 毎回ランダムな salt を内部で混ぜる → 同じ PW でも保存値が違う

→ 攻撃者が 「よく使われる PW のハッシュ表」 (レインボーテーブル) を持ってきても

salt が違うので 1 件ずつ別々に総当たりするしかない = 時間がかかりすぎて事実上無理

STEP8: 実機 (sqlite3 で覗いても)

!w:1100

→ 列名 password_hash + $2b$ で始まる 60 文字の不可逆ハッシュ。 流出しても元の PW に戻せない

🔴 STEP9: 認証は通った。 でも 「他人のタスク」 が見える

tasks テーブルに owner_id を入れて 「持ち主」 を持たせた。

ところが API 側で 「自分の owner_id だけ返す」 絞り込みをしていない

@app.get("/tasks")
def list_tasks(user: sqlite3.Row = Depends(get_current_user)):
    # ❌ ここで owner_id で絞らない
    rows = conn.execute("SELECT * FROM tasks").fetchall()
    return [dict(row) for row in rows]

alice でログインして GET /tasks/{bobのid} を直叩き → 200 で内容が返ってくる

STEP9: 攻撃を試す

cd step09
python demo_attack_idor.py

出力 (抜粋):

=== ② alice が GET /tasks で一覧を取る ===
  status=200  件数=2
    id=1  owner_id=2  title='bob の秘密 (見られたくない)'
    id=2  owner_id=1  title='alice のメモ'
  ⚠️ alice なのに bob のタスクが混ざっている (= IDOR)

これが IDOR (Insecure Direct Object Reference)

「URL の id が連番だから次の id も覗ける」 系の事故。

STEP9: 実機 (curl で攻撃が通る様子)

!w:1100

STEP9: なぜ起きるか — 認証 ≠ 認可

用語意味
認証 (Authentication)「あなたは alice 本人ですか?」 を確認
認可 (Authorization)「alice はこの資源にアクセスして良いか?」 を判定

Basic 認証や Bearer Token は 認証 だけ。

「alice は alice のタスクだけ触れる」 を別途実装する必要がある = 認可

→ STEP10 で 「自分の owner_id だけ」 を絞る道具を入れる

🟢 STEP10: 自分のものだけ絞る

@app.get("/tasks")
def list_tasks(user: sqlite3.Row = Depends(get_current_user)):
    rows = conn.execute(
        "SELECT * FROM tasks WHERE owner_id = ?",
        (user["id"],),                          # ← ログイン中の id で絞る
    ).fetchall()
    return [dict(row) for row in rows]

GET/PATCH/DELETE /tasks/{id} には専用のヘルパで:

def _find_own(conn, task_id, owner_id):
    row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
    if row is None or row["owner_id"] != owner_id:
        raise HTTPException(404, "task not found")    # ← 403 でなく 404
    return row

STEP10: なぜ 403 でなく 404 か

直感的には 「権限なし」 = 403 Forbidden が筋。

しかし /tasks/12345 を試して:

  • 404 → 「そんな id は無い」 と区別がつかない
  • 403 → 「id は存在する、 でもあなたには見せない」 と漏れる

攻撃者は 「id を 1 から舐めて 403 と 404 を分ける」 だけで 存在する id を列挙 できる。

存在を漏らさない ためには 404 を返すのが安全

(社内ツールなど 「列挙されても困らない」 なら 403 でも OK)

STEP10: 動作確認

# bob のタスク (id=1) に alice でアクセス
curl -u alice:alice-pw http://localhost:8000/tasks/1
# {"detail":"task not found"}    ← 404 (情報漏えいなし)

curl -u alice:alice-pw http://localhost:8000/tasks
# [{"id":2, "owner_id":1, ...}]   ← 自分のだけ

「Depends(current_user) の戻り値を where owner_id = ? に渡す」

このパターンは 以降の全 API で繰り返し使う

→ FastAPI なら Depends の合成で簡潔に書ける = うっかり忘れにくい

STEP10: 実機 (同じ攻撃が 404 で塞がれる)

!w:1100

🔴 STEP11: 「便利だから」 と入力モデルに owner_id を入れた

class TaskUpdate(BaseModel):
    title:    str  | None = None
    done:     bool | None = None
    owner_id: int  | None = None    # ← なんとなく入れた

@app.patch("/tasks/{task_id}")
def update_task(task_id, payload, user = Depends(get_current_user)):
    fields = payload.model_dump(exclude_unset=True)
    for col, val in fields.items():
        conn.execute(f"UPDATE tasks SET {col} = ? WHERE id = ?", (val, task_id))

→ alice が PATCH /tasks/1 {"owner_id": 2} を送ると、 owner が bob に書き換わる

STEP11: 攻撃を試す

cd step11
python demo_attack_mass_assignment.py

出力:

=== ② alice が PATCH で owner_id=2 (bob) を送り込む ===
  status=200  task={'id': 1, 'title': 'alice のメモ', 'owner_id': 2, ...}
  ⚠️ owner_id を勝手に書き換えられた (= Mass Assignment 成立)

=== ③ alice 視点では消えた、 bob 視点で見える ===
  alice の一覧: []
  bob の一覧:   ['alice のメモ']    ← 横取り成立

クライアントから送れる項目 = 全て改ざんの対象

STEP11: なぜ起きるか — 入力モデルが広すぎる

「PATCH の入力モデル」 と 「テーブルの列」 を 一致 させてしまうと:

  • 列が増えるたびに 「PATCH で書ける項目」 も自動的に増える
  • 学習者は気づかないうちに 「危険な項目まで触らせている」 状態

教科書的な事故例:

  • is_admin: bool を入れて → 任意のユーザが管理者に昇格
  • verified: bool を入れて → スパムアカウントが認証済みになる
  • price: int を入れて → 1 円で商品購入

「クライアントが書ける項目」 と 「テーブルの列」 は別物として設計 すべき

🟢 STEP12: allowlist で書ける項目を絞る

from pydantic import BaseModel, ConfigDict

class TaskUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")    # ← 知らないキーは 422
    title: str | None = None
    done:  bool | None = None
    # owner_id は意図的に書かない (= 書けない)

PATCH_ALLOWED_COLUMNS = {"title", "done"}        # ← サーバ側で念のため二重ガード

for col, val in fields.items():
    if col not in PATCH_ALLOWED_COLUMNS:
        continue
    conn.execute(f"UPDATE tasks SET {col} = ? WHERE id = ?", (val, task_id))

STEP12: 動作確認

# owner_id を送ろうとすると 422 で拒否
curl -X PATCH -u alice:alice-pw \
  -H "Content-Type: application/json" \
  -d '{"owner_id":2}' \
  http://localhost:8000/tasks/1
# 422 Unprocessable Entity: Extra inputs are not permitted

# title だけなら通る
curl -X PATCH -u alice:alice-pw \
  -H "Content-Type: application/json" \
  -d '{"title":"renamed"}' \
  http://localhost:8000/tasks/1
# 200 OK

→ Pydantic を 「入力 schema 専用のモデル」 として使う = テーブルと別物

STEP12: 関連トピック

  • 「テーブルの ORM クラス」 と 「API 入力モデル」 と 「API 出力モデル」 は 別物に分ける
  • DTO (Data Transfer Object) と呼ばれるパターン
  • FastAPI なら response_model=TaskOut も使うと 「内部だけ持つ列 (例: internal_note) は外に出さない」 も実現できる

→ 入口と出口の両方で 「通すものを明示」 する設計に倒す

🟢 STEP13: 毎回パスワードを送るのをやめる

Basic 認証は リクエストごとに username:password をヘッダで送る

これは:

  • ログが残るたびに PW がそのまま記録される
  • フロントのコードが PW を保持し続ける必要がある
  • 取り消し (ログアウト / セッション切れ) が難しい

→ 一度ログインしたら、 短命の トークン に置き換える方式に切り替える

FastAPI の OAuth2PasswordBearer を使う。

STEP13: コード骨格

from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
oauth2 = OAuth2PasswordBearer(tokenUrl="login")
TOKEN_STORE: dict[str, tuple[int, datetime]] = {}     # {token: (user_id, exp)}

@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = _authenticate(form.username, form.password)
    if user is None:
        raise HTTPException(401, "invalid")
    token = secrets.token_urlsafe(32)                 # ランダム文字列
    TOKEN_STORE[token] = (user["id"], datetime.now() + timedelta(minutes=30))
    return {"access_token": token, "token_type": "bearer"}

def get_current_user(token: str = Depends(oauth2)):
    entry = TOKEN_STORE.get(token)
    if entry is None or entry[1] < datetime.now():
        raise HTTPException(401, "invalid or expired")
    ...

STEP13: 動作確認

# ログインしてトークンを得る
curl -X POST -F username=alice -F password=alice-pw \
  http://localhost:8000/login
# {"access_token":"K7sf...XYz","token_type":"bearer"}

# トークンを Authorization: Bearer で送る
curl -H "Authorization: Bearer K7sf...XYz" \
  http://localhost:8000/tasks
# 200 OK

# Basic は通らない
curl -u alice:alice-pw http://localhost:8000/tasks
# 401 Unauthorized

STEP13: 実機 (curl で 2 ステップフロー)

!w:1100

→ 1) POST /login で短命トークン取得 → 2) Authorization: Bearer <tk> で API。 Basic はもう通らない

STEP13: 設計メモ

観点本教材本番
トークン保管プロセス内 dictRedis / DB
寿命30 分5-15 分 + リフレッシュ
形式ランダム文字列JWT (署名付き)
失効プロセス再起動で全消失明示 revoke / TTL

「プロセス内 dict」 でも仕組みは同じ。 まずは形を覚える。

本番では JWT + Redis などに置き換える。 (発展課題)

🔴 STEP14: トークンを localStorage に置くと…

フロント (素朴な SPA) を 1 ファイル足してみる:

const r = await fetch('/login', {method: 'POST', body: fd});
const j = await r.json();
localStorage.setItem('token', j.access_token);   // ← ここが問題

// 以降の API 呼び出し
const tk = localStorage.getItem('token');
await fetch('/tasks', {headers: {Authorization: 'Bearer ' + tk}});

step14/answer/main.py を起動して http://localhost:8000/ を開く →

DevTools (F12) → Console で localStorage.getItem('token')生のトークンが見える

STEP14: 何が問題か

localStorage同一オリジンの全 JavaScript から getItem できる。

攻撃者の経路結果
XSS でスクリプトを 1 行注入localStorage.getItem('token') が読める
怪しいブラウザ拡張同上
広告タグや埋め込み 3rd party JS同上
別タブで同じドメインを開いている悪意ある JS同上

XSS は CSP / 入力エスケープで防ぐ、 とは別に、 「盗まれにくい場所に置く」 という対策がある。

🟢 STEP15: HttpOnly Cookie に逃がす (最終形)

from fastapi import Cookie, Response

@app.post("/login")
def login(response: Response, form: OAuth2PasswordRequestForm = Depends()):
    token = ...
    response.set_cookie(
        key="access_token",
        value=token,
        max_age=1800,
        httponly=True,     # ← JS から触れない
        secure=False,      # ← 本番は True (https のみ送る)
        samesite="lax",    # ← 別オリジンから自動送付しない
        path="/",
    )
    return {"access_token": token, "token_type": "bearer"}  # Bearer 互換も維持

STEP15: 認証側も Cookie を読めるようにする

oauth2 = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)

def get_current_user(
    cookie_token: str | None = Cookie(default=None, alias="access_token"),
    header_token: str | None = Depends(oauth2),
):
    token = cookie_token or header_token       # cookie 優先、 ヘッダもまだ受ける
    entry = TOKEN_STORE.get(token)
    if entry is None or entry[1] < datetime.now():
        raise HTTPException(401, "invalid or expired")
    ...

→ Cookie 派 / Bearer 派 どちらでも動く 「移行期」 の構成

STEP15: フロントは「明示的に Authorization を組まない」

await fetch('/login', {method:'POST', body: fd, credentials:'same-origin'});
// ↑ Set-Cookie を受け取る

await fetch('/tasks', {credentials: 'same-origin'});
// ↑ Authorization を組まない (cookie が自動で乗る)

DevTools Console で打っても 空文字 :

> document.cookie
""              // ← HttpOnly は出てこない

→ 同じ XSS が起きても トークン抜き取りは難しくなる

STEP15: 動作確認

# /login で Set-Cookie が返る
curl -i -X POST -F username=alice -F password=alice-pw \
  http://localhost:8000/login
# Set-Cookie: access_token=...; HttpOnly; SameSite=lax; Max-Age=1800; Path=/

# cookie 付きで /tasks (-c でクッキー保存、 -b で送付)
curl -c jar.txt -X POST -F username=alice -F password=alice-pw \
  http://localhost:8000/login
curl -b jar.txt http://localhost:8000/tasks
# 200 OK (Authorization 不要)

curl -X POST -b jar.txt http://localhost:8000/logout
# 204 (cookie が消える)

STEP15: 実機 (Set-Cookie ヘッダ)

!w:1100

→ レスポンスヘッダに HttpOnly + SameSite=lax が乗る = JS から読めない / 別オリジン送付されない

STEP15: 実機 (DevTools Console で確認)

!w:1100

document.cookielocalStorage.getItem('token') も空。 トークンが JS から触れる場所に存在しない

STEP15: SameSite と Secure の意味

属性効果本教材本番
HttpOnlyJS から document.cookie で読めない
SecureHTTPS でしか送らないFalse (開発)True
SameSite=Lax別オリジンの GET 以外には載らない
SameSite=Strict別オリジン経由のリンクからも乗らない(場合により)(場合により)
SameSite=None + Secureクロスサイト前提 (3rd party 連携)-(要 CSRF 別対策)

HttpOnly + Secure + SameSite=Lax が 「Web アプリの cookie の基本セット」

完成形チェック: 機能 ↔ エンドポイント

機能エンドポイント守られていること
ログインPOST /loginbcrypt 照合 / Bearer 発行 / Set-Cookie HttpOnly
ログアウトPOST /logoutcookie 削除
一覧GET /tasksowner_id 絞り込み + 認証必須
追加POST /tasks認証必須 + owner_id 自動設定
1 件GET/PATCH/DELETE /tasks/{id}他人のは 404
更新項目PATCH /tasks/{id}title/done のみ (allowlist)

→ STEP1 で 「全部 200」 だった API が、 守られた状態に育った

振り返り: 機能 ↔ FastAPI の道具 ↔ STEP

守りたいこと使う道具STEP
入口の認証HTTPBasic / OAuth2PasswordBearer2, 13
比較の時間差secrets.compare_digest4
SQL の構文混入? プレースホルダ6
保存値の流出耐性passlib[bcrypt]8
自分のだけ操作Depends(current_user) + WHERE owner_id10
書ける項目の限定Pydantic extra='forbid' + allowlist12
トークン盗難Set-Cookie httponly secure samesite=lax15

チャレンジ題 (余裕があったら)

課題嬉しいこと
/register を作って自分でユーザ追加学習者間で別 ID を使い分けられる
JWT に切り替えるプロセス再起動でログイン消えない
/me (自分の情報) を作るフロント側で 「誰でログイン中か」 が分かる
失敗回数を数えて 5 回でロックブルートフォース対策
PATCH の doneLiteral[True, False] 厳格化入力の型エラーを早く返せる
Set-Cookie の Secure を True にして HTTPS で試す本番に近い形

FAQ

QA
token を JWT にすべき?この日は最小構成 (dict 保管) で OK。 本格運用なら JWT
secrets.compare_digest を全てに使うべき?「秘密 vs 秘密」 の比較なら基本使う。 通常データの比較は不要
HttpOnly Cookie で CSRF は完全に防げる?緩和はするが万能ではない。 別途 CSRF トークンが必要な場面もある
Basic と Bearer 両方サポートで動かないOAuth2PasswordBearer の auto_error=False を忘れがち
bcrypt が Windows で失敗pip install --only-binary :all: bcrypt<4.1

倫理ルール (再掲 / 重要)

  • 本教材の 攻撃手順は自分のローカル環境のみ に試すこと
  • 他人のサーバ / 別組織のサービスに対しての実行は 不正アクセス禁止法違反 になります
  • 業務でセキュリティを試したい場合は、 必ず関係者の書面合意 を取って実施

→ 「攻撃を知る」 のは 「防ぐため」 です。

攻撃者の発想を持つことで、 普段書くコードの 「ここが弱い」 が見えるようになる。

次に繋がる話 (発展トピック)

  • CORS: フロントとバックを別オリジンに置いた時の cookie / Authorization の扱い
  • CSRF: HttpOnly Cookie で起きる別の攻撃と、 CSRF トークン / SameSite の使い分け
  • rate limit: ログイン試行回数の制限 (ブルートフォース対策)
  • secrets 管理: os.environ / Azure Key Vault / .env の使い分け
  • 監査ログ: 誰がいつ何をしたかを残す (incident response の起点)

→ 本教材で身につけた Depends(current_user) / owner_id / passlib

開発演習でもそのまま使える基本セット

まとめ

「公開しても困らない Task API」 になった = 15 STEP を 1 日で踏破

  • 🔴 攻撃を体験する STEP (3, 5, 7, 9, 11, 14)
  • 🟢 最小差分で直す STEP (2, 4, 6, 8, 10, 12, 13, 15)
  • 🟡 現状認識 STEP (1)

セキュリティは 「全部知る」 でなく 「攻撃 → 回避 のパターンを 1 セットずつ増やす」 ことから。

これからのコードで、 1 行直すたびに 「これを攻撃するなら…」 を考える癖を。

おつかれさまでした 🎉

力試ししたい方は クイズ形式の演習(任意) もあります。読むだけでも十分です。