05 - Securitate API: OAuth 2.1, JWT și Rate Limiting
API-urile sunt coloana vertebrală a aplicațiilor moderne. Fiecare microserviciu, fiecare aplicație mobilă, fiecare integrare SaaS trece prin punctele finale HTTP care expun date și funcționalități critice. Conform raportului Salt Security Starea securității API 2024, atacurile API au crescut cu 167% în ultimele 12 luni, cu 94% dintre organizații care se confruntă cu cel puțin una Incident de securitate legat de API pe parcursul anului. Acestea nu sunt departe de statistici realitatea cotidiană: marea majoritate a acestor vulnerabilități se referă la modele pe care dezvoltatorii se repetă în fiecare zi.
Problema este structurală. API-urile sunt proiectate având în vedere funcționalitatea, iar securitatea vine adăugat ulterior ca strat de suprafață. Adăugați un token JWT și luați în considerare lucrare terminată. Dar OWASP API Security Top 10:2023 arată că cei mai critici vectori nu sunt tehnici: sunt logici. Broken Object Level Authorization, Broken Function Level Authorization, Unrestricted Consumul de resurse. Vulnerabilități pe care niciun framework nu le rezolvă automat, pentru că le necesită decizii arhitecturale deliberate.
Acest articol vă prezintă întreaga suprafață de atac a API-urilor moderne: din API-ul OWASP Limitarea ratei de top 10:2023 cu găleată de token și algoritmi de fereastră glisantă, de la OAuth 2.1 cu domenii granular până la validarea intrărilor, de la configurația CORS corectă la modelele de gateway API. Cod practic Node.js/Express pentru fiecare secțiune, cu o listă de verificare finală specifică Angular.
Ce vei învăța
- OWASP API Security Top 10:2023: cele mai critice 10 vulnerabilități cu exemple practice
- Limitarea ratei: găleată de jetoane și implementarea ferestrei glisante cu Redis în Node.js
- OAuth 2.1 cu domenii granulare: diferențe față de OAuth 2.0 și PKCE obligatoriu
- Cele mai bune practici JWT: algoritmi siguri, rotație de token, revocare și lista neagră
- Chei API vs jetoane Bearer: când să folosiți ce abordare și cum să le gestionați în siguranță
- Validarea și igienizarea intrărilor cu Zod pentru TypeScript
- Configurație CORS securizată: lista albă de origine și gestionarea acreditărilor
- Modele de gateway API: autentificare centralizată, întrerupător de circuit, înregistrare securizată
- Monitorizare și alertă: detectează modelele de atac în timp real
- Listă de verificare angulară pentru apeluri API sigure de la interfață
OWASP API Security Top 10:2023
OWASP API Security Top 10:2023 și lista de referințe pentru înțelegerea celor mai critice riscuri în API-urile moderne. În comparație cu versiunea din 2019, introduce trei categorii noi și reordonează prioritățile pe baza datelor reale despre accidente. Nu este o listă teoretică: fiecare intrare corespunde unor vulnerabilități documentate care au cauzat încălcări reale în ultimii ani.
API1:2023 - Autorizare la nivel de obiect spart (BOLA)
BOLA a fost numărul unu de trei ediții consecutive și reprezintă aproximativ 40% din totalul atacurilor
API-uri documentate. API-ul returnează resurse identificate printr-un ID fără a verifica utilizatorul
autentificat are drepturi asupra acelei resurse specifice. Și versiunea API a clasicului IDOR
(Insecure Direct Object Reference): clientul întreabă /api/invoices/1234 si serverul
răspunde fără a verifica dacă factura 1234 aparține utilizatorului care o solicită.
// 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 - Autentificare întreruptă
Autentificarea întreruptă depășește doar o lipsă de conectare. Include token JWT cu algoritm
none, chei slabe de semnare, lipsa validării audienței (aud) e
al emitentului (iss), jetoane care nu expiră niciodată și lipsa protecției împotriva forței brute
pe punctele finale de conectare. Un atacator care găsește un JWT semnat cu HS256 si unul
cheie slabă ca secret orice rol poate fi reatribuit.
API3:2023 - Autorizare la nivel de proprietate a obiectelor sparte (BOPLA)
Nou în 2023, BOPLA combină două vulnerabilități anterioare: Expunerea excesivă a datelor
(API-ul returnează mai multe câmpuri decât este necesar, inclusiv date sensibile) e Misiunea în masă
(API-ul acceptă câmpuri pe care nu ar trebui, permițând utilizatorului să editeze isAdmin
o role). Modelul sigur și proiecția explicită a câmpurilor atât în intrare, cât și în ieșire.
// 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 - Consum nerestricționat de resurse
Înlocuitor pentru „Lipsa resurselor și limitarea ratei”, această categorie acoperă toate scenariile în unde un API nu limitează consumul de resurse: solicitări fără paginare care returnează milioane de înregistrări, încărcare de fișiere fără limite de dimensiune, interogări GraphQL cu adâncime nelimitată, webhook-uri fără timeout-uri și operațiuni intensive de CPU fără limitare. Numai limitarea ratei nu este suficientă: aveți nevoie de limite la fiecare nivel al conductei.
API5 - API10: Alte vulnerabilități critice
- API5 - Autorizare la nivel de funcție întreruptă: Puncte finale administrative accesibile utilizatorilor obișnuiți (deseori ascunse în documentație, dar nu sunt protejate de cod)
- API6 - Acces nerestricționat la fluxurile de afaceri sensibile: Abuzul de fluxuri legitime, cum ar fi crearea de conturi în masă, achizițiile automate sau trimiterea de OTP în bloc
- API7 - Falsificarea cererii pe partea serverului (SSRF): API-ul acceptă adrese URL furnizate de client și le solicită pe partea de server, permițându-vă să accesați serviciile interne
- API8 - Configurare greșită de securitate: Antete lipsă, CORS metacar, modul de depanare în producție, versiuni API învechite expuse fără autentificare
- API9 - Gestionarea incorectă a inventarului: Versiunile API depreciate nu au fost eliminate, punctele finale de testare în producție, API-urile umbră nu sunt documentate
- API10 - Consum nesigur de API: Încredere oarbă în API-urile terțe fără validare a ieșirii și gestionarea erorilor
Limitarea ratei: algoritmi și implementare
Limitarea ratei este mecanismul care previne abuzul API prin limitarea numărului de solicitări pe care un client o poate face într-o perioadă de timp. Nu este doar o măsură anti-DDoS: protejează de la umplerea acreditărilor, scraping, enumerarea resurselor și abuzul de flux de afaceri. Alegerea a algoritmului influențează experiența utilizatorului și protecția eficientă.
Algoritmul pentru găleți de jetoane
Bucket-ul de jetoane este cel mai comun algoritm pentru limitarea ratei. Fiecare client are o „găleată” cu capacitate maximă N jetoane. Jetoanele sunt adăugate la o rată constantă R jetoane pe secundă. Fiecare cerere consumă un token. Dacă compartimentul este gol, cererea este respinsă cu HTTP 429. Avantajul este că permite izbucnirea traficului până la capacitatea găleții, apoi se stabilizează la R cereri pe secundă. Ideal pentru API-uri publice cu trafic cu valuri naturale.
// 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
});
Algoritmul ferestrei glisante
Fereastra fixată are o problemă cunoscută: un atacator poate concentra N cereri la sfârșitul uneia fereastră și N cereri la începutul următoarei, rezultând 2N solicitări într-un timp scurt de timp fără a încălca limita tehnică. Fereastra glisantă rezolvă acest lucru ținând o numărătoare exact pentru fiecare fereastră de timp care „curge” cu timpul, folosind un set sortat Redis unde fiecare elementul are marca temporală a cererii ca punctaj.
// 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
)
);
Găleată de jetoane vs fereastră glisantă: Ghid de alegere
| Criteriu | Găleți de jetoane | Fereastra glisantă |
|---|---|---|
| Traficul a izbucnit | Permite explozii până la capacitatea găleții | Aplicați limită uniformă, fără explozie |
| Limitați precizia | Aproximativ (depinde de rata de reumplere) | Precizie la milisecundă |
| Stocare Redis | 2 valori per cheie (hash ușor) | N elemente per cheie (set sortat, proporțional cu trafic) |
| Comportament de vârf | Absoarbe vârfurile naturale fără erori | Greu: dincolo de limită returnează 429 |
| Caz de utilizare ideal | API-uri publice, CDN, trafic de utilizatori variabil | Puncte finale critice: auth, plăți, OTP |
OAuth 2.1 și JWT: Autentificare API modernă
OAuth 2.1 (schița RFC) consolidează cele mai bune practici de securitate ale OAuth 2.0. Diferențele cheie în comparație cu OAuth 2.0, acestea sunt: PKCE obligatoriu pentru toți clienții (nu doar public clienti), fluxul implicit eliminat, acreditările parolei proprietarului resursei debitul eliminat, Și potrivirea exactă a URI de redirecționare obligatoriu (nimic potrivirea modelului cu wildcard). Dacă implementarea dvs. OAuth 2.0 a urmat deja securitatea OWASP Cheat Sheet, trecerea la OAuth 2.1 necesită modificări minime.
Domenii granulare: privilegii minime pentru API
Domeniile OAuth definesc ce poate face un token. Scopuri prea largi ca read:all
o admin încalcă principiul cel mai mic privilegiu. Un jeton furat cu scop
invoices:read:own cauzează daune mult mai limitate decât unul cu
read:all. Granularitatea domeniilor trebuie să reflecte operațiunile reale ale 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: Cele 6 greseli fatale
JWT este utilizat pe scară largă, dar implementat la fel de prost. Aceste șase modele incorecte ele transformă un mecanism robust de autentificare într-o vulnerabilitate critică. Fiecare punct corespunde unui CVE documentat sau unui model de atac real.
// 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
Chei API: Management securizat pentru integrări de la server la server
Cheile API sunt alegerea potrivită pentru comunicațiile de la mașină la mașină acolo unde se află clientul controlate de proprietarul contului (scripturi de automatizare, backend terți, integrări CI/CD). Sunt mai simple decât OAuth, dar necesită o gestionare atentă: o cheie API ar trebui tratată ca o parolă, niciodată expusă în jurnalele sau codul sursă.
// 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.',
});
});
Validare de intrare cu Zod
Validarea intrărilor este prima linie de apărare împotriva injectării, alocării în masă și comportamente neașteptate. Pentru API TypeScript, Zod și instrumentul de referință în 2025: definiți schema o dată și obțineți validarea runtime plus tipurile TypeScript deduse automat, fără duplicare între interfețele TypeScript și validatorii de rulare. Fiecare punct final trebuie să valideze separat corpul, parametrii și șirul de interogare.
// 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);
}
);
Configurație CORS sigură
CORS este adesea prima configurație pe care o atinge un dezvoltator atunci când efectuează apeluri API din frontend
ele eşuează în dezvoltare. Cel mai frecvent și greșit răspuns: Access-Control-Allow-Origin: *.
Acest lucru dezactivează protecția de aceeași origine pentru întregul dvs. API. Mai rău încă,
Access-Control-Allow-Origin: * cu credentials: true nu merge deloc
pentru browserele moderne (pentru securitate) și ar trebui să trimită o alertă imediată în examinarea codului.
// 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 Gateway Patterns for Security
Un gateway API centralizează preocupările transversale, cum ar fi autentificarea, limitarea ratei, înregistrarea în jurnal. și procesarea cererilor. Într-o arhitectură de microservicii, evitați replicarea logicii siguranta in fiecare serviciu. Compoziția middleware-ului Express trebuie să urmeze o anumită ordine: anteturi de securitate înainte de fiecare procesare, apoi limitarea ratei, apoi autentificarea, apoi validarea, in sfarsit traseele.
// 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');
});
Monitorizare: Detectează atacurile în timp real
Un sistem eficient de monitorizare API se concentrează pe valori specifice de securitate: Rate de eroare 401/403 per IP și per punct final, modele de enumerare pe ID-urile resurselor, solicitări anormale pentru dimensiunea încărcăturii utile și accese la puncte finale nedocumentate sau depreciate. Valorile generale ale aplicației nu sunt suficiente: sunt necesare contoare orientate spre siguranță.
// 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());
});
Lista de verificare angulară pentru securitatea API
Interfața Angular are responsabilități specifice în securitatea API. Alegerea unde stocarea jetoanelor are consecințe directe asupra suprafeței de atac XSS. Interceptorul HTTP centralizează managementul securității evitând distribuirea logicii critice în zeci de servicii.
// 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]))
Lista de verificare a securității API - Angular + Node.js
| Zonă | Verifica | Prioritate |
|---|---|---|
| Autorizarea obiectului | Verificare BOLA: fiecare interogare filtrează după proprietar înainte de a returna date | CRITICĂ |
| JWT | Algoritm RS256, expirare 15 min, revendicări iss/aud/exp validate | CRITICĂ |
| Stocare de jetoane | Jeton de acces în memorie Angular, jeton de reîmprospătare în cookie HttpOnly SameSite=Strict | CRITICĂ |
| Limitarea ratei | Grup de jetoane pentru API generic, fereastră glisantă pentru autentificare/plăți | RIDICAT |
| Validarea intrărilor | Zod pe corp/parametri/interogare, limite de lungime, enumerare pentru valorile implicite | RIDICAT |
| CORS | Lista albă cu origini explicite, fără caractere metalice cu acreditări | RIDICAT |
| Domenii OAuth | Granular după resursă:operație:context, verificat pe fiecare punct final | RIDICAT |
| Gestionarea erorilor | Fără urmă de stivă în producție, ID de corelare pentru suport | MEDIE |
| Monitorizare | Contoare 401/403/429, detecție enumerare, alertă anomalii | MEDIE |
| Chei API | Doar hash în DB, niciodată în clar; comparație sigură în timp; prefix pentru identificare | MEDIE |
Anti-modele de evitat absolut
- JWT în localStorage: Accesibil din orice script JS de pe pagină. Utilizați în memorie pentru jetonul de acces și cookie-ul HttpOnly pentru jetonul de reîmprospătare
- Caracter joker CORS cu acreditări:
Access-Control-Allow-Origin: *cucredentials: trueeste respins de browserele moderne prin design; indică o configurație greșită - Limitarea ratei numai pentru IP: Un botnet distribuie atacuri pe mii de IP-uri. Combinați limitarea ratei per IP cu limitarea ratei per ID de utilizator
- Domenii prea largi:
adminoread:allîncalcă cel mai mic privilegiu. Un jeton furat cu scopinvoices:read:ownprovoacă daune semnificativ mai mici - Urme de stivă expuse în producție: Dezvăluie căile interne, versiunile bibliotecii, structura DB. Utilizați întotdeauna un handler de erori care ascunde detaliile în producție
- Algoritm JWT nespecificat: Specificați întotdeauna
algorithms: ['RS256']. Unele biblioteci JWT cu configurație implicită acceptă algoritmulnone - BOLA neverificată: 40% din atacurile API exploatează BOLA. Fiecare punct final care acceptă un ID trebuie să verifice dacă utilizatorul este proprietarul resursei
Concluzii
Securitatea API nu este rezolvată cu un singur instrument sau configurație. Este o practică continuă care necesită atenție la fiecare nivel: de la proiectarea punctului final (verificați BOLA sistematic, domenii granulare), la implementarea limitării ratei (alegerea algoritmului dreptul pentru fiecare caz de utilizare), la gestionarea corectă a OAuth 2.1 și JWT (RS256, termene scurte, rotația jetonului de reîmprospătare), până la monitorizare pentru a detecta tiparele de atac înainte ca acestea să provoace daune.
Datele din 2024-2025 arată că majoritatea incidentelor API nu implică vulnerabilități exotic: se referă la erorile de permisiuni de bază (nu se verifică dacă resursa aparține utilizatorului care o solicită), configurații CORS permisive, jetoane JWT fără expirare și absență limitarea ratei la punctele finale de autentificare. Acestea sunt probleme care pot fi rezolvate cu modele binecunoscute aplicate cu disciplina.
Dacă utilizați instrumente de codare asistate de AI, rețineți că 45% din codul generat de AI nu trece testele de securitate: Codul generat implementează adesea autentificarea, dar o omite verificarea proprietății resurselor (BOLA), utilizează limitarea superficială a ratei bazată numai pe IP, de ex acceptă câmpuri arbitrare ca intrare fără validare (atribuire în masă). Utilizați această listă de verificare articol ca punct de verificare sistematică.
Următorii pași în seria Web Security
- Articolul precedent: Autentificare sigură: sesiune, cookie-uri și identitate modernă - Gestionarea sesiunii, cookie-uri securizate, OAuth 2.1 PKCE și WebAuthn
- Articolul următor: Securitatea lanțului de aprovizionare: audit npm și SBOM - audit npm, Dependabot, generare SBOM și management securizat al dependenței
- Fundamente: OWASP Top 10 2025 - Prezentare completă a celor mai critice vulnerabilități web, inclusiv A03 Supply Chain și A10 Error Handling
- DevOps conexe: Vezi seria DevOps Frontend pentru a integra securitatea API în conducta dvs. CI/CD
- AI și securitate: Vezi serialul Codare Vibe pentru a înțelege riscurile codului generat de AI și cum să le testați sistematic







