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 Token | OAuth2PasswordBearer |
| 14 | 🔴 | localStorage 盗難 | localStorage.getItem('token') |
| 15 | 🟢 | HttpOnly Cookie | Set-Cookie httponly secure |
→ 攻撃 6 / 回避 8 / 概念 1 = 15 STEP
進め方
- まず STEP1 を起動して 「現状は丸見え」 を確認 (5 分)
- 以降は STEP ごとに
cd stepNN/answer && uvicorn main:app --reload - 攻撃 STEP では
demo_attack_*.pyを別ターミナルで走らせて 「攻撃が成立する」 体験 - 回避 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)
→ 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:secret | 200 OK |
| 🚨 admin:wrong | 401 Unauthorized |
→ 「鍵を持っている人だけ通す」 が 5 行で実現
STEP2: 実機の Swagger UI
→ 認証なしで Execute すると 401。 右上に Authorize ボタンが現れる
STEP2: Authorize ダイアログ
→ 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.usernamebytes 同士で渡す必要があるので .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 # [] ← 正規ユーザは通る
| 入力 | STEP5 | STEP6 |
|---|---|---|
| alice:alice-pw | 200 | 200 |
| alice:wrong | 401 | 401 |
' 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 で覗く)
→ 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...")
# → Truerequirements.txt に passlib[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 で覗いても)
→ 列名 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 で攻撃が通る様子)
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 rowSTEP10: なぜ 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 で塞がれる)
🔴 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 UnauthorizedSTEP13: 実機 (curl で 2 ステップフロー)
→ 1) POST /login で短命トークン取得 → 2) Authorization: Bearer <tk> で API。 Basic はもう通らない
STEP13: 設計メモ
| 観点 | 本教材 | 本番 |
|---|---|---|
| トークン保管 | プロセス内 dict | Redis / 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 ヘッダ)
→ レスポンスヘッダに HttpOnly + SameSite=lax が乗る = JS から読めない / 別オリジン送付されない
STEP15: 実機 (DevTools Console で確認)
→ document.cookie も localStorage.getItem('token') も空。 トークンが JS から触れる場所に存在しない
STEP15: SameSite と Secure の意味
| 属性 | 効果 | 本教材 | 本番 |
|---|---|---|---|
| HttpOnly | JS から document.cookie で読めない | ✅ | ✅ |
| Secure | HTTPS でしか送らない | False (開発) | True |
| SameSite=Lax | 別オリジンの GET 以外には載らない | ✅ | ✅ |
| SameSite=Strict | 別オリジン経由のリンクからも乗らない | (場合により) | (場合により) |
| SameSite=None + Secure | クロスサイト前提 (3rd party 連携) | - | (要 CSRF 別対策) |
→ HttpOnly + Secure + SameSite=Lax が 「Web アプリの cookie の基本セット」
完成形チェック: 機能 ↔ エンドポイント
| 機能 | エンドポイント | 守られていること |
|---|---|---|
| ログイン | POST /login | bcrypt 照合 / Bearer 発行 / Set-Cookie HttpOnly |
| ログアウト | POST /logout | cookie 削除 |
| 一覧 | GET /tasks | owner_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 / OAuth2PasswordBearer | 2, 13 |
| 比較の時間差 | secrets.compare_digest | 4 |
| SQL の構文混入 | ? プレースホルダ | 6 |
| 保存値の流出耐性 | passlib[bcrypt] | 8 |
| 自分のだけ操作 | Depends(current_user) + WHERE owner_id | 10 |
| 書ける項目の限定 | Pydantic extra='forbid' + allowlist | 12 |
| トークン盗難 | Set-Cookie httponly secure samesite=lax | 15 |
チャレンジ題 (余裕があったら)
| 課題 | 嬉しいこと |
|---|---|
/register を作って自分でユーザ追加 | 学習者間で別 ID を使い分けられる |
| JWT に切り替える | プロセス再起動でログイン消えない |
/me (自分の情報) を作る | フロント側で 「誰でログイン中か」 が分かる |
| 失敗回数を数えて 5 回でロック | ブルートフォース対策 |
PATCH の done を Literal[True, False] 厳格化 | 入力の型エラーを早く返せる |
| Set-Cookie の Secure を True にして HTTPS で試す | 本番に近い形 |
FAQ
| Q | A |
|---|---|
| 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 行直すたびに 「これを攻撃するなら…」 を考える癖を。
おつかれさまでした 🎉