🔌
概念 #セキュリティ #Webアプリ #API #REST #GraphQL #SSRF #OWASP 📚 Webアプリセキュリティ

API セキュリティ(REST・GraphQL・OWASP API Top 10)

OWASP API Security Top 10(2023)

API は現代の Webアプリの中核だが、従来の OWASP Top 10 とは異なる脅威が存在する。

#カテゴリ概要
API1Broken Object Level Authorization他ユーザーのオブジェクトを ID で直接取得できる(IDOR)
API2Broken Authentication弱い認証・トークン管理の欠陥
API3Broken Object Property Level Authorization更新可能なフィールドを制限していない(Mass Assignment)
API4Unrestricted Resource Consumptionレートリミットなしで無制限にリソースを消費
API5Broken Function Level Authorization管理者機能へのアクセス制御の欠陥
API6Unrestricted Access to Sensitive Business Flowsビジネスロジックの悪用(大量注文・OTP ブルートフォース)
API7Server Side Request Forgery(SSRF)サーバーから内部リソースへの不正リクエスト
API8Security Misconfigurationデフォルト設定・不要な機能の有効化
API9Improper Inventory Management旧バージョン API・シャドウ API の放置
API10Unsafe Consumption of APIs外部 API から受け取ったデータを無検証で使用

API1: Broken Object Level Authorization(IDOR)

# 危険: オブジェクト ID を検証なしで使う
@app.route('/api/v1/orders/<int:order_id>')
@jwt_required
def get_order(order_id):
    order = Order.query.get(order_id)
    return jsonify(order.to_dict())
    # /api/v1/orders/1001 → 他人の注文が取れる

# 安全: ログインユーザーのリソースかを常に検証
@app.route('/api/v1/orders/<int:order_id>')
@jwt_required
def get_order(order_id):
    order = Order.query.filter_by(
        id=order_id,
        user_id=get_jwt_identity()   # ← JWT から取得したユーザー ID で絞り込む
    ).first_or_404()
    return jsonify(order.to_dict())
設計の鉄則:
  ・すべてのオブジェクト取得・更新・削除でオーナーシップを検証する
  ・数値 ID の代わりに UUID(推測困難)を使う
  ・データ層(クエリ)でフィルタする(コントローラー層の if 文だけに頼らない)

API3: Mass Assignment(プロパティレベルの認可ミス)

ユーザーが送ってきた JSON をそのままモデルに渡すと、想定外のフィールドを書き換えられる。

# 危険: リクエストボディをそのままモデルに渡す
@app.route('/api/v1/users/me', methods=['PUT'])
@jwt_required
def update_profile():
    user = User.query.get(get_jwt_identity())
    user.update(**request.json)   # ← {"role": "admin"} を送られると管理者に昇格できる
    db.session.commit()

# 安全: 更新を許可するフィールドを明示的にホワイトリスト化
ALLOWED_FIELDS = {'display_name', 'bio', 'avatar_url'}

@app.route('/api/v1/users/me', methods=['PUT'])
@jwt_required
def update_profile():
    user = User.query.get(get_jwt_identity())
    data = {k: v for k, v in request.json.items() if k in ALLOWED_FIELDS}
    user.update(**data)
    db.session.commit()
# Pydantic でスキーマ定義(FastAPI の場合)
from pydantic import BaseModel
from typing import Optional

class UserUpdateRequest(BaseModel):
    display_name: Optional[str] = None
    bio: Optional[str] = None
    avatar_url: Optional[str] = None
    # role, is_admin などの機密フィールドは定義しない → 自動的に無視される

@app.put('/api/v1/users/me')
def update_profile(body: UserUpdateRequest, user=Depends(get_current_user)):
    user.update(**body.model_dump(exclude_none=True))

API4: Unrestricted Resource Consumption(レートリミット)

攻撃パターン:
  ・OTP ブルートフォース: /api/auth/verify-otp を高速リクエスト
  ・クレデンシャルスタッフィング: /api/auth/login に大量のパスワードリスト
  ・DDoS: 計算コストの高い API(検索・PDF生成等)を大量呼び出し
  ・課金系 API の不正呼び出し: SMS 送信・メール送信 API を悪用
# Flask-Limiter を使ったレートリミット
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"]
)

# ログインエンドポイント: 厳しく制限
@app.route('/api/auth/login', methods=['POST'])
@limiter.limit("5 per minute")   # 1分あたり5回まで
def login():
    ...

# OTP 検証: さらに厳しく(ブルートフォース対策)
@app.route('/api/auth/verify-otp', methods=['POST'])
@limiter.limit("3 per 5 minutes")
def verify_otp():
    ...
// Express + express-rate-limit
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15分
  max: 10,                     // 最大10回
  message: { error: 'Too many login attempts, please try again later' },
  standardHeaders: true,       // RateLimit-* ヘッダーを返す
  legacyHeaders: false,
});

app.post('/api/auth/login', loginLimiter, loginHandler);
レスポンスヘッダーで残りリクエスト数を伝える(標準化: RFC 6585):
  RateLimit-Limit: 10
  RateLimit-Remaining: 7
  RateLimit-Reset: 1680000000
  Retry-After: 60   ← 制限超過時

API7: SSRF(Server Side Request Forgery)

サーバーに任意の URL へのリクエストを発行させ、内部ネットワークやクラウドメタデータ API を攻撃する。

攻撃フロー:
  1. API が URL を受け取ってコンテンツを取得する機能を持つ
     POST /api/fetch-preview  {"url": "https://example.com/image.png"}

  2. 攻撃者が内部リソースの URL を指定
     {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
     → クラウドの IAM 認証情報が返る

  3. さらに内部サービスへのアクセス
     {"url": "http://internal-db:5432/"}
     {"url": "http://admin-panel.internal/"}
# 安全な URL フェッチ実装
import ipaddress
from urllib.parse import urlparse
import requests

ALLOWED_SCHEMES = {'https'}                           # http は禁止
BLOCKED_HOSTS = {'localhost', '127.0.0.1', '0.0.0.0'}

def is_safe_url(url: str) -> bool:
    try:
        parsed = urlparse(url)
    except Exception:
        return False

    # スキーム検証
    if parsed.scheme not in ALLOWED_SCHEMES:
        return False

    # ホスト検証
    hostname = parsed.hostname
    if not hostname or hostname in BLOCKED_HOSTS:
        return False

    # プライベート IP アドレス・ループバックを拒否
    try:
        ip = ipaddress.ip_address(hostname)
        if ip.is_private or ip.is_loopback or ip.is_link_local:
            return False
    except ValueError:
        pass  # ホスト名の場合は IP アドレスではないので続行

    return True

def fetch_url(url: str) -> bytes:
    if not is_safe_url(url):
        raise ValueError("URL is not allowed")

    # リダイレクト追跡を無効化(リダイレクトで内部 URL に誘導される可能性)
    response = requests.get(url, allow_redirects=False, timeout=5)
    return response.content
追加の SSRF 対策:
  ・DNS rebinding 対策: URL 解決後の IP アドレスも検証する
  ・アウトバウンド通信を外部専用 Egress に限定(ネットワーク分離)
  ・クラウドメタデータ API へのアクセスをインスタンスレベルで制限
    (AWS: IMDSv2 の強制で Token 必須に)

API5: Broken Function Level Authorization

管理者用エンドポイントの認可チェック漏れ。

# 危険: HTTP メソッドで機能を分けているだけで認可していない
@app.route('/api/v1/users/<int:user_id>', methods=['GET', 'DELETE'])
@jwt_required
def user_endpoint(user_id):
    if request.method == 'GET':
        return jsonify(User.query.get_or_404(user_id).to_dict())
    elif request.method == 'DELETE':
        # ← DELETE は管理者だけのはずなのに認可チェックがない
        User.query.get_or_404(user_id).delete()
        db.session.commit()
        return '', 204

# 安全: メソッドごとに認可を明示
@app.route('/api/v1/users/<int:user_id>', methods=['GET'])
@jwt_required
def get_user(user_id):
    ...

@app.route('/api/v1/users/<int:user_id>', methods=['DELETE'])
@admin_required   # ← 管理者専用デコレータ
def delete_user(user_id):
    ...
よくある見落とし:
  ・/api/v1/users → GET(通常ユーザー)
  ・/api/admin/users → DELETE(管理者)の URL 分離だけに頼るパターン
  → admin URL がドキュメント化されていなくても推測できる(/api/v2/admin, /api/internal 等)
  → 必ずコード内で認可チェックを実装する

GraphQL のセキュリティ

GraphQL は REST より柔軟な分、固有のセキュリティリスクがある。

Introspection の制限

// 本番環境では Introspection を無効化(スキーマの公開を防ぐ)
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',  // 本番は無効
});
Introspection が有効だと:
  ・スキーマ構造(全フィールド・型)が誰でも取得できる
  ・攻撃者がカスタムクエリを設計する材料になる
  ・InQL(Burp Extension)で自動的に攻撃クエリが生成される

クエリの深さ・複雑度の制限

// graphql-depth-limit + graphql-query-complexity
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5),                          // ネスト深さ上限
    createComplexityLimitRule(1000, {       // クエリ複雑度の上限
      onCost: (cost) => console.log('Query cost:', cost),
    }),
  ],
});
クエリ深さ攻撃(Nested Query Attack):
  query {
    user {
      friends {
        friends {
          friends {       ← 無限ネストで DB を爆発的に負荷
            friends { ... }
          }
        }
      }
    }
  }

バッチリクエストの制限

// GraphQL はバッチクエリでレートリミットを回避できる
// [{ query: "query1" }, { query: "query2" }, ..., { query: "query1000" }]

// Apollo: バッチサイズを制限
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [{
    requestDidStart() {
      return {
        didResolveOperation({ request }) {
          if (Array.isArray(request) && request.length > 10) {
            throw new Error('Batch size limit exceeded');
          }
        },
      };
    },
  }],
});

REST API のセキュリティベストプラクティス

API バージョン管理と廃止ポリシー

API9: Improper Inventory Management
  旧バージョン API(/api/v1/)が脆弱なまま残っている問題。

対策:
  ・/api/v1/, /api/v2/ など全バージョンに同じ認証・認可・バリデーションを適用
  ・廃止するバージョンは Deprecation ヘッダーで予告し、一定期間後に削除
  ・API インベントリを管理し、シャドウ API(非公式エンドポイント)を定期的に探索
# Deprecation ヘッダー(RFC 8594)
@app.route('/api/v1/users')
def get_users_v1():
    response = make_response(jsonify(users))
    response.headers['Deprecation'] = 'true'
    response.headers['Sunset'] = 'Sat, 31 Dec 2026 23:59:59 GMT'
    response.headers['Link'] = '</api/v2/users>; rel="successor-version"'
    return response

レスポンスの情報漏洩を防ぐ

# 危険: DB エラーをそのまま返す
@app.errorhandler(Exception)
def handle_error(e):
    return jsonify({'error': str(e)}), 500
    # → "UNIQUE constraint failed: users.email" のような DB 情報が漏れる

# 安全: 詳細はログに記録し、クライアントには汎用メッセージのみ返す
import logging

@app.errorhandler(Exception)
def handle_error(e):
    logging.error(f"Unhandled exception: {e}", exc_info=True)
    return jsonify({'error': 'Internal server error'}), 500
# フィールドの選択的な公開(機密フィールドを除外)
class UserSchema(Schema):
    id = fields.UUID()
    display_name = fields.String()
    email = fields.Email()
    created_at = fields.DateTime()
    # password_hash, mfa_secret などは定義しない → 自動的にシリアライズされない

@app.route('/api/v1/users/<uuid:user_id>')
def get_user(user_id):
    user = User.query.get_or_404(user_id)
    return UserSchema().dump(user)   # スキーマで制御されたフィールドのみ返す

API キー管理

# API キーの安全な生成と保存
import secrets
import hashlib

def generate_api_key() -> tuple[str, str]:
    """API キーを生成し、(平文キー, ハッシュ)のペアを返す"""
    raw_key = secrets.token_urlsafe(32)     # 256bit のランダムキー
    key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
    return raw_key, key_hash               # 平文は1回だけユーザーに表示

# DB には key_hash のみ保存(平文は保存しない)
# 検証時: 受け取ったキーを SHA256 して DB の key_hash と比較
def verify_api_key(raw_key: str, stored_hash: str) -> bool:
    computed = hashlib.sha256(raw_key.encode()).hexdigest()
    return secrets.compare_digest(computed, stored_hash)  # タイミング攻撃対策

SSRF を使ったクラウドメタデータ攻撃と IMDSv2

AWS の IMDS(Instance Metadata Service)攻撃:

  # IMDSv1(脆弱): 単純な GET で IAM 認証情報が取れる
  curl http://169.254.169.254/latest/meta-data/iam/security-credentials/

  # SSRF を悪用した攻撃:
  POST /api/fetch-url
  {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role"}
  → Access Key / Secret Key / Session Token が返る → AWS リソースを自由に操作
# IMDSv2 の強制(Token 必須): SSRF では Token 取得ができない
# Terraform で IMDSv2 を強制
resource "aws_instance" "app" {
  ami           = "ami-xxx"
  instance_type = "t3.micro"

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"   # IMDSv2 を強制
    http_put_response_hop_limit = 1            # コンテナからの SSRF を制限
  }
}

API セキュリティのチェックリスト

□ 全エンドポイントでオブジェクトレベルの認可検証(オーナーシップチェック)をしている
□ リクエストボディの更新可能フィールドをホワイトリスト化している(Mass Assignment 防止)
□ ログイン・OTP・SMS 送信エンドポイントにレートリミットを設定している
□ GraphQL の Introspection を本番環境で無効化している
□ GraphQL のクエリ深さ・複雑度に上限を設けている
□ URL フェッチ機能でプライベート IP・メタデータ URL をブロックしている(SSRF 対策)
□ 管理者機能に認可チェックを実装している(URL の難読化だけに頼らない)
□ エラーレスポンスに DB・スタックトレースの詳細を含めていない
□ API キーを DB にハッシュ化して保存している(平文保存しない)
□ 廃止バージョン API を Deprecation ヘッダーで告知し、スケジュール通りに削除している
□ AWS IMDSv2 を強制している(http_tokens = required)
□ レスポンスに含める機密フィールドをスキーマで制御している

参考文献

  • OWASP API Security Top 10 2023(owasp.org/API-Security)— API 固有のリスクカテゴリと対策の公式ガイド
  • PortSwigger Web Security Academy『API testing』— API の認証・認可・SSRF の実践ラボ
  • PortSwigger Web Security Academy『Server-side request forgery(SSRF)』— SSRF の攻撃パターンと防止手法
  • Dafydd Stuttard & Marcus Pinto『The Web Application Hacker’s Handbook』(Wiley, 2011)— API 攻撃の体系的解説
  • AWS Security Best Practices『IMDSv2』— EC2 メタデータサービスのセキュリティ強化ガイド

出典: OWASP API Security Top 10 2023 / The Web Application Hacker's Handbook(Stuttard & Pinto, 2011)/ PortSwigger Web Security Academy / AWS Security Best Practices