Sicurezza fullstack: per ogni dato, quale livello tiene la linea
Chi scrive sia il frontend sia il backend commette di solito un errore preciso: mette una difesa nel livello sbagliato. Valida un campo nel form e non sul server, nasconde un pulsante «Elimina» e non controlla il proprietario dell'oggetto, si fida di una CSP e smette di codificare l'output. La regola che tiene insieme tutto è una sola: il browser è dell'utente, quindi ciò che arriva dal browser lo decide chi attacca; ogni controllo che conta si fa sul server, e le intestazioni che il server manda al browser sono la seconda rete, non la prima. Questo articolo prova la regola su un progetto costruito apposta, in due versioni: una vulnerabile e una difesa. Cinque attacchi, tutti contro la mia applicazione di prova eseguita in-process, senza porte aperte né rete, ciascuno affiancato dalla contromisura e da un controllo che la verifica. Gli approfondimenti per ogni singolo tema stanno negli articoli di sicurezza richiamati lungo il testo; gli altri articoli di questa serie sono nella pagina della serie.
La mappa: quali rischi, in quale livello
L'OWASP Top 10:2025 (owasp.org/Top10/2025, consultata il 2026-11-13) è l'elenco di riferimento, e ha un pregio per chi lavora da fullstack: le sue categorie si lasciano assegnare a un livello. Il controllo degli accessi (A01) comprende fra gli altri l'accesso ai dati altrui cambiando un identificatore e il CSRF; l'iniezione (A05) comprende SQL injection e XSS; la cattiva configurazione (A02) comprende fra l'altro il server che non invia le intestazioni di sicurezza; i difetti di autenticazione (A07) sono l'argomento del prossimo articolo. La scheda A01 elenca come scenari tipici proprio l'accesso a un account altrui fornendo il suo identificatore e le API senza controlli su POST, PUT e DELETE; la scheda A05 indica XSS e SQL injection come due facce della stessa categoria.
- Il server decide chi sei e a quali oggetti puoi accedere: proprietà degli oggetti, campi modificabili, origine delle richieste che cambiano stato.
- Il server trasforma i dati in query e in HTML solo attraverso parametri e codifica, mai concatenando stringhe.
- Il browser applica le regole che il server gli dichiara con le intestazioni: CSP,
nosniff, HSTS.
Le tre righe non sono in ordine di importanza ma di fiducia crescente nel client: nella prima il browser non ha alcun ruolo, nella seconda ne ha uno passivo (riceve ciò che il server ha già reso innocuo), nella terza collabora, ma solo se obbedisce. Un'applicazione che ha la terza riga e non la prima è pronta per i giorni in cui un browser vecchio o uno script a riga di comando ignora le intestazioni. Per questo la prova che segue si concentra sul server.
Il bersaglio: una sola applicazione, due versioni
L'applicazione di prova ha cinque percorsi: ricerca nelle proprie note, lettura di una nota, modifica del profilo, creazione di una nota e commenti. Il flag difeso sceglie in ogni percorso fra il codice vulnerabile e quello corretto, così la differenza è nello stesso punto e si legge accanto. Due semplificazioni dichiarate: la sessione è una mappa in memoria da cookie a utente (come si costruisce davvero un'autenticazione è l'argomento di Authentication: JWT, OAuth2, session vs token), e il database è node:sqlite in memoria, un modulo che la documentazione di Node 24 classifica come «release candidate» (stabilità 1.2), cioè non ancora stabile. Le versioni usate sono Node 24.11.0 e Fastify 5.12.8.
import Fastify from 'fastify';
import { DatabaseSync } from 'node:sqlite';
import { proteggi } from './intestazioni.mjs';
import { origineAmmessa } from './origine.mjs';
const ORIGINE = 'https://app.example';
const sessioni = new Map([['s-ada', 1], ['s-bob', 2]]);
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export function costruisci({ difeso }) {
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE utenti (id INTEGER PRIMARY KEY, nome TEXT, ruolo TEXT);
CREATE TABLE note (id INTEGER PRIMARY KEY, proprietario INTEGER, titolo TEXT);
CREATE TABLE commenti (id INTEGER PRIMARY KEY, testo TEXT);
INSERT INTO utenti VALUES (1,'ada','utente'),(2,'bob','utente');
INSERT INTO note VALUES (1,1,'spesa'),(2,2,'password-wifi'),(3,2,'bozza');`);
const app = Fastify();
if (difeso) proteggi(app);
app.addHook('preHandler', async (req, reply) => {
req.uid = sessioni.get((req.headers.cookie ?? '').replace('sid=', ''));
if (!req.uid) return reply.code(401).send({ errore: 'non autenticato' });
if (difeso && !origineAmmessa(req, ORIGINE)) return reply.code(403).send({ errore: 'origine non ammessa' });
});
app.get('/cerca', async (req) => difeso
? db.prepare('SELECT titolo FROM note WHERE proprietario = ? AND titolo = ?').all(req.uid, req.query.q)
: db.prepare(`SELECT titolo FROM note WHERE proprietario = ${req.uid} AND titolo = '${req.query.q}'`).all());
app.get('/note/:id', async (req, reply) => {
const r = difeso
? db.prepare('SELECT * FROM note WHERE id = ? AND proprietario = ?').get(req.params.id, req.uid)
: db.prepare('SELECT * FROM note WHERE id = ?').get(req.params.id);
return r ?? reply.code(404).send({ errore: 'non trovata' });
});
app.post('/profilo', async (req) => {
const ammessi = difeso ? ['nome'].filter((c) => c in req.body) : Object.keys(req.body);
for (const campo of ammessi) db.prepare(`UPDATE utenti SET ${campo} = ? WHERE id = ?`).run(req.body[campo], req.uid);
return db.prepare('SELECT nome, ruolo FROM utenti WHERE id = ?').get(req.uid);
});
app.post('/note', async (req, reply) => {
db.prepare('INSERT INTO note (proprietario, titolo) VALUES (?, ?)').run(req.uid, String(req.body.titolo));
return reply.code(201).send({ ok: true });
});
app.post('/commenti', async (req, reply) => {
db.prepare('INSERT INTO commenti (testo) VALUES (?)').run(String(req.body.testo));
return reply.code(201).send({ ok: true });
});
app.get('/commenti', async (req, reply) => {
const righe = db.prepare('SELECT testo FROM commenti').all().map((c) => `<li>${difeso ? esc(c.testo) : c.testo}</li>`);
return reply.type('text/html').send(`<ul>${righe.join('')}</ul><script nonce="${req.nonce ?? ''}">/* app */</script>`);
});
return { app, db };
}
Le quattro righe che contano sono quelle in cui il ramo difeso e il ramo vulnerabile divergono. Nella ricerca la versione difesa passa req.uid e req.query.q come parametri del prepare, mentre quella vulnerabile li incolla dentro la stringa SQL. Nella lettura della nota la versione difesa aggiunge AND proprietario = ?: se la nota esiste ma non è tua, la risposta è identica a quella di una nota inesistente (404), così nemmeno l'esistenza dell'identificatore trapela. Nel profilo la versione difesa ammette solo il campo nome, mentre quella vulnerabile applica ogni chiave che arriva, ruolo compreso: è la mass assignment descritta dalla scheda OWASP sulla Mass Assignment. Nei commenti la versione difesa codifica i cinque caratteri speciali dell'HTML prima di inserirli. Tutto il resto è comune.
Il codice ha anche un difetto che nessun attacco qui sotto mostra: il ramo difeso non valida il formato dei dati in ingresso, quindi una richiesta a /cerca senza il parametro q produce un errore 500 (ERR_INVALID_ARG_TYPE, provato a parte) invece di un 400 pulito. Lo schema di validazione sul bordo, con Ajv e i suoi comportamenti a sorpresa, è trattato in SQL Injection e Input Validation.
Le due difese che stanno nelle intestazioni
La prima riguarda il CSRF. Un sito malevolo può far partire dal browser dell'utente una richiesta POST verso la tua applicazione, e il browser allega il cookie di sessione. Il browser moderno però dichiara da dove viene la richiesta con l'intestazione Sec-Fetch-Site, che può valere same-origin, same-site, cross-site o none (MDN, Sec-Fetch-Site). La scheda OWASP sul CSRF la indica come un metodo leggero per bloccare le richieste evidentemente cross-site, e aggiunge che serve un ripiego sul controllo di Origin per i browser che non la inviano (OWASP, CSRF Prevention). La funzione sotto fa esattamente questo, e lascia passare i metodi di sola lettura.
const METODI_SICURI = new Set(['GET', 'HEAD', 'OPTIONS']);
// Difesa CSRF senza token: Fetch Metadata, con ripiego sull'intestazione Origin.
export function origineAmmessa(req, originePropria) {
if (METODI_SICURI.has(req.method)) return true;
const sito = req.headers['sec-fetch-site'];
if (sito) return sito === 'same-origin' || sito === 'none';
const origine = req.headers.origin;
return origine === undefined || origine === originePropria;
}
Quando Sec-Fetch-Site c'è, la decisione si ferma lì: ammesse solo le richieste dal tuo stesso sito o avviate dall'utente (none); same-site è escluso di proposito, perché un altro sottodominio non è fidato quanto il tuo. Se manca, si guarda Origin. Il caso senza nessuna delle due viene lasciato passare: secondo MDN i browser aggiungono Origin alle richieste cross-origin e a quelle same-origin diverse da GET e HEAD (con alcune eccezioni), quindi l'assenza di entrambe su un POST indica quasi sempre un client non-browser, che non allega i cookie da solo. Un Origin: null, invece, viene rifiutato, perché diverso dal tuo (MDN, Origin). Se la tua applicazione ha un comportamento diverso, cambia quella riga. Questa funzione non sostituisce SameSite sul cookie né un token anti-CSRF dove servono: l'articolo XSS, CSRF e CSP spiega come si combinano.
La seconda difesa è l'insieme di intestazioni sulle risposte. La CSP con nonce dice al browser di eseguire solo gli script che portano il valore segreto di quella risposta; X-Content-Type-Options: nosniff impedisce di eseguire come script o stile una risposta con un tipo diverso da quello atteso (MDN, X-Content-Type-Options); HSTS dice per quanti secondi il browser deve ricordarsi di usare solo HTTPS con quel host (MDN, Strict-Transport-Security).
import { randomBytes } from 'node:crypto';
// Un nonce nuovo per ogni risposta: il browser esegue solo gli script che lo portano.
export function proteggi(app) {
app.addHook('onRequest', async (req) => {
req.nonce = randomBytes(16).toString('base64');
});
app.addHook('onSend', async (req, reply, payload) => {
reply.header('Content-Security-Policy',
`default-src 'self'; script-src 'nonce-${req.nonce}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`);
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Strict-Transport-Security', 'max-age=31536000');
return payload;
});
}
Il nonce è generato in onRequest con 16 byte casuali, e finisce sia nell'intestazione sia (nel percorso dei commenti) nell'attributo nonce del tag script: se lo script porta lo stesso valore dell'intestazione, il browser lo esegue; uno script iniettato da un attaccante non può conoscerlo, perché cambia a ogni risposta. Il valore max-age=31536000 equivale a 365 giorni (365 per 86 400 secondi); non ho aggiunto includeSubDomains, perché con quell'opzione la regola vale anche per tutti i sottodomini, e una scelta del genere va fatta sapendolo. Come ricorda MDN, una CSP non è un'alternativa alla codifica dell'input: sono due difese in profondità, ed è per questo che la prova che segue le tiene separate.
Cinque attacchi e due controlli
Il programma di prova costruisce, per ogni attacco, un'applicazione vulnerabile e una difesa nuove, esegue la stessa richiesta su entrambe con inject() e riporta che cosa è successo. Due righe sono controlli: una ricerca legittima e un POST dallo stesso sito, per dimostrare che le difese non rompono l'uso normale. Dove possibile l'esito non lo legge la risposta HTTP ma il database, un secondo canale indipendente: il ruolo si legge con una query dopo l'attacco, e così il numero di note dopo il POST cross-site.
import { costruisci } from './app.mjs';
const ada = { cookie: 'sid=s-ada' };
const json = { ...ada, 'content-type': 'application/json' };
const risultati = [];
async function attacco(nome, esegui) {
const riga = { nome };
for (const [chiave, difeso] of [['vulnerabile', false], ['difesa', true]]) {
const { app, db } = costruisci({ difeso });
riga[chiave] = await esegui(app, db);
}
risultati.push(riga);
}
await attacco('SQL injection su /cerca', async (app) => {
const r = await app.inject({ url: '/cerca?q=' + encodeURIComponent("x' OR '1'='1"), headers: ada });
return `${r.json().length} titoli restituiti`;
});
await attacco('Controllo: ricerca "spesa"', async (app) => {
const r = await app.inject({ url: '/cerca?q=spesa', headers: ada });
return `${r.json().length} titolo restituito`;
});
await attacco('Nota altrui su /note/2', async (app) => {
const r = await app.inject({ url: '/note/2', headers: ada });
return r.statusCode === 200 ? `letta: ${r.json().titolo}` : `negata (${r.statusCode})`;
});
await attacco('Campo ruolo in /profilo', async (app, db) => {
await app.inject({ method: 'POST', url: '/profilo', headers: json, payload: { nome: 'ada', ruolo: 'admin' } });
return `ruolo nel DB: ${db.prepare('SELECT ruolo FROM utenti WHERE id = 1').get().ruolo}`;
});
await attacco('POST da un altro sito', async (app, db) => {
const h = { ...json, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' };
const r = await app.inject({ method: 'POST', url: '/note', headers: h, payload: { titolo: 'inserita da fuori' } });
return `${r.statusCode}, note di ada: ${db.prepare('SELECT COUNT(*) AS n FROM note WHERE proprietario = 1').get().n}`;
});
await attacco('Controllo: POST stesso sito', async (app) => {
const h = { ...json, origin: 'https://app.example', 'sec-fetch-site': 'same-origin' };
const r = await app.inject({ method: 'POST', url: '/note', headers: h, payload: { titolo: 'mia' } });
return `${r.statusCode}`;
});
await attacco('Commento con <script>', async (app) => {
await app.inject({ method: 'POST', url: '/commenti', headers: json, payload: { testo: '<script>alert(1)</script>' } });
const html = (await app.inject({ url: '/commenti', headers: ada })).body;
return html.includes('<script>alert') ? 'tag script nel testo' : 'script reso inerte';
});
for (const r of risultati) console.log(`${r.nome.padEnd(30)} | ${r.vulnerabile.padEnd(28)} | ${r.difesa}`);
const { app } = costruisci({ difeso: true });
const a = (await app.inject({ url: '/commenti', headers: ada })).headers;
const b = (await app.inject({ url: '/commenti', headers: ada })).headers;
console.log('CSP diversa a ogni risposta:', a['content-security-policy'] !== b['content-security-policy']);
console.log('nosniff:', a['x-content-type-options'], '| HSTS:', a['strict-transport-security']);
// Output:
// SQL injection su /cerca | 3 titoli restituiti | 0 titoli restituiti
// Controllo: ricerca "spesa" | 1 titolo restituito | 1 titolo restituito
// Nota altrui su /note/2 | letta: password-wifi | negata (404)
// Campo ruolo in /profilo | ruolo nel DB: admin | ruolo nel DB: utente
// POST da un altro sito | 201, note di ada: 2 | 403, note di ada: 1
// Controllo: POST stesso sito | 201 | 201
// Commento con <script> | tag script nel testo | script reso inerte
// CSP diversa a ogni risposta: true
// nosniff: nosniff | HSTS: max-age=31536000
La tabella dice che la SQL injection restituisce tutte e tre le note di entrambi gli utenti alla versione vulnerabile e nessuna a quella difesa, mentre la ricerca legittima ne restituisce una a entrambe. Dice che la nota di Bob è leggibile da Ada solo senza il filtro per proprietario, e che il campo ruolo diventa admin nel database solo senza l'allowlist. Il POST con Sec-Fetch-Site: cross-site crea una nota (201, due note) senza la difesa e viene rifiutato (403, una nota) con la difesa, mentre il POST dallo stesso sito passa in entrambi i casi. Il commento con un tag script finisce nell'HTML così com'è nella versione vulnerabile e viene codificato in quella difesa. Le ultime due righe verificano che il nonce cambia a ogni risposta e che le altre intestazioni sono presenti. L'uscita è identica in due esecuzioni consecutive.
Che cosa questa prova non dimostra
Nessun browser è stato avviato. La prova mostra che il server rifiuta, codifica e dichiara; non mostra che un browser blocchi davvero uno script senza nonce, né che invii Sec-Fetch-Site nel modo descritto, perché per questo servirebbe un browser reale. Non copre l'autenticazione vera (cookie con HttpOnly, Secure, SameSite; rotazione della sessione), per cui rimando a Autenticazione sicura: session e cookie, né CORS e l'autorizzazione a livello di oggetto in profondità, trattati in API Security: OAuth 2.1, JWT e rate limiting. Non copre la catena di fornitura e le dipendenze, né i log. E non è un test di penetrazione: ogni attacco è un singolo caso scelto, che dimostra una classe di difetto, non la sua assenza nel tuo codice.
Che cosa sai fare adesso
Sai assegnare un rischio dell'OWASP Top 10:2025 al livello che deve fermarlo, e dire quale difesa sta nel server (parametri, controllo del proprietario, allowlist dei campi, controllo dell'origine, codifica dell'output) e quale nel browser su ordine del server (CSP con nonce, nosniff, HSTS). Sai scrivere per ognuna un test che attacca la tua applicazione con la richiesta sbagliata e verifica l'esito nel database, non solo nella risposta. Per approfondire un tema alla volta parti da OWASP Top 10 2025: guida per developer; il prossimo articolo della serie, Authentication, costruisce la parte che qui è stata simulata con una mappa, e l'articolo precedente, Core Web Vitals, ha mostrato un endpoint pubblico che raccoglie dati dai visitatori e va validato per la stessa ragione.
Domande frequenti
Basta una CSP per essere protetti dall'XSS?
No. MDN lo dice esplicitamente: la CSP non è un'alternativa alla codifica e alla sanificazione dell'input, ma una difesa in profondità. Nella prova la codifica dell'output è ciò che rende inerte il commento; la CSP resta la seconda rete.
Perché restituire 404 e non 403 per la nota di un altro utente?
Perché con il 403 confermi che quell'identificatore esiste. La versione difesa filtra per proprietario già nella query, quindi una nota altrui è indistinguibile da una nota che non c'è.
Il controllo di Sec-Fetch-Site sostituisce il token anti-CSRF?
Non per tutti i casi. La scheda OWASP lo presenta come un metodo leggero per bloccare le richieste cross-site più evidenti, con un ripiego su Origin per i browser che non lo inviano. Dove serve una difesa più forte, o hai richieste che non passano da un browser moderno, il token resta.
L'ultimo articolo della serie, CI/CD multi-ambiente, propone di far girare questi controlli come test a ogni push.
Fonti
- OWASP Top 10:2025 - elenco e nomi delle dieci categorie, consultata il 2026-11-13
- A01:2025 Broken Access Control - scenari di accesso tramite identificatore e CWE-352 (CSRF) fra i CWE mappati, consultata il 2026-11-13
- A05:2025 Injection - XSS (CWE-79) e SQL injection (CWE-89) nella stessa categoria, consultata il 2026-11-13
- OWASP, Cross-Site Request Forgery Prevention Cheat Sheet - Fetch Metadata come metodo leggero e ripiego sull'intestazione Origin, consultata il 2026-11-13
- OWASP, Mass Assignment Cheat Sheet - definizione e soluzione con allowlist, consultata il 2026-11-13
- Content Security Policy (MDN) - nonce per gli script e la CSP come difesa in profondità, non alternativa alla sanificazione, consultata il 2026-11-13
- Sec-Fetch-Site (MDN) - i quattro valori dell'intestazione, consultata il 2026-11-13
- Origin (MDN) - quando i browser aggiungono l'intestazione e le eccezioni, consultata il 2026-11-13
- Strict-Transport-Security (MDN) - significato di max-age e di includeSubDomains, consultata il 2026-11-13
- X-Content-Type-Options (MDN) - effetto di nosniff su script e stili, consultata il 2026-11-13
- Node.js v24, node:sqlite - stabilità 1.2 (release candidate) e istruzioni preparate con parametri, consultata il 2026-11-13
Tutti i listati sono stati eseguiti con Node 24.11.0 e Fastify 5.12.8 tramite inject(), due volte con uscita identica; nessuna porta aperta, nessuna rete, nessun file di altri progetti analizzato. Gli attacchi sono contro un'applicazione di prova costruita per questo articolo. Non è stato avviato nessun browser: il comportamento di CSP e di Sec-Fetch-Site nei browser reali è quello descritto da MDN e OWASP, non verificato qui.
Commenti
Caricamento commenti...
Accedi per commentare