Salta al contenuto principale

05 - API Security: OAuth 2.1, JWT e Rate Limiting

BOLA, mass assignment, JWT, rate limiting e CORS dimostrati su un'API di prova eseguita in-process, ognuno con la correzione e la prova che regge.

Cosa ne pensi di questo articolo?

05 - API Security: OAuth 2.1, JWT e Rate Limiting
Sicurezza WebFoto:Lisa from PexelsPexels

In Breve

BOLA, mass assignment, JWT, rate limiting e CORS dimostrati su un'API di prova eseguita in-process, ognuno con la correzione e la prova che regge.

Un token valido non dice di chi è la fattura

Il difetto più frequente nelle API non è un token falsificato: è un token autentico usato per leggere ciò che non è tuo. L'utente Anna è autenticata, chiede /invoices/2, e il server le restituisce la fattura di Bruno perché controlla chi sei ma non di chi è la risorsa. L'OWASP lo mette al primo posto da quando esiste la lista dedicata alle API: API1:2023, Broken Object Level Authorization, in termini di classificazione CWE-639, «Authorization Bypass Through User-Controlled Key».

In questo articolo ogni vulnerabilità viene dimostrata contro una piccola applicazione Express scritta qui apposta, e poi corretta con la prova che la correzione regge. Nessun server viene avviato e nessuna porta si apre: le richieste passano dal client di test light-my-request, che chiama direttamente la funzione dell'applicazione. I listati sono stati eseguiti con Node v24.11.0, Express 5.3.0, jose 6.2.17, zod 4.6.7, cors 2.8.6 e light-my-request 6.8.0 (versioni lette sul registro npm il 2026-11-13), e l'uscita riportata sotto ogni blocco è quella di due esecuzioni identiche. Le prove stanno nell'ordine in cui si incontrano in una revisione: autorizzazione sulla risorsa, sulle proprietà, validità del token, poi limiti di frequenza e CORS.

BOLA: il filtro per proprietario sta nella query

Il primo blocco contiene due versioni dello stesso endpoint. La /v1 cerca la fattura per id e basta; la /v2 mette ownerId === req.user.id dentro la condizione di ricerca. Il middleware authenticate è un'ipotesi di lavoro: legge l'identità da un header di prova, come se un controllo a monte l'avesse già verificata. Il punto della prova è che anche con identità certa la /v1 è vulnerabile.

import express from 'express';
import inject from 'light-my-request';

const invoices = [
  { id: 1, ownerId: 'anna', total: 120, iban: 'IT00-ANNA' },
  { id: 2, ownerId: 'bruno', total: 980, iban: 'IT00-BRUNO' },
];

// Autenticazione di prova: l'identità arriva da un header, come se un middleware l'avesse già verificata.
const authenticate = (req, res, next) => {
  req.user = { id: req.get('x-test-user') };
  req.user.id ? next() : res.status(401).json({ error: 'unauthenticated' });
};

const app = express();

// VULNERABILE: controlla chi sei, non di chi è la fattura
app.get('/v1/invoices/:id', authenticate, (req, res) => {
  const inv = invoices.find((i) => i.id === Number(req.params.id));
  return inv ? res.json(inv) : res.status(404).json({ error: 'not found' });
});

// CORRETTA: il proprietario fa parte del filtro; l'estraneo riceve lo stesso 404 di chi cerca un id inesistente
app.get('/v2/invoices/:id', authenticate, (req, res) => {
  const inv = invoices.find(
    (i) => i.id === Number(req.params.id) && i.ownerId === req.user.id,
  );
  return inv ? res.json(inv) : res.status(404).json({ error: 'not found' });
});

const call = async (url, user) => {
  const r = await inject(app, { method: 'GET', url, headers: { 'x-test-user': user } });
  console.log(`${user} GET ${url} -> ${r.statusCode} ${r.body}`);
};

await call('/v1/invoices/2', 'anna');   // anna legge la fattura di bruno
await call('/v2/invoices/2', 'anna');   // stessa richiesta, versione corretta
await call('/v2/invoices/99', 'anna');  // id inesistente: risposta identica
await call('/v2/invoices/2', 'bruno');  // il proprietario legge la propria
// Output:
// anna GET /v1/invoices/2 -> 200 {"id":2,"ownerId":"bruno","total":980,"iban":"IT00-BRUNO"}
// anna GET /v2/invoices/2 -> 404 {"error":"not found"}
// anna GET /v2/invoices/99 -> 404 {"error":"not found"}
// bruno GET /v2/invoices/2 -> 200 {"id":2,"ownerId":"bruno","total":980,"iban":"IT00-BRUNO"}

La prima riga dell'uscita è il difetto: Anna riceve iban e importo di Bruno con un 200. La seconda è la stessa richiesta sulla versione corretta e dà 404. La terza mostra la scelta di progetto che conta: l'id inesistente (99) e l'id di un altro utente (2) producono la stessa identica risposta. Con un 403 per «esiste ma non è tuo» Anna potrebbe enumerare gli id validi e dedurre quante fatture ha il sistema; il 404 uniforme non regala quell'informazione. Il costo è che il debug diventa meno parlante: nei log conviene distinguere i due casi, ma nella risposta no. Dove ci si fa male davvero è il confronto messo dopo la lettura (leggo, poi controllo): basta una dimenticanza in un solo handler su cento e il difetto è tornato, per questo il filtro per proprietario va incorporato nel livello di accesso ai dati e non ricordato a mano in ogni route.

BOPLA: chi scrive e chi legge decide quali campi esistono

API3:2023 è nata unendo due voci del 2019: l'esposizione eccessiva di dati e il mass assignment. Lo dice l'OWASP stessa, che nella descrizione scrive «previously named» per entrambe. Il codice sotto ha i due difetti nello stesso endpoint: Object.assign(db, req.body) copia nel record qualsiasi chiave arrivi dal client (CWE-915), e la risposta restituisce l'oggetto intero, passwordHash compreso. La versione corretta usa z.strictObject di zod, che rifiuta le chiavi non dichiarate invece di ignorarle in silenzio, e una funzione publicView che elenca a mano i campi in uscita.

import express from 'express';
import inject from 'light-my-request';
import { z } from 'zod';

const db = { id: 7, name: 'Anna', email: 'anna@example.test', role: 'user', passwordHash: 'x$9f2c...' };
const app = express();
app.use(express.json({ limit: '10kb' }));

// VULNERABILI: copiano tutto ciò che arriva e restituiscono tutto ciò che hanno
app.patch('/v1/me', (req, res) => { Object.assign(db, req.body); res.json(db); });

// CORRETTI: schema in ingresso con chiavi ammesse, proiezione esplicita in uscita
const UpdateMe = z.strictObject({
  name: z.string().min(2).max(100).optional(),
  email: z.email().optional(),
});
const publicView = ({ id, name, email }) => ({ id, name, email });

app.patch('/v2/me', (req, res) => {
  const parsed = UpdateMe.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: 'validation', issues: parsed.error.issues.map((i) => i.code + ':' + i.keys?.join(',')) });
  }
  Object.assign(db, parsed.data);
  res.json(publicView(db));
});

const patch = async (url, body) => {
  const r = await inject(app, {
    method: 'PATCH', url, headers: { 'content-type': 'application/json' }, payload: JSON.stringify(body),
  });
  console.log(`PATCH ${url} ${JSON.stringify(body)}\n  -> ${r.statusCode} ${r.body}`);
};

await patch('/v1/me', { name: 'Anna R.', role: 'admin' });   // mass assignment riuscito, e la risposta mostra anche passwordHash
db.role = 'user';
await patch('/v2/me', { name: 'Anna R.', role: 'admin' });   // chiave non ammessa: rifiutata
await patch('/v2/me', { name: 'Anna R.' });                  // campo ammesso: accettato
console.log('role in archivio:', db.role);
// Output:
// PATCH /v1/me {"name":"Anna R.","role":"admin"}
//   -> 200 {"id":7,"name":"Anna R.","email":"anna@example.test","role":"admin","passwordHash":"x$9f2c..."}
// PATCH /v2/me {"name":"Anna R.","role":"admin"}
//   -> 400 {"error":"validation","issues":["unrecognized_keys:role"]}
// PATCH /v2/me {"name":"Anna R."}
//   -> 200 {"id":7,"name":"Anna R.","email":"anna@example.test"}
// role in archivio: user

