ストーリー
佐藤CTOがAPI一覧を見せました。
OWASP API Security Top 10 (2023)
| 順位 | リスク | 説明 |
|---|---|---|
| API1 | 壊れたオブジェクトレベル認可(BOLA) | オブジェクトIDを操作して他ユーザーのデータにアクセス |
| API2 | 壊れた認証 | 認証メカニズムの欠陥 |
| API3 | 壊れたオブジェクトプロパティレベル認可 | 過剰なデータ公開、マスアサインメント |
| API4 | 無制限のリソース消費 | レート制限の欠如 |
| API5 | 壊れた関数レベル認可 | 管理者APIへの不正アクセス |
| API6 | サーバーサイドリクエストフォージェリ | 内部リソースへのSSRF |
| API7 | セキュリティの設定ミス | CORSの設定不備等 |
| API8 | 自動化された脅威の管理不足 | ボット攻撃への対策不足 |
| API9 | 不適切なインベントリ管理 | 古いAPIバージョンの放置 |
| API10 | 安全でないAPIの消費 | 外部APIの応答を信頼しすぎる |
API1: BOLA(Broken Object Level Authorization)
// 脆弱なコード: オブジェクトレベルの認可チェックなし
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
// 危険: 認証済みなら誰でもアクセス可能
const order = await orderService.findById(req.params.orderId);
res.json(order);
});
// 安全なコード: オブジェクト所有者の検証
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await orderService.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
// オブジェクトの所有者を検証
if (order.userId !== req.user.id && !req.user.roles.includes('admin')) {
// 403ではなく404を返す(情報漏洩防止)
return res.status(404).json({ error: 'Not found' });
}
res.json(order);
});
// 汎用的なBOLA防御ミドルウェア
function checkObjectOwnership(resourceType: string, ownerField: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const resourceId = req.params.id || req.params[`${resourceType}Id`];
const resource = await getResource(resourceType, resourceId);
if (!resource || resource[ownerField] !== req.user.id) {
return res.status(404).json({ error: 'Not found' });
}
req.resource = resource;
next();
};
}
API3: 過剰なデータ公開
// 脆弱なコード: 全フィールドをレスポンスに含める
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
res.json(user); // passwordHash, internalNotes 等も含まれる
});
// 安全なコード: レスポンスのシリアライゼーション
interface UserResponse {
id: string;
name: string;
email: string;
role: string;
createdAt: string;
}
function toUserResponse(user: User): UserResponse {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt.toISOString(),
// passwordHash, internalNotes は含めない
};
}
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: { id: true, name: true, email: true, role: true, createdAt: true },
});
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(toUserResponse(user));
});
API4: レート制限
// 多層レート制限の実装
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// グローバルレート制限(全エンドポイント)
const globalLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args: string[]) => redis.call(...args) }),
windowMs: 15 * 60 * 1000, // 15分
max: 1000,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: res.getHeader('Retry-After'),
});
},
});
// 認証エンドポイント用(厳格)
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args: string[]) => redis.call(...args) }),
windowMs: 15 * 60 * 1000,
max: 10, // 15分間に10回まで
keyGenerator: (req) => `auth:${req.ip}:${req.body?.email || 'unknown'}`,
skipSuccessfulRequests: true, // 成功したリクエストはカウントしない
});
// API検索エンドポイント用(コスト考慮)
const searchLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args: string[]) => redis.call(...args) }),
windowMs: 60 * 1000, // 1分
max: 30,
keyGenerator: (req) => `search:${req.user?.id || req.ip}`,
});
app.use(globalLimiter);
app.use('/api/auth', authLimiter);
app.use('/api/search', searchLimiter);
API7: セキュリティの設定ミス
CORS設定
// 脆弱なCORS設定
app.use(cors({ origin: '*' })); // 危険: 全オリジン許可
// 安全なCORS設定
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS policy violation'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true,
maxAge: 3600, // プリフライトキャッシュ
}));
セキュリティヘッダー
// 包括的なセキュリティヘッダー
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '0'); // CSPを使用するため無効化
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Cache-Control', 'no-store'); // APIレスポンスはキャッシュしない
res.setHeader('Pragma', 'no-cache');
res.removeHeader('X-Powered-By'); // 技術スタック情報の隠蔽
next();
});
API9: 不適切なインベントリ管理
// APIバージョン管理とレガシーAPI検出
interface ApiInventory {
version: string;
status: 'active' | 'deprecated' | 'retired';
deprecationDate?: string;
retirementDate?: string;
endpoints: string[];
documentation: string;
}
const apiInventory: ApiInventory[] = [
{
version: 'v3',
status: 'active',
endpoints: ['/api/v3/*'],
documentation: 'https://docs.example.com/api/v3',
},
{
version: 'v2',
status: 'deprecated',
deprecationDate: '2024-01-01',
retirementDate: '2024-07-01',
endpoints: ['/api/v2/*'],
documentation: 'https://docs.example.com/api/v2',
},
{
version: 'v1',
status: 'retired',
retirementDate: '2023-06-01',
endpoints: [], // アクセス不可
documentation: 'Archived',
},
];
// 非推奨APIへのアクセス時に警告ヘッダーを返す
app.use('/api/v2', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 01 Jul 2024 00:00:00 GMT');
res.setHeader('Link', '</api/v3>; rel="successor-version"');
next();
});
まとめ
| ポイント | 内容 |
|---|---|
| BOLA(API1) | オブジェクトレベルの認可チェックを全エンドポイントに実装 |
| データ公開(API3) | レスポンスシリアライゼーションで必要最小限のフィールドのみ返す |
| レート制限(API4) | エンドポイントの重要度に応じた多層レート制限 |
| 設定ミス(API7) | CORS、セキュリティヘッダー、エラーメッセージの適切な設定 |
| インベントリ管理(API9) | 全APIバージョンの管理とレガシーAPIの計画的廃止 |
チェックリスト
- OWASP API Security Top 10の主要リスクを理解した
- BOLA防御の汎用ミドルウェアを実装できる
- レスポンスシリアライゼーションで過剰なデータ公開を防止できる
- 多層レート制限を設計・実装できる
- APIのバージョン管理とインベントリ管理を設計できる
次のステップへ
次は「脆弱性の修正と報告」を学びます。CVSSスコアリング、修正の優先順位付け、パッチ管理の方法を身につけましょう。
推定読了時間: 40分