Un’API Express che risponde con Access-Control-Allow-Origin: * e allo stesso tempo accetta cookie di sessione è una delle configurazioni più pericolose che un backend possa esporre. Il browser blocca da solo la combinazione wildcard più credenziali, ma basta sostituire l’asterisco con una funzione che riflette l’header Origin ricevuto per riaprire la stessa falla senza che nessun test manuale se ne accorga. Secondo un’analisi di Seclinq condotta su oltre 100.000 applicazioni web, circa il 35% presentava almeno una configurazione CORS sfruttabile per rubare credenziali o dati riservati. Il blog di ricerca Kensai ha inoltre segnalato la CORS misconfiguration come una delle categorie più ricorrenti tra le segnalazioni accettate nei programmi di bug bounty nel 2026. In questo tutorial costruiamo, passo dopo passo, un’API Node.js con Express 5 che gestisce il CORS in modo esplicito, verificabile e pronto per la produzione, usando i pacchetti cors 2.8.6 e helmet 8.3.0.
Per chi sviluppa in Italia o comunque per il mercato europeo, il problema ha anche un risvolto legale oltre che tecnico. Un’API che espone dati personali a qualsiasi origine per un errore di configurazione ricade facilmente nell’ambito del GDPR, dato che l’esfiltrazione non richiede un vero e proprio attacco: basta un sito malevolo che una vittima autenticata visita per errore mentre ha ancora una sessione attiva. Il tutorial è pensato per chi gestisce già un backend Express in produzione e vuole passare da una configurazione permissiva, spesso ereditata da un progetto di partenza, a una policy scritta e testata riga per riga.
Cos’è il CORS e perché non è la stessa cosa della Same-Origin Policy
Il browser applica di default la Same-Origin Policy (SOP): uno script caricato da https://sito-a.it non può leggere le risposte di richieste fatte verso https://sito-b.it, anche se la richiesta parte comunque e il server risponde regolarmente. Il CORS (Cross-Origin Resource Sharing) è il meccanismo che permette a un server di dire al browser “fidati, questa origine specifica può leggere la mia risposta”. Non è quindi un sistema di autenticazione, ma un insieme di header HTTP che allentano selettivamente la SOP.
Quando la richiesta usa metodi diversi da GET/POST semplici, oppure include header personalizzati come Authorization, il browser invia prima una richiesta preflight con metodo OPTIONS. Il server deve rispondere con gli header giusti (Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers) prima che la richiesta reale venga eseguita. La documentazione MDN sul CORS descrive questo scambio in dettaglio, mentre la Fetch Standard del WHATWG ne definisce il comportamento esatto lato browser. OWASP colloca oggi la CORS misconfiguration all’interno della categoria A01:2025 Broken Access Control, la stessa che raggruppa gran parte dei controlli di accesso saltati o mal implementati.
Non tutte le richieste cross-origin attivano il preflight. Una richiesta “semplice” (GET, HEAD o POST con Content-Type tra text/plain, multipart/form-data o application/x-www-form-urlencoded, senza header personalizzati) parte direttamente e solo il browser decide, dopo aver ricevuto la risposta, se lo script può leggerla oppure no. Questa distinzione è importante quando debugghi un problema: se vedi la richiesta arrivare al server nei log applicativi ma il frontend riceve comunque un errore di rete, il problema quasi sempre è nella lettura della risposta, non nell’invio della richiesta.
Prerequisiti: versioni e strumenti per seguire il tutorial
Prima di iniziare, verifica di avere questi strumenti installati. Le versioni indicate sono quelle correnti al momento della scrittura, verificale con npm view <pacchetto> version prima di procedere in un progetto reale.
| Strumento | Versione consigliata | Ruolo nel progetto |
|---|---|---|
| Node.js | 24.21.0 LTS “Krypton” | Runtime, usa una versione LTS in produzione |
| npm | bundled con Node 24.x | Gestione dei pacchetti |
| express | 5.2.1 | Framework HTTP su cui applichiamo il middleware CORS |
| cors | 2.8.6 | Middleware ufficiale per gli header CORS |
| helmet | 8.3.0 | Header di sicurezza complementari (HSTS, CSP, ecc.) |
| express-rate-limit | 8.7.0 | Limita i tentativi ripetuti da origini sospette |
| curl | qualsiasi versione recente | Test manuale degli header di risposta |
| DevTools del browser | Chrome, Firefox o Edge aggiornati | Ispezione delle richieste preflight nella scheda Network |
Serve anche un editor di codice e una cartella di progetto vuota. Il tutorial presuppone una conoscenza di base di Express e npm, non serve altro. Se lavori già con Docker, puoi anche eseguire l’intero esempio in un container Node 24 senza installare nulla sulla macchina locale, basta montare la cartella del progetto come volume ed esporre la porta 3000.
Passo 1: Creare il progetto e installare le dipendenze
Crea una cartella dedicata e inizializza il progetto come modulo ES, che è il formato consigliato per i nuovi progetti Node.js dal 2025 in poi.
mkdir cors-secure-api && cd cors-secure-api
npm init -y
npm pkg set type="module"
npm install [email protected] [email protected] [email protected] [email protected] dotenv
A questo punto hai un package.json con le dipendenze fissate a versioni note. Fissare le versioni (anziché usare i caret ^ per i pacchetti di sicurezza critici) riduce il rischio di comportamenti diversi tra ambiente di sviluppo e produzione dopo un aggiornamento automatico.
Passo 2: Capire le opzioni principali del middleware cors
Il pacchetto cors espone un oggetto di configurazione con poche opzioni ma ognuna cambia il comportamento in modo sostanziale. Le più rilevanti per la sicurezza sono origin, credentials, methods, allowedHeaders e maxAge. La pagina ufficiale del middleware elenca tutte le opzioni disponibili, ma la documentazione da sola non basta: il valore di default di origin è *, quindi se importi il pacchetto senza configurarlo stai già aprendo l’API a qualsiasi sito.
// NON fare così in produzione: origin di default, nessun controllo
import cors from 'cors';
app.use(cors());
Questa riga funziona per un prototipo locale, ma va sostituita prima di qualsiasi rilascio pubblico. Nei prossimi passi costruiamo una configurazione esplicita che elenca solo le origini realmente autorizzate.
Passo 3: Costruire un allowlist dinamico delle origini
Invece di scrivere le origini autorizzate direttamente nel codice, conviene leggerle da una variabile d’ambiente. Questo permette di avere allowlist diverse tra sviluppo, staging e produzione senza toccare una riga di JavaScript.
import 'dotenv/config';
const allowedOrigins = new Set(
(process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
);
const corsOptions = {
origin(origin, callback) {
// Richieste server-to-server, curl o Postman non inviano l'header Origin
if (!origin) return callback(null, true);
if (allowedOrigins.has(origin)) {
return callback(null, true);
}
return callback(new Error('Origine non consentita dalla policy CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
optionsSuccessStatus: 204,
};
Il controllo allowedOrigins.has(origin) confronta la stringa intera, protocollo compreso. Questo evita un errore comune: accettare qualsiasi host che “contiene” il dominio giusto, un controllo con includes() che un attaccante può aggirare registrando un dominio come app.example.com.evil.io.
Se il numero di origini da gestire cresce (multi-tenant, sottodomini per ogni cliente, ambienti di anteprima generati automaticamente), un semplice Set statico diventa scomodo da mantenere. In questi casi conviene caricare l’allowlist da un database o da un servizio di configurazione remoto, mantenendo comunque il confronto esatto della stringa origine e aggiungendo una cache in memoria con una scadenza breve, così un aggiornamento della lista si propaga in pochi secondi senza dover riavviare il processo Node.js a ogni modifica.
Passo 4: Gestire cookie e credenziali cross-origin in sicurezza
Se la tua API imposta cookie di sessione o accetta l’header Authorization, devi impostare credentials: true sia lato server sia lato client (con fetch(url, { credentials: 'include' })). Il browser, però, rifiuta categoricamente la combinazione Access-Control-Allow-Origin: * con Access-Control-Allow-Credentials: true: quando i due header coesistono con un wildcard, la richiesta fallisce silenziosamente lato client. Per questo motivo, quando servono le credenziali, l’unica opzione valida è restituire un’origine specifica, esattamente come fa la funzione origin scritta al passo precedente.
Aggiungi anche l’header Vary: Origin nella risposta. Senza questo header, una cache condivisa (un CDN o un proxy intermedio) può memorizzare la risposta destinata a un’origine e servirla per errore a un’altra origine che effettua la stessa richiesta subito dopo.
app.use((req, res, next) => {
res.setHeader('Vary', 'Origin');
next();
});
Il pacchetto cors imposta già questo header quando la funzione origin restituisce un valore dinamico, ma verificarlo esplicitamente con curl (lo facciamo al passo 8) toglie ogni dubbio.
C’è anche un legame diretto tra CORS e l’attributo SameSite del cookie di sessione. Un cookie con SameSite=Strict non viene mai inviato in una richiesta cross-origin, indipendentemente da quanto sia permissiva la policy CORS: in quel caso il problema non è il CORS ma il cookie stesso, e la diagnosi corretta parte dalla scheda Application dei DevTools, non dagli header di risposta. Per un frontend e un backend su domini diversi che devono condividere la sessione, l’attributo giusto è di solito SameSite=None; Secure, sempre in combinazione con un’origine esplicita e mai con il wildcard.
Passo 5: Configurare correttamente le richieste preflight OPTIONS
Il browser invia una richiesta preflight quando il metodo non è GET, HEAD o POST semplice, oppure quando sono presenti header personalizzati. Express deve rispondere a queste richieste OPTIONS con status 204 e senza body, altrimenti il browser interpreta la mancata risposta come un rifiuto e blocca la richiesta reale. Il middleware cors gestisce automaticamente le OPTIONS quando è montato prima delle route, ma solo se non hai un altro handler che intercetta OPTIONS prima di lui.
import express from 'express';
import cors from 'cors';
const app = express();
// L'ordine conta: cors() deve venire prima delle route
app.use(cors(corsOptions));
app.use(express.json());
app.get('/api/data', (req, res) => {
res.json({ message: 'Dati riservati accessibili solo alle origini autorizzate' });
});
L’opzione maxAge: 600 dice al browser di ricordare l’esito del preflight per 600 secondi, evitando una richiesta OPTIONS aggiuntiva per ogni chiamata. Un valore troppo alto, però, ritarda la propagazione di un cambio nella policy: se rimuovi un’origine dall’allowlist, i client che hanno già in cache il preflight continueranno a considerarla valida fino alla scadenza del maxAge.
Passo 6: Aggiungere Helmet e indurire gli header di risposta
Il CORS regola solo chi può leggere la risposta, non protegge da clickjacking, MIME sniffing o connessioni non cifrate. Helmet 8.3.0 imposta un gruppo di header consigliati con una sola riga di codice, e va montato prima di cors() nella catena dei middleware.
import helmet from 'helmet';
app.use(helmet());
app.use(cors(corsOptions));
Helmet imposta, tra gli altri, Strict-Transport-Security, X-Content-Type-Options: nosniff e una Content-Security-Policy di base. Se la tua API serve anche risorse statiche condivise cross-origin (font, immagini), verifica l’opzione crossOriginResourcePolicy, che di default blocca il caricamento cross-origin di quelle risorse anche se il CORS le permetterebbe.
Se la tua API serve solo dati JSON e non pagine HTML, la Content-Security-Policy di default di Helmet può essere più restrittiva di quanto serve e non richiede quasi mai personalizzazioni. Il discorso cambia se dallo stesso processo Express servi anche una piccola area di amministrazione con pagine HTML: in quel caso vale la pena rivedere le direttive script-src e connect-src caso per caso, elencando solo i domini realmente necessari invece di disabilitare la policy per comodità.
Passo 7: Separare la configurazione tra sviluppo, staging e produzione
Un errore frequente è lasciare attiva in produzione una configurazione pensata per il debug locale, con origini come http://localhost:5173 ancora nell’allowlist. La soluzione più pulita è un file .env per ogni ambiente, caricato dal processo di deploy.
# .env.development
NODE_ENV=development
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# .env.production
NODE_ENV=production
ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
Aggiungi un controllo di avvio che blocca il processo se in produzione l’allowlist è vuota o contiene ancora un dominio locale: è un errore banale da individuare in fase di deploy, molto più costoso da scoprire dopo che l’API è già online.
if (process.env.NODE_ENV === 'production') {
const hasLocalOrigin = [...allowedOrigins].some((o) => o.includes('localhost'));
if (allowedOrigins.size === 0 || hasLocalOrigin) {
throw new Error('ALLOWED_ORIGINS non valido per un ambiente di produzione');
}
}
Passo 8: Testare la policy CORS con curl e con i DevTools
Prima di considerare il lavoro concluso, verifica il comportamento reale con tre richieste curl distinte: origine autorizzata, preflight e origine non autorizzata.
# 1. Origine autorizzata
curl -i -H "Origin: https://app.example.com" http://localhost:3000/api/data
# 2. Richiesta preflight simulata
curl -i -X OPTIONS http://localhost:3000/api/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET"
# 3. Origine non autorizzata
curl -i -H "Origin: https://sito-sconosciuto.test" http://localhost:3000/api/data
Per la prima richiesta ti aspetti un output simile a questo:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Content-Type: application/json; charset=utf-8
{"message":"Dati riservati accessibili solo alle origini autorizzate"}
Per la terza richiesta, invece, l’header Access-Control-Allow-Origin deve mancare del tutto dalla risposta: è quello che impedisce al browser di consegnare i dati allo script che ha effettuato la chiamata. Nella scheda Network dei DevTools, filtra per “Fetch/XHR” e controlla la voce Preflight per ogni richiesta: se vedi ripetuto un preflight a ogni chiamata anche verso la stessa origine, il valore di maxAge non sta funzionando come previsto.
Vale la pena ripetere questi tre test dopo ogni modifica alla configurazione, non solo la prima volta. Un cambiamento apparentemente innocuo, come l’aggiunta di un nuovo header personalizzato letto dal frontend, può richiedere di aggiornare anche allowedHeaders lato server: senza quell’aggiornamento il preflight fallisce e la richiesta reale non parte mai, anche se il codice dell’endpoint è corretto.
Passo 9: Bloccare l’origin reflection e gestire l’origine null
La variante più insidiosa della configurazione errata non usa affatto il wildcard: riflette semplicemente qualsiasi valore ricevuto nell’header Origin come se fosse sempre valido. Il codice sembra funzionale nei test (ogni origine “passa”), ma è identico, dal punto di vista della sicurezza, a un wildcard con credenziali abilitate.
// NON FARE MAI QUESTO: riflette qualsiasi origine ricevuta
app.use(cors({
origin: (origin, callback) => callback(null, origin),
credentials: true,
}));
Un secondo caso da gestire con attenzione è l’origine null, che il browser invia per pagine caricate da file locali (file://), sandbox iframe o redirect particolari. Se non hai un motivo specifico per accettarla, escludila esplicitamente nella funzione origin, dato che l’allowlist basata su Set già la rifiuta di default a meno che tu non aggiunga la stringa "null" manualmente.
Questa classe di errori è catalogata nel database MITRE come CWE-942, “Permissive Cross-domain Policy with Untrusted Domains”, ed è la stessa categoria richiamata nelle ricerche di Intigriti e Safeguard citate in questo tutorial. Il punto in comune di tutte le varianti (wildcard con credenziali, reflection non controllata, confronto per sottostringa) è che il codice supera i test funzionali senza problemi: nessuno di questi bug produce un errore visibile durante lo sviluppo, ed è proprio questo che li rende comuni nelle segnalazioni di sicurezza raccolte dopo il rilascio.
Passo 10: Loggare e allertare sui tentativi di origine non autorizzata
Un’origine rifiutata ripetutamente dalla stessa API può indicare un tentativo di ricognizione o un frontend legittimo dimenticato in configurazione. Registrare questi eventi con un formato strutturato permette di inoltrarli a un SIEM per la correlazione con altri segnali.
function logRejectedOrigin(origin, path, ip) {
const entry = {
timestamp: new Date().toISOString(),
event: 'cors_origin_rejected',
origin: origin || 'assente',
path,
ip,
};
console.warn(JSON.stringify(entry));
}
app.use((err, req, res, next) => {
if (err.message === 'Origine non consentita dalla policy CORS') {
logRejectedOrigin(req.headers.origin, req.path, req.ip);
return res.status(403).json({ error: 'Origine non autorizzata' });
}
next(err);
});
Se in azienda usate già uno stack di raccolta log, questi eventi in formato JSON si integrano facilmente con una pipeline SIEM. Chi ha già seguito il nostro tutorial su Wazuh può creare una regola dedicata che alza una allerta quando la stessa origine viene rifiutata più di dieci volte in un’ora.
Definisci la soglia in base al traffico reale della tua API, non a un numero arbitrario copiato da un altro progetto. Un’API interna con poche decine di richieste al giorno può permettersi un’allerta già al terzo tentativo respinto, mentre un’API pubblica con milioni di chiamate giornaliere ha bisogno di una soglia più alta per evitare falsi positivi generati da normali errori di configurazione lato client.
Passo 11: Private Network Access per le richieste verso reti interne
Chrome applica controlli aggiuntivi quando una pagina pubblica prova a contattare un indirizzo di rete privata (come 192.168.x.x o localhost), un meccanismo noto come Private Network Access (PNA), descritto nel blog degli sviluppatori Chrome. In questi casi il browser invia un preflight con l’header Access-Control-Request-Private-Network: true, e il server deve rispondere in modo esplicito per autorizzare la richiesta.
app.use((req, res, next) => {
if (req.headers['access-control-request-private-network']) {
res.setHeader('Access-Control-Allow-Private-Network', 'true');
}
next();
});
Questo scenario riguarda soprattutto dashboard interne, router domestici gestiti via browser o strumenti di sviluppo locale esposti su una porta. Se la tua API non deve mai essere raggiunta da una pagina pubblica su internet, valuta se serve davvero rispondere a questo header oppure se è più sicuro ignorarlo.
Passo 12: Mettere in produzione dietro un reverse proxy Nginx
Molte configurazioni CORS “misteriosamente rotte” in produzione nascono da un reverse proxy che aggiunge i propri header CORS sopra quelli già impostati da Express, generando header duplicati che il browser rifiuta. La regola è semplice: lascia che sia l’applicazione Node.js a gestire il CORS, e configura Nginx solo per inoltrare la richiesta senza toccare gli header di risposta relativi a CORS.
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Non aggiungere qui Access-Control-Allow-Origin:
# la gestisce già il middleware cors() in Express
}
Se il tuo team usa già un API gateway per centralizzare autenticazione e rate limiting, come descritto nella nostra guida API Gateway sicuro in Node.js, valuta di spostare lì anche la policy CORS, così hai un solo punto in cui aggiornare l’allowlist quando cambia un frontend.
Lo stesso principio vale se davanti all’applicazione hai un CDN o un Web Application Firewall come quelli confrontati nella nostra guida WAF Cloudflare vs AWS vs ModSecurity: verifica sempre che quel livello intermedio non riscriva o duplichi gli header CORS già impostati da Express, altrimenti finisci a debuggare due configurazioni diverse convinto di averne una sola.
CORS e GDPR: perché una policy permissiva è anche un rischio legale
Per un’azienda che tratta dati di cittadini europei, una policy CORS troppo aperta non è solo un problema tecnico da correggere alla prossima sprint. Se un endpoint che restituisce dati personali (email, indirizzi, dettagli di fatturazione) è raggiungibile in lettura da qualsiasi origine con la sessione dell’utente attiva, si configura un’esposizione di dati che rientra nell’ambito degli articoli sulla sicurezza del trattamento del GDPR, indipendentemente dal fatto che qualcuno abbia effettivamente sfruttato la falla o meno. La differenza rispetto a un data breach classico è che qui non serve superare alcun controllo di autenticazione: la vittima è già autenticata, ed è il browser stesso, ingannato da una policy CORS scorretta, a consegnare i suoi dati a un’origine che non dovrebbe poterli leggere.
Questo è anche il motivo per cui vale la pena includere un controllo della configurazione CORS nelle revisioni di sicurezza periodiche, non solo nei penetration test annuali. Un cambio di provider hosting, un nuovo ambiente di staging o una migrazione a un nuovo dominio sono tutti eventi che possono alterare silenziosamente l’allowlist, e la finestra tra l’introduzione dell’errore e la sua scoperta è spesso il periodo in cui il rischio è più alto.
Errori comuni da evitare quando configuri il CORS
- Usare
origin: '*'insieme acredentials: true. Il browser rifiuta la combinazione per le richieste con cookie, ma un fallback lato server mal scritto può comunque esporre dati a client che ignorano il controllo del browser. - Riflettere l’header
Originsenza validarlo. È il pattern segnalato più spesso da Intigriti nella sua guida allo sfruttamento delle misconfigurazioni CORS: sembra un allowlist dinamico, ma accetta qualsiasi valore. - Validare l’origine con
includes()invece di un confronto esatto. Un dominio cometrusted.example.com.attacker.iosupera il controllo se il codice cerca solo la sottostringatrusted.example.com. - Dimenticare l’header
Vary: Origin. Senza, una cache condivisa può servire la risposta destinata a un’origine a un’altra origine che effettua la stessa richiesta. - Configurare CORS separatamente in Express e nel reverse proxy. Il risultato sono header duplicati o contraddittori che il browser scarta, con errori difficili da diagnosticare perché il server risponde comunque con status 200.
- Lasciare gli host di sviluppo nell’allowlist di produzione. Un
http://localhost:5173dimenticato in produzione non è sfruttabile da remoto, ma segnala una gestione poco rigorosa della configurazione tra ambienti diversi. - Confondere il CORS con un meccanismo di autorizzazione. Anche con un allowlist perfetto, ogni endpoint deve comunque verificare che l’utente autenticato abbia il permesso di leggere o modificare quella specifica risorsa: il CORS regola solo chi può leggere la risposta dal browser, non chi ha diritto ai dati.
Risoluzione dei problemi: gli errori che si ripetono più spesso
Anche con una configurazione scritta correttamente, l’interazione tra browser, proxy e client HTTP genera comportamenti che sembrano incoerenti se non conosci esattamente dove guardare. La tabella seguente raccoglie i casi più frequenti riscontrati nei team che passano da una configurazione permissiva a un allowlist esplicito, insieme alla causa più probabile e alla verifica da fare per confermarla.
| Problema | Causa probabile | Soluzione |
|---|---|---|
| “No ‘Access-Control-Allow-Origin’ header is present” | Il middleware cors() non è montato, oppure è montato dopo le route | Sposta app.use(cors(corsOptions)) prima di ogni definizione di route |
| Il preflight OPTIONS restituisce 404 | Un router personalizzato intercetta OPTIONS prima del middleware cors | Verifica l’ordine dei middleware e rimuovi eventuali handler OPTIONS custom in conflitto |
| I cookie di sessione non arrivano al client | Manca credentials: true lato server o credentials: 'include' nella fetch client | Allinea entrambe le configurazioni e verifica che l’origine non sia * |
| Funziona in sviluppo ma non in produzione | L’allowlist di produzione non include il dominio reale del frontend | Controlla la variabile ALLOWED_ORIGINS effettivamente caricata dal processo in produzione |
| Il preflight viene ripetuto ad ogni chiamata | maxAge assente o troppo basso, oppure un proxy che rimuove l’header | Imposta maxAge a un valore ragionevole (es. 600) e controlla la risposta con curl |
| Errore “Origin not allowed” anche per l’origine corretta | Discrepanza tra protocollo (http/https) o porta indicata nell’allowlist | Confronta carattere per carattere la stringa origine, incluso lo schema e la porta |
| Header CORS duplicati nella risposta | Sia Nginx sia Express aggiungono gli header CORS | Rimuovi la configurazione CORS dal reverse proxy e lasciala solo in Express |
| Richieste da app mobile o Postman bloccate | La funzione origin tratta l’assenza di header Origin come rifiuto | Gestisci esplicitamente il caso !origin per le richieste non-browser |
| CORS funziona ma i dati sensibili restano esposti | Il CORS è stato confuso con un controllo di autorizzazione | Aggiungi verifica di autenticazione e autorizzazione indipendente dal CORS |
Il progetto completo: il codice finale pronto all’uso
Ecco il file server.js che riunisce tutti i passaggi visti finora in un unico progetto funzionante: allowlist da variabile d’ambiente, gestione delle credenziali, Helmet, rate limiting, logging dei rifiuti e supporto per il Private Network Access. Puoi copiarlo così com’è come punto di partenza per un progetto reale, modificando solo i valori specifici della tua applicazione.
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
const app = express();
const isProd = process.env.NODE_ENV === 'production';
const allowedOrigins = new Set(
(process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
);
if (isProd) {
const hasLocalOrigin = [...allowedOrigins].some((o) => o.includes('localhost'));
if (allowedOrigins.size === 0 || hasLocalOrigin) {
throw new Error('ALLOWED_ORIGINS non valido per un ambiente di produzione');
}
}
function logRejectedOrigin(origin, path, ip) {
console.warn(JSON.stringify({
timestamp: new Date().toISOString(),
event: 'cors_origin_rejected',
origin: origin || 'assente',
path,
ip,
}));
}
const corsOptions = {
origin(origin, callback) {
if (!origin) return callback(null, true);
if (allowedOrigins.has(origin)) return callback(null, true);
return callback(new Error('Origine non consentita dalla policy CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
optionsSuccessStatus: 204,
};
app.use(helmet());
app.use(cors(corsOptions));
app.use(express.json());
app.use((req, res, next) => {
res.setHeader('Vary', 'Origin');
if (req.headers['access-control-request-private-network']) {
res.setHeader('Access-Control-Allow-Private-Network', 'true');
}
next();
});
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', apiLimiter);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', env: isProd ? 'production' : 'development' });
});
app.get('/api/data', (req, res) => {
res.json({ message: 'Dati riservati accessibili solo alle origini autorizzate' });
});
app.use((err, req, res, next) => {
if (err.message === 'Origine non consentita dalla policy CORS') {
logRejectedOrigin(req.headers.origin, req.path, req.ip);
return res.status(403).json({ error: 'Origine non autorizzata' });
}
next(err);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server avviato sulla porta ${PORT} (${isProd ? 'produzione' : 'sviluppo'})`);
});
Avvialo con node server.js dopo aver creato un file .env con almeno la variabile ALLOWED_ORIGINS. Da qui puoi collegare un frontend reale su una delle origini elencate e verificare che i cookie di sessione arrivino correttamente.
Consigli avanzati per team e API su larga scala
Una volta che la configurazione di base funziona ed è testata, restano alcuni accorgimenti che emergono solo quando l’API cresce oltre un singolo frontend o un singolo team. Sono le stesse pratiche che distinguono una policy CORS scritta una volta e mai più toccata da una che resta corretta anche dopo mesi di modifiche.
- CORS per route, non solo globale. Se solo alcune rotte servono più origini (ad esempio un endpoint pubblico di health check), passa un oggetto di opzioni diverso direttamente a quella route invece di allentare la policy globale.
- Attenzione al CORS nei WebSocket. L’handshake iniziale di un WebSocket passa comunque per una richiesta HTTP e rispetta l’header
Origin: chi ha seguito la nostra guida su WebSocket sicuro con Node.js dovrebbe applicare la stessa allowlist anche lì. - Difesa in profondità. Il CORS non sostituisce la protezione da CSRF né la validazione dell’input: le nostre guide su protezione CSRF e su validazione input con Zod e Joi coprono i controlli complementari da aggiungere sempre.
- Test automatico in CI/CD. Aggiungi al pipeline uno script che invia richieste con origini valide e non valide e verifica gli status code attesi, così una modifica accidentale all’allowlist viene intercettata prima del deploy.
- Un solo punto di verità per l’allowlist. Se hai più servizi che rispondono su domini diversi, centralizza la lista delle origini autorizzate in un file di configurazione condiviso o in un secret manager, evitando che ogni microservizio mantenga una copia propria che finisce per disallinearsi.
- Includi il CORS nel programma di bug bounty o nelle revisioni interne. Chi gestisce già un programma tramite piattaforme come quelle confrontate nella nostra guida HackerOne vs Bugcrowd vs Intigriti dovrebbe indicare esplicitamente la CORS misconfiguration tra gli scenari di interesse, dato che è una delle categorie di segnalazione più comuni sulle piattaforme di ricerca indipendente.
CORS sicuro vs CORS pericoloso: la tabella di riferimento rapido
| Configurazione | Comportamento | Da usare quando |
|---|---|---|
origin: '*', senza credenziali | Accessibile da qualsiasi sito, nessun cookie | API pubbliche senza dati riservati (es. meteo, valute) |
origin: '*', con credentials: true | Combinazione rifiutata dal browser per richieste con cookie | Mai, e comunque va evitata anche se “sembra” funzionare in alcuni client non browser |
| Origin riflesso senza controllo | Accetta qualsiasi origine come se fosse in allowlist | Mai: equivale a un wildcard con credenziali abilitate |
| Allowlist statica con confronto esatto | Solo le origini elencate ricevono l’header di risposta | La maggior parte delle API con frontend noti e stabili |
| Allowlist dinamica da variabile d’ambiente | Origini diverse per ogni ambiente di deploy | Team con più ambienti (dev, staging, produzione) o più frontend |
| Nessuna configurazione CORS (default browser) | Solo richieste same-origin funzionano | API consumata esclusivamente dal proprio frontend sullo stesso dominio |
Domande frequenti sul CORS in Node.js
Il CORS protegge dagli attacchi CSRF?
No. Il CORS decide solo se il browser mostra allo script la risposta di una richiesta cross-origin, ma non impedisce alla richiesta di partire e di essere eseguita dal server. Per bloccare il CSRF servono token dedicati o cookie con attributo SameSite, come spiegato nella nostra guida su protezione CSRF in Node.js.
Posso usare Access-Control-Allow-Origin: * in produzione?
Solo per endpoint pubblici che non richiedono autenticazione e non restituiscono dati personali. Per qualsiasi endpoint che gestisce sessioni, cookie o token, serve un’origine esplicita nell’allowlist.
Perché ricevo l’errore “No Access-Control-Allow-Origin header”?
Nella maggior parte dei casi il middleware cors() non è montato, è montato dopo le route, oppure l’origine che sta chiamando l’API non è presente nell’allowlist configurata lato server.
Le richieste server-to-server rispettano il CORS?
No, il CORS è una restrizione applicata dai browser. Una richiesta fatta da un altro server, da un job schedulato o da uno script curl non invia l’header Origin nello stesso modo e non subisce il blocco preflight. Questo significa anche che un allowlist CORS non protegge in alcun modo da uno scraper o da un bot che chiama direttamente l’API senza passare da un browser: per quel tipo di traffico servono autenticazione, rate limiting e, se necessario, un controllo sul token API.
Devo configurare CORS anche per un’API GraphQL?
Sì. Un endpoint GraphQL espone comunque una singola route HTTP raggiunta via POST, quindi vale la stessa logica di allowlist e la stessa attenzione ai cookie di sessione se l’autenticazione passa da lì.
Il preflight rallenta le performance dell’API?
L’impatto è minimo se configuri correttamente maxAge, perché il browser esegue una sola richiesta preflight per combinazione di origine, metodo e header, e la riusa per la durata configurata invece di ripeterla a ogni chiamata.
Come testo la policy CORS in automatico nella pipeline CI/CD?
Scrivi un test che avvia il server in un ambiente isolato e invia richieste con header Origin validi e non validi, verificando che lo status code e la presenza dell’header Access-Control-Allow-Origin corrispondano a quanto atteso per ciascun caso.
Cosa succede se dimentico di aggiornare l’allowlist dopo aver cambiato dominio al frontend?
Il frontend smette di ricevere risposte utilizzabili dall’API: la richiesta arriva comunque al server, ma il browser blocca lo script dal leggere la risposta. È uno degli scenari più comuni segnalati dopo un cambio di dominio o l’aggiunta di un sottodominio dedicato al nuovo ambiente. Se il team di frontend lavora in modo indipendente da quello di backend, conviene documentare l’allowlist in un posto visibile a entrambi, ad esempio nel file README del repository dell’API, così un cambio di dominio programmato include anche l’aggiornamento della variabile ALLOWED_ORIGINS tra i passaggi del rilascio.