Sulla /v1 la richiesta {"name":"Anna R.","role":"admin"} promuove Anna ad amministratrice e la risposta stampa anche l'hash della password: due danni in una sola chiamata. Sulla /v2 la stessa richiesta riceve un 400 che nomina la chiave sospetta, e l'ultima riga conferma che il ruolo in archivio è rimasto user. Il dettaglio da non trascurare è strictObject: con il semplice z.object le chiavi sconosciute vengono scartate e la richiesta passa con 200. Dal punto di vista della protezione va bene (nella prova con zod 4.6.7 il campo role non arriva ai dati validati), ma nasconde i tentativi, che in un sistema reale è utile registrare e contare. Il comportamento predefinito, cioè scartare, non è quello che ti avverte: se vuoi un 400 devi chiederlo con strictObject.

JWT: l'algoritmo lo sceglie il server, mai il token

Un JWT firmato dichiara nell'header con quale algoritmo è stato firmato. Un verificatore ingenuo si fida di quell'header, e da lì nascono due attacchi classici. Il primo è alg: "none", token senza firma. Il secondo è la confusione di algoritmo: il server verifica con una chiave pubblica RSA, l'attaccante costruisce un token HS256 e usa come segreto HMAC proprio quella chiave pubblica, che per definizione conosce. La RFC 8725, le Best Current Practices sui JWT, chiede al §3.1 che le librerie permettano al chiamante di fissare l'insieme degli algoritmi e non ne usino altri. La falla reale più citata di questa famiglia è CVE-2015-9235: secondo la scheda NVD riguarda il modulo jsonwebtoken prima della 4.2.2, dove un token firmato con un algoritmo simmetrico veniva accettato al posto di uno asimmetrico. È dunque una confusione di algoritmo, non il caso none, a cui la scheda NVD non si riferisce.

Il bersaglio di prova ha due verificatori. naiveVerify implementa a mano la logica ingenua, con tre rami che dipendono dall'header. Il secondo è jwtVerify di jose con tre vincoli espliciti: algorithms: ['RS256'], issuer e audience. I token malevoli sono costruiti nel listato stesso, con crittografia locale, e non toccano nessun servizio.

import crypto from 'node:crypto';
import { SignJWT, jwtVerify, generateKeyPair, exportSPKI } from 'jose';

const b64 = (o) => Buffer.from(typeof o === 'string' ? o : JSON.stringify(o)).toString('base64url');

// Verificatore ingenuo: legge l'algoritmo dall'header del token e usa "la chiave" che ha in casa.
function naiveVerify(token, key) {
  const [h, p, s] = token.split('.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg === 'none') return JSON.parse(Buffer.from(p, 'base64url'));
  if (header.alg === 'HS256') {
    const mac = crypto.createHmac('sha256', key).update(`${h}.${p}`).digest('base64url');
    if (mac === s) return JSON.parse(Buffer.from(p, 'base64url'));
  }
  if (header.alg === 'RS256') {
    const ok = crypto.verify('sha256', Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url'));
    if (ok) return JSON.parse(Buffer.from(p, 'base64url'));
  }
  throw new Error('firma non valida');
}

const { publicKey, privateKey } = await generateKeyPair('RS256');
const publicPem = await exportSPKI(publicKey);
const ISS = 'https://auth.example.test', AUD = 'https://api.example.test';

// Token legittimo, emesso dal server di autorizzazione con la chiave privata
const good = await new SignJWT({ scope: 'invoices:read:own' })
  .setProtectedHeader({ alg: 'RS256' }).setSubject('anna').setIssuer(ISS).setAudience(AUD)
  .setExpirationTime('15m').sign(privateKey);

// Attacco 1: alg "none", firma vuota
const none = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: 'anna', scope: 'admin' })}.`;
// Attacco 2: confusione di algoritmo. Il PEM pubblico, che è pubblico, usato come segreto HMAC
const h2 = b64({ alg: 'HS256', typ: 'JWT' }), p2 = b64({ sub: 'anna', scope: 'admin' });
const confused = `${h2}.${p2}.` + crypto.createHmac('sha256', publicPem).update(`${h2}.${p2}`).digest('base64url');

const tryNaive = (label, t) => {
  try { const c = naiveVerify(t, publicPem); console.log('ingenuo ', label, `-> ACCETTATO sub=${c.sub} scope=${c.scope}`); }
  catch { console.log('ingenuo ', label, '-> rifiutato'); }
};
tryNaive('legittimo ', good);
tryNaive('alg none  ', none);
tryNaive('confusione', confused);

// Verifica corretta: algoritmo deciso dal server, issuer e audience controllati
const strict = (t) => jwtVerify(t, publicKey, { algorithms: ['RS256'], issuer: ISS, audience: AUD });
for (const [label, t] of [['legittimo', good], ['alg none', none], ['confusione', confused]]) {
  try { const { payload } = await strict(t); console.log('jose     ', label.padEnd(10), '-> accettato, sub =', payload.sub); }
  catch (e) { console.log('jose     ', label.padEnd(10), '-> rifiutato:', e.code); }
}

// Token valido ma emesso per un altro servizio (audience diversa)
const other = await new SignJWT({}).setProtectedHeader({ alg: 'RS256' }).setSubject('anna')
  .setIssuer(ISS).setAudience('https://altro.example.test').setExpirationTime('15m').sign(privateKey);
try { await strict(other); } catch (e) { console.log('jose      altra audience -> rifiutato:', e.code); }
// Output:
// ingenuo  legittimo  -> ACCETTATO sub=anna scope=invoices:read:own
// ingenuo  alg none   -> ACCETTATO sub=anna scope=admin
// ingenuo  confusione -> ACCETTATO sub=anna scope=admin
// jose      legittimo  -> accettato, sub = anna
// jose      alg none   -> rifiutato: ERR_JOSE_ALG_NOT_ALLOWED
// jose      confusione -> rifiutato: ERR_JOSE_ALG_NOT_ALLOWED
// jose      altra audience -> rifiutato: ERR_JWT_CLAIM_VALIDATION_FAILED

Le prime tre righe sono il quadro che nessun test funzionale mostra: il verificatore ingenuo accetta il token legittimo e anche i due falsi, con lo scope admin. jose rifiuta entrambi con ERR_JOSE_ALG_NOT_ALLOWED perché l'algoritmo dell'header non è nell'elenco; l'ultima riga mostra un token vero, firmato dalla chiave giusta, rifiutato perché emesso per un'altra audience. Questo è il caso che la RFC 8725 §3.9 prevede: se lo stesso emittente serve più applicazioni, un token destinato a una può essere riusato presso un'altra, e solo il controllo di aud lo ferma. Analogamente il §3.8 impone di verificare che le chiavi appartengano all'emittente indicato in iss. Provando jwtVerify senza l'opzione algorithms (verifica fatta a parte), jose rifiuta comunque il token none e il token HS256 presentato con una chiave RSA, perché il tipo di chiave non corrisponde all'algoritmo; l'elenco esplicito resta utile perché rende il contratto leggibile e impedisce di accettare algoritmi che non hai scelto.

Resta la chiave simmetrica debole. Il blocco seguente firma un token HS256 con la chiave mysecretkey123 e la ritrova da un dizionario di sei parole, ricalcolando l'HMAC offline. Nessuna richiesta parte verso un server, ed è per questo che i limiti di frequenza non servono a niente contro questo attacco.

import crypto from 'node:crypto';
import { SignJWT } from 'jose';

// Il "bersaglio" è un token HS256 firmato da noi con una chiave di dizionario
const token = await new SignJWT({ sub: 'anna', scope: 'invoices:read:own' })
  .setProtectedHeader({ alg: 'HS256' }).sign(new TextEncoder().encode('mysecretkey123'));

const [h, p, s] = token.split('.');
const wordlist = ['secret', 'password', 'changeme', 'jwtsecret', 'mysecretkey123', 'admin'];

// Attacco offline: nessuna richiesta al server, si ricalcola l'HMAC finché coincide con la firma
const found = wordlist.find(
  (w) => crypto.createHmac('sha256', w).update(`${h}.${p}`).digest('base64url') === s,
);
console.log('chiave trovata nel dizionario:', found);

// Contromisura: 32 byte casuali (256 bit), mai scritti nel codice
const strong = crypto.randomBytes(32);
const strongToken = await new SignJWT({ sub: 'anna' }).setProtectedHeader({ alg: 'HS256' }).sign(strong);
const [h2, p2, s2] = strongToken.split('.');
const hit = wordlist.find((w) => crypto.createHmac('sha256', w).update(`${h2}.${p2}`).digest('base64url') === s2);
console.log('chiave casuale trovata nel dizionario:', hit ?? 'no');
// Output:
// chiave trovata nel dizionario: mysecretkey123
// chiave casuale trovata nel dizionario: no

Con una chiave da dizionario l'attacco finisce al quinto tentativo; con 32 byte casuali il dizionario non trova nulla, ed è esattamente la condizione che la RFC 8725 §3.5 pone: una parola memorizzabile non deve mai essere usata direttamente come chiave di un MAC come HS256. Nella pratica la chiave va generata, non scritta, e tenuta fuori dal repository (la gestione dei segreti è trattata in Secret management e rotazione delle credenziali). Per sistemi con più verificatori conviene l'asimmetrico, così chi verifica non può firmare.

  • Algoritmo: un elenco fissato lato server, mai quello dell'header
  • Chiave: 256 bit casuali per HMAC, oppure una coppia RSA o EC
  • iss e aud: entrambi verificati, altrimenti un token di un altro servizio passa
  • exp: durata breve per l'access token, perché un JWT firmato non si revoca da solo

L'elenco sintetizza quattro controlli che si possono dimenticare in modo indipendente, e l'ultimo è il più costoso da rimediare a posteriori: un token autonomo resta valido fino a exp anche se l'utente viene disattivato. Per questo la durata va tenuta nell'ordine dei minuti e la revoca affidata al refresh token, che sta sul server e si può cancellare.

OAuth 2.1 e gli scope: quello che l'access token autorizza

OAuth 2.1 non è ancora uno standard: l'IETF Datatracker lo elenca come Internet-Draft, draft-ietf-oauth-v2-1, revisione 17 del 31 ottobre 2026, con scadenza il 4 maggio 2027, e dichiara che se approvato sostituirà la RFC 6749 e la RFC 6750. L'elenco delle differenze rispetto a OAuth 2.0 è nel §10 del draft, «Differences from OAuth 2.0», e conviene leggerlo dalla fonte perché consolida decisioni già prese nella RFC 9700, la Best Current Practice sulla sicurezza di OAuth 2.0.

  • Il flusso authorization code richiede i parametri PKCE (RFC 7636)
  • I redirect URI si confrontano con corrispondenza esatta di stringa
  • L'Implicit grant e il Resource Owner Password Credentials grant sono omessi
  • L'access token non si invia nella query string dell'URI
  • I refresh token dei client pubblici sono vincolati al mittente oppure monouso

Queste cinque voci non hanno un ordine di importanza, ma hanno un denominatore comune: ciascuna chiude un punto in cui un segreto di breve durata poteva finire in un posto dove qualcuno lo leggeva (cronologia del browser, log del server, un redirect verso un dominio simile). Se il tuo codice usa ancora il password grant o accetta redirect con caratteri jolly, il passaggio a 2.1 non è «modifiche minime»: è la parte da rifare per prima.

Resta da applicare il token alla risorsa. Il blocco seguente implementa requireScope. La RFC 6750 §3.1 dice che per insufficient_scope il server SHOULD rispondere 403 e MAY indicare lo scope necessario, ed è ciò che fa l'header WWW-Authenticate. Nello stesso file c'è anche la prova CORS, trattata più sotto.

import express from 'express';
import cors from 'cors';
import inject from 'light-my-request';

const app = express();
const authenticate = (req, _res, next) => {
  req.user = { id: 'anna', scope: (req.get('x-test-scope') ?? '').split(' ').filter(Boolean) };
  next();
};

// Scope richiesti: RFC 6750 §3.1 prescrive 403 + WWW-Authenticate con error="insufficient_scope"
const requireScope = (...needed) => (req, res, next) => {
  if (needed.every((s) => req.user.scope.includes(s))) return next();
  res.set('WWW-Authenticate', `Bearer error="insufficient_scope", scope="${needed.join(' ')}"`);
  res.status(403).json({ error: 'insufficient_scope' });
};
app.get('/own', authenticate, requireScope('invoices:read:own'), (_req, res) => res.json({ ok: 'own' }));
app.get('/all', authenticate, requireScope('invoices:read:all'), (_req, res) => res.json({ ok: 'all' }));

for (const url of ['/own', '/all']) {
  const r = await inject(app, { url, headers: { 'x-test-scope': 'invoices:read:own' } });
  console.log(`token con invoices:read:own  GET ${url} -> ${r.statusCode} ${r.headers['www-authenticate'] ?? ''}`);
}

// CORS. VULNERABILE: riflette qualunque Origin e permette le credenziali
const reflect = express();
reflect.use(cors({ origin: true, credentials: true }));
reflect.get('/me', (_req, res) => res.json({ secret: 'dati di anna' }));

// CORRETTA: allowlist esatta
const allowlist = new Set(['https://app.example.test']);
const strict = express();
strict.use(cors({ origin: (o, cb) => cb(null, !o || allowlist.has(o)), credentials: true }));
strict.get('/me', (_req, res) => res.json({ secret: 'dati di anna' }));

for (const [name, a] of [['riflettente', reflect], ['allowlist  ', strict]]) {
  for (const origin of ['https://app.example.test', 'https://evil.example.test']) {
    const r = await inject(a, { url: '/me', headers: { origin } });
    console.log(`${name} Origin=${origin.padEnd(26)} ACAO=${r.headers['access-control-allow-origin'] ?? '(assente)'} ACAC=${r.headers['access-control-allow-credentials'] ?? '(assente)'}`);
  }
}
// Output:
// token con invoices:read:own  GET /own -> 200 
// token con invoices:read:own  GET /all -> 403 Bearer error="insufficient_scope", scope="invoices:read:all"
// riflettente Origin=https://app.example.test   ACAO=https://app.example.test ACAC=true
// riflettente Origin=https://evil.example.test  ACAO=https://evil.example.test ACAC=true
// allowlist   Origin=https://app.example.test   ACAO=https://app.example.test ACAC=true
// allowlist   Origin=https://evil.example.test  ACAO=(assente) ACAC=(assente)

Le prime due righe riguardano gli scope: lo stesso token passa su /own e viene respinto con 403 su /all, con l'indicazione di che cosa mancava. Lo scope giusto non sostituisce il controllo sulla risorsa visto con BOLA: invoices:read:own dice «puoi leggere fatture tue», ma quale sia «tua» lo decide ancora la query. Lo scope limita il danno di un token rubato, non risolve l'autorizzazione.

Rate limiting: tre algoritmi, tre comportamenti ai confini

API4:2023 si chiama «Unrestricted Resource Consumption» e API6:2023 «Unrestricted Access to Sensitive Business Flows»; per le contromisure sui tentativi ripetuti il riferimento CWE è il 307 (tentativi di autenticazione eccessivi) e il 770 (risorse allocate senza limiti). Il listato confronta tre algoritmi con un orologio simulato, così la prova non aspetta nulla e dà lo stesso risultato ovunque: una finestra fissa, una finestra scorrevole, un token bucket. Il test è la raffica più scomoda: cinque richieste a 9 secondi e cinque a 10, con limite di cinque ogni dieci secondi.

import express from 'express';
import inject from 'light-my-request';

// Orologio iniettato: la prova non dorme e non dipende dalla macchina
let now = 0;
const clock = () => now;

// Finestra fissa: un contatore per intervallo [k*W, (k+1)*W)
const fixedWindow = (limit, windowMs) => {
  const counts = new Map();
  return () => {
    const k = Math.floor(clock() / windowMs);
    const c = (counts.get(k) ?? 0) + 1;
    counts.set(k, c);
    return c <= limit;
  };
};

// Finestra scorrevole (log): conta le richieste accettate negli ultimi windowMs
const slidingWindow = (limit, windowMs) => {
  let log = [];
  return () => {
    log = log.filter((t) => t > clock() - windowMs);
    if (log.length >= limit) return false;
    log.push(clock());
    return true;
  };
};

// Token bucket: capacity token, ricarica refillPerSec token al secondo
const tokenBucket = (capacity, refillPerSec) => {
  let tokens = capacity, last = clock();
  return () => {
    tokens = Math.min(capacity, tokens + ((clock() - last) / 1000) * refillPerSec);
    last = clock();
    if (tokens < 1) return false;
    tokens -= 1;
    return true;
  };
};

// Raffica di 5 richieste appena prima del confine di finestra e 5 subito dopo (limite: 5 ogni 10 s)
const burst = (allow) => {
  let ok = 0;
  for (const t of [9_000, 9_000, 9_000, 9_000, 9_000, 10_000, 10_000, 10_000, 10_000, 10_000]) {
    now = t; if (allow()) ok++;
  }
  return ok;
};
console.log('finestra fissa   accettate:', burst(fixedWindow(5, 10_000)), 'su 10 in 1 s');
console.log('finestra mobile  accettate:', burst(slidingWindow(5, 10_000)), 'su 10 in 1 s');
console.log('token bucket     accettate:', burst(tokenBucket(5, 0.5)), 'su 10 in 1 s');

// Middleware Express: 429 con Retry-After (RFC 9110) e i campi del draft httpapi-ratelimit-headers
const bucketPerKey = new Map();
const limiter = (req, res, next) => {
  const key = req.get('x-test-user') ?? req.ip;
  if (!bucketPerKey.has(key)) bucketPerKey.set(key, tokenBucket(3, 1)); // 3 di raffica, 1 al secondo
  if (bucketPerKey.get(key)()) return next();
  res.set('Retry-After', '1');
  res.set('RateLimit', '"default";r=0;t=1');
  res.status(429).json({ error: 'too_many_requests' });
};
const app = express();
app.post('/login', limiter, (req, res) => res.json({ ok: true }));

now = 0;
const hit = async (user) => (await inject(app, { method: 'POST', url: '/login', headers: { 'x-test-user': user } }));
const codes = [];
for (let i = 0; i < 5; i++) codes.push((await hit('anna')).statusCode);
console.log('anna, 5 tentativi a t=0 s :', codes.join(' '));
const r = await hit('anna');
console.log('anna, 6° tentativo        :', r.statusCode, 'Retry-After =', r.headers['retry-after'], 'RateLimit =', r.headers['ratelimit']);
console.log('bruno, primo tentativo    :', (await hit('bruno')).statusCode);
now = 1_000;
console.log('anna dopo 1 s             :', (await hit('anna')).statusCode);

// Secondo calcolo indipendente: formula chiusa del token bucket, accettate = capacity + rate * secondi
const formula = (capacity, rate, secs, rps) => Math.min(rps * secs, Math.floor(capacity + rate * secs));
console.log('formula capacity+rate*t (cap 3, 1/s, 10 s, 100 rps):', formula(3, 1, 10, 100));
now = 0;
const b = tokenBucket(3, 1); let acc = 0;
for (let ms = 0; ms <= 10_000; ms += 10) { now = ms; if (b()) acc++; }
console.log('simulazione a 100 richieste al secondo per 10 s       :', acc);
// Output:
// finestra fissa   accettate: 10 su 10 in 1 s
// finestra mobile  accettate: 5 su 10 in 1 s
// token bucket     accettate: 5 su 10 in 1 s
// anna, 5 tentativi a t=0 s : 200 200 200 429 429
// anna, 6° tentativo        : 429 Retry-After = 1 RateLimit = "default";r=0;t=1
// bruno, primo tentativo    : 200
// anna dopo 1 s             : 200
// formula capacity+rate*t (cap 3, 1/s, 10 s, 100 rps): 13
// simulazione a 100 richieste al secondo per 10 s       : 13

La finestra fissa lascia passare tutte e dieci le richieste, il doppio del limite dichiarato, perché 9 s e 10 s cadono in due finestre diverse. Finestra scorrevole e token bucket ne accettano cinque. Poi c'è il middleware: Anna esaurisce i tre token di raffica e dal quarto tentativo riceve 429 con Retry-After (RFC 9110 §10.2.3), Bruno ha un secchio suo e non risente di nulla, e un secondo dopo Anna è di nuovo ammessa. L'ultimo controllo usa un secondo calcolo indipendente: la formula chiusa «capacità + ritmo × tempo» dà 13 richieste ammesse in 10 secondi e la simulazione a 100 richieste al secondo ne ammette 13.

I limiti di questa prova sono precisi. I contatori stanno in memoria di un solo processo: con più istanze il budget reale è il limite moltiplicato per il numero di istanze, e serve uno stato condiviso, caso trattato in Rate limiting multi-bucket con Bucket4j. La chiave per IP non ferma chi dispone di molti indirizzi, e la chiave per utente non protegge un endpoint di login, dove l'utente non è ancora noto. Quanto all'intestazione RateLimit, è definita da un Internet-Draft (draft-ietf-httpapi-ratelimit-headers, revisione 11 del 23 maggio 2026) e non da un RFC: la forma "default";r=0;t=1 è quella dei suoi esempi e può cambiare prima della pubblicazione.

CORS: una regola del browser, non una difesa del server

CORS decide se il browser consegna la risposta al JavaScript di un'altra origine; non impedisce a curl di chiamare l'API, e non sostituisce l'autenticazione. Diventa un difetto di sicurezza (CWE-942, «Permissive Cross-domain Security Policy with Untrusted Domains») quando il server riflette qualunque Origin ricevuto e insieme abilita credentials: una pagina qualsiasi può allora leggere i dati dell'utente loggato. La seconda parte dell'uscita del listato precedente mostra proprio questo.

Con il pacchetto cors, origin: true significa «rifletti l'origine della richiesta»: l'origine evil.example.test riceve Access-Control-Allow-Origin con il proprio nome e Access-Control-Allow-Credentials: true. Con l'allowlist esatta la stessa origine non riceve nessuna intestazione CORS, e il browser blocca la lettura. Il valore che il pacchetto usa se non scrivi nulla è * senza credenziali: va bene per risorse pubbliche, non per dati di sessione. L'allowlist si confronta con uguaglianza di stringa, non con endsWith o espressioni regolari non ancorate, perché https://app.example.test.evil.example le soddisferebbe.

Che cosa questa prova non copre

Le cinque dimostrazioni toccano API1, API2, API3, API4 e in parte API6. Le altre voci della lista 2023 richiedono altri esperimenti: API5 (autorizzazione a livello di funzione), API7 (SSRF), API8 (configurazione errata), API9 (inventario) e API10 (consumo non sicuro di API di terzi). Qui sono solo nominate, e per ciascuna la pagina OWASP contiene scenari e contromisure. L'articolo precedente della serie, Autenticazione sicura con sessioni e cookie, tratta l'alternativa al token nei browser; SQL injection e input validation completa la parte sulla validazione, e il quadro d'insieme è in OWASP Top 10 2025. Il prossimo, Supply chain security, sposta lo sguardo dalle richieste alle dipendenze.

Che cosa sai fare adesso

Sai riconoscere in una revisione tre difetti che i test funzionali non vedono: la risorsa letta senza filtro per proprietario, il record aggiornato con tutto ciò che arriva, il token verificato con l'algoritmo che dichiara lui. Sai scrivere per ciascuno una prova che fallisce prima della correzione e passa dopo, senza server e senza rete. Sai scegliere un algoritmo di rate limit in base al comportamento ai confini invece che al nome, e sai che cosa dice OAuth 2.1 oggi, con la riserva che è ancora una bozza.

Domande frequenti

Che cos'è il BOLA e come lo si corregge?

È la vulnerabilità per cui un utente autenticato legge o modifica una risorsa di un altro cambiando l'identificatore nella richiesta. Si corregge mettendo il proprietario nella condizione di accesso ai dati e rispondendo allo stesso modo a risorsa inesistente e risorsa altrui.

Perché un JWT va verificato con un elenco fisso di algoritmi?

Perché l'header del token dichiara l'algoritmo ed è sotto controllo di chi lo presenta. Se il server lo segue, un token con alg none o firmato in HMAC con la chiave pubblica viene accettato. La RFC 8725 chiede che sia il chiamante a fissare gli algoritmi ammessi.

OAuth 2.1 è uno standard?

No, al 2026-11-13 è un Internet-Draft dell'IETF, revisione 17 del 31 ottobre 2026. Raccoglie decisioni già presenti nella RFC 9700: PKCE nel flusso authorization code, redirect URI esatti, niente Implicit né password grant.

La finestra fissa è sbagliata per il rate limiting?

Non è sbagliata, ma ai confini di finestra lascia passare fino al doppio del limite dichiarato, come mostra la prova. Per endpoint sensibili come il login conviene la finestra scorrevole o il token bucket.

Nella stessa serie trovi anche XSS, CSRF e CSP.

Fonti

I sei listati sono stati eseguiti due volte ciascuno con uscita identica; sono script di prova, non codice da incollare in produzione. Non sono stati eseguiti: l'integrazione con un server di autorizzazione reale, Redis e la rete.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti