05 - Zabezpečení API: OAuth 2.1, JWT a omezení rychlosti
API jsou páteří moderních aplikací. Každá mikroslužba, každá mobilní aplikace, každá integrace SaaS prochází koncovými body HTTP, které odhalují důležitá data a funkce. Podle zprávy Salt Security Stav zabezpečení API 2024, počet útoků API vzrostl o 167 % za posledních 12 měsíců, přičemž 94 % organizací zažívá alespoň jeden Bezpečnostní incident související s API během roku. Ty nemají daleko ke statistikám každodenní realita: naprostá většina těchto zranitelností se týká vzorů, které vývojáři replikují se každý den.
Problém je strukturální. Rozhraní API jsou navržena s ohledem na funkčnost a zabezpečení přidán později jako povrchová vrstva. Přidáte token JWT a zvážíte hotovou práci. Ale OWASP API Security Top 10:2023 ukazuje, že nejkritičtější vektory nejsou technické: jsou logické. Oprávnění na úrovni poškozeného objektu, Oprávnění na úrovni poškozené funkce, Neomezené Spotřeba zdrojů. Zranitelnosti, které žádný framework neřeší automaticky, protože vyžadují promyšlená architektonická rozhodnutí.
Tento článek vás provede celým útočným povrchem moderních API: od OWASP API Nejlepší omezení rychlosti 10:2023 se segmentem tokenů a algoritmy posuvného okna, od OAuth 2.1 s rozsahy granulární až po ověření vstupu, od správné konfigurace CORS po vzory brány API. Praktický kód Node.js/Express pro každou sekci s konečným kontrolním seznamem specifickým pro Angular.
Co se naučíte
- OWASP API Security Top 10:2023: 10 nejkritičtějších zranitelností s praktickými příklady
- Omezení sazby: Implementace bloku tokenů a posuvného okna s Redis v Node.js
- OAuth 2.1 s podrobnými rozsahy: rozdíly oproti OAuth 2.0 a povinnému PKCE
- Osvědčené postupy JWT: bezpečné algoritmy, rotace tokenů, odvolání a blacklisting
- Klíče API vs tokeny nosiče: kdy použít který přístup a jak je bezpečně spravovat
- Ověření vstupu a dezinfekce pomocí Zod pro TypeScript
- Zabezpečená konfigurace CORS: seznam povolených zdrojů a správa pověření
- Vzory API brány: centralizované ověřování, jistič, zabezpečené protokolování
- Monitorování a upozornění: detekce vzorců útoků v reálném čase
- Úhlový kontrolní seznam pro bezpečná volání API z frontendu
OWASP API Nejlepší zabezpečení 10: 2023
OWASP API Security Top 10:2023 a referenční seznam pro pochopení nejkritičtějších rizik v moderních API. Oproti verzi 2019 zavádí tři nové kategorie a mění pořadí priorit na základě skutečných údajů o nehodách. Nejde o teoretický seznam: každý záznam odpovídá zranitelnostem zdokumentovány, které v posledních letech způsobily skutečná porušení.
API1:2023 – Oprávnění na úrovni poškozeného objektu (BOLA)
BOLA je jedničkou ve třech po sobě jdoucích vydáních a představuje přibližně 40 % všech útoků
Dokumentovaná rozhraní API. Rozhraní API vrací prostředky identifikované ID bez ověření uživatele
autentizovaný má práva k tomuto konkrétnímu zdroji. A API verze klasického IDORu
(Nezabezpečený přímý objektový odkaz): Klient se ptá /api/invoices/1234 a server
odpoví, aniž by ověřil, že faktura 1234 patří uživateli, který ji požaduje.
// 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 – Nefunkční ověření
Poškozená autentizace jde nad rámec pouhého nedostatku přihlášení. Zahrnuje token JWT s algoritmem
none, slabé podpisové klíče, chybějící ověření publika (aud) e
emitenta (iss), tokeny, které nikdy nevyprší, a nedostatek ochrany hrubou silou
na přihlašovacích koncových bodech. Útočník, který najde JWT podepsaného s HS256 a jeden
slabý klíč jako secret jakákoli role může být změněna.
API3:2023 – Oprávnění na úrovni nefunkčního objektu (BOPLA)
Nově v roce 2023, BOPLA kombinuje dvě předchozí zranitelnosti: Nadměrné vystavení údajům
(API vrací více polí, než je nutné, včetně citlivých dat) e Hromadné zadání
(API přijímá pole, která by neměla, což umožňuje uživateli upravovat isAdmin
o role). Bezpečný vzor a explicitní projekce polí na vstupu i výstupu.
// 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 – Neomezená spotřeba zdrojů
Náhrada za „Nedostatek zdrojů a omezení rychlosti“, tato kategorie pokrývá všechny scénáře kde API neomezuje spotřebu zdrojů: požadavky bez stránkování, které vracejí miliony záznamů, nahrávání souborů bez omezení velikosti, dotazy GraphQL s neomezenou hloubkou, webhooky bez časových limitů a operací náročných na CPU bez omezení. Samotné omezení sazeb nestačí: potřebujete limity na každé úrovni potrubí.
API5 – API10: Další kritické chyby zabezpečení
- API5 – Oprávnění na úrovni poškozené funkce: Administrativní koncové body přístupné běžným uživatelům (často skryté v dokumentaci, ale nechráněné kódem)
- API6 – Neomezený přístup k citlivým obchodním tokům: Zneužívání legitimních toků, jako je hromadné vytváření účtů, automatizované nákupy nebo odesílání hromadných jednorázových hesel
- API7 – Server Side Request Forgery (SSRF): Rozhraní API přijímá adresy URL poskytnuté klientem a požaduje je na straně serveru, což vám umožňuje získat přístup k interním službám
- API8 – Chybná konfigurace zabezpečení: Chybějící záhlaví, zástupný znak CORS, režim ladění v produkci, zastaralé verze API odhalené bez ověření
- API9 – Nesprávná správa zásob: Zastaralé verze rozhraní API nebyly odstraněny, testování koncových bodů v produkci, stínová rozhraní API nejsou zdokumentována
- API10 – Nebezpečná spotřeba API: Slepá důvěra v API třetích stran bez ověřování výstupu a zpracování chyb
Omezení rychlosti: Algoritmy a implementace
Omezení rychlosti je mechanismus, který zabraňuje zneužití API omezením počtu požadavků které klient může udělat za určitou dobu. Není to jen opatření proti DDoS: chrání od vycpávání pověření, škrábání, výčtu zdrojů a zneužívání obchodních toků. Výběr algoritmu ovlivňuje uživatelskou zkušenost a účinnou ochranu.
Algoritmus tokenového bloku
Token bucket je nejběžnějším algoritmem pro omezení rychlosti. Každý klient má „kýbl“ s maximální kapacita N tokenů. Žetony jsou přidávány konstantní rychlostí R tokenů za sekundu. Každý požadavek spotřebuje token. Pokud je kontejner prázdný, požadavek je odmítnut pomocí HTTP 429. Výhodou je, že to umožňuje dopravní výpadek do kapacity lopaty, pak se stabilizuje na R požadavků za sekundu. Ideální pro veřejná rozhraní API s přirozeným vlnovým provozem.
// 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
});
Algoritmus posuvného okna
Opravené okno má známý problém: útočník může soustředit N požadavků na konci jednoho okna a N požadavků na začátku dalšího, což má za následek 2N požadavků v krátkém čase času bez porušení technického limitu. Posuvné okno to řeší udržováním počtu přesné pro každé časové okno, které "plyne" s časem, pomocí Redis Sorted Set, kde každý prvek má jako své skóre časové razítko požadavku.
// 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
)
);
Token Bucket vs Sliding Window: Průvodce výběrem
| Kritérium | Kbelíky tokenů | Posuvné okno |
|---|---|---|
| Výbuch dopravy | Umožňuje výbuchy až do kapacity kbelíku | Použijte jednotný limit, žádné prasknutí |
| Omezte přesnost | Přibližné (závisí na rychlosti doplňování) | Přesné na milisekundu |
| Skladování Redis | 2 hodnoty na klíč (odlehčený hash) | N prvků na klíč (seřazená sada, úměrná provozu) |
| Špičkové chování | Absorbuje přirozené vrcholy bez chyb | Těžký: za limitem vrátí 429 |
| Ideální případ použití | Veřejná API, CDN, variabilní uživatelský provoz | Kritické koncové body: ověření, platby, OTP |
OAuth 2.1 a JWT: Modern API Authentication
OAuth 2.1 (koncept RFC) konsoliduje osvědčené bezpečnostní postupy OAuth 2.0. Klíčové rozdíly ve srovnání s OAuth 2.0 jsou: PKCE povinné pro všechny klienty (nejen veřejné klienti), implicitní tok odstraněn, přihlašovací údaje pro heslo vlastníka zdroje průtok odstraněn, A přesná shoda URI přesměrování povinné (nic shoda vzoru se zástupným znakem). Pokud vaše implementace OAuth 2.0 již následovala zabezpečení OWASP Cheat Sheet, přechod na OAuth 2.1 vyžaduje minimální změny.
Granular Scopes: Minimální oprávnění pro API
Rozsahy OAuth definují, co token může dělat. Rozsahy jsou příliš široké read:all
o admin porušují zásadu nejmenšího privilegia. Ukradený token s rozsahem
invoices:read:own způsobí mnohem omezenější škody než jeden s
read:all. Granularita rozsahů musí odrážet skutečné operace rozhraní 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 fatálních chyb
JWT je široce používán, ale stejně široce špatně implementován. Těchto šest nesprávných vzorů proměňují robustní autentizační mechanismus v kritickou zranitelnost. Každý bod odpovídá zdokumentovanému CVE nebo skutečnému vzoru útoku.
// 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
Klíče API: Zabezpečená správa pro integraci mezi servery
Klíče API jsou správnou volbou pro komunikaci mezi stroji, kde je klient ovládané vlastníkem účtu (automatizační skripty, backendy třetích stran, integrace CI/CD). Jsou jednodušší než OAuth, ale vyžadují pečlivou správu: klíč API mělo by se s ním zacházet jako s heslem, nikdy by se nemělo zobrazovat v protokolech nebo zdrojovém kódu.
// 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.',
});
});
Ověření vstupu pomocí Zod
Ověření vstupu je první linií obrany proti vstřikování, hromadnému přiřazení a neočekávané chování. Pro TypeScript API, Zod a referenční nástroj v roce 2025: definovat schéma jednou a získat ověření za běhu plus odvozené typy TypeScript automaticky, bez duplikace mezi rozhraními TypeScript a runtime validátory. Každý koncový bod musí samostatně ověřit tělo, parametry a řetězec dotazu.
// 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);
}
);
Bezpečná konfigurace CORS
CORS je často první konfigurací, které se vývojář dotkne při volání API z frontendu
selhávají ve vývoji. Nejčastější a nesprávná odpověď: Access-Control-Allow-Origin: *.
Tím zakážete ochranu stejného původu pro celé vaše API. Ještě horší,
Access-Control-Allow-Origin: * con credentials: true vůbec to nejde
pro moderní prohlížeče (kvůli bezpečnosti) a měl by vyvolat okamžité upozornění při kontrole kódu.
// 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' },
}));
Vzory brány API pro zabezpečení
API brána centralizuje průřezové problémy, jako je autentizace, omezení rychlosti, protokolování a zpracování žádostí. V architektuře mikroslužeb se vyhněte replikaci logiky bezpečnost v každé službě. Složení middlewaru Express se musí řídit konkrétním pořadím: bezpečnostní hlavičky před každým zpracováním, pak omezení rychlosti, pak autentizace, pak validace, konečně trasy.
// 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');
});
Monitorování: Detekce útoků v reálném čase
Efektivní systém monitorování API se zaměřuje na konkrétní metriky zabezpečení: chybovost 401/403 na IP a na koncový bod, vzory výčtu na ID zdrojů, anomální požadavky na velikost užitečného zatížení a přístupy k nezdokumentovaným nebo zastaralým koncovým bodům. Obecné aplikační metriky nestačí: jsou potřeba počítadla zaměřená na bezpečnost.
// 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());
});
Úhlový kontrolní seznam pro zabezpečení API
Frontend Angular má specifické povinnosti v zabezpečení API. Volba kam ukládání tokenů má přímé důsledky na plochu útoku XSS. Zachycovač HTTP centralizuje správu zabezpečení tím, že se vyhne distribuci kritické logiky mezi desítky služeb.
// 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]))
Kontrolní seznam zabezpečení API – Angular + Node.js
| Plocha | Kontrola | Přednost |
|---|---|---|
| Autorizace objektu | Kontrola BOLA: každý dotaz před vrácením dat filtruje podle vlastníka | KRITIKA |
| JWT | Algoritmus RS256, expirace 15 minut, nároky iss/aud/exp ověřeny | KRITIKA |
| Úložiště tokenů | Přístupový token v paměti Angular, obnovovací token v souboru cookie HttpOnly SameSite=Strict | KRITIKA |
| Omezení sazby | Token bucket pro generické API, posuvné okno pro ověření/platby | VYSOKÝ |
| Ověření vstupu | Zod na tělo/paramy/dotaz, omezení délky, výčet výchozích hodnot | VYSOKÝ |
| CORS | Seznam povolených explicitních původů, žádné zástupné znaky s přihlašovacími údaji | VYSOKÝ |
| Rozsahy OAuth | Granulární podle zdroje:operace:kontext, zaškrtnuté u každého koncového bodu | VYSOKÝ |
| Zpracování chyb | Žádné trasování zásobníku v produkci, ID korelace pro podporu | PRŮMĚRNÝ |
| Sledování | Čítače 401/403/429, detekce výčtu, upozornění na anomálie | PRŮMĚRNÝ |
| Klíče API | Pouze hash v DB, nikdy v čistém; srovnání bezpečné načasování; prefix pro identifikaci | PRŮMĚRNÝ |
Anti-vzorce, kterým se absolutně vyhnout
- JWT v localStorage: Přístupné z libovolného skriptu JS na stránce. Pro přístupový token použijte in-memory a pro obnovovací token soubor cookie HttpOnly
- CORS zástupný znak s přihlašovacími údaji:
Access-Control-Allow-Origin: *concredentials: trueje designem odmítnut moderními prohlížeči; označuje nesprávnou konfiguraci - Omezení rychlosti pouze pro IP: Botnet distribuuje útoky na tisíce IP adres. Kombinujte omezení rychlosti na IP s omezením rychlosti na ID uživatele
- Rozsahy jsou příliš široké:
adminoread:allporušují sebemenší výsadu. Ukradený token s rozsaheminvoices:read:ownzpůsobí podstatně menší škody - Trasa zásobníku vystavena ve výrobě: Odhalit vnitřní cesty, verze knihoven, strukturu DB. Vždy používejte obslužný program chyb, který zakrývá detaily ve výrobě
- Nespecifikovaný algoritmus JWT: Vždy specifikujte
algorithms: ['RS256']. Některé knihovny JWT s výchozí konfigurací přijímají tento algoritmusnone - BOLA neověřeno: 40 % útoků API využívá BOLA. Každý koncový bod, který přijímá ID, musí ověřit, že uživatel je vlastníkem prostředku
Závěry
Zabezpečení API není vyřešeno jediným nástrojem nebo konfigurací. Je to praxe pokračuje, která vyžaduje pozornost na každé úrovni: od návrhu koncového bodu (zkontrolujte BOLA systematicky, granulární rozsahy), až po implementaci omezení rychlosti (výběr algoritmu). právo pro každý případ použití), ke správné správě OAuth 2.1 a JWT (RS256, krátké termíny, obnovovací rotace tokenů), až po monitorování, které odhalí vzory útoků dříve, než způsobí poškození.
Data z let 2024–2025 ukazují, že většina incidentů API nezahrnuje zranitelnosti exotické: týká se základních chyb oprávnění (neověřuje se, že zdroj patří uživateli, který o to požádá), tolerantní konfigurace CORS, tokeny JWT bez expirace a absence omezení rychlosti na koncových bodech autentizace. To jsou problémy, které lze vyřešit známými vzory aplikovaný s disciplínou.
Pokud používáte kódovací nástroje s podporou AI, pamatujte si to 45 % kódu generovaného umělou inteligencí neprojde bezpečnostními testy: Generovaný kód často implementuje autentizaci, ale vynechává ji kontrola vlastnictví zdrojů (BOLA), používá povrchové omezení rychlosti založené pouze na IP, např přijímá libovolná pole jako vstup bez ověření (hromadné přiřazení). Použijte tento kontrolní seznam článek jako bod systematického ověřování.
Další kroky v řadě Web Security Series
- Předchozí článek: Bezpečná autentizace: relace, soubory cookie a moderní identita - Správa relací, zabezpečené soubory cookie, OAuth 2.1 PKCE a WebAuthn
- Další článek: Zabezpečení dodavatelského řetězce: audit npm a SBOM - npm audit, Dependabot, generování SBOM a bezpečná správa závislostí
- základy: OWASP Top 10 2025 - Kompletní přehled nejkritičtějších webových zranitelností včetně A03 Supply Chain a A10 Error Handling
- Související DevOps: Podívejte se na seriál Frontend DevOps pro integraci zabezpečení API do vašeho kanálu CI/CD
- AI a bezpečnost: Podívejte se na seriál Vibe kódování pochopit rizika kódu generovaného umělou inteligencí a jak je systematicky testovat







