05 - API セキュリティ: OAuth 2.1、JWT、レート制限
API は最新のアプリケーションのバックボーンです。すべてのマイクロサービス、すべてのモバイル アプリ、すべての統合 SaaS は、重要なデータと機能を公開する HTTP エンドポイントを通過します。報告書によると Salt Security API セキュリティの現状 2024、API 攻撃が増加 過去 12 か月で 167%、94% の組織が少なくとも 1 つの問題を経験しています。 この年に発生した API 関連のセキュリティ インシデント。これらは統計から遠く離れたものではありません 日常の現実: これらの脆弱性の大部分は、開発者が意図するパターンに関係しています。 彼らは毎日複製します。
問題は構造的なものです。 API は機能性を念頭に置いて設計されており、セキュリティも考慮されています 後から表面層として追加されます。 JWT トークンを追加して、 終わった仕事。しかし、OWASP API Security Top 10:2023 は、最も重要なベクトルは技術的なものではないことを示しています。 彼らは論理的です。壊れたオブジェクト レベルの認可、壊れた関数レベルの認可、無制限 リソースの消費。必要なため、どのフレームワークも自動的に解決できない脆弱性 意図的なアーキテクチャ上の決定。
この記事では、OWASP API から、最新の API の攻撃対象領域全体について説明します。 トークン バケットおよびスライディング ウィンドウ アルゴリズムによるトップ 10:2023 レート制限 (スコープ付き OAuth 2.1 から) 正しい CORS 設定から API ゲートウェイ パターンに至るまで、入力検証を詳細に行うことができます。 各セクションの実践的な Node.js/Express コードと、Angular 固有の最終チェックリスト。
何を学ぶか
- OWASP API セキュリティ トップ 10:2023: 最も重大な 10 の脆弱性と実際の例
- レート制限: Node.js の Redis を使用したトークン バケットとスライディング ウィンドウの実装
- 詳細なスコープを備えた OAuth 2.1: OAuth 2.0 および必須の PKCE との違い
- JWT のベスト プラクティス: 安全なアルゴリズム、トークンのローテーション、失効、ブラックリストへの登録
- API キーとベアラー トークン: どのアプローチをいつ使用するか、およびそれらを安全に管理する方法
- Zod for TypeScript を使用した入力検証とサニタイズ
- 安全な CORS 構成: 送信元のホワイトリストと資格情報の管理
- API ゲートウェイ パターン: 集中認証、サーキット ブレーカー、安全なロギング
- 監視と警告: 攻撃パターンをリアルタイムで検出
- フロントエンドからの安全な API 呼び出しのための Angular チェックリスト
OWASP API セキュリティ トップ 10:2023
OWASP API セキュリティ トップ 10:2023 と最も重大なリスクを理解するための参考リスト 最新の API で。 2019 年バージョンと比較して、3 つの新しいカテゴリが導入され、優先順位が並べ替えられています。 実際の事故データに基づいています。これは理論上のリストではありません。各エントリは脆弱性に対応します。 近年実際の侵害を引き起こしたことが文書化されています。
API1:2023 - 壊れたオブジェクト レベルの認証 (BOLA)
BOLA は 3 版連続でナンバー 1 であり、全攻撃の約 40% を占めています。
文書化された API。 API はユーザーを検証せずに ID で識別されるリソースを返します。
認証されたユーザーは、その特定のリソースに対する権利を持ちます。クラシック IDOR の API バージョン
(安全でない直接オブジェクト参照): クライアントは尋ねます。 /api/invoices/1234 そしてサーバー
請求書 1234 が要求しているユーザーのものであることを確認せずに応答します。
// VULNERABILE: L'utente può leggere le fatture di qualsiasi cliente
app.get('/api/invoices/:id', authenticate, async (req, res) => {
// Manca la verifica che req.user.id == invoice.customerId
const invoice = await Invoice.findById(req.params.id);
res.json(invoice); // Restituisce qualsiasi fattura, a chiunque sia autenticato
});
// CORRETTO: Verifica sempre che la risorsa appartenga all'utente
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.id,
customerId: req.user.id, // Filtra per proprietario nel query
});
if (!invoice) {
// Restituisci 404, non 403 (non rivelare l'esistenza della risorsa)
return res.status(404).json({ error: 'Invoice not found' });
}
res.json(invoice);
});
// Con TypeORM: verifica BOLA con query builder
const invoice = await invoiceRepository.findOne({
where: {
id: parseInt(req.params.id),
customer: { id: req.user.id }, // JOIN implicito con verifica ownership
},
});
// Pattern helper per verifiche BOLA sistematiche
async function findOwnedResource<T>(
model: Model<T>,
resourceId: string,
ownerId: string,
ownerField = 'userId'
): Promise<T | null> {
return model.findOne({
_id: resourceId,
[ownerField]: ownerId,
});
}
API2:2023 - 認証の失敗
認証の失敗は、単にログインがないだけではありません。アルゴリズムを備えた JWT トークンが含まれています
none、弱い署名キー、視聴者の検証の欠如 (aud) e
発行者の(iss)、期限切れのないトークン、ブルート フォース保護の欠如
ログインエンドポイント上で。で署名された JWT を発見した攻撃者 HS256 そして1つ
ような弱いキー secret どの役割も再割り当てできます。
API3:2023 - 壊れたオブジェクト プロパティ レベルの認証 (BOPLA)
2023 年に新しく追加された BOPLA は、以前の 2 つの脆弱性を組み合わせたものです。 過剰なデータ漏洩
(API は機密データを含む必要以上のフィールドを返します) e 一括割り当て
(API は、受け入れるべきではないフィールドを受け入れ、ユーザーが編集できるようにします isAdmin
o role)。入力と出力の両方におけるフィールドの安全なパターンと明示的な投影。
// VULNERABILE: Mass Assignment - l'utente può settare isAdmin
app.put('/api/users/:id', authenticate, async (req, res) => {
// req.body può contenere { name: 'John', isAdmin: true, role: 'superadmin' }
await User.findByIdAndUpdate(req.params.id, req.body); // Accetta tutto
});
// CORRETTO: Whitelist esplicita dei campi modificabili con Zod
import { z } from 'zod';
const UpdateUserSchema = z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
bio: z.string().max(500).optional(),
// isAdmin, role, permissions: NON presenti nello schema = non accettati
});
app.put('/api/users/:id', authenticate, async (req, res) => {
const parsed = UpdateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten() });
}
// Solo i campi validati vengono aggiornati
await User.findByIdAndUpdate(req.params.id, parsed.data);
res.json({ success: true });
});
// VULNERABILE: Excessive Data Exposure
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // Restituisce passwordHash, twoFactorSecret, internalNotes...
});
// CORRETTO: Proiezione esplicita - solo campi pubblici
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id)
.select('name email avatar bio createdAt -_id');
res.json(user);
});
API4:2023 - 無制限のリソース消費
「リソース不足とレート制限」に代わるこのカテゴリは、次のすべてのシナリオをカバーします。 API がリソース消費を制限しない場合: ページネーションのないリクエストで数百万を返す レコード数、サイズ制限なしのファイルアップロード、無制限の深さの GraphQL クエリ、Webhook タイムアウトなし、スロットルなしの CPU 集中型操作。レート制限だけでは十分ではありません。 パイプラインのあらゆるレベルで制限が必要です。
API5 - API10: その他の重大な脆弱性
- API5 - 壊れた機能レベルの認証: 通常のユーザーがアクセスできる管理エンドポイント (多くの場合、ドキュメントに隠されていますが、コードで保護されていません)
- API6 - 機密性の高いビジネス フローへの無制限のアクセス: 大量のアカウント作成、自動購入、一括 OTP の送信などの正当なフローの悪用
- API7 - サーバー側リクエスト フォージェリ (SSRF): API はクライアントから提供された URL を受け入れ、サーバー側でリクエストして、内部サービスにアクセスできるようにします。
- API8 - セキュリティの構成ミス: ヘッダーの欠落、ワイルドカード CORS、本番環境でのデバッグ モード、認証なしで公開される古い API バージョン
- API9 - 不適切な在庫管理: API の非推奨バージョンは削除されず、本番環境でエンドポイントをテストし、シャドウ API が文書化されていない
- API10 - API の安全でない使用: 出力検証やエラー処理を行わないサードパーティ API へのブラインドトラスト
レート制限: アルゴリズムと実装
レート制限は、リクエスト数を制限することで API の悪用を防ぐメカニズムです。 クライアントが一定期間にわたって作成できるもの。これは単なる DDoS 対策ではありません。 資格情報のスタッフィング、スクレイピング、リソースの列挙、ビジネス フローの悪用から保護します。選択 アルゴリズムの変化は、ユーザー エクスペリエンスと効果的な保護に影響を与えます。
トークンバケットアルゴリズム
トークン バケットは、レート制限の最も一般的なアルゴリズムです。各クライアントには次のような「バケット」があります。 最大容量 N トークン。トークンは、1 秒あたり R トークンという一定のレートで追加されます。 各リクエストはトークンを消費します。バケットが空の場合、リクエストは HTTP 429 で拒否されます。 利点は、それが可能であることです トラフィックバースト バケツの容量まで、 その後、1 秒あたり R リクエストで安定します。自然な波のトラフィックを伴うパブリック API に最適です。
// Token Bucket con Redis - implementazione production-ready
// npm install ioredis
import Redis from 'ioredis';
import { Request, Response, NextFunction } from 'express';
const redis = new Redis(process.env.REDIS_URL!);
interface TokenBucketConfig {
capacity: number; // capacità massima del secchio (burst max)
refillRate: number; // Token aggiunti per secondo (regime stazionario)
}
class TokenBucketLimiter {
constructor(private config: TokenBucketConfig) {}
async checkLimit(key: string): Promise<{
allowed: boolean;
remaining: number;
resetMs: number;
}> {
const now = Date.now();
const bucketKey = `rl:tb:${key}`;
// Lua script per operazione atomica - critico per evitare race condition
const luaScript = `
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local last_ts = tonumber(data[2]) or now
-- Calcola token da aggiungere in base al tempo trascorso
local elapsed_sec = (now - last_ts) / 1000.0
local refilled = math.min(capacity, tokens + elapsed_sec * refill_rate)
if refilled >= 1.0 then
local remaining = refilled - 1.0
redis.call('HMSET', KEYS[1], 'tokens', remaining, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 3600000)
return {1, math.floor(remaining)}
else
redis.call('HMSET', KEYS[1], 'tokens', refilled, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 3600000)
return {0, 0}
end
`;
const result = await redis.eval(
luaScript, 1, bucketKey,
this.config.capacity,
this.config.refillRate,
now
) as [number, number];
const resetMs = result[0] === 0
? Math.ceil((1 / this.config.refillRate) * 1000)
: 0;
return {
allowed: result[0] === 1,
remaining: result[1],
resetMs,
};
}
middleware() {
return async (req: Request, res: Response, next: NextFunction) => {
// Chiave per utente autenticato, per IP altrimenti
const key = (req as any).user?.id
? `user:${(req as any).user.id}`
: `ip:${req.ip}`;
const { allowed, remaining, resetMs } = await this.checkLimit(key);
// Headers standard RateLimit (RFC 6585 + draft-ietf-httpapi-ratelimit-headers)
res.setHeader('X-RateLimit-Limit', this.config.capacity);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', Date.now() + resetMs);
res.setHeader('Retry-After', Math.ceil(resetMs / 1000));
if (!allowed) {
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry after ${Math.ceil(resetMs / 1000)}s`,
retryAfter: Math.ceil(resetMs / 1000),
});
}
next();
};
}
}
// Limiter differenziati per endpoint con profili diversi
export const apiLimiter = new TokenBucketLimiter({
capacity: 100, // 100 richieste burst
refillRate: 10, // 10 req/s regime stazionario
});
export const authLimiter = new TokenBucketLimiter({
capacity: 5, // Solo 5 tentativi consecutivi (anti brute-force)
refillRate: 0.017, // ~1 token/minuto
});
export const uploadLimiter = new TokenBucketLimiter({
capacity: 10,
refillRate: 0.1, // 1 upload ogni 10 secondi
});
スライディング ウィンドウ アルゴリズム
固定ウィンドウには既知の問題があります。攻撃者は 1 つのリクエストの最後に N 個のリクエストを集中させることができます。 ウィンドウと次のウィンドウの開始時に N 個のリクエストが発生し、短時間で 2N 個のリクエストが発生する 技術的制限に違反することなく、一定の時間を維持します。スライディング ウィンドウはカウントを維持することでこれを解決します Redis Sorted Set を使用して、時間とともに「流れる」各時間枠に対して正確に一致します。 要素にはリクエストのタイムスタンプがスコアとして含まれます。
// Sliding Window Counter con Redis Sorted Set
class SlidingWindowLimiter {
constructor(
private limit: number, // Numero massimo richieste nella finestra
private windowMs: number // Dimensione finestra in millisecondi
) {}
async isAllowed(identifier: string): Promise<{
allowed: boolean;
count: number;
resetMs: number;
}> {
const now = Date.now();
const windowStart = now - this.windowMs;
const key = `rl:sw:${identifier}`;
// Pipeline Redis per operazioni atomiche in sequenza
const pipeline = redis.pipeline();
// 1. Rimuovi elementi fuori dalla finestra temporale
pipeline.zremrangebyscore(key, '-inf', windowStart);
// 2. Aggiungi la richiesta corrente (member unico con timestamp + random)
pipeline.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`);
// 3. Conta le richieste nella finestra corrente
pipeline.zcard(key);
// 4. TTL automatico per non accumulare chiavi orfane
pipeline.pexpire(key, this.windowMs);
const results = await pipeline.exec();
const count = results![2][1] as number;
// Calcola quando la richiesta più vecchia uscira dalla finestra
const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES');
const resetMs = oldest.length > 1
? Math.max(0, parseInt(oldest[1]) + this.windowMs - now)
: this.windowMs;
return {
allowed: count <= this.limit,
count,
resetMs,
};
}
}
// Factory per middleware Express con key generator personalizzabile
function slidingWindowMiddleware(
limit: number,
windowMs: number,
keyGen?: (req: Request) => string
) {
const limiter = new SlidingWindowLimiter(limit, windowMs);
return async (req: Request, res: Response, next: NextFunction) => {
const key = keyGen
? keyGen(req)
: ((req as any).user?.id ?? req.ip ?? 'anon');
const { allowed, count, resetMs } = await limiter.isAllowed(key);
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count));
res.setHeader('X-RateLimit-Reset', new Date(Date.now() + resetMs).toISOString());
if (!allowed) {
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: Math.ceil(resetMs / 1000),
});
}
next();
};
}
// Utilizzo: rate limit globale + specifici per endpoint sensibili
app.use('/api/', slidingWindowMiddleware(1000, 60_000)); // 1000/min globale
app.use('/api/auth/', slidingWindowMiddleware(10, 15 * 60_000)); // 10/15min per auth
app.post(
'/api/payments',
slidingWindowMiddleware(
5, 60 * 60_000,
(req) => `pay:${(req as any).user!.id}` // Per-user invece che per-IP
)
);
トークンバケットとスライディングウィンドウ: 選択ガイド
| 基準 | トークンバケット | 引き違い窓 |
|---|---|---|
| トラフィックバースト | バケット容量までバースト可能 | 一律制限を適用し、バーストなし |
| 限界精度 | おおよその値 (補充速度によって異なります) | ミリ秒単位の精度 |
| ストレージRedis | キーごとに 2 つの値 (軽量ハッシュ) | キーあたり N 要素 (ソートされたセット、トラフィックに比例) |
| ピーク時の動作 | 自然なピークをエラーなく吸収 | ハード: 制限を超えると 429 が返されます |
| 理想的な使用例 | パブリック API、CDN、変動するユーザー トラフィック | 重要なエンドポイント: 認証、支払い、OTP |
OAuth 2.1 と JWT: 最新の API 認証
OAuth 2.1 (RFC ドラフト) は、OAuth 2.0 のセキュリティのベスト プラクティスを統合します。主な違い OAuth 2.0 と比較すると次のとおりです。 PKCEはすべてのクライアントに必須 (公立に限らず クライアント)、 暗黙的なフローが削除されました, リソース所有者のパスワード認証情報 フローが削除されました、 そして リダイレクト URI の正確な一致 必須(何もしない) ワイルドカードを使用したパターン マッチング)。 OAuth 2.0 実装がすでに OWASP セキュリティに従っている場合 チートシート、OAuth 2.1 への移行には最小限の変更が必要です。
詳細なスコープ: API の最小権限
OAuth スコープは、トークンができることを定義します。スコープが広すぎる read:all
o admin 最小特権の原則に違反します。スコープ付きの盗まれたトークン
invoices:read:own よりもはるかに限定的なダメージを引き起こします
read:all。スコープの粒度は、API の実際の操作を反映する必要があります。
// Definizione scopes granulari per API di fatturazione
const SCOPES = {
// Formato: risorsa:operazione:contesto
'invoices:read:own': 'Leggere le proprie fatture',
'invoices:read:all': 'Leggere tutte le fatture (solo admin)',
'invoices:create': 'Creare nuove fatture',
'invoices:update:own': 'Modificare le proprie fatture',
'invoices:delete:own': 'Eliminare le proprie fatture',
'customers:read:own': 'Leggere il proprio profilo cliente',
'customers:read:all': 'Leggere tutti i clienti (solo admin)',
'reports:read:financial': 'Accedere ai report finanziari',
'payments:create': 'Effettuare pagamenti',
'webhooks:manage': 'Gestire i webhook',
} as const;
type Scope = keyof typeof SCOPES;
// Middleware scope check
function requireScope(...requiredScopes: Scope[]) {
return (req: Request, res: Response, next: NextFunction) => {
const tokenScopes: string[] = ((req as any).user?.scope ?? '').split(' ');
const hasScope = requiredScopes.every(s => tokenScopes.includes(s));
if (!hasScope) {
return res.status(403).json({
error: 'insufficient_scope',
required: requiredScopes,
granted: tokenScopes,
});
}
next();
};
}
// Route con scope checking granulare
app.get('/api/invoices',
authenticate,
requireScope('invoices:read:own'),
async (req, res) => {
const invoices = await Invoice.find({ userId: (req as any).user.id });
res.json(invoices);
}
);
app.get('/api/admin/invoices',
authenticate,
requireScope('invoices:read:all'), // Scope admin separato
async (req, res) => {
const invoices = await Invoice.find({});
res.json(invoices);
}
);
// Scope multipli richiesti contemporaneamente
app.post('/api/reports/financial',
authenticate,
requireScope('reports:read:financial', 'invoices:read:all'),
async (req, res) => {
// Richiede ENTRAMBI gli scopes
}
);
JWT: 6 つの致命的な間違い
JWT は広く使用されていますが、同様に広く実装されていません。この6つの間違ったパターン これらは堅牢な認証メカニズムを重大な脆弱性に変えます。あらゆる点 文書化された CVE または実際の攻撃パターンに対応します。
// ERRORE 1: Accettare l'algoritmo 'none'
// CVE-2015-9235 - Molte librerie JWT pre-2016 lo accettavano
// Un attaccante modifica header.alg = "none" e rimuove la firma
// VULNERABILE:
const decoded = jwt.verify(token, secret); // Default: accetta tutti gli algoritmi
// CORRETTO: Whitelist esplicita degli algoritmi
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Solo asimmetrico RS256 per sistemi distribuiti
// oppure ['HS256'] con chiave forte (min 256 bit) per sistemi single-server
});
// ERRORE 2: Chiave simmetrica debole
// VULNERABILE: Brute-forceable in pochi secondi
const token = jwt.sign(payload, 'secret');
const token2 = jwt.sign(payload, 'mysecretkey123');
// CORRETTO: Usa RS256 (asimmetrico) oppure HS256 con chiave forte
// Genera chiave RS256:
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
import fs from 'fs';
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// ERRORE 3: Nessuna validazione claims iss/aud
// Un token emesso per un altro servizio potrebbe essere accettato
// CORRETTO: Verifica issuer e audience
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.myapp.com', // Chi ha emesso il token
audience: 'https://api.myapp.com', // Per chi e il token
// exp verificato automaticamente dalla libreria
});
// ERRORE 4: Access token con durata eccessiva
// VULNERABILE: Token valido 30 giorni = finestra di attacco enorme
const token = jwt.sign(payload, secret, { expiresIn: '30d' });
// CORRETTO: Access token breve (15min) + refresh token
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
scope: user.scopes.join(' '),
jti: crypto.randomUUID(), // JWT ID univoco per blacklisting
},
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
}
);
// Refresh token: opaco, salvato nel DB, revocabile
const refreshToken = crypto.randomBytes(32).toString('base64url');
await RefreshToken.create({
token: await bcrypt.hash(refreshToken, 12), // Hash nel DB, mai in chiaro
userId: user.id,
jti: crypto.randomUUID(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
userAgent: req.get('User-Agent') ?? '',
ipAddress: req.ip ?? '',
});
// ERRORE 5: Refresh token senza rotazione
// Rotation: ogni uso genera un nuovo refresh token, il vecchio viene invalidato
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
// Cerca token attivi per l'utente
const stored = await RefreshToken.findOne({
userId: (req as any).session?.userId,
used: false,
expiresAt: { $gt: new Date() },
});
if (!stored || !(await bcrypt.compare(refreshToken, stored.token))) {
// Token non valido o già usato: REVOCA TUTTO (possibile furto)
await RefreshToken.deleteMany({ userId: stored?.userId });
return res.status(401).json({ error: 'Invalid refresh token' });
}
await stored.updateOne({ used: true }); // Invalida il vecchio
// Emetti nuovi token
const newAccessToken = generateAccessToken(stored.userId);
const newRefreshToken = crypto.randomBytes(32).toString('base64url');
await RefreshToken.create({
token: await bcrypt.hash(newRefreshToken, 12),
userId: stored.userId,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
// Refresh token via HttpOnly cookie (non nel body)
res.cookie('rt', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh',
});
res.json({ accessToken: newAccessToken });
});
// ERRORE 6: JWT in localStorage
// localStorage e accessibile da qualsiasi script JS sulla pagina (XSS)
// CORRETTO: access token in-memory, refresh token in HttpOnly cookie
// Vedi sezione Angular checklist per l'implementazione lato client
API キー: サーバー間統合のための安全な管理
API キーは、クライアントが存在するマシン間通信に最適です。 アカウント所有者によって制御されます (自動化スクリプト、サードパーティのバックエンド、 CI/CD 統合)。 OAuth よりもシンプルですが、API キーという注意深い管理が必要です。 パスワードのように扱う必要があり、ログやソース コードに決して公開しないでください。
// Gestione sicura API Keys - pattern ispirato a Stripe
import crypto from 'crypto';
import { timingSafeEqual } from 'crypto';
// Formato con prefisso identificativo del tipo di chiave
// sk_live_xxxx = production, sk_test_xxxx = sandbox
function generateApiKey(env: 'live' | 'test' = 'live'): {
key: string;
hash: string;
prefix: string;
} {
const random = crypto.randomBytes(24).toString('base64url');
const key = `sk_${env}_${random}`;
const prefix = key.slice(0, 10) + '...'; // Per display nell'UI
// SHA-256 del token: se il DB viene compromesso, le chiavi sono al sicuro
const hash = crypto.createHash('sha256').update(key).digest('hex');
return { key, hash, prefix };
}
// Autenticazione via API key con timing-safe comparison
async function authenticateApiKey(req: Request, res: Response, next: NextFunction) {
const rawKey =
req.headers['x-api-key'] as string ??
req.headers.authorization?.replace('Bearer ', '');
if (!rawKey) {
return res.status(401).json({ error: 'API key required' });
}
const incomingHash = crypto.createHash('sha256').update(rawKey).digest('hex');
// Cerca per hash nel DB
const storedKey = await ApiKey.findOne({
hash: incomingHash,
active: true,
expiresAt: { $gt: new Date() },
}).populate('owner');
if (!storedKey) {
// Timing-safe: evita timing attack per dedurre chiavi valide
const fakeBuf = Buffer.alloc(32, 0);
timingSafeEqual(
Buffer.from(incomingHash, 'hex'),
fakeBuf
);
return res.status(401).json({ error: 'Invalid or expired API key' });
}
// Aggiorna metadati per auditing
await ApiKey.findByIdAndUpdate(storedKey._id, {
lastUsedAt: new Date(),
$inc: { requestCount: 1 },
lastUsedIp: req.ip,
});
(req as any).user = storedKey.owner;
(req as any).apiKey = storedKey; // Dati chiave per rate limiting specifico
next();
}
// Endpoint creazione chiave con scopes e rate limit personalizzato
app.post('/api/keys', authenticate, async (req, res) => {
const schema = z.object({
name: z.string().min(1).max(100),
scopes: z.array(z.string()).min(1),
expiresInDays: z.number().int().min(1).max(365).default(90),
rateLimit: z.number().int().min(10).max(10000).default(1000),
environment: z.enum(['live', 'test']).default('live'),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten() });
}
const { key, hash, prefix } = generateApiKey(parsed.data.environment);
await ApiKey.create({
name: parsed.data.name,
hash, // Solo hash nel DB, MAI la chiave in chiaro
prefix, // Per identificazione nell'UI senza esporre la chiave
ownerId: (req as any).user.id,
scopes: parsed.data.scopes,
rateLimit: parsed.data.rateLimit,
expiresAt: new Date(Date.now() + parsed.data.expiresInDays * 86_400_000),
});
// Restituisce la chiave UNA SOLA VOLTA (pattern Stripe/GitHub)
res.status(201).json({
key, // Solo in questo momento, non più recuperabile
prefix, // Per display futuro
message: 'Store this key securely. It will not be shown again.',
});
});
Zod による入力検証
入力検証は、インジェクション、一括割り当て、および 予期せぬ行動。 TypeScript APIの場合、 ゾッド 2025 年の参照ツール: スキーマを一度定義すると、ランタイム検証と推論された TypeScript 型を取得します TypeScript インターフェイスとランタイム バリデータ間で重複することなく、自動的に実行されます。すべてのエンドポイント 本体、パラメータ、クエリ文字列を個別に検証する必要があります。
// Input validation completa con Zod
// npm install zod
import { z } from 'zod';
// Schema con regole di sicurezza specifiche
const CreateOrderSchema = z.object({
// UUID format: previene injection via ID malformati
customerId: z.string().uuid('ID cliente non valido'),
// Limiti di lunghezza espliciti: previene DoS via stringhe enormi
notes: z.string().max(1000).optional().transform(v => v?.trim()),
// Array con limiti: previene mass insertion
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100), // Limiti di business
unitPrice: z.number().positive().max(99_999.99),
})).min(1).max(50),
// Enum: solo valori predefiniti, no stringhe arbitrarie
shippingMethod: z.enum(['standard', 'express', 'overnight']),
// Data con validazione semantica
requestedDelivery: z.string().datetime().transform(v => new Date(v))
.refine(d => d > new Date(), 'La data deve essere nel futuro')
.optional(),
// URL con vincolo HTTPS
webhookUrl: z.string().url()
.refine(u => u.startsWith('https://'), 'URL deve usare HTTPS')
.optional(),
});
type CreateOrderInput = z.infer<typeof CreateOrderSchema>; // Tipo inferito automaticamente
// Middleware di validazione riutilizzabile
function validateBody<T extends z.ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
fieldErrors: result.error.flatten().fieldErrors,
});
}
req.body = result.data; // Sostituisci con dati validati e trasformati
next();
};
}
function validateQuery<T extends z.ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({ error: 'Invalid query parameters' });
}
(req as any).validatedQuery = result.data;
next();
};
}
// Schema per paginazione sicura
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).max(10_000).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
// Ricerca testuale con sanitizzazione
q: z.string().max(100).optional().transform(v => v?.replace(/[^\w\s-]/g, '')),
});
// Utilizzo nella route
app.post(
'/api/orders',
authenticate,
requireScope('orders:create'),
apiLimiter.middleware(),
validateBody(CreateOrderSchema),
async (req: Request, res: Response) => {
const body = req.body as CreateOrderInput; // Tipizzato e validato
const order = await OrderService.create(body, (req as any).user.id);
res.status(201).json(order);
}
);
安全な CORS 構成
CORS は、開発者がフロントエンドから API 呼び出しを行うときに最初に触れる設定であることがよくあります。
彼らは開発に失敗します。最も一般的で間違った答え: Access-Control-Allow-Origin: *。
これにより、API 全体の同一オリジン保護が無効になります。さらに悪いことに、
Access-Control-Allow-Origin: * con credentials: true まったく機能しません
最新のブラウザの場合は (セキュリティのため)、コード レビューで即時に警告を発する必要があります。
// Configurazione CORS sicura con whitelist dinamica
// npm install cors @types/cors
import cors from 'cors';
// Whitelist separata per ambiente: mai mescolare dev e produzione
const PROD_ORIGINS = [
'https://app.mycompany.com',
'https://admin.mycompany.com',
];
const DEV_ORIGINS = [
'http://localhost:4200', // Angular CLI dev server
'http://localhost:3000',
...PROD_ORIGINS,
];
const allowedOrigins =
process.env.NODE_ENV === 'production' ? PROD_ORIGINS : DEV_ORIGINS;
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
// Permetti richieste senza Origin header (curl, Postman, server-to-server)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origine '${origin}' non autorizzata`));
}
},
credentials: true, // Necessario per cookie cross-origin (refresh token)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-API-Key',
'X-Correlation-ID',
'X-Requested-With',
],
// Headers che il client può leggere dalla risposta
exposedHeaders: [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'X-Correlation-ID',
],
maxAge: 86_400, // Cache preflight OPTIONS per 24 ore
};
app.use(cors(corsOptions));
// Gestione esplicita degli errori CORS
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err.message.startsWith('CORS:')) {
return res.status(403).json({
error: 'CORS Policy Violation',
message: 'Origin not allowed',
});
}
next(err);
});
// Security headers con helmet
import helmet from 'helmet';
app.use(helmet({
// Previeni MIME-type sniffing
noSniff: true,
// Rimuovi X-Powered-By (fingerprinting)
hidePoweredBy: true,
// HSTS: forza HTTPS per 1 anno
hsts: {
maxAge: 31_536_000,
includeSubDomains: true,
preload: true,
},
// Limita Referrer nelle richieste cross-origin
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
セキュリティのための API ゲートウェイ パターン
API ゲートウェイは、認証、レート制限、ロギングなどの横断的な問題を一元管理します。 およびリクエストの処理。マイクロサービス アーキテクチャでは、ロジックの複製を避ける あらゆるサービスにおける安全性。 Express ミドルウェアの構成は、特定の順序に従う必要があります。 各処理の前にセキュリティ ヘッダー、次にレート制限、次に認証、次に検証、 最後にルート。
// API Gateway middleware stack - ordine critico per la sicurezza
import express from 'express';
import helmet from 'helmet';
import { v4 as uuidv4 } from 'uuid';
const app = express();
// STEP 1: Correlation ID - traccia ogni richiesta end-to-end
app.use((req: Request, res: Response, next: NextFunction) => {
const cid = (req.headers['x-correlation-id'] as string) ?? uuidv4();
(req as any).correlationId = cid;
res.setHeader('X-Correlation-ID', cid);
next();
});
// STEP 2: Security headers
app.use(helmet());
app.use(cors(corsOptions));
// STEP 3: Body parsing con limiti (previene DoS via payload enormi)
app.use(express.json({
limit: '10kb', // 10KB max per JSON - adatta per upload separati
strict: true, // Solo array/oggetti JSON validi
}));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// STEP 4: Rate limiting globale (prima dell'autenticazione)
app.use('/api/', slidingWindowMiddleware(5000, 60_000)); // 5000 req/min per IP
// STEP 5: Autenticazione (dopo rate limit - non sprecare risorse su token validazione)
app.use('/api/v1/', authenticateRequest);
// STEP 6: Rate limiting per utente autenticato (dopo auth per avere user ID)
app.use('/api/v1/auth/', slidingWindowMiddleware(
10, 15 * 60_000,
(req) => `auth:${req.ip}`
));
// STEP 7: Routes applicative
app.use('/api/v1/', v1Router);
// STEP 8: Error handler sicuro - SEMPRE in fondo
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
const correlationId = (req as any).correlationId;
// Log interno dettagliato (solo per team di sviluppo)
console.error({
correlationId,
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: (req as any).user?.id ?? 'anonymous',
});
// Risposta esterna: nessun dettaglio interno in produzione
if (process.env.NODE_ENV === 'production') {
res.status(500).json({
error: 'Internal Server Error',
correlationId, // Per il supporto: consente di trovare il log corrispondente
});
} else {
res.status(500).json({
error: err.message,
correlationId,
});
}
});
// Circuit breaker per servizi upstream - evita cascade failures
import CircuitBreaker from 'opossum';
const paymentBreaker = new CircuitBreaker(
async (payload: unknown) => {
const resp = await fetch('https://payment-svc/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(3000), // 3s timeout
});
if (!resp.ok) throw new Error(`Payment service error: ${resp.status}`);
return resp.json();
},
{
timeout: 3000,
errorThresholdPercentage: 50, // Apre il circuito se 50% delle chiamate fallisce
resetTimeout: 30_000, // Riprova dopo 30s
volumeThreshold: 5, // Minimo 5 chiamate prima di aprire
}
);
paymentBreaker.fallback(() => {
throw new Error('Payment service temporarily unavailable');
});
モニタリング: リアルタイムで攻撃を検出
効果的な API 監視システムは、特定のセキュリティ メトリクスに焦点を当てています。 IP ごとおよびエンドポイントごとの 401/403 エラー率、リソース ID の列挙パターン、 ペイロード サイズに対する異常なリクエスト、および文書化されていないエンドポイントまたは非推奨のエンドポイントへのアクセス。 一般的なアプリケーションのメトリックだけでは十分ではありません。安全性を重視したカウンターが必要です。
// Monitoring sicurezza API con metriche orientate agli attacchi
// npm install prom-client
import { Counter, Histogram, register } from 'prom-client';
const authFailureCounter = new Counter({
name: 'api_auth_failures_total',
help: 'Numero totale di fallimenti autenticazione',
labelNames: ['failure_type', 'endpoint'],
});
const rateLimitCounter = new Counter({
name: 'api_rate_limit_hits_total',
help: 'Rate limit superato',
labelNames: ['endpoint', 'identifier_type'],
});
const suspiciousActivityCounter = new Counter({
name: 'api_suspicious_activity_total',
help: 'Attivita potenzialmente sospette rilevate',
labelNames: ['type', 'severity'],
});
// Rilevamento pattern di enumerazione in memoria
// In produzione usa Redis per coordinare più istanze
const enumTracker = new Map<string, { count: number; firstSeen: number }>();
export async function detectEnumeration(
ip: string,
endpoint: string,
statusCode: number
): Promise<void> {
if (statusCode !== 404 && statusCode !== 403) return;
const key = `${ip}:${endpoint.split('/')[2] ?? 'root'}`; // Raggruppa per risorsa
const now = Date.now();
const WINDOW = 60_000; // 1 minuto
const existing = enumTracker.get(key);
if (!existing || now - existing.firstSeen > WINDOW) {
enumTracker.set(key, { count: 1, firstSeen: now });
return;
}
existing.count++;
if (existing.count >= 20) {
suspiciousActivityCounter.inc({
type: 'enumeration_attempt',
severity: existing.count >= 50 ? 'critical' : 'high',
});
if (existing.count === 20) {
// Blocca IP per 1 ora
await redis.setex(`blocked:${ip}`, 3600, '1');
console.warn(`[SECURITY] Enumeration detected from ${ip}, endpoint: ${endpoint}`);
}
}
}
// Middleware raccolta metriche
app.use((req: Request, res: Response, next: NextFunction) => {
res.on('finish', () => {
const endpoint = (req.route?.path ?? req.path).replace(/\/\d+/g, '/:id');
if (res.statusCode === 401) {
authFailureCounter.inc({ failure_type: 'invalid_token', endpoint });
} else if (res.statusCode === 403) {
authFailureCounter.inc({ failure_type: 'forbidden', endpoint });
} else if (res.statusCode === 429) {
const identType = (req as any).user ? 'user' : 'ip';
rateLimitCounter.inc({ endpoint, identifier_type: identType });
}
// Rileva pattern sospetti asincrono (non blocca la risposta)
detectEnumeration(req.ip ?? '', req.path, res.statusCode).catch(console.error);
});
next();
});
// Endpoint metriche (solo per monitoring interno - protetto)
app.get('/internal/metrics', authenticateInternal, async (_, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
API セキュリティのための Angular チェックリスト
Angular フロントエンドには、API セキュリティにおいて特別な責任があります。場所の選択 トークンの保存は、XSS 攻撃対象領域に直接的な影響を及ぼします。 HTTP インターセプター 数十のサービスにわたる重要なロジックの分散を回避することで、セキュリティ管理を一元化します。
// HTTP Interceptor Angular per sicurezza API
// src/app/interceptors/api-security.interceptor.ts
import { inject } from '@angular/core';
import {
HttpInterceptorFn,
HttpRequest,
HttpHandlerFn,
HttpErrorResponse,
} from '@angular/common/http';
import { catchError, switchMap, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const apiSecurityInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
) => {
const auth = inject(AuthService);
const router = inject(Router);
// Aggiungi token solo per le nostre API (non per CDN o API di terze parti)
const isOurApi = req.url.startsWith('/api') ||
req.url.startsWith('https://api.myapp.com');
const accessToken = isOurApi ? auth.getAccessToken() : null;
const secureReq = accessToken
? req.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`,
'X-Correlation-ID': crypto.randomUUID(),
},
// Invia cookie HttpOnly (refresh token) solo per le nostre API
withCredentials: isOurApi,
})
: req;
return next(secureReq).pipe(
catchError((error: HttpErrorResponse) => {
switch (error.status) {
case 401:
// Token scaduto: tenta refresh automatico una volta
return auth.refreshToken().pipe(
switchMap((newToken: string) =>
next(req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` },
withCredentials: true,
}))
),
catchError(() => {
auth.logout();
router.navigate(['/login'], {
queryParams: { reason: 'session_expired' },
});
return throwError(() => error);
})
);
case 403:
router.navigate(['/forbidden']);
return throwError(() => error);
case 429:
// Non loggare come errore critico: e il rate limiter che funziona
const retryAfter = error.headers.get('Retry-After');
console.warn(`Rate limited. Riprova tra ${retryAfter}s`);
return throwError(() => error);
default:
return throwError(() => error);
}
})
);
};
// AuthService con access token in-memory (resistente a XSS)
import { Injectable, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, map } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
// CRITICO: In-memory, non localStorage/sessionStorage
// Perso al refresh della pagina (feature, non bug: forza re-auth)
private readonly _accessToken = signal<string | null>(null);
readonly isAuthenticated = signal<boolean>(false);
getAccessToken(): string | null {
return this._accessToken();
}
login(email: string, password: string): Observable<void> {
return this.http.post<{ accessToken: string }>(
'/api/auth/login',
{ email, password },
{ withCredentials: true } // Riceve HttpOnly cookie con refresh token
).pipe(
tap(res => {
this._accessToken.set(res.accessToken);
this.isAuthenticated.set(true);
}),
map(() => void 0)
);
}
refreshToken(): Observable<string> {
return this.http.post<{ accessToken: string }>(
'/api/auth/refresh', {},
{ withCredentials: true } // Invia HttpOnly cookie automaticamente
).pipe(
tap(res => this._accessToken.set(res.accessToken)),
map(res => res.accessToken)
);
}
logout(): void {
this._accessToken.set(null);
this.isAuthenticated.set(false);
// Invalida il refresh token lato server
this.http.post('/api/auth/logout', {}, { withCredentials: true })
.subscribe();
}
}
// Registrazione interceptor in app.config.ts
// provideHttpClient(withInterceptors([apiSecurityInterceptor]))
API セキュリティ チェックリスト - Angular + Node.js
| エリア | チェック | 優先度 |
|---|---|---|
| オブジェクトの認可 | BOLA チェック: 各クエリはデータを返す前に所有者によってフィルタリングされます。 | 批判 |
| JWT | RS256 アルゴリズム、有効期限 15 分、iss/aud/exp クレーム検証済み | 批判 |
| トークンストレージ | メモリ内のアクセス トークン Angular、HttpOnly Cookie のリフレッシュ トークン SameSite=Strict | 批判 |
| レート制限 | 汎用 API 用のトークン バケット、認証/支払い用のスライディング ウィンドウ | 高い |
| 入力の検証 | body/params/query の Zod、長さ制限、デフォルト値の enum | 高い |
| コルス | 明示的なオリジンのホワイトリスト、認証情報にワイルドカードは不要 | 高い |
| スコープ OAuth | リソース:オペレーション:コンテキストごとに細分化され、各エンドポイントでチェックされます | 高い |
| エラー処理 | 運用環境ではスタック トレースなし、サポート用の相関 ID | 平均 |
| 監視 | 401/403/429 カウンタ、列挙検出、異常時のアラート | 平均 |
| APIキー | DB ではハッシュのみが保存され、平文では保存されません。タイミングセーフな比較。識別用のプレフィックス | 平均 |
絶対に避けるべきアンチパターン
- localStorage 内の JWT: ページ上の任意の JS スクリプトからアクセスできます。アクセス トークンにはインメモリを使用し、リフレッシュ トークンには HttpOnly Cookie を使用します。
- 資格情報を含む CORS ワイルドカード:
Access-Control-Allow-Origin: *concredentials: true最新のブラウザでは設計上拒否されます。構成ミスを示します - IP のみのレート制限: ボットネットは、数千の IP に攻撃を分散させます。 IP ごとのレート制限とユーザー ID ごとのレート制限を組み合わせる
- 範囲が広すぎます:
adminoread:all彼らはわずかな特権を侵害します。スコープ付きの盗まれたトークンinvoices:read:ownダメージが大幅に軽減される - 本番環境で公開されるスタック トレース: 内部パス、ライブラリのバージョン、DB 構造を明らかにします。運用環境では詳細を不明瞭にするエラー ハンドラーを常に使用してください。
- 未指定の JWT アルゴリズム: 必ず指定してください
algorithms: ['RS256']。デフォルト構成の一部の JWT ライブラリはアルゴリズムを受け入れますnone - BOLA は検証されていません: API 攻撃の 40% は BOLA を悪用します。 ID を受け入れる各エンドポイントは、ユーザーがリソースの所有者であることを確認する必要があります。
結論
API のセキュリティは、単一のツールや構成だけでは解決できません。それは練習です 引き続き、エンドポイント設計からのあらゆるレベルで注意が必要です (BOLA を確認してください) 体系的、詳細な範囲)、レート制限の実装(アルゴリズムの選択) 各ユースケースに適したもの)、OAuth 2.1 と JWT(RS256、短い期限、 リフレッシュ トークン ローテーション)、被害を引き起こす前に攻撃パターンを検出するための監視までを行います。
2024 年から 2025 年のデータは、API インシデントの大部分に脆弱性が関与していないことを示しています exotic: 基本的な権限エラーに関するもの (リソースが属しているかどうかを確認しない) 要求したユーザーに提供)、寛容な CORS 構成、有効期限や不在のない JWT トークン 認証エンドポイントのレート制限。これらはよく知られたパターンで解決できる問題です 規律をもって適用されます。
AI 支援コーディング ツールを使用する場合は、次の点に注意してください。 コードの 45% が AI によって生成される セキュリティテストに失敗する: 生成されたコードでは認証が実装されているものの、認証が省略されていることがよくあります。 リソース所有権チェック (BOLA)、IP のみに基づく表面的なレート制限を使用します。 検証なしで任意のフィールドを入力として受け入れます (一括割り当て)。このチェックリストを使用してください 体系的な検証のポイントとしての記事。
Web セキュリティ シリーズの次のステップ
- 前の記事: 安全な認証: セッション、Cookie、モダン ID - セッション管理、セキュア Cookie、OAuth 2.1 PKCE、WebAuthn
- 次の記事: サプライ チェーン セキュリティ: npm 監査と SBOM - npm監査、Dependabot、SBOM生成、安全な依存関係管理
- 基礎: OWASP トップ 10 2025 - A03 サプライ チェーンおよび A10 エラー処理を含む、最も重大な Web 脆弱性の完全な概要
- 関連する DevOps: シリーズをチェックしてください DevOps フロントエンド API セキュリティを CI/CD パイプラインに統合する
- AI とセキュリティ: シリーズを見る バイブコーディング AI によって生成されたコードのリスクと、それらを体系的にテストする方法を理解する







