はじめに
前回の記事では、
攻撃者はブラウザを開いて最初の5分で何を見るのか
というテーマで、フロントエンド(Next.js)から取得できる情報を追いました。そこで最後に見つかるのが API です。
では攻撃者は、APIを見つけたあと何を考えるのか。
結論から言うと、派手な攻撃はほとんど使いません。「当たり前に実装されているはず」の認可・検証・制限を、一つずつ疑って確認していくだけです。そして、その"当たり前"が抜けている箇所を突きます。
私が普段バグバウンティで報告している脆弱性も、大半がこの種の「基本の抜け」です。特別なテクニックではなく、認可のチェックが1行足りない、それだけで成立します。
この記事では前回同様、
「攻撃者はここを見る」→「だから、あなたはこう直す」
の順で、バックエンドで守るべきポイントをコード付きで見ていきます。実際のシステムはフロントとバックで言語が違うことが多いので、ここではバックエンドを Python / FastAPI で書きます。考え方自体はどの言語でも同じです。
※この記事は防御を目的とした内容です。
Step1. 「APIが見えること」は問題ではない
前回のとおり、Networkタブを開けばAPIは丸見えです。
GET /api/users/123
PUT /api/users/123
DELETE /api/admin/users/123
初心者はここで「URLがバレた」と気にしますが、攻撃者は気にしません。URLは隠せない前提だからです。
攻撃者が確認するのは、URLが見えるかどうかではなく、次の3つです。
- 他人のデータにアクセスできないか(オブジェクトの認可)
- 触ってはいけない項目を書き換えられないか(プロパティの認可)
- 本来使えない機能を呼べないか(機能の認可)
これはOWASP API Security Top 10 の BOLA(API1)/BOPLA(API3)/BFLA(API5) に対応する、API脆弱性の中核です。これらは自動スキャナでは見つけにくく、コードを読まないと分からない認可ロジックの不備です。裏を返せば、実装する側が意識すれば確実に潰せます。
以降、この3層を順に見ていきます。
Step2. BOLA:IDを書き換えて、他人のデータを取る
攻撃者はここを見る
最初に試すのは、これ以上ないほど単純な操作です。
GET /api/users/123 ← 自分
GET /api/users/124 ← 124に変えてみる
124 で他人の情報が返ってきたら、その時点で BOLA(Broken Object Level Authorization)。OWASP API1、最も多く踏まれている認可不備です。
重要なのは、認証は突破されていないことです。攻撃者は正規ユーザーとして正しくログインしています。壊れているのは「このユーザーは、このオブジェクトを見てよいか」という認可の側です。
- 認証(Authentication)= あなたは誰か
- 認可(Authorization)= あなたはそれをしてよいか
✅ 直すべきこと
「ログインしているか」ではなく「このオブジェクトの所有者か」を毎回検証します。FastAPIなら認証済みユーザーを Depends で受け取り、対象と突き合わせます。
from fastapi import Depends, HTTPException, status
@router.get("/api/users/{user_id}", response_model=UserOut)
async def get_user(
user_id: int,
current_user: User = Depends(get_current_user), # 認証
):
target = await users.get(user_id)
if target is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
# ❌ return target ← これがBOLA
# ✅ 「リクエスト元 == 所有者」を検証する
if target.id != current_user.id and not current_user.is_admin:
# 存在を隠すため 403 ではなく 404 を返す設計もある
raise HTTPException(status.HTTP_404_NOT_FOUND)
return target
所有者チェックが複数のエンドポイントに散らばるなら、依存関数に切り出して使い回すと抜け漏れが減ります。
async def get_owned_user(
user_id: int,
current_user: User = Depends(get_current_user),
) -> User:
target = await users.get(user_id)
if target is None or (target.id != current_user.id and not current_user.is_admin):
raise HTTPException(status.HTTP_404_NOT_FOUND)
return target
補足:ID採番を連番からUUIDにするのは推測(enumeration)を難しくする"緩和"であって、認可の代わりにはなりません。他人のUUIDを知った瞬間にアクセスできるなら、それは認可が無いのと同じです。
Step3. BOPLA / Mass Assignment:role を紛れ込ませて権限昇格する
攻撃者はここを見る
プロフィール更新APIがあるとします。
PUT /api/users/123
{ "name": "sei", "email": "sei@example.com" }
攻撃者はここに、本来更新できないはずの項目をこっそり足します。
PUT /api/users/123
{ "name": "sei", "email": "sei@example.com", "role": "admin" }
これが通れば、自分を管理者に昇格できてしまいます。これが Mass Assignment(OWASP API3 BOPLA)。BOLAが「オブジェクトごと」の認可漏れなら、こちらはオブジェクト内のプロパティ単位の認可漏れです。
✅ 直すべきこと
FastAPIの強みがここで効きます。受け取るPydanticスキーマに書いていない項目は、そもそも入り込めません。つまりスキーマ自体が「更新を許可する項目のホワイトリスト」になります。
from pydantic import BaseModel, ConfigDict, EmailStr
class UserUpdate(BaseModel):
# 更新を許可する項目だけを定義する。role はここに無い。
name: str
email: EmailStr
# 想定外のキーが来たら弾く(明示的に拒否)
model_config = ConfigDict(extra="forbid")
@router.put("/api/users/{user_id}", response_model=UserOut)
async def update_user(
payload: UserUpdate, # role は受け取れない
target: User = Depends(get_owned_user), # Step2の所有者チェックを再利用
):
await users.update(target.id, payload.model_dump())
return await users.get(target.id)
ポイントは、リクエストを丸ごとモデルに流し込まないこと。request の生データや dict をそのまま update に渡す実装は、role の混入を許します。「入力スキーマ」と「DBモデル」を分け、権限に関わる項目(role、is_admin、plan、price など)は入力スキーマに一切載せないのが原則です。
同時に、返す側も response_model=UserOut で項目を絞れば、パスワードハッシュや内部フラグの漏洩(BOPLAのもう半分=Excessive Data Exposure)も同じ仕組みで防げます。
class UserOut(BaseModel):
id: int
name: str
email: EmailStr
# password_hash や is_admin は返さない
Step4. BFLA:管理者向け機能を、一般ユーザーが直接呼ぶ
攻撃者はここを見る
前回のフロント編で見たとおり、UIでボタンを隠していても意味がありません。
{user.role === "admin" && <DeleteButton />}
攻撃者はUIを無視して、管理APIを直接叩きます。
DELETE /api/admin/users/123
このエンドポイントに認可チェックが無ければ、一般ユーザーでも実行できてしまう。これが BFLA(Broken Function Level Authorization、OWASP API5)。BOLAが「他人のデータ」なら、BFLAは「本来使えないはずの機能」を使えてしまう問題です。
✅ 直すべきこと
管理系には、依存関数で明示的な権限チェックを付けます。ルーター単位でまとめて掛ければ、管理エンドポイントの付け忘れを防げます。
def require_admin(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return current_user
# 管理ルーターは丸ごと権限で囲う
admin_router = APIRouter(
prefix="/api/admin",
dependencies=[Depends(require_admin)], # 全エンドポイントに適用
)
@admin_router.delete("/users/{user_id}")
async def delete_user(user_id: int):
await users.delete(user_id)
return {"ok": True}
前回のフロント編と用語を揃えておくと、ステータスコードの使い分けはこうです。
- 未ログイン → 401 Unauthorized(認証が無い)
- ログイン済みだが権限なし → 403 Forbidden(認可で弾く)
「管理エンドポイントを、匿名・一般ユーザー・他人・管理者の4パターンで叩いて、403であるべき所が200になっていないか」を確認する——これは攻撃者が必ずやるチェックであり、そのまま自分の受け入れテストにできます。
Step5. エラーメッセージ:500は攻撃者への"自己紹介"
攻撃者はここを見る
攻撃者は500エラーを歓迎します。詳細が返れば環境が読めるからです。
sqlalchemy.exc.ProgrammingError: column "xxx" does not exist
File "/app/api/users.py", line 42, in get_user
Python 3.12 / FastAPI 0.11x
フレームワーク・バージョン・DB・ファイルパスが分かれば、次に狙う既知の脆弱性を絞り込めます。
✅ 直すべきこと
本番では、例外の中身を利用者に返さないこと。FastAPIは既定ではトレースバックをクライアントに出しませんが、debug=True での起動や、str(exc) をレスポンスに載せる自前ハンドラで漏れることがあります。汎用メッセージだけを返し、詳細はサーバーログに送ります。
import logging
from fastapi import Request
from fastapi.responses import JSONResponse
logger = logging.getLogger("app")
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
# 詳細はログにだけ残す(クライアントには返さない)
logger.exception("unhandled error at %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={"detail": "Internal Server Error"}, # 汎用メッセージ
)
本番起動時は debug=True や --reload を使わないこと、リバースプロキシ側の詳細エラーページも無効化することを確認します。
Step6. レート制限:ログインAPIは何回でも叩けるか
攻撃者はここを見る
ログインエンドポイントを見つけたら、何回叩いても弾かれないかを確認します。
POST /api/login ← 何百回でも送れる?
制限が無ければ、パスワードの総当たり(ブルートフォース)やクレデンシャルスタッフィングが成立します。
✅ 直すべきこと
FastAPIは標準にレート制限を持たないので、slowapi などでIP単位の制限を掛けます。
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@router.post("/api/login")
@limiter.limit("5/minute") # IPあたり1分5回、超過で429
async def login(request: Request, form: LoginIn):
...
ただしIP単位だけだと、IPを分散されると回避されます。ログインは「アカウント単位」でも失敗回数を数え、閾値を超えたら一時ロックやCAPTCHAを挟むのが有効です(IPはslowapi、アカウントはキャッシュで別カウント、の二段構え)。
# アカウント単位の失敗カウント(Redis等のキャッシュを利用)
async def check_account_lock(email: str):
fails = await cache.get(f"login_fail:{email}") or 0
if int(fails) >= 5:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS)
# ログイン失敗時にインクリメント、成功時にリセットする
Step7. 入力値:クライアントが送る数字を信じない
攻撃者はここを見る
購入APIにこんなパラメータがあるとします。
POST /api/orders
{ "product_id": 10, "quantity": 2, "price": 3000 }
攻撃者は想定外の値を試します。
{ "product_id": 10, "quantity": 2, "price": 1 } ← 値段を1円に
{ "product_id": 10, "quantity": -5, "price": 3000 } ← 数量をマイナスに
フロントの入力制限は、HTTPリクエストを直接組み立てれば簡単に無視できます。
✅ 直すべきこと
バリデーションはバックエンドで必須。Pydanticなら型と制約で宣言的に書けます。そして最も重要なのは、金額のような重要な値はクライアントから受け取らず、サーバーで再計算することです。
from pydantic import BaseModel, Field
class OrderIn(BaseModel):
product_id: int
quantity: int = Field(gt=0, le=100) # 1〜100に制約
# price はクライアントから受け取らない
@router.post("/api/orders")
async def create_order(payload: OrderIn, current_user: User = Depends(get_current_user)):
product = await products.get(payload.product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
# ✅ 金額はサーバー側の正データから計算する
total = product.price * payload.quantity
await orders.create(user_id=current_user.id, product_id=product.id,
quantity=payload.quantity, total=total)
「クライアントが送ってきた合計金額を信じる」実装は、EC系で最も典型的な事故です。信頼できるのはサーバーが持つデータだけ、を原則にします。
Step8. ファイルアップロード:その画像、本当に画像ですか?
ここは扱いを軽くしがちですが、成立すると被害が最も大きいため厚めに書きます。
攻撃者はここを見る
プロフィール画像アップロードを見つけたら、拡張子・Content-Type・中身を差し替えて、画像に見せかけた実行ファイルを通せないか試します。
POST /api/avatar
Content-Type: multipart/form-data
filename="avatar.php" ← 拡張子を偽装
Content-Type: image/png ← ヘッダは詐称
<?php system($_GET['c']); ?> ← 中身はスクリプト
もしこれが公開ディレクトリに元の名前のまま保存され、かつ実行できてしまえば、/uploads/avatar.php?c=... で**サーバー上で任意コマンド実行(RCE)**につながります。フロントで accept="image/*" を指定していても、リクエストは自由に組めるので無意味です。
✅ 直すべきこと
「拡張子とContent-Typeのチェック」だけでは不十分です。それらは詐称できる前提で、中身を実際に検証し、万一おかしなファイルでも実行させない設計にします。
import uuid
from pathlib import Path
from fastapi import UploadFile, HTTPException, status
from PIL import Image, UnidentifiedImageError
ALLOWED = {"image/jpeg", "image/png", "image/webp"}
# 公開ディレクトリの外に保存する
UPLOAD_DIR = Path("/var/app/storage/avatars")
@router.post("/api/avatar")
async def upload_avatar(file: UploadFile, current_user: User = Depends(get_current_user)):
# 1. Content-Type は一次フィルタ(信用はしない)
if file.content_type not in ALLOWED:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid type")
data = await file.read()
if len(data) > 2 * 1024 * 1024: # 2MB上限
raise HTTPException(status.HTTP_400_BAD_REQUEST, "too large")
# 2. 中身を画像として検証(本当に画像か)
try:
Image.open(io.BytesIO(data)).verify()
except UnidentifiedImageError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "not an image")
# 3. ファイル名は自前で再生成(avatar.php を残さない)
dest = UPLOAD_DIR / f"{uuid.uuid4().hex}.png"
dest.write_bytes(data)
await users.update(current_user.id, {"avatar_path": str(dest)})
押さえるべきは4点です。
- 中身を検証する(Pillowで画像として開けるか)。拡張子・Content-Typeは詐称前提。
-
公開ディレクトリの外に保存する(
static/に直接置かない)。 -
ファイル名を再生成する(
avatar.phpのまま残さず、UUID+固定拡張子に)。 - 保存先でスクリプトを実行させない(アップロード先でPHP等が実行されないようサーバー設定を確認)。
表示はディレクトリ直リンクではなく、認可を通したエンドポイント経由で配信すると、Step2のBOLA対策とも接続できます。
Step9. ログと検知:破られた「あと」に気づけるか
攻撃者が"嫌う"こと
攻撃者にとって最もやりにくいのは、試行が記録され、気づかれる環境です。逆に、認可拒否や連続失敗が誰にも見られていなければ、時間をかけて安全に探索できます。
✅ 直すべきこと
防御(防ぐ)だけでなく**検知(気づく)**もセキュリティの一部です。最低限、次のイベントは追跡できるようにします。
- ログイン成功/失敗(誰が・いつ・どのIPから)
- 認可拒否(403)——正規ユーザーが権限外を叩いた記録は、攻撃の初期兆候
- 管理操作(誰が何を削除・変更したか)
logger.warning(
"authorization denied",
extra={"user_id": current_user.id,
"route": f"{request.method} {request.url.path}",
"ip": request.client.host},
)
そして同じくらい重要なのが、ログに残してはいけないものです。
- パスワード、トークン、セッションID
- クレジットカード番号などの機微情報
これらをうっかり出力すると、ログ自体が新たな漏洩源になります。リクエストボディをまるごとログに吐く実装は特に危険なので、機微項目はマスクしてから記録します。
まとめ:バックエンドで直すべきことリスト
攻撃者の思考をなぞると、やるべきことは基本の積み重ねに収束します。
認可(ここが本丸・OWASP API1/3/5)
- オブジェクト単位で所有者を検証しているか(BOLA)
-
role等の項目を入力スキーマに載せていないか(Mass Assignment / BOPLA) - 管理機能にサーバー側の権限チェックがあるか(BFLA)
- 未ログインは401、権限なしは403で弾いているか
入力・値の信頼
- 金額など重要な値をサーバーで再計算しているか
- Pydanticで入力を検証し、余分なキーを拒否しているか
濫用・情報漏洩の防止
- ログインにレート制限(IP+アカウント)があるか
- 本番でトレースバックをクライアントに返していないか
- アップロードは公開ディレクトリ外・再生成名・実行不可か
検知
- 認可拒否・ログイン失敗・管理操作を記録しているか
- ログにパスワードやトークンを残していないか
おわりに
フロント編では「ブラウザから見える情報」を、今回はその先の「APIは本当に守られているか」を見てきました。
実際のペネトレーションテストやCTF、そしてバグバウンティでも、特別な攻撃から始まることはほとんどありません。まずAPIの挙動を観察し、「認証はあるか」「認可は適切か」「想定外の入力を受け入れないか」という基本を、一つずつ確認していくだけです。
だからこそ、守る側も同じ順番で基本を潰していけば、多くの攻撃は成立しなくなります。派手なテクニックよりも、認可・検証・制限・検知の積み重ねが、システムの安全性を決めます。
この記事が、ご自身のAPIを「もし最初の5分で叩かれたら」という目で見直すきっかけになれば幸いです。