ブログ記事

API Security 2025完全ガイド - OWASP API Top 10の実践的対策

OWASP API Security Top 10 2023年版の最新リストに基づく、APIセキュリティの脅威と実践的な対策方法を徹底解説。95%の組織が経験するAPI攻撃への対策を、本番環境で使える具体的な実装例と共に紹介します。

プログラミング
API セキュリティ OWASP バックエンド 認証
API Security 2025完全ガイド - OWASP API Top 10の実践的対策のヒーロー画像

API セキュリティは、現代の Web 開発において最も重要な課題の 1 つです。OWASP API Security Top 10 は、API 開発者が直面する最も深刻なセキュリティリスクを特定し、対策を提供しています。

2025年のAPI攻撃の現状

95%の組織が何らかの API セキュリティインシデントを経験し、BOLA は全 API 攻撃の約 40%を占めています。2025 年までに 5 億の新規 API が作成される中、最新の脅威に対応した対策が不可欠です。

OWASP API Security Top 10 (2023年版 - 最新)

Broken Object Level Authorization (BOLA)

オブジェクトレベルの認可不備 - 全API攻撃の40%

Broken Authentication

認証の不備

Broken Object Property Level Authorization

プロパティレベルの認可不備 - 過剰なデータ公開とMass Assignmentを統合

Unrestricted Resource Consumption

無制限のリソース消費 - DoS攻撃やコスト爆発の原因

Broken Function Level Authorization (BFLA)

機能レベルの認可不備

Unrestricted Access to Sensitive Business Flows

センシティブなビジネスフローへの無制限アクセス - 新規追加

Server Side Request Forgery (SSRF)

サーバーサイドリクエストフォージェリ - 新規追加

Security Misconfiguration

セキュリティ設定の不備

Improper Inventory Management

不適切なインベントリ管理

Unsafe Consumption of APIs

APIの安全でない使用 - 新規追加

1. Broken Object Level Authorization (BOLA)

最も一般的で深刻な脆弱性で、全 API 攻撃の約 40%を占めています。ユーザーが他のユーザーのデータにアクセスできてしまう問題です。

実際の被害事例

  • Microsoft Exchange (2021年): 数万社に影響を与えた大規模な侵害
  • 自動車メーカーのモバイルAPI: 他人の車両を遠隔操作可能な脆弱性
  • E-コマースサイト: /shops/{shopName}/revenue_data.json パターンで数千店舗の売上データが流出

脆弱な実装例と対策

// 危険:ユーザーIDの検証なし
app.get('/api/users/:id/profile', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);
});

// 危険:直接的なオブジェクト参照
app.get('/api/orders/:orderId', async (req, res) => {
  const order = await Order.findById(req.params.orderId);
  res.json(order);
});
// 安全:認証とオブジェクトレベルの認可チェック
app.get('/api/users/:id/profile', authenticate, async (req, res) => {
  // 自分のプロファイルのみアクセス可能
  if (req.user.id !== req.params.id && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  const user = await User.findById(req.params.id);
  res.json(user);
});

// 安全:所有権の確認
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await Order.findOne({
    _id: req.params.orderId,
    userId: req.user.id // 所有権を確認
  });
  
  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }
  
  res.json(order);
});
脆弱な実装
// 危険:ユーザーIDの検証なし
app.get('/api/users/:id/profile', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);
});

// 危険:直接的なオブジェクト参照
app.get('/api/orders/:orderId', async (req, res) => {
  const order = await Order.findById(req.params.orderId);
  res.json(order);
});
安全な実装
// 安全:認証とオブジェクトレベルの認可チェック
app.get('/api/users/:id/profile', authenticate, async (req, res) => {
  // 自分のプロファイルのみアクセス可能
  if (req.user.id !== req.params.id && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  const user = await User.findById(req.params.id);
  res.json(user);
});

// 安全:所有権の確認
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await Order.findOne({
    _id: req.params.orderId,
    userId: req.user.id // 所有権を確認
  });
  
  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }
  
  res.json(order);
});

実装パターン:認可ミドルウェア

// authorization.middleware.js
const checkResourceOwnership = (resourceType) => {
  return async (req, res, next) => {
    try {
      const resourceId = req.params.id || req.params[`${resourceType}Id`];
      const Model = require(`../models/${resourceType}`);
      
      const resource = await Model.findById(resourceId);
      
      if (!resource) {
        return res.status(404).json({ error: 'Resource not found' });
      }
      
      // 所有権の確認
      if (resource.userId.toString() !== req.user.id && !req.user.isAdmin) {
        return res.status(403).json({ error: 'Access denied' });
      }
      
      req.resource = resource;
      next();
    } catch (error) {
      res.status(500).json({ error: 'Server error' });
    }
  };
};

// 使用例
app.get('/api/orders/:id', 
  authenticate, 
  checkResourceOwnership('order'), 
  (req, res) => {
    res.json(req.resource);
  }
);
# authorization.py
from functools import wraps
from fastapi import HTTPException, Depends, status
from typing import Callable

def check_resource_ownership(resource_type: str):
    def decorator(func: Callable) -> Callable:
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # 現在のユーザーを取得
            current_user = kwargs.get('current_user')
            resource_id = kwargs.get('resource_id')
            
            # リソースを取得
            Model = globals()[resource_type.capitalize()]
            resource = await Model.get(resource_id)
            
            if not resource:
                raise HTTPException(
                    status_code=status.HTTP_404_NOT_FOUND,
                    detail="Resource not found"
                )
            
            # 所有権の確認
            if resource.user_id != current_user.id and not current_user.is_admin:
                raise HTTPException(
                    status_code=status.HTTP_403_FORBIDDEN,
                    detail="Access denied"
                )
            
            kwargs['resource'] = resource
            return await func(*args, **kwargs)
        
        return wrapper
    return decorator

# 使用例
@app.get("/orders/{order_id}")
@check_resource_ownership("order")
async def get_order(
    order_id: str,
    current_user: User = Depends(get_current_user),
    resource: Order = None
):
    return resource
// middleware/authorization.go
func CheckResourceOwnership(resourceType string) gin.HandlerFunc {
    return func(c *gin.Context) {
        userID := c.GetString("userID")
        resourceID := c.Param("id")
        
        var ownerID string
        
        switch resourceType {
        case "order":
            var order models.Order
            if err := db.First(&order, resourceID).Error; err != nil {
                c.JSON(404, gin.H{"error": "Resource not found"})
                c.Abort()
                return
            }
            ownerID = order.UserID
            c.Set("resource", order)
            
        case "profile":
            var profile models.Profile
            if err := db.First(&profile, resourceID).Error; err != nil {
                c.JSON(404, gin.H{"error": "Resource not found"})
                c.Abort()
                return
            }
            ownerID = profile.UserID
            c.Set("resource", profile)
        }
        
        // 所有権の確認
        if ownerID != userID && !isAdmin(userID) {
            c.JSON(403, gin.H{"error": "Access denied"})
            c.Abort()
            return
        }
        
        c.Next()
    }
}

// 使用例
router.GET("/orders/:id", 
    AuthMiddleware(),
    CheckResourceOwnership("order"),
    handlers.GetOrder,
)

2. Broken Authentication

認証の実装不備は、アカウントの乗っ取りやなりすましを可能にします。

JWT実装のベストプラクティス

// 危険:署名検証なし、有効期限なし
const jwt = require('jsonwebtoken');

app.post('/api/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  
  if (user && user.password === req.body.password) {
    // 危険:平文パスワード、弱い秘密鍵
    const token = jwt.sign({ userId: user.id }, 'secret');
    res.json({ token });
  }
});

// 危険:トークン検証が不十分
app.get('/api/protected', (req, res) => {
  const token = req.headers.authorization;
  const decoded = jwt.decode(token); // 検証なし!
  res.json({ userId: decoded.userId });
});
// 安全:適切な認証実装
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

// 環境変数から強力な秘密鍵を取得
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  
  // レート制限チェック
  if (await isRateLimited(req.ip)) {
    return res.status(429).json({ error: 'Too many attempts' });
  }
  
  const user = await User.findOne({ email });
  
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // タイミング攻撃を防ぐため、常に同じ応答時間
    await bcrypt.compare('dummy', '$2b$10$dummy.hash');
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // アクセストークンとリフレッシュトークンを生成
  const accessToken = jwt.sign(
    { 
      userId: user.id, 
      type: 'access',
      jti: crypto.randomUUID() // JWT ID for revocation
    },
    JWT_SECRET,
    { 
      expiresIn: JWT_EXPIRY,
      issuer: 'api.example.com',
      audience: 'app.example.com'
    }
  );
  
  const refreshToken = jwt.sign(
    { 
      userId: user.id, 
      type: 'refresh',
      jti: crypto.randomUUID()
    },
    JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );
  
  // リフレッシュトークンをデータベースに保存
  await saveRefreshToken(user.id, refreshToken);
  
  res.json({ 
    accessToken,
    refreshToken,
    expiresIn: 900 // 15 minutes in seconds
  });
});

// 安全:適切なトークン検証
const authenticate = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'No token provided' });
    }
    
    const token = authHeader.substring(7);
    
    // トークンがブラックリストにないか確認
    if (await isTokenRevoked(token)) {
      return res.status(401).json({ error: 'Token revoked' });
    }
    
    const decoded = jwt.verify(token, JWT_SECRET, {
      issuer: 'api.example.com',
      audience: 'app.example.com'
    });
    
    if (decoded.type !== 'access') {
      return res.status(401).json({ error: 'Invalid token type' });
    }
    
    req.user = await User.findById(decoded.userId);
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
};
脆弱な実装
// 危険:署名検証なし、有効期限なし
const jwt = require('jsonwebtoken');

app.post('/api/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  
  if (user && user.password === req.body.password) {
    // 危険:平文パスワード、弱い秘密鍵
    const token = jwt.sign({ userId: user.id }, 'secret');
    res.json({ token });
  }
});

// 危険:トークン検証が不十分
app.get('/api/protected', (req, res) => {
  const token = req.headers.authorization;
  const decoded = jwt.decode(token); // 検証なし!
  res.json({ userId: decoded.userId });
});
安全な実装
// 安全:適切な認証実装
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

// 環境変数から強力な秘密鍵を取得
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  
  // レート制限チェック
  if (await isRateLimited(req.ip)) {
    return res.status(429).json({ error: 'Too many attempts' });
  }
  
  const user = await User.findOne({ email });
  
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // タイミング攻撃を防ぐため、常に同じ応答時間
    await bcrypt.compare('dummy', '$2b$10$dummy.hash');
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // アクセストークンとリフレッシュトークンを生成
  const accessToken = jwt.sign(
    { 
      userId: user.id, 
      type: 'access',
      jti: crypto.randomUUID() // JWT ID for revocation
    },
    JWT_SECRET,
    { 
      expiresIn: JWT_EXPIRY,
      issuer: 'api.example.com',
      audience: 'app.example.com'
    }
  );
  
  const refreshToken = jwt.sign(
    { 
      userId: user.id, 
      type: 'refresh',
      jti: crypto.randomUUID()
    },
    JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );
  
  // リフレッシュトークンをデータベースに保存
  await saveRefreshToken(user.id, refreshToken);
  
  res.json({ 
    accessToken,
    refreshToken,
    expiresIn: 900 // 15 minutes in seconds
  });
});

// 安全:適切なトークン検証
const authenticate = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'No token provided' });
    }
    
    const token = authHeader.substring(7);
    
    // トークンがブラックリストにないか確認
    if (await isTokenRevoked(token)) {
      return res.status(401).json({ error: 'Token revoked' });
    }
    
    const decoded = jwt.verify(token, JWT_SECRET, {
      issuer: 'api.example.com',
      audience: 'app.example.com'
    });
    
    if (decoded.type !== 'access') {
      return res.status(401).json({ error: 'Invalid token type' });
    }
    
    req.user = await User.findById(decoded.userId);
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
};

多要素認証(MFA)の実装

// TOTP (Time-based One-Time Password) 実装
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// MFAセットアップ
app.post('/api/mfa/setup', authenticate, async (req, res) => {
  // 既にMFAが有効な場合は拒否
  if (req.user.mfaEnabled) {
    return res.status(400).json({ error: 'MFA already enabled' });
  }
  
  // シークレットを生成
  const secret = speakeasy.generateSecret({
    name: `MyApp (${req.user.email})`,
    issuer: 'MyApp',
    length: 32
  });
  
  // 一時的にシークレットを保存
  await User.updateOne(
    { _id: req.user.id },
    { 
      tempMfaSecret: secret.base32,
      tempMfaSecretExpiry: new Date(Date.now() + 10 * 60 * 1000) // 10分
    }
  );
  
  // QRコードを生成
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
  
  res.json({
    secret: secret.base32,
    qrCode: qrCodeUrl,
    manualEntryKey: secret.base32
  });
});

// MFA検証と有効化
app.post('/api/mfa/verify', authenticate, async (req, res) => {
  const { token } = req.body;
  
  if (!req.user.tempMfaSecret || new Date() > req.user.tempMfaSecretExpiry) {
    return res.status(400).json({ error: 'No pending MFA setup' });
  }
  
  // トークンを検証
  const verified = speakeasy.totp.verify({
    secret: req.user.tempMfaSecret,
    encoding: 'base32',
    token: token,
    window: 1 // 前後1つのウィンドウを許容
  });
  
  if (!verified) {
    return res.status(400).json({ error: 'Invalid token' });
  }
  
  // バックアップコードを生成
  const backupCodes = Array.from({ length: 10 }, () => 
    crypto.randomBytes(4).toString('hex')
  );
  
  // MFAを有効化
  await User.updateOne(
    { _id: req.user.id },
    {
      mfaSecret: req.user.tempMfaSecret,
      mfaEnabled: true,
      mfaBackupCodes: backupCodes.map(code => 
        bcrypt.hashSync(code, 10)
      ),
      $unset: { tempMfaSecret: 1, tempMfaSecretExpiry: 1 }
    }
  );
  
  res.json({
    message: 'MFA enabled successfully',
    backupCodes: backupCodes
  });
});

// ログイン時のMFA検証
app.post('/api/login/mfa', async (req, res) => {
  const { sessionToken, mfaToken } = req.body;
  
  // セッショントークンを検証
  const session = await LoginSession.findOne({
    token: sessionToken,
    expiry: { $gt: new Date() },
    mfaPending: true
  });
  
  if (!session) {
    return res.status(401).json({ error: 'Invalid session' });
  }
  
  const user = await User.findById(session.userId);
  
  // TOTPトークンを検証
  const verified = speakeasy.totp.verify({
    secret: user.mfaSecret,
    encoding: 'base32',
    token: mfaToken,
    window: 1
  });
  
  if (!verified) {
    // バックアップコードを試す
    const backupCodeValid = await checkBackupCode(user, mfaToken);
    if (!backupCodeValid) {
      return res.status(401).json({ error: 'Invalid MFA token' });
    }
  }
  
  // セッションを更新
  session.mfaPending = false;
  await session.save();
  
  // JWTトークンを発行
  const tokens = generateTokens(user);
  res.json(tokens);
});
// WebAuthn/Passkeys実装
const { 
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} = require('@simplewebauthn/server');

// WebAuthn登録開始
app.post('/api/webauthn/register/start', authenticate, async (req, res) => {
  const user = await User.findById(req.user.id);
  
  // 既存の認証器を取得
  const existingAuthenticators = user.authenticators || [];
  
  const options = await generateRegistrationOptions({
    rpName: 'MyApp',
    rpID: 'example.com',
    userID: user.id,
    userName: user.email,
    userDisplayName: user.name,
    attestationType: 'none',
    excludeCredentials: existingAuthenticators.map(auth => ({
      id: auth.credentialID,
      type: 'public-key',
      transports: auth.transports,
    })),
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      requireResidentKey: true,
      residentKey: 'preferred',
      userVerification: 'preferred'
    },
  });
  
  // チャレンジを保存
  await User.updateOne(
    { _id: user.id },
    { 
      currentChallenge: options.challenge,
      challengeExpiry: new Date(Date.now() + 5 * 60 * 1000)
    }
  );
  
  res.json(options);
});

// WebAuthn登録完了
app.post('/api/webauthn/register/finish', authenticate, async (req, res) => {
  const user = await User.findById(req.user.id);
  const { credential } = req.body;
  
  if (!user.currentChallenge || new Date() > user.challengeExpiry) {
    return res.status(400).json({ error: 'Challenge expired' });
  }
  
  try {
    const verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: 'https://example.com',
      expectedRPID: 'example.com',
      requireUserVerification: true,
    });
    
    if (!verification.verified) {
      return res.status(400).json({ error: 'Verification failed' });
    }
    
    const { credentialPublicKey, credentialID, counter } = 
      verification.registrationInfo;
    
    // 認証器を保存
    await User.updateOne(
      { _id: user.id },
      {
        $push: {
          authenticators: {
            credentialID: Buffer.from(credentialID),
            credentialPublicKey: Buffer.from(credentialPublicKey),
            counter,
            transports: credential.response.transports,
            createdAt: new Date()
          }
        },
        $unset: { currentChallenge: 1, challengeExpiry: 1 }
      }
    );
    
    res.json({ verified: true });
  } catch (error) {
    console.error(error);
    res.status(400).json({ error: 'Registration failed' });
  }
});

// WebAuthn認証
app.post('/api/webauthn/login/start', async (req, res) => {
  const { email } = req.body;
  const user = await User.findOne({ email });
  
  if (!user || !user.authenticators?.length) {
    // ユーザーが存在しないことを隠す
    return res.json({
      challenge: crypto.randomBytes(32).toString('base64'),
      allowCredentials: []
    });
  }
  
  const options = await generateAuthenticationOptions({
    allowCredentials: user.authenticators.map(auth => ({
      id: auth.credentialID,
      type: 'public-key',
      transports: auth.transports,
    })),
    userVerification: 'preferred',
  });
  
  // チャレンジを保存
  await LoginSession.create({
    userId: user.id,
    challenge: options.challenge,
    expiry: new Date(Date.now() + 5 * 60 * 1000)
  });
  
  res.json(options);
});
// SMS/Email OTP実装
const twilio = require('twilio');
const nodemailer = require('nodemailer');

const twilioClient = twilio(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN
);

// OTP送信
app.post('/api/otp/send', authenticate, async (req, res) => {
  const { method, destination } = req.body;
  
  // レート制限
  const key = `otp:${req.user.id}:${method}`;
  const attempts = await redis.incr(key);
  
  if (attempts === 1) {
    await redis.expire(key, 300); // 5分で3回まで
  } else if (attempts > 3) {
    return res.status(429).json({ 
      error: 'Too many attempts. Try again later.' 
    });
  }
  
  // 6桁のOTPを生成
  const otp = crypto.randomInt(100000, 999999).toString();
  
  // OTPをRedisに保存(5分間有効)
  await redis.setex(
    `otp:${req.user.id}:${otp}`,
    300,
    JSON.stringify({ method, destination, attempts: 0 })
  );
  
  try {
    if (method === 'sms') {
      await twilioClient.messages.create({
        body: `Your verification code is: ${otp}`,
        to: destination,
        from: process.env.TWILIO_PHONE_NUMBER
      });
    } else if (method === 'email') {
      const transporter = nodemailer.createTransport({
        host: process.env.SMTP_HOST,
        port: 587,
        secure: false,
        auth: {
          user: process.env.SMTP_USER,
          pass: process.env.SMTP_PASS
        }
      });
      
      await transporter.sendMail({
        from: '"MyApp" <noreply@example.com>',
        to: destination,
        subject: 'Your verification code',
        html: `
          <p>Your verification code is:</p>
          <h2>${otp}</h2>
          <p>This code will expire in 5 minutes.</p>
        `
      });
    }
    
    res.json({ 
      message: 'OTP sent successfully',
      expiresIn: 300
    });
  } catch (error) {
    console.error('OTP send error:', error);
    res.status(500).json({ error: 'Failed to send OTP' });
  }
});

// OTP検証
app.post('/api/otp/verify', authenticate, async (req, res) => {
  const { otp } = req.body;
  
  const key = `otp:${req.user.id}:${otp}`;
  const data = await redis.get(key);
  
  if (!data) {
    return res.status(400).json({ error: 'Invalid or expired OTP' });
  }
  
  const otpData = JSON.parse(data);
  
  // 試行回数をチェック
  if (otpData.attempts >= 3) {
    await redis.del(key);
    return res.status(400).json({ error: 'Too many failed attempts' });
  }
  
  // OTPを削除(一度だけ使用可能)
  await redis.del(key);
  
  // セッションを更新またはトークンを発行
  const tokens = generateTokens(req.user);
  res.json({
    message: 'OTP verified successfully',
    ...tokens
  });
});

3. Broken Object Property Level Authorization

特定のプロパティへのアクセス制御が不適切な場合に発生します。

// 危険:すべてのフィールドを更新可能
app.put('/api/users/:id', authenticate, async (req, res) => {
  const user = await User.findByIdAndUpdate(
    req.params.id,
    req.body, // すべてのフィールドを受け入れる
    { new: true }
  );
  res.json(user);
});

// 危険:機密情報を含むすべてのフィールドを返す
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user); // パスワードハッシュ、APIキーなども含む
});
// 安全:ホワイトリスト方式でフィールドを制限
const updateableFields = ['name', 'bio', 'avatar'];
const publicFields = ['id', 'name', 'bio', 'avatar', 'createdAt'];
const privateFields = [...publicFields, 'email', 'phone'];

app.put('/api/users/:id', authenticate, async (req, res) => {
  // 自分のプロファイルのみ更新可能
  if (req.user.id !== req.params.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  // 更新可能なフィールドのみ抽出
  const updates = {};
  updateableFields.forEach(field => {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  });
  
  // 特別な権限が必要なフィールド
  if (req.body.role && req.user.isAdmin) {
    updates.role = req.body.role;
  }
  
  const user = await User.findByIdAndUpdate(
    req.params.id,
    updates,
    { new: true, runValidators: true }
  );
  
  // 返すフィールドも制限
  const response = pickFields(user, privateFields);
  res.json(response);
});

// 安全:コンテキストに応じたフィールド制御
app.get('/api/users/:id', authenticate, async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  let fields;
  if (req.user.id === req.params.id) {
    // 自分のプロファイル:プライベートフィールドを含む
    fields = privateFields;
  } else if (req.user.isAdmin) {
    // 管理者:すべてのフィールド(パスワード以外)
    fields = [...privateFields, 'lastLogin', 'ipAddress'];
  } else {
    // その他:パブリックフィールドのみ
    fields = publicFields;
  }
  
  const response = pickFields(user, fields);
  res.json(response);
});

// ヘルパー関数
function pickFields(obj, fields) {
  return fields.reduce((result, field) => {
    if (obj[field] !== undefined) {
      result[field] = obj[field];
    }
    return result;
  }, {});
}
脆弱な実装
// 危険:すべてのフィールドを更新可能
app.put('/api/users/:id', authenticate, async (req, res) => {
  const user = await User.findByIdAndUpdate(
    req.params.id,
    req.body, // すべてのフィールドを受け入れる
    { new: true }
  );
  res.json(user);
});

// 危険:機密情報を含むすべてのフィールドを返す
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user); // パスワードハッシュ、APIキーなども含む
});
安全な実装
// 安全:ホワイトリスト方式でフィールドを制限
const updateableFields = ['name', 'bio', 'avatar'];
const publicFields = ['id', 'name', 'bio', 'avatar', 'createdAt'];
const privateFields = [...publicFields, 'email', 'phone'];

app.put('/api/users/:id', authenticate, async (req, res) => {
  // 自分のプロファイルのみ更新可能
  if (req.user.id !== req.params.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  // 更新可能なフィールドのみ抽出
  const updates = {};
  updateableFields.forEach(field => {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  });
  
  // 特別な権限が必要なフィールド
  if (req.body.role && req.user.isAdmin) {
    updates.role = req.body.role;
  }
  
  const user = await User.findByIdAndUpdate(
    req.params.id,
    updates,
    { new: true, runValidators: true }
  );
  
  // 返すフィールドも制限
  const response = pickFields(user, privateFields);
  res.json(response);
});

// 安全:コンテキストに応じたフィールド制御
app.get('/api/users/:id', authenticate, async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  let fields;
  if (req.user.id === req.params.id) {
    // 自分のプロファイル:プライベートフィールドを含む
    fields = privateFields;
  } else if (req.user.isAdmin) {
    // 管理者:すべてのフィールド(パスワード以外)
    fields = [...privateFields, 'lastLogin', 'ipAddress'];
  } else {
    // その他:パブリックフィールドのみ
    fields = publicFields;
  }
  
  const response = pickFields(user, fields);
  res.json(response);
});

// ヘルパー関数
function pickFields(obj, fields) {
  return fields.reduce((result, field) => {
    if (obj[field] !== undefined) {
      result[field] = obj[field];
    }
    return result;
  }, {});
}

4. Unrestricted Resource Consumption

リソース消費の制限がない場合、DoS 攻撃の対象となります。

包括的なレート制限実装

// 基本的なレート制限(express-rate-limit使用)
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
  password: process.env.REDIS_PASSWORD
});

// グローバルレート制限
const globalLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:global:'
  }),
  windowMs: 15 * 60 * 1000, // 15分
  max: 100, // 最大100リクエスト
  message: 'Too many requests from this IP',
  standardHeaders: true,
  legacyHeaders: false,
});

// API エンドポイント別のレート制限
const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:api:'
  }),
  windowMs: 1 * 60 * 1000, // 1分
  max: 20,
  keyGenerator: (req) => {
    // 認証済みユーザーはユーザーIDで、未認証はIPで制限
    return req.user ? `user:${req.user.id}` : `ip:${req.ip}`;
  }
});

// 厳しい制限が必要なエンドポイント
const strictLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:strict:'
  }),
  windowMs: 15 * 60 * 1000, // 15分
  max: 5, // 最大5回
  skipSuccessfulRequests: true, // 成功したリクエストはカウントしない
});

// 適用
app.use(globalLimiter);
app.use('/api/', apiLimiter);
app.use('/api/auth/login', strictLimiter);
app.use('/api/auth/register', strictLimiter);
app.use('/api/password-reset', strictLimiter);
// 高度なレート制限(スライディングウィンドウ、コスト基準)
class AdvancedRateLimiter {
  constructor(redis) {
    this.redis = redis;
  }
  
  // コストベースのレート制限
  async checkCostBasedLimit(key, cost, options = {}) {
    const {
      maxCost = 1000,
      windowMs = 60000,
      refillRate = 100 // 毎秒回復するコスト
    } = options;
    
    const now = Date.now();
    const windowStart = now - windowMs;
    
    // Lua スクリプトでアトミックに処理
    const luaScript = `
      local key = KEYS[1]
      local now = tonumber(ARGV[1])
      local cost = tonumber(ARGV[2])
      local maxCost = tonumber(ARGV[3])
      local windowMs = tonumber(ARGV[4])
      local refillRate = tonumber(ARGV[5])
      
      -- 現在のコストを取得
      local currentCost = tonumber(redis.call('get', key) or 0)
      local lastRefill = tonumber(redis.call('get', key .. ':lastRefill') or now)
      
      -- コストを回復
      local timePassed = (now - lastRefill) / 1000
      local refilled = math.min(currentCost, timePassed * refillRate)
      currentCost = math.max(0, currentCost - refilled)
      
      -- 新しいコストをチェック
      if currentCost + cost > maxCost then
        return {0, currentCost, maxCost}
      end
      
      -- コストを追加
      currentCost = currentCost + cost
      redis.call('set', key, currentCost)
      redis.call('set', key .. ':lastRefill', now)
      redis.call('expire', key, windowMs / 1000)
      redis.call('expire', key .. ':lastRefill', windowMs / 1000)
      
      return {1, currentCost, maxCost}
    `;
    
    const result = await this.redis.eval(
      luaScript,
      1,
      key,
      now,
      cost,
      maxCost,
      windowMs,
      refillRate
    );
    
    return {
      allowed: result[0] === 1,
      currentCost: result[1],
      maxCost: result[2],
      remainingCost: Math.max(0, result[2] - result[1])
    };
  }
  
  // スライディングウィンドウ
  async checkSlidingWindow(key, options = {}) {
    const {
      windowMs = 60000,
      max = 60
    } = options;
    
    const now = Date.now();
    const windowStart = now - windowMs;
    
    const pipe = this.redis.pipeline();
    
    // 古いエントリを削除
    pipe.zremrangebyscore(key, '-inf', windowStart);
    
    // 現在のカウントを取得
    pipe.zcard(key);
    
    // 新しいエントリを追加(まだ制限内の場合)
    pipe.zadd(key, now, `${now}-${Math.random()}`);
    
    // TTLを設定
    pipe.expire(key, Math.ceil(windowMs / 1000));
    
    const results = await pipe.exec();
    const count = results[1][1];
    
    if (count >= max) {
      // 制限を超えた場合、追加したエントリを削除
      await this.redis.zrem(key, `${now}-${Math.random()}`);
      return {
        allowed: false,
        count: count,
        max: max,
        resetAt: windowStart + windowMs
      };
    }
    
    return {
      allowed: true,
      count: count + 1,
      max: max,
      resetAt: windowStart + windowMs
    };
  }
}

// 使用例
const limiter = new AdvancedRateLimiter(redis);

// APIコストミドルウェア
const costBasedLimiter = (costMap) => {
  return async (req, res, next) => {
    const endpoint = `${req.method}:${req.route.path}`;
    const cost = costMap[endpoint] || 1;
    const key = req.user ? `cost:user:${req.user.id}` : `cost:ip:${req.ip}`;
    
    const result = await limiter.checkCostBasedLimit(key, cost, {
      maxCost: req.user?.subscription === 'premium' ? 5000 : 1000,
      windowMs: 3600000, // 1時間
      refillRate: req.user?.subscription === 'premium' ? 200 : 50
    });
    
    // ヘッダーに情報を追加
    res.set({
      'X-RateLimit-Cost': cost,
      'X-RateLimit-Remaining': result.remainingCost,
      'X-RateLimit-Limit': result.maxCost
    });
    
    if (!result.allowed) {
      return res.status(429).json({
        error: 'Rate limit exceeded',
        retryAfter: Math.ceil(cost / (result.maxCost / 3600))
      });
    }
    
    next();
  };
};

// エンドポイントごとのコスト定義
const apiCosts = {
  'GET:/api/users': 1,
  'GET:/api/users/:id': 1,
  'POST:/api/users': 10,
  'GET:/api/reports/generate': 100,
  'POST:/api/ai/generate': 50
};

app.use('/api', costBasedLimiter(apiCosts));
// 分散環境対応(複数インスタンス、地理的分散)
const Redlock = require('redlock');

class DistributedRateLimiter {
  constructor(redisNodes) {
    this.clients = redisNodes.map(node => new Redis(node));
    this.redlock = new Redlock(this.clients, {
      retryCount: 3,
      retryDelay: 200
    });
  }
  
  // グローバル分散レート制限
  async checkGlobalLimit(key, options = {}) {
    const {
      max = 1000,
      windowMs = 60000,
      shardCount = this.clients.length
    } = options;
    
    // 各シャードの制限を計算
    const maxPerShard = Math.ceil(max / shardCount);
    
    // ローカルシャードをチェック
    const localShard = this.getShardForKey(key);
    const localResult = await this.checkLocalLimit(
      localShard, 
      key, 
      maxPerShard, 
      windowMs
    );
    
    if (!localResult.allowed) {
      // ローカルで拒否された場合、他のシャードをチェック
      return this.checkOtherShards(key, max, windowMs);
    }
    
    return localResult;
  }
  
  // シャード間でのレート制限チェック
  async checkOtherShards(key, max, windowMs) {
    const results = await Promise.all(
      this.clients.map(client => 
        this.getShardCount(client, key, windowMs)
      )
    );
    
    const totalCount = results.reduce((sum, count) => sum + count, 0);
    
    return {
      allowed: totalCount < max,
      count: totalCount,
      max: max,
      distributed: true
    };
  }
  
  // 地理的に分散したレート制限
  async checkGeoDistributedLimit(key, options = {}) {
    const {
      regions = ['us-east', 'eu-west', 'ap-south'],
      maxPerRegion = 1000,
      maxGlobal = 2500,
      windowMs = 60000
    } = options;
    
    // 現在のリージョンを特定
    const currentRegion = this.getCurrentRegion();
    
    // ローカルリージョンのチェック
    const localKey = `${key}:${currentRegion}`;
    const localResult = await this.checkLocalLimit(
      this.getRegionalClient(currentRegion),
      localKey,
      maxPerRegion,
      windowMs
    );
    
    if (!localResult.allowed) {
      return { ...localResult, region: currentRegion };
    }
    
    // グローバル制限のチェック(非同期)
    this.checkGlobalLimitAsync(key, maxGlobal, windowMs);
    
    return {
      ...localResult,
      region: currentRegion,
      globalCheckPending: true
    };
  }
  
  // バースト対応のトークンバケット
  async checkTokenBucket(key, options = {}) {
    const {
      capacity = 100,
      refillRate = 10, // トークン/秒
      tokensRequested = 1
    } = options;
    
    const luaScript = `
      local key = KEYS[1]
      local capacity = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local tokensRequested = tonumber(ARGV[3])
      local now = tonumber(ARGV[4])
      
      -- 現在のトークン数と最終更新時刻を取得
      local bucket = redis.call('hmget', key, 'tokens', 'lastRefill')
      local tokens = tonumber(bucket[1] or capacity)
      local lastRefill = tonumber(bucket[2] or now)
      
      -- トークンを補充
      local timePassed = math.max(0, now - lastRefill)
      local tokensToAdd = math.floor(timePassed * refillRate / 1000)
      tokens = math.min(capacity, tokens + tokensToAdd)
      
      -- トークンをチェック
      if tokens < tokensRequested then
        -- トークン不足
        local waitTime = math.ceil((tokensRequested - tokens) * 1000 / refillRate)
        return {0, tokens, waitTime}
      end
      
      -- トークンを消費
      tokens = tokens - tokensRequested
      redis.call('hmset', key, 'tokens', tokens, 'lastRefill', now)
      redis.call('expire', key, 3600)
      
      return {1, tokens, 0}
    `;
    
    const result = await this.clients[0].eval(
      luaScript,
      1,
      key,
      capacity,
      refillRate,
      tokensRequested,
      Date.now()
    );
    
    return {
      allowed: result[0] === 1,
      remainingTokens: result[1],
      waitTimeMs: result[2],
      capacity: capacity
    };
  }
}

// 使用例
const distributedLimiter = new DistributedRateLimiter([
  { host: 'redis1.example.com', port: 6379 },
  { host: 'redis2.example.com', port: 6379 },
  { host: 'redis3.example.com', port: 6379 }
]);

// ミドルウェア
app.use(async (req, res, next) => {
  const key = req.user ? `user:${req.user.id}` : `ip:${req.ip}`;
  
  // バーストトラフィックに対応
  const result = await distributedLimiter.checkTokenBucket(key, {
    capacity: 100,      // バースト時の最大
    refillRate: 10,     // 通常時は10req/秒
    tokensRequested: 1
  });
  
  res.set({
    'X-RateLimit-Remaining': result.remainingTokens,
    'X-RateLimit-Capacity': result.capacity
  });
  
  if (!result.allowed) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: Math.ceil(result.waitTimeMs / 1000)
    });
  }
  
  next();
});

5. セキュリティ設定の最適化

Helmet.jsを使った包括的なセキュリティヘッダー設定

const helmet = require('helmet');

// 本番環境用の厳格なセキュリティ設定
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.example.com"],
      styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.example.com"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
      reportUri: "/api/csp-report"
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  },
  frameguard: { action: 'deny' },
  noSniff: true,
  xssFilter: true,
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  permittedCrossDomainPolicies: false
}));

// CORS設定
const cors = require('cors');
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com'
    ];
    
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  maxAge: 86400 // プリフライトリクエストのキャッシュ
}));

// セキュリティ監査ログ
app.use((req, res, next) => {
  const securityEvents = [
    '/api/auth/login',
    '/api/auth/logout',
    '/api/users/*/delete',
    '/api/admin/*'
  ];
  
  if (securityEvents.some(pattern => 
    req.path.match(new RegExp(pattern.replace('*', '.*')))
  )) {
    logger.security({
      event: 'security_audit',
      method: req.method,
      path: req.path,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
      userId: req.user?.id,
      timestamp: new Date().toISOString()
    });
  }
  
  next();
});

パフォーマンスとセキュリティのバランス

セキュリティ対策のパフォーマンス影響
対策 パフォーマンス影響 推奨設定 代替案
JWTトークン検証 低(<1ms) 全リクエストで実施 セッション管理
レート制限 低(Redis使用時) エンドポイント別に設定 CloudflareのRate Limiting
入力検証 中(複雑な検証時) スキーマベース検証 WAF併用
暗号化 高(大量データ時) センシティブデータのみ ハードウェア暗号化
監査ログ 重要操作のみ 非同期処理
CORS検証 厳格に設定 API Gateway利用

まとめ

API セキュリティは、単一の対策ではなく、多層防御が重要です。本記事で紹介した対策を組み合わせることで、堅牢な API を構築できます。

実装の優先順位

  1. 認証・認可の実装(BOLA、認証、BFLA 対策)
  2. レート制限とリソース管理
  3. 入力検証とサニタイゼーション
  4. セキュリティヘッダーとCORS設定
  5. 監査ログとモニタリング

定期的なセキュリティ監査とペネトレーションテストも忘れずに実施しましょう。

この記事は役に立ちましたか?

Daily Hackでは、開発者の皆様に役立つ情報を毎日発信しています。