Vai al contenuto
·32 min di lettura

Best practice per la sicurezza delle API: la checklist che eseguo su ogni progetto

Autenticazione, autorizzazione, validazione dell'input, rate limiting, CORS, gestione dei segreti e la OWASP API Top 10. Cosa verifico prima di ogni deployment in produzione.

Condividi:X / TwitterLinkedIn

Ho rilasciato API completamente aperte. Non per malizia, non per pigrizia — semplicemente non sapevo cosa non sapevo. Un endpoint che restituiva tutti i campi dell'oggetto utente, incluse le password hashate. Un rate limiter che controllava solo gli indirizzi IP, il che significava che chiunque dietro un proxy poteva martellare l'API. Un'implementazione JWT dove avevo dimenticato di verificare il claim iss, quindi i token di un servizio completamente diverso funzionavano benissimo.

Ognuno di quegli errori è arrivato in produzione. Ognuno è stato scoperto — alcuni da me, alcuni dagli utenti, uno da un ricercatore di sicurezza che è stato abbastanza gentile da scrivermi una email invece di postarlo su Twitter.

Questo post è la checklist che ho costruito da quegli errori. La eseguo prima di ogni deployment in produzione. Non perché sono paranoico, ma perché ho imparato che i bug di sicurezza sono quelli che fanno più male. Un pulsante rotto infastidisce gli utenti. Un flusso auth rotto espone i loro dati.

Autenticazione vs Autorizzazione#

Queste due parole vengono usate in modo intercambiabile nelle riunioni, nella documentazione, persino nei commenti del codice. Non sono la stessa cosa.

L'autenticazione risponde a: "Chi sei?" È il passo del login. Username e password, flusso OAuth, magic link — qualunque cosa dimostri la tua identità.

L'autorizzazione risponde a: "Cosa ti è permesso fare?" È il passo dei permessi. Questo utente può cancellare questa risorsa? Può accedere a questo endpoint admin? Può leggere i dati di un altro utente?

Il bug di sicurezza più comune che ho visto nelle API in produzione non è un flusso di login rotto. È un controllo di autorizzazione mancante. L'utente è autenticato — ha un token valido — ma l'API non verifica mai se è autorizzato a eseguire l'azione che sta richiedendo.

JWT: anatomia e gli errori che contano#

I JWT sono ovunque. Sono anche fraintesi ovunque. Un JWT ha tre parti, separate da punti:

header.payload.signature

L'header dice quale algoritmo è stato usato. Il payload contiene i claim (ID utente, ruoli, scadenza). La firma prova che nessuno ha manomesso le prime due parti.

Ecco una verifica JWT corretta in Node.js:

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // Never allow "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 seconds leeway for clock skew
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expired");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Invalid token");
    }
    throw new ApiError(401, "Authentication failed");
  }
}

Alcune cose da notare:

  1. algorithms: ["HS256"] — Questo è critico. Se non specifichi l'algoritmo, un attaccante può inviare un token con "alg": "none" nell'header e saltare completamente la verifica. Questo è l'attacco alg: none, e ha colpito sistemi reali in produzione.

  2. issuer e audience — Senza questi, un token generato per il Servizio A funziona sul Servizio B. Se gestisci più servizi che condividono lo stesso segreto (cosa che non dovresti fare, ma la gente lo fa), ecco come avviene l'abuso di token cross-service.

  3. Gestione specifica degli errori — Non restituire "invalid token" per ogni fallimento. Distinguere tra scaduto e non valido aiuta il client a sapere se deve fare refresh o ri-autenticarsi.

Rotazione dei refresh token#

Gli access token dovrebbero essere di breve durata — 15 minuti è lo standard. Ma non vuoi che gli utenti reinseriscano la password ogni 15 minuti. Ecco dove entrano in gioco i refresh token.

Il pattern che funziona davvero in produzione:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Token family for rotation detection
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token not found — either expired or already used.
    // If already used, this is a potential replay attack.
    // Invalidate the entire token family.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Delete the old token immediately — single use only
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Generate new tokens
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Store the new refresh token with the same family
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 days
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Scan for all tokens in this family and delete them.
  // This is the nuclear option — if someone replays a refresh token,
  // we kill every token in the family, forcing re-authentication.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

Il concetto di token family è ciò che rende tutto sicuro. Ogni refresh token appartiene a una famiglia (creata al login). Quando ruoti, il nuovo token eredita la famiglia. Se un attaccante replica un vecchio refresh token, rilevi il riutilizzo e distruggi l'intera famiglia. L'utente legittimo viene disconnesso, ma l'attaccante non entra.

Questo dibattito va avanti da anni, e la risposta è chiara: cookie httpOnly per i refresh token, sempre.

localStorage è accessibile a qualsiasi JavaScript in esecuzione sulla tua pagina. Se hai una singola vulnerabilità XSS — e su larga scala, prima o poi ne avrai una — l'attaccante può leggere il token ed estrarlo. Fine della storia.

I cookie httpOnly non sono accessibili a JavaScript. Punto. Una vulnerabilità XSS può ancora fare richieste per conto dell'utente (perché i cookie vengono inviati automaticamente), ma l'attaccante non può rubare il token stesso. Questa è una differenza significativa.

typescript
// Setting a secure refresh token cookie
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Not accessible via JavaScript
    secure: true,       // HTTPS only
    sameSite: "strict", // No cross-site requests
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
    path: "/api/auth",  // Only sent to auth endpoints
  });
}

Il path: "/api/auth" è un dettaglio che la maggior parte delle persone perde. Per impostazione predefinita, i cookie vengono inviati a ogni endpoint sul tuo dominio. Il tuo refresh token non ha bisogno di andare a /api/users o /api/products. Restringi il path, riduci la superficie di attacco.

Per gli access token, li tengo in memoria (una variabile JavaScript). Non localStorage, non sessionStorage, non un cookie. In memoria. Hanno vita breve (15 minuti), e quando la pagina si aggiorna, il client colpisce silenziosamente l'endpoint di refresh per ottenerne uno nuovo. Sì, questo significa una richiesta extra al caricamento della pagina. Ne vale la pena.

Validazione dell'input: non fidarti mai del client#

Il client non è tuo amico. Il client è un estraneo che è entrato a casa tua e ha detto "ho il permesso di essere qui." Tu controlli il documento comunque.

Ogni dato che proviene dall'esterno del tuo server — body della richiesta, parametri della query, parametri URL, header — è input non affidabile. Non importa che il tuo form React abbia la validazione. Qualcuno la bypasserà con curl.

Zod per la validazione type-safe#

Zod è la cosa migliore che sia successa alla validazione dell'input in Node.js. Ti dà validazione runtime con tipi TypeScript gratis:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Invalid email format")
    .max(254, "Email too long")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Password must be at least 12 characters")
    .max(128, "Password too long") // Prevent bcrypt DoS
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Password must contain uppercase, lowercase, and a number"
    ),
 
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Note: "admin" is intentionally not an option here.
  // Admin role assignment goes through a separate, privileged endpoint.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Usage in an Express handler
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data is fully typed as CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Alcuni dettagli rilevanti per la sicurezza:

  • max(128) sulla password — bcrypt ha un limite di input di 72 byte, e alcune implementazioni troncano silenziosamente. Ma più importante, se permetti una password da 10MB, bcrypt impiegherà un tempo significativo a hasharla. Questo è un vettore DoS.
  • max(254) sull'email — RFC 5321 limita gli indirizzi email a 254 caratteri. Qualsiasi cosa più lunga non è un'email valida.
  • Enum per il ruolo, senza admin — Il mass assignment è una delle vulnerabilità API più vecchie. Se accetti il ruolo dal body della richiesta senza validarlo, qualcuno invierà "role": "admin" sperando nel meglio.

La SQL injection non è risolta#

"Usa un ORM" non ti protegge se scrivi query raw per le prestazioni. E tutti scrivono query raw per le prestazioni prima o poi.

typescript
// VULNERABILE — concatenazione di stringhe
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// SICURO — query parametrizzata
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Con Prisma, sei per lo più al sicuro — ma $queryRaw può comunque morderti:

typescript
// VULNERABILE — template literal in $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// SICURO — uso di Prisma.sql per la parametrizzazione
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL injection#

MongoDB non usa SQL, ma non è immune all'injection. Se passi input utente non sanitizzato come oggetto query, le cose vanno male:

typescript
// VULNERABILE — se req.body.username è { "$gt": "" }
// questo restituisce il primo utente nella collection
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// SICURO — converti esplicitamente a stringa
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// MEGLIO — valida con Zod prima
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

La soluzione è semplice: valida i tipi di input prima che raggiungano il driver del database. Se username dovrebbe essere una stringa, asserisci che sia una stringa.

Path traversal#

Se la tua API serve file o legge da un path che include input dell'utente, il path traversal rovinerà la tua settimana:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalize and resolve to an absolute path
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Critical: verify the resolved path is still within the allowed directory
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Verify the file actually exists
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Without this check:
// GET /api/files?name=../../../etc/passwd
// resolves to /etc/passwd

Il pattern path.resolve + startsWith è l'approccio corretto. Non provare a rimuovere ../ manualmente — ci sono troppi trucchi di encoding (..%2F, ..%252F, ....//) che bypasseranno la tua regex.

Rate Limiting#

Senza rate limiting, la tua API è un buffet all-you-can-eat per i bot. Attacchi brute force, credential stuffing, esaurimento delle risorse — il rate limiting è la prima difesa contro tutti questi.

Token bucket vs sliding window#

Token bucket: Hai un secchio che contiene N token. Ogni richiesta costa un token. I token si ricaricano a un tasso fisso. Se il secchio è vuoto, la richiesta viene rifiutata. Questo permette burst — se il secchio è pieno, puoi fare N richieste istantaneamente.

Sliding window: Conta le richieste all'interno di una finestra temporale mobile. Più prevedibile, più difficile da sfondare con burst.

Uso sliding window per la maggior parte delle cose perché il comportamento è più facile da ragionare e spiegare al team:

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // Remove entries outside the window
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Count entries in the window
  multi.zcard(key);
 
  // Add the current request (we'll remove it if over limit)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Set expiry on the key
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis transaction failed");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Over limit — remove the entry we just added
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Rate limit a livelli#

Un singolo rate limit globale non è sufficiente. Endpoint diversi hanno profili di rischio diversi:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Auth endpoints — limiti stretti, obiettivo di brute force
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // Letture dati — più generosi
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Scritture dati — moderati
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Fallback globale
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

Nota: gli utenti autenticati vengono limitati per ID utente, non per IP. Questo è importante perché molti utenti legittimi condividono IP (reti aziendali, VPN, operatori mobili). Se limiti solo per IP, bloccherai interi uffici.

Header del rate limit#

Dì sempre al client cosa sta succedendo:

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "Too many requests",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

Configurazione CORS#

CORS è probabilmente il meccanismo di sicurezza più frainteso nello sviluppo web. Metà delle risposte su Stack Overflow riguardo a CORS sono "imposta Access-Control-Allow-Origin: * e funziona." Tecnicamente vero. Ed è anche il modo in cui apri la tua API a ogni sito malevolo su internet.

Cosa fa davvero CORS (e cosa non fa)#

CORS è un meccanismo del browser. Dice al browser se il JavaScript dall'Origine A è autorizzato a leggere la risposta dall'Origine B. Tutto qui.

Cosa CORS non fa:

  • Non protegge la tua API da curl, Postman o richieste server-to-server
  • Non autentica le richieste
  • Non cripta nulla
  • Non previene il CSRF da solo (anche se aiuta combinato con altri meccanismi)

Cosa CORS fa:

  • Impedisce a sito-malevolo.com di fare richieste fetch a tua-api.com e leggere la risposta nel browser dell'utente
  • Impedisce al JavaScript dell'attaccante di estrarre dati attraverso la sessione autenticata della vittima

La trappola del wildcard#

typescript
// PERICOLOSO — permette a qualsiasi sito web di leggere le risposte della tua API
app.use(cors({ origin: "*" }));
 
// ANCHE PERICOLOSO — questo è un approccio "dinamico" comune che è solo * con passi extra
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Allows everything
    },
  })
);

Il problema con * è che rende le risposte della tua API leggibili da qualsiasi JavaScript su qualsiasi pagina. Se la tua API restituisce dati utente e l'utente è autenticato tramite cookie, qualsiasi sito web che l'utente visita può leggere quei dati.

Ancora peggio: Access-Control-Allow-Origin: * non può essere combinato con credentials: true. Quindi se hai bisogno dei cookie (per l'auth), letteralmente non puoi usare il wildcard. Ma ho visto persone provare ad aggirare questo riflettendo l'header Origin — che equivale a * con credenziali, il peggio di entrambi i mondi.

La configurazione corretta#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Allow requests with no origin (mobile apps, curl, server-to-server)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    },
    credentials: true, // Allow cookies
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflight for 24 hours
  })
);

Decisioni chiave:

  • Set esplicito di origini, non una regex. Le regex sono insidiose — yourapp.com potrebbe matchare evilyourapp.com se la tua regex non è ancorata correttamente.
  • credentials: true perché usiamo cookie httpOnly per i refresh token.
  • maxAge: 86400 — Le richieste preflight (OPTIONS) aggiungono latenza. Dire al browser di cachare il risultato CORS per 24 ore riduce i round trip non necessari.
  • exposedHeaders — Per impostazione predefinita, il browser espone solo un pugno di header di risposta "semplici" a JavaScript. Se vuoi che il client legga gli header del rate limit, devi esporli esplicitamente.

Richieste preflight#

Quando una richiesta non è "semplice" (usa un header non standard, un metodo non standard, o un content type non standard), il browser invia prima una richiesta OPTIONS per chiedere il permesso. Questo è il preflight.

Se la tua configurazione CORS non gestisce OPTIONS, le richieste preflight falliranno e la richiesta effettiva non verrà mai inviata. La maggior parte delle librerie CORS gestisce questo automaticamente, ma se stai usando un framework che non lo fa, devi gestirlo:

typescript
// Manual preflight handling (most frameworks do this for you)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

Header di sicurezza#

Gli header di sicurezza sono il miglioramento di sicurezza più economico che puoi fare. Sono header di risposta che dicono al browser di abilitare funzionalità di sicurezza. La maggior parte di essi è una singola riga di configurazione, e proteggono contro intere classi di attacchi.

Gli header che contano#

typescript
import helmet from "helmet";
 
// One line. This is the fastest security win in any Express app.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Needed for many CSS-in-JS solutions
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Cosa fa ogni header:

Content-Security-Policy (CSP) — L'header di sicurezza più potente. Dice al browser esattamente quali sorgenti sono permesse per script, stili, immagini, font, ecc. Se un attaccante inietta un tag <script> che carica da evil.com, CSP lo blocca. Questa è la singola difesa più efficace contro XSS.

Strict-Transport-Security (HSTS) — Dice al browser di usare sempre HTTPS, anche se l'utente digita http://. La direttiva preload ti permette di inviare il tuo dominio alla lista HSTS integrata del browser, così anche la prima richiesta è forzata su HTTPS.

X-Frame-Options — Impedisce al tuo sito di essere incorporato in un iframe. Questo ferma gli attacchi clickjacking dove un attaccante sovrappone la tua pagina con elementi invisibili. Helmet lo imposta a SAMEORIGIN per impostazione predefinita. La sostituzione moderna è frame-ancestors nel CSP.

X-Content-Type-Options: nosniff — Impedisce al browser di indovinare (sniffing) il tipo MIME di una risposta. Senza questo, se servi un file con il Content-Type sbagliato, il browser potrebbe eseguirlo come JavaScript.

Referrer-Policy — Controlla quante informazioni URL vengono inviate nell'header Referer. strict-origin-when-cross-origin invia l'URL completo per le richieste same-origin ma solo l'origine per le richieste cross-origin. Questo impedisce la fuga di parametri URL sensibili a terze parti.

Testare gli header#

Dopo il deploy, controlla il tuo punteggio su securityheaders.com. Punta a un rating A+. Ci vogliono circa cinque minuti di configurazione per arrivarci.

Puoi anche verificare gli header programmaticamente:

typescript
import { describe, it, expect } from "vitest";
 
describe("Security headers", () => {
  it("should include all required security headers", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet removes this
  });
});

Il controllo su x-powered-by è sottile ma importante. Express imposta X-Powered-By: Express per impostazione predefinita, dicendo agli attaccanti esattamente quale framework stai usando. Helmet lo rimuove.

Gestione dei segreti#

Questa dovrebbe essere ovvia, ma la vedo ancora nelle pull request: chiavi API, password del database e segreti JWT hardcoded nei file sorgente. O committati in file .env che non erano nel .gitignore. Una volta che è nella cronologia git, è lì per sempre, anche se cancelli il file nel commit successivo.

Le regole#

  1. Non committare mai segreti su git. Non nel codice, non in .env, non in file di configurazione, non in file Docker Compose, non in commenti "solo per test".

  2. Usa .env.example come template. Documenta quali variabili d'ambiente sono necessarie, senza contenere valori reali:

bash
# .env.example — committa questo
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — NON committare mai questo
# Elencato in .gitignore
  1. Valida le variabili d'ambiente all'avvio. Non aspettare che una richiesta colpisca un endpoint che ha bisogno dell'URL del database. Fallisci subito:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Invalid environment variables:");
    console.error(result.error.format());
    process.exit(1); // Don't start with bad config
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Usa un secret manager in produzione. Le variabili d'ambiente funzionano per setup semplici, ma hanno limitazioni: sono visibili nei listing dei processi, persistono in memoria e possono essere leakate attraverso i log degli errori.

Per i sistemi in produzione, usa un secret manager dedicato:

  • AWS Secrets Manager o SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (se vuoi qualcosa che funziona su tutti i cloud)

Il pattern è lo stesso indipendentemente da quale usi: l'applicazione recupera i segreti all'avvio dal secret manager, non dalle variabili d'ambiente.

  1. Ruota i segreti regolarmente. Se stai usando lo stesso segreto JWT da due anni, è tempo di ruotare. Implementa la rotazione delle chiavi: supporta multiple chiavi di firma valide contemporaneamente, firma i nuovi token con la nuova chiave, verifica sia con la vecchia che con la nuova, e ritira la vecchia chiave dopo che tutti i token esistenti sono scaduti.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Only the active key signs new tokens
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Returns all valid keys
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Try the next key
    }
  }
 
  throw new ApiError(401, "Invalid token");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Include key ID in the header
  });
}

OWASP API Security Top 10#

La OWASP API Security Top 10 è la lista standard di settore delle vulnerabilità API. Viene aggiornata periodicamente, e ogni voce nella lista è qualcosa che ho visto in codebase reali. Vediamole una per una.

API1: Broken Object Level Authorization (BOLA)#

La vulnerabilità API più comune. L'utente è autenticato, ma l'API non controlla se ha accesso all'oggetto specifico che sta richiedendo.

typescript
// VULNERABILE — qualsiasi utente autenticato può accedere ai dati di qualsiasi utente
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// CORRETTO — verifica che l'utente stia accedendo ai propri dati (o sia admin)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Access denied" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

La versione vulnerabile è ovunque. Passa ogni controllo auth — l'utente ha un token valido — ma non verifica che sia autorizzato ad accedere a questa risorsa specifica. Cambia l'ID nell'URL e ottieni i dati di qualcun altro.

API2: Broken Authentication#

Meccanismi di login deboli, MFA mancante, token che non scadono mai, password salvate in chiaro. Questo copre il livello di autenticazione stesso.

La soluzione è tutto ciò che abbiamo discusso nella sezione autenticazione: requisiti password forti, bcrypt con round sufficienti, access token di breve durata, rotazione dei refresh token, blocco account dopo tentativi falliti.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Increment failed attempts
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Same error message for both cases — don't reveal whether the email exists
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Reset failed attempts on successful login
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Il commento su "stesso messaggio di errore" è importante. Se la tua API restituisce "utente non trovato" per email non valide e "password errata" per email valide con password sbagliate, stai dicendo a un attaccante quali email esistono nel tuo sistema.

API3: Broken Object Property Level Authorization#

Restituire più dati del necessario, o permettere agli utenti di modificare proprietà che non dovrebbero.

typescript
// VULNERABILE — restituisce l'intero oggetto utente, inclusi campi interni
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Response includes: passwordHash, internalNotes, billingId, ...
});
 
// CORRETTO — allowlist esplicita dei campi restituiti
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

Non restituire mai interi oggetti del database. Seleziona sempre i campi che vuoi esporre. Questo si applica anche alle scritture — non fare spread dell'intero body della richiesta nella tua query di update:

typescript
// VULNERABILE — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Attacker sends: { "role": "admin", "verified": true }
});
 
// CORRETTO — seleziona i campi permessi
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4: Unrestricted Resource Consumption#

La tua API è una risorsa. CPU, memoria, bandwidth, connessioni al database — sono tutti finiti. Senza limiti, un singolo client può esaurirli tutti.

Questo va oltre il rate limiting. Include:

typescript
// Limit request body size
app.use(express.json({ limit: "1mb" }));
 
// Limit query complexity
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// Limit file upload size
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Invalid file type"));
    }
  },
});
 
// Timeout long-running requests
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Diverso da BOLA. Questo riguarda l'accesso a funzioni (endpoint) a cui non dovresti avere accesso, non oggetti. L'esempio classico: un utente normale che scopre endpoint admin.

typescript
// Middleware that checks role-based access
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Log the attempt — this might be an attack
      logger.warn("Unauthorized access attempt", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Insufficient permissions" });
    }
 
    next();
  };
}
 
// Apply to routes
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

Non affidarti a nascondere gli endpoint. "La sicurezza tramite oscuramento" non è sicurezza. Anche se l'URL del pannello admin non è linkato da nessuna parte, qualcuno troverà /api/admin/users con il fuzzing.

API6: Unrestricted Access to Sensitive Business Flows#

Abuso automatizzato di funzionalità business legittime. Pensa: bot che comprano articoli a stock limitato, creazione automatica di account per spam, scraping dei prezzi dei prodotti.

Le mitigazioni sono specifiche del contesto: CAPTCHA, device fingerprinting, analisi comportamentale, autenticazione step-up per operazioni sensibili. Non esiste uno snippet di codice universale.

API7: Server Side Request Forgery (SSRF)#

Se la tua API recupera URL forniti dall'utente (webhook, URL delle foto profilo, anteprime dei link), un attaccante può far richiedere al tuo server risorse interne:

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "Invalid URL");
  }
 
  // Only allow HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Resolve the hostname and check if it's a private IP
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Internal addresses are not allowed");
    }
  }
 
  // Now fetch with a timeout and size limit
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Don't follow redirects (they could redirect to internal IPs)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Dettagli chiave: risolvi il DNS prima e controlla l'IP prima di fare la richiesta. Blocca i redirect — un attaccante può hostare un URL che reindirizza a http://169.254.169.254/ (endpoint dei metadati AWS) per bypassare il tuo controllo a livello di URL.

API8: Security Misconfiguration#

Credenziali di default lasciate invariate, metodi HTTP non necessari abilitati, messaggi di errore verbosi in produzione, directory listing abilitato, CORS mal configurato. Questa è la categoria "ti sei dimenticato di chiudere la porta a chiave."

typescript
// Don't leak stack traces in production
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Unhandled error", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Generic error message — don't reveal internals
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Include a request ID for debugging
    });
  } else {
    // In development, show the full error
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Disable unnecessary HTTP methods
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Method not allowed" });
  }
  next();
});

API9: Improper Inventory Management#

Hai deployato la v2 dell'API ma hai dimenticato di spegnere la v1. Oppure c'è un endpoint /debug/ che era utile durante lo sviluppo e ancora attivo in produzione. Oppure un server di staging pubblicamente accessibile con dati di produzione.

Questo non è un fix di codice — è una disciplina operativa. Mantieni una lista di tutti gli endpoint API, tutte le versioni deployate e tutti gli ambienti. Usa scanning automatizzato per trovare servizi esposti. Elimina ciò che non ti serve.

API10: Unsafe Consumption of APIs#

La tua API consuma API di terze parti. Validi le loro risposte? Cosa succede se un payload webhook da Stripe proviene in realtà da un attaccante?

typescript
import crypto from "crypto";
 
// Verify Stripe webhook signatures
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // Reject old timestamps (prevent replay attacks)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 minute tolerance
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Verifica sempre le firme sui webhook. Valida sempre la struttura delle risposte di API di terze parti. Imposta sempre timeout sulle richieste in uscita. Non fidarti mai dei dati solo perché provengono da "un partner fidato."

Audit Logging#

Quando qualcosa va storto — e andrà storto — gli audit log sono il modo per capire cosa è successo. Ma il logging è un'arma a doppio taglio. Logga troppo poco e sei cieco. Logga troppo e crei una responsabilità per la privacy.

Cosa loggare#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // Additional context
  requestId: string;        // For correlating with application logs
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Write to a separate, append-only data store
  // This should NOT be the same database your application uses
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // For critical actions, also write to an immutable external log
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Logga questi eventi:

  • Autenticazione: login, logout, tentativi falliti, refresh dei token
  • Autorizzazione: eventi di accesso negato (sono spesso indicatori di attacco)
  • Modifiche ai dati: creazioni, aggiornamenti, cancellazioni — chi ha cambiato cosa, quando
  • Azioni admin: cambi di ruolo, gestione utenti, cambi di configurazione
  • Eventi di sicurezza: trigger del rate limit, violazioni CORS, richieste malformate

Cosa NON loggare#

Non loggare mai:

  • Password (neanche quelle hashate — l'hash è una credenziale)
  • Numeri completi di carte di credito (logga solo le ultime 4 cifre)
  • Numeri di previdenza sociale o documenti d'identità
  • Chiavi API o token (logga al massimo un prefisso: sk_live_...abc)
  • Informazioni sanitarie personali
  • Body completi delle richieste che potrebbero contenere PII
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Log a prova di manomissione#

Se un attaccante ottiene accesso al tuo sistema, una delle prime cose che farà è modificare i log per coprire le sue tracce. Il logging a prova di manomissione rende questo rilevabile:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // The initial hash in the chain
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// To verify the chain integrity:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // Chain is broken — logs have been tampered with
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

È lo stesso concetto di una blockchain — l'hash di ogni voce di log dipende dalla voce precedente. Se qualcuno modifica o cancella una voce, la catena si rompe.

Sicurezza delle dipendenze#

Il tuo codice potrebbe essere sicuro. Ma che dire degli 847 pacchetti npm nel tuo node_modules? Il problema della supply chain è reale, e negli anni è peggiorato.

npm audit è il minimo indispensabile#

bash
# Run this in CI, fail the build on high/critical vulnerabilities
npm audit --audit-level=high
 
# Fix what can be auto-fixed
npm audit fix
 
# See what you're actually pulling in
npm ls --all

Ma npm audit ha limitazioni. Controlla solo il database advisory di npm, e le sue valutazioni di severità non sono sempre accurate. Aggiungi strumenti aggiuntivi:

Scansione automatica delle dipendenze#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Group minor and patch updates to reduce PR noise
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Il lockfile è uno strumento di sicurezza#

Committa sempre il tuo package-lock.json (o pnpm-lock.yaml, o yarn.lock). Il lockfile fissa le versioni esatte di ogni dipendenza, incluse quelle transitive. Senza di esso, npm install potrebbe scaricare una versione diversa da quella che hai testato — e quella versione diversa potrebbe essere compromessa.

bash
# In CI, use ci instead of install — it respects the lockfile strictly
npm ci

npm ci fallisce se il lockfile non corrisponde a package.json, invece di aggiornarlo silenziosamente. Questo cattura i casi in cui qualcuno ha modificato package.json ma ha dimenticato di aggiornare il lockfile.

Valuta prima di installare#

Prima di aggiungere una dipendenza, chiediti:

  1. Ne ho davvero bisogno? Posso scriverlo in 20 righe invece di aggiungere un pacchetto?
  2. Quanti download ha? Conteggi bassi non sono necessariamente negativi, ma significano meno occhi che revisionano il codice.
  3. Quando è stato aggiornato l'ultima volta? Un pacchetto non aggiornato da 3 anni potrebbe avere vulnerabilità non patchate.
  4. Quante dipendenze porta dentro? is-odd dipende da is-number che dipende da kind-of. Sono tre pacchetti per fare qualcosa che una riga di codice può fare.
  5. Chi lo mantiene? Un singolo maintainer è un singolo punto di compromissione.
typescript
// You don't need a package for this:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Or this:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Or this:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

La checklist pre-deploy#

Questa è la checklist effettiva che uso prima di ogni deployment in produzione. Non è esaustiva — la sicurezza non è mai "finita" — ma cattura gli errori che contano di più.

#ControlloCriterio di superamentoPriorità
1AutenticazioneJWT verificati con algoritmo, issuer e audience espliciti. No alg: none.Critico
2Scadenza tokenAccess token scadono in 15 min o meno. I refresh token ruotano all'uso.Critico
3Storage dei tokenRefresh token in cookie httpOnly secure. Nessun token in localStorage.Critico
4Autorizzazione su ogni endpointOgni endpoint di accesso ai dati controlla i permessi a livello di oggetto. BOLA testato.Critico
5Validazione dell'inputTutti gli input utente validati con Zod o equivalente. Nessun req.body raw nelle query.Critico
6SQL/NoSQL injectionTutte le query al database usano query parametrizzate o metodi ORM. Nessuna concatenazione di stringhe.Critico
7Rate limitingEndpoint auth: 5/15min. API generica: 60/min. Header rate limit restituiti.Alto
8CORSAllowlist di origini esplicita. Nessun wildcard con credenziali. Preflight cachato.Alto
9Header di sicurezzaCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy tutti presenti.Alto
10Gestione erroriGli errori in produzione restituiscono messaggi generici. Nessun stack trace, nessun errore SQL esposto.Alto
11SegretiNessun segreto nel codice o nella cronologia git. .env in .gitignore. Validati all'avvio.Critico
12Dipendenzenpm audit pulito (nessun high/critical). Lockfile committato. npm ci in CI.Alto
13Solo HTTPSHSTS abilitato con preload. HTTP reindirizza a HTTPS. Flag secure sul cookie impostato.Critico
14LoggingEventi auth, accesso negato e mutazioni dati loggati. Nessun PII nei log.Medio
15Limiti dimensione richiesteBody parser limitato (1MB default). Upload file cappati. Paginazione query forzata.Medio
16Protezione SSRFURL forniti dall'utente validati. IP privati bloccati. Redirect disabilitati o validati.Medio
17Blocco accountI tentativi di login falliti attivano il blocco dopo 5 tentativi. Blocco loggato.Alto
18Verifica webhookTutti i webhook in ingresso verificati con firme. Protezione replay tramite timestamp.Alto
19Endpoint adminControllo di accesso basato sui ruoli su tutte le route admin. Tentativi loggati.Critico
20Mass assignmentEndpoint di update usano schema Zod con campi in allowlist. Nessun body raw espanso.Alto

La tengo come template di issue GitHub. Prima di taggare una release, qualcuno nel team deve controllare ogni riga e approvare. Non è glamour, ma funziona.

Il cambio di mentalità#

La sicurezza non è una feature che aggiungi alla fine. Non è uno sprint che fai una volta all'anno. È un modo di pensare a ogni riga di codice che scrivi.

Quando scrivi un endpoint, pensa: "Cosa succede se qualcuno invia dati che non mi aspetto?" Quando aggiungi un parametro, pensa: "Cosa succede se qualcuno lo cambia nell'ID di qualcun altro?" Quando aggiungi una dipendenza, pensa: "Cosa succede se questo pacchetto viene compromesso martedì prossimo?"

Non catturerai tutto. Nessuno lo fa. Ma eseguire questa checklist — metodicamente, prima di ogni deployment — cattura le cose che contano di più. Le vittorie facili. I buchi ovvi. Gli errori che trasformano una brutta giornata in una violazione dei dati.

Costruisci l'abitudine. Esegui la checklist. Rilascia con fiducia.

Articoli correlati