Přeskočit na obsah
29 min čtení

Best practices pro bezpečnost API: Checklist, který spouštím na každém projektu

Autentizace, autorizace, validace vstupů, rate limiting, CORS, správa secrets a OWASP API Top 10. Co kontroluji před každým produkčním deploymentem.

Sdílet:X / TwitterLinkedIn

Nasadil jsem API, která byla zcela otevřená. Ne záměrně, ne z lenosti — prostě jsem nevěděl, co nevím. Endpoint, který vracel každé pole v objektu uživatele, včetně hashovaných hesel. Rate limiter, který kontroloval pouze IP adresy, což znamenalo, že kdokoliv za proxy mohl bombardovat API. JWT implementace, kde jsem zapomněl ověřit claim iss, takže tokeny z úplně jiné služby fungovaly bez problémů.

Každá z těchto chyb se dostala do produkce. Každou z nich někdo odhalil — některé já, některé uživatelé, jednu bezpečnostní výzkumník, který byl dost laskavý, aby mi napsal email místo toho, aby to zveřejnil na Twitteru.

Tento příspěvek je checklist, který jsem vytvořil z těchto chyb. Procházím ho před každým produkčním deploymentem. Ne proto, že bych byl paranoidní, ale protože jsem se naučil, že bezpečnostní chyby bolí nejvíc. Rozbitý button uživatele otráví. Rozbitý auth flow jim vystaví data.

Autentizace vs Autorizace#

Tato dvě slova se v meetinzích, v dokumentaci, dokonce i v komentářích v kódu používají zaměnitelně. Nejsou to samé.

Autentizace odpovídá na: „Kdo jsi?" Je to krok přihlášení. Uživatelské jméno a heslo, OAuth flow, magic link — cokoliv, co prokáže vaši identitu.

Autorizace odpovídá na: „Co smíš dělat?" Je to krok oprávnění. Může tento uživatel smazat tento zdroj? Může přistoupit k tomuto admin endpointu? Může číst data jiného uživatele?

Nejčastější bezpečnostní chyba, kterou jsem viděl v produkčních API, není rozbitý login flow. Je to chybějící kontrola autorizace. Uživatel je autentizovaný — má platný token — ale API nikdy neověří, zda je oprávněn provést akci, kterou požaduje.

JWT: Anatomie a chyby, na kterých záleží#

JWT jsou všude. Jsou také všude špatně pochopené. JWT má tři části oddělené tečkami:

header.payload.signature

Header říká, který algoritmus byl použit. Payload obsahuje claims (ID uživatele, role, expirace). Signature dokazuje, že nikdo nezměnil první dvě části.

Zde je správné ověření JWT v 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"], // Nikdy nepovolujte "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 sekund tolerance pro odchylku hodin
    }) 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");
  }
}

Několik věcí, kterých si všimněte:

  1. algorithms: ["HS256"] — Toto je kritické. Pokud neurčíte algoritmus, útočník může poslat token s "alg": "none" v headeru a přeskočit ověření úplně. To je útok alg: none a postihl reálné produkční systémy.

  2. issuer a audience — Bez nich token vytvořený pro Službu A funguje na Službě B. Pokud provozujete více služeb sdílejících stejný secret (což byste neměli, ale lidé to dělají), tímto způsobem dochází ke zneužití tokenů napříč službami.

  3. Specifické zpracování chyb — Nevracejte "invalid token" pro každé selhání. Rozlišení mezi expirovaným a neplatným pomáhá klientovi vědět, zda má obnovit nebo znovu autentizovat.

Rotace Refresh tokenů#

Access tokeny by měly být krátkodobé — 15 minut je standard. Ale nechcete, aby uživatelé zadávali heslo každých 15 minut. K tomu slouží refresh tokeny.

Vzor, který skutečně funguje v produkci:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Rodina tokenů pro detekci rotace
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token nenalezen — buď expiroval, nebo byl již použit.
    // Pokud byl již použit, jedná se o potenciální replay útok.
    // Zneplatníme celou rodinu tokenů.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Okamžitě smažeme starý token — jednorázové použití
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Vygenerujeme nové tokeny
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Uložíme nový refresh token se stejnou rodinou
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 dní
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Prohledáme všechny tokeny v této rodině a smažeme je.
  // Toto je nukleární varianta — pokud někdo přehraje refresh token,
  // zabijeme každý token v rodině a vynutíme opětovnou autentizaci.
  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);
      }
    }
  }
}

Koncept rodiny tokenů je to, co to dělá bezpečným. Každý refresh token patří do rodiny (vytvořené při přihlášení). Při rotaci nový token zdědí rodinu. Pokud útočník přehraje starý refresh token, detekujete opětovné použití a zabijete celou rodinu. Legitimní uživatel je odhlášen, ale útočník se nedostane dovnitř.

Tato debata probíhá léta a odpověď je jasná: httpOnly cookies pro refresh tokeny, vždy.

localStorage je přístupný jakémukoli JavaScriptu běžícímu na vaší stránce. Pokud máte jedinou XSS zranitelnost — a ve velkém měřítku ji nakonec budete mít — útočník může přečíst token a exfiltrovat ho. Konec hry.

httpOnly cookies nejsou přístupné JavaScriptu. Tečka. XSS zranitelnost může stále provádět požadavky jménem uživatele (protože cookies se posílají automaticky), ale útočník nemůže ukrást samotný token. To je významný rozdíl.

typescript
// Nastavení zabezpečeného refresh token cookie
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Nepřístupné přes JavaScript
    secure: true,       // Pouze HTTPS
    sameSite: "strict", // Žádné cross-site požadavky
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dní
    path: "/api/auth",  // Odesílá se pouze na auth endpointy
  });
}

path: "/api/auth" je detail, který většina lidí přehlédne. Ve výchozím nastavení se cookies odesílají na každý endpoint vaší domény. Váš refresh token nemusí putovat na /api/users nebo /api/products. Omezte cestu, zmenšete útočnou plochu.

Pro access tokeny je ukládám v paměti (JavaScriptová proměnná). Ne localStorage, ne sessionStorage, ne cookie. V paměti. Mají krátkou životnost (15 minut) a při obnovení stránky klient tiše zavolá refresh endpoint, aby získal nový. Ano, to znamená extra požadavek při načtení stránky. Stojí to za to.

Validace vstupů: Nikdy nevěřte klientovi#

Klient není váš přítel. Klient je cizinec, který vešel do vašeho domu a řekl: „Mám tu povolení být." Stejně mu zkontrolujete průkaz.

Každý kus dat, který přichází z vnějšku vašeho serveru — tělo požadavku, query parametry, URL parametry, headery — je nedůvěryhodný vstup. Nezáleží na tom, že váš React formulář má validaci. Někdo ji obejde pomocí curl.

Zod pro typově bezpečnou validaci#

Zod je to nejlepší, co se stalo validaci vstupů v Node.js. Dává vám runtime validaci s TypeScript typy zadarmo:

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") // Prevence 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"),
  // Poznámka: "admin" zde záměrně není volba.
  // Přiřazení admin role probíhá přes samostatný, privilegovaný endpoint.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Použití v Express handleru
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 je plně typované jako CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Několik bezpečnostně relevantních detailů:

  • max(128) na heslo — bcrypt má limit vstupu 72 bajtů a některé implementace tiše oříznou. Ale hlavně, pokud povolíte 10MB heslo, bcrypt stráví značný čas jeho hashováním. To je DoS vektor.
  • max(254) na email — RFC 5321 omezuje emailové adresy na 254 znaků. Cokoliv delšího není platný email.
  • Enum pro roli, bez admin — Mass assignment je jedna z nejstarších zranitelností API. Pokud přijmete roli z těla požadavku bez validace, někdo pošle "role": "admin" a bude doufat v nejlepší.

SQL Injection není vyřešený#

„Prostě použijte ORM" vás neochrání, pokud píšete raw dotazy kvůli výkonu. A raw dotazy kvůli výkonu nakonec píše každý.

typescript
// ZRANITELNÉ — spojování řetězců
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// BEZPEČNÉ — parametrizovaný dotaz
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

S Prisma jste většinou v bezpečí — ale $queryRaw vás může stále kousnout:

typescript
// ZRANITELNÉ — template literal v $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// BEZPEČNÉ — použití Prisma.sql pro parametrizaci
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL Injection#

MongoDB nepoužívá SQL, ale není imunní vůči injection. Pokud předáte nesanitizovaný uživatelský vstup jako query objekt, věci se pokazí:

typescript
// ZRANITELNÉ — pokud req.body.username je { "$gt": "" }
// toto vrátí prvního uživatele v kolekci
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// BEZPEČNÉ — explicitní převod na řetězec
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// LEPŠÍ — nejprve validace pomocí Zod
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

Oprava je jednoduchá: validujte typy vstupů dříve, než se dostanou k vašemu databázovému ovladači. Pokud username má být řetězec, ověřte, že je to řetězec.

Path Traversal#

Pokud vaše API servíruje soubory nebo čte z cesty, která obsahuje uživatelský vstup, path traversal vám zničí týden:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalizace a rozlišení na absolutní cestu
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Kritické: ověření, že rozlišená cesta je stále v povoleném adresáři
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Ověření, že soubor skutečně existuje
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Bez této kontroly:
// GET /api/files?name=../../../etc/passwd
// se rozliší na /etc/passwd

Vzor path.resolve + startsWith je správný přístup. Nepokoušejte se ručně odstraňovat ../ — existuje příliš mnoho encoding triků (..%2F, ..%252F, ....//), které obejdou váš regex.

Rate Limiting#

Bez rate limitingu je vaše API neomezený bufet pro boty. Brute force útoky, credential stuffing, vyčerpání zdrojů — rate limiting je první obrana proti všemu z toho.

Token Bucket vs Sliding Window#

Token bucket: Máte kbelík, který pojme N tokenů. Každý požadavek stojí jeden token. Tokeny se doplňují konstantní rychlostí. Pokud je kbelík prázdný, požadavek je zamítnut. To umožňuje shluky — pokud je kbelík plný, můžete udělat N požadavků okamžitě.

Sliding window: Počítá požadavky v pohybujícím se časovém okně. Předvídatelnější, těžší prorazit shluky.

Pro většinu věcí používám sliding window, protože chování je snazší pochopit a vysvětlit týmu:

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();
 
  // Odstranění záznamů mimo okno
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Počet záznamů v okně
  multi.zcard(key);
 
  // Přidání aktuálního požadavku (odstraníme ho, pokud jsme přes limit)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Nastavení expirace na klíči
  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) {
    // Přes limit — odstraníme záznam, který jsme právě přidali
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Vrstvené Rate Limity#

Jeden globální rate limit nestačí. Různé endpointy mají různé rizikové profily:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Auth endpointy — přísné limity, cíl brute force útoků
  "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 },
 
  // Čtení dat — velkorysejší
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Zápis dat — střední
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Globální fallback
  "*": { 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}`;
}

Všimněte si: autentizovaní uživatelé jsou rate-limitováni podle ID uživatele, ne IP. To je důležité, protože mnoho legitimních uživatelů sdílí IP (firemní sítě, VPN, mobilní operátoři). Pokud limitujete pouze podle IP, zablokujete celé kanceláře.

Hlavičky Rate Limitu#

Vždy sdělte klientovi, co se děje:

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),
    });
  }
}

Konfigurace CORS#

CORS je pravděpodobně nejhůře pochopený bezpečnostní mechanismus ve vývoji webu. Polovina odpovědí na Stack Overflow o CORS zní „prostě nastavte Access-Control-Allow-Origin: * a funguje to." To je technicky pravda. Tak také otevřete své API každému škodlivému webu na internetu.

Co CORS skutečně dělá (a nedělá)#

CORS je mechanismus prohlížeče. Říká prohlížeči, zda JavaScript z Originu A smí číst odpověď z Originu B. To je vše.

Co CORS nedělá:

  • Neochrání vaše API před curl, Postmanem nebo server-to-server požadavky
  • Neautentizuje požadavky
  • Nešifruje nic
  • Sám o sobě nebrání CSRF (i když pomáhá v kombinaci s jinými mechanismy)

Co CORS dělá:

  • Brání malicious-website.com v provádění fetch požadavků na your-api.com a čtení odpovědi v prohlížeči uživatele
  • Brání JavaScriptu útočníka v exfiltraci dat prostřednictvím autentizované session oběti

Past s wildcard#

typescript
// NEBEZPEČNÉ — umožňuje jakémukoli webu číst odpovědi vašeho API
app.use(cors({ origin: "*" }));
 
// TAKÉ NEBEZPEČNÉ — toto je běžný "dynamický" přístup, který je jen * s extra kroky
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Povoluje vše
    },
  })
);

Problém s * je, že odpovědi vašeho API jsou čitelné jakýmkoli JavaScriptem na jakékoli stránce. Pokud vaše API vrací uživatelská data a uživatel je autentizovaný přes cookies, jakýkoli web, který uživatel navštíví, může tato data číst.

Ještě hůře: Access-Control-Allow-Origin: * nelze kombinovat s credentials: true. Takže pokud potřebujete cookies (pro auth), doslova nemůžete použít wildcard. Ale viděl jsem lidi, jak se to pokouší obejít odrazením Origin hlavičky zpět — což je ekvivalent * s credentials, to nejhorší z obou světů.

Správná konfigurace#

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) => {
      // Povolení požadavků bez originu (mobilní aplikace, 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, // Povolení cookies
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflightu na 24 hodin
  })
);

Klíčová rozhodnutí:

  • Explicitní množina originů, ne regex. Regexy jsou záludné — yourapp.com by mohl odpovídat evilyourapp.com, pokud váš regex není správně ukotven.
  • credentials: true, protože používáme httpOnly cookies pro refresh tokeny.
  • maxAge: 86400 — Preflight požadavky (OPTIONS) přidávají latenci. Řeknete-li prohlížeči, aby cachoval výsledek CORS na 24 hodin, snížíte zbytečné round tripy.
  • exposedHeaders — Ve výchozím nastavení prohlížeč vystavuje JavaScriptu jen hrstku "jednoduchých" odpovědních hlaviček. Pokud chcete, aby klient mohl číst vaše hlavičky rate limitu, musíte je explicitně vystavit.

Preflight požadavky#

Když požadavek není „jednoduchý" (používá nestandardní hlavičku, nestandardní metodu nebo nestandardní content type), prohlížeč nejprve pošle OPTIONS požadavek, aby požádal o povolení. To je preflight.

Pokud vaše CORS konfigurace nezpracovává OPTIONS, preflight požadavky selžou a skutečný požadavek nebude nikdy odeslán. Většina CORS knihoven to řeší automaticky, ale pokud používáte framework, který to nedělá, musíte to zpracovat:

typescript
// Ruční zpracování preflightu (většina frameworků to dělá za vás)
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();
});

Bezpečnostní hlavičky#

Bezpečnostní hlavičky jsou nejlevnější bezpečnostní vylepšení, které můžete udělat. Jsou to odpovědní hlavičky, které říkají prohlížeči, aby zapnul bezpečnostní funkce. Většina z nich je jediný řádek konfigurace a chrání proti celým třídám útoků.

Hlavičky, na kterých záleží#

typescript
import helmet from "helmet";
 
// Jeden řádek. Toto je nejrychlejší bezpečnostní výhra v jakékoli Express aplikaci.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Potřeba pro mnoho CSS-in-JS řešení
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 rok
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Co každá hlavička dělá:

Content-Security-Policy (CSP) — Nejsilnější bezpečnostní hlavička. Říká prohlížeči přesně, které zdroje jsou povoleny pro skripty, styly, obrázky, fonty atd. Pokud útočník vloží <script> tag, který načítá z evil.com, CSP ho zablokuje. Toto je nejefektivnější obrana proti XSS.

Strict-Transport-Security (HSTS) — Říká prohlížeči, aby vždy používal HTTPS, i když uživatel napíše http://. Direktiva preload vám umožňuje odeslat vaši doménu do vestavěného HSTS seznamu prohlížeče, takže i první požadavek je vynucen přes HTTPS.

X-Frame-Options — Zabraňuje vložení vaší stránky do iframe. To zastavuje clickjacking útoky, kde útočník překrývá vaši stránku neviditelnými elementy. Helmet to nastavuje na SAMEORIGIN ve výchozím nastavení. Moderní náhradou je frame-ancestors v CSP.

X-Content-Type-Options: nosniff — Zabraňuje prohlížeči hádat (sniffovat) MIME typ odpovědi. Bez toho, pokud servírujete soubor se špatným Content-Type, prohlížeč ho může spustit jako JavaScript.

Referrer-Policy — Řídí, kolik URL informací se odesílá v hlavičce Referer. strict-origin-when-cross-origin posílá plné URL pro same-origin požadavky, ale pouze origin pro cross-origin požadavky. To zabraňuje úniku citlivých URL parametrů třetím stranám.

Testování vašich hlaviček#

Po nasazení zkontrolujte své skóre na securityheaders.com. Cílte na hodnocení A+. Zabere to asi pět minut konfigurace, abyste se tam dostali.

Hlavičky můžete také ověřit programaticky:

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 toto odstraní
  });
});

Kontrola x-powered-by je nenápadná, ale důležitá. Express nastavuje X-Powered-By: Express ve výchozím nastavení, čímž útočníkům říká přesně, jaký framework používáte. Helmet ho odstraní.

Správa Secrets#

Toto by mělo být zřejmé, ale stále to vidím v pull requestech: API klíče, databázová hesla a JWT secrets napevno v zdrojových souborech. Nebo commitnuté v .env souborech, které nebyly v .gitignore. Jakmile je to v git historii, je to tam navždy, i když soubor smažete v dalším commitu.

Pravidla#

  1. Nikdy necommitujte secrets do gitu. Ne v kódu, ne v .env, ne v konfiguračních souborech, ne v Docker Compose souborech, ne v komentářích „jen pro testování".

  2. Použijte .env.example jako šablonu. Dokumentuje, které proměnné prostředí jsou potřeba, aniž by obsahoval skutečné hodnoty:

bash
# .env.example — toto commitněte
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 — NIKDY toto necommitujte
# Uvedeno v .gitignore
  1. Validujte proměnné prostředí při startu. Nečekejte, až požadavek dorazí na endpoint, který potřebuje URL databáze. Selžte rychle:
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); // Nestartujte se špatnou konfigurací
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. V produkci používejte secret manager. Proměnné prostředí fungují pro jednoduché nastavení, ale mají omezení: jsou viditelné ve výpisech procesů, přetrvávají v paměti a mohou uniknout přes chybové logy.

Pro produkční systémy použijte řádný secret manager:

  • AWS Secrets Manager nebo SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (pokud chcete něco, co funguje napříč všemi cloudy)

Vzor je stejný bez ohledu na to, který použijete: aplikace načítá secrets při startu ze secret manageru, ne z proměnných prostředí.

  1. Pravidelně rotujte secrets. Pokud používáte stejný JWT secret dva roky, je čas ho rotovat. Implementujte rotaci klíčů: podporujte více platných podpisových klíčů současně, podepisujte nové tokeny novým klíčem, ověřujte jak starým, tak novým, a starý klíč vyřaďte poté, co všechny existující tokeny expirují.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Pouze aktivní klíč podepisuje nové tokeny
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Vrátí všechny platné klíče
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Zkusíme další klíč
    }
  }
 
  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, // Zahrnout ID klíče v headeru
  });
}

OWASP API Security Top 10#

OWASP API Security Top 10 je průmyslový standard seznamu zranitelností API. Aktualizuje se periodicky a každá položka na seznamu je něco, co jsem viděl ve skutečných kódových bázích. Pojďme si projít každou z nich.

API1: Broken Object Level Authorization (BOLA)#

Nejčastější zranitelnost API. Uživatel je autentizovaný, ale API neověřuje, zda má přístup ke konkrétnímu objektu, který požaduje.

typescript
// ZRANITELNÉ — jakýkoli autentizovaný uživatel může přistoupit k datům jakéhokoli uživatele
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// OPRAVENÉ — ověření, že uživatel přistupuje ke svým vlastním datům (nebo je 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);
});

Zranitelná verze je všude. Projde každou auth kontrolou — uživatel má platný token — ale neověřuje, že je oprávněn přistoupit k tomuto konkrétnímu zdroji. Změňte ID v URL a získáte data někoho jiného.

API2: Broken Authentication#

Slabé přihlašovací mechanismy, chybějící MFA, tokeny, které nikdy nevyprší, hesla uložená v plaintextu. To pokrývá samotnou autentizační vrstvu.

Oprava je vše, co jsme probrali v sekci o autentizaci: silné požadavky na hesla, bcrypt s dostatečným počtem kol, krátkodobé access tokeny, rotace refresh tokenů, uzamčení účtu po neúspěšných pokusech.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minut
 
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))) {
    // Inkrementace neúspěšných pokusů
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Stejná chybová zpráva pro oba případy — neprozrazujte, zda email existuje
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Reset neúspěšných pokusů při úspěšném přihlášení
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Komentář o „stejné chybové zprávě" je důležitý. Pokud vaše API vrací „user not found" pro neplatné emaily a „wrong password" pro platné emaily se špatným heslem, říkáte útočníkovi, které emaily existují ve vašem systému.

API3: Broken Object Property Level Authorization#

Vracení více dat než je nutné, nebo umožnění uživatelům modifikovat vlastnosti, které by neměli.

typescript
// ZRANITELNÉ — vrací celý objekt uživatele, včetně interních polí
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Odpověď obsahuje: passwordHash, internalNotes, billingId, ...
});
 
// OPRAVENÉ — explicitní allowlist vrácených polí
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,
  });
});

Nikdy nevracejte celé databázové objekty. Vždy vyberte pole, která chcete vystavit. To platí i pro zápisy — nerozšiřujte celé tělo požadavku do vašeho update dotazu:

typescript
// ZRANITELNÉ — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Útočník pošle: { "role": "admin", "verified": true }
});
 
// OPRAVENÉ — výběr povolených polí
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#

Vaše API je zdroj. CPU, paměť, šířka pásma, databázová připojení — to vše je konečné. Bez limitů může jediný klient vyčerpat vše.

To jde za rámec rate limitingu. Zahrnuje to:

typescript
// Omezení velikosti těla požadavku
app.use(express.json({ limit: "1mb" }));
 
// Omezení složitosti dotazu
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),
});
 
// Omezení velikosti nahrávaných souborů
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 dlouho běžících požadavků
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Odlišné od BOLA. Toto je o přístupu k funkcím (endpointům), ke kterým byste neměli mít přístup, ne k objektům. Klasický příklad: běžný uživatel objeví admin endpointy.

typescript
// Middleware pro kontrolu přístupu na základě rolí
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)) {
      // Zalogování pokusu — může jít o útok
      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();
  };
}
 
// Aplikace na routy
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);

Nespoléhejte na skrývání endpointů. „Bezpečnost skrze nejasnost" není bezpečnost. I když URL admin panelu nikde není odkazována, někdo najde /api/admin/users pomocí fuzzingu.

API6: Unrestricted Access to Sensitive Business Flows#

Automatizované zneužívání legitimní obchodní funkčnosti. Představte si: boty skupující zboží s omezenými zásobami, automatická tvorba účtů pro spam, scrapování cen produktů.

Mitigace jsou kontextově specifické: CAPTCHA, fingerprinting zařízení, behaviorální analýza, step-up autentizace pro citlivé operace. Neexistuje univerzální kód.

API7: Server Side Request Forgery (SSRF)#

Pokud vaše API načítá URL poskytnuté uživatelem (webhooky, URL profilových obrázků, náhledy odkazů), útočník může přimět váš server, aby požádal o interní zdroje:

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");
  }
 
  // Povolit pouze HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Rozlišení hostname a kontrola, zda jde o privátní 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");
    }
  }
 
  // Nyní fetch s timeoutem a omezením velikosti
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Nesledovat přesměrování (mohla by přesměrovat na interní IP)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Klíčové detaily: nejprve rozlište DNS a zkontrolujte IP před provedením požadavku. Blokujte přesměrování — útočník může hostovat URL, které přesměruje na http://169.254.169.254/ (AWS metadata endpoint) k obejití vaší kontroly na úrovni URL.

API8: Security Misconfiguration#

Výchozí přihlašovací údaje ponechané beze změny, povolené nepotřebné HTTP metody, upovídané chybové zprávy v produkci, povolený výpis adresářů, špatně nakonfigurovaný CORS. To je kategorie „zapomněli jste zamknout dveře".

typescript
// Nevyzrazujte stack traces v produkci
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") {
    // Generická chybová zpráva — neprozrazujte interní detaily
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Zahrnout ID požadavku pro debugging
    });
  } else {
    // Ve vývoji zobrazíme plnou chybu
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Zakázání nepotřebných HTTP metod
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#

Nasadili jste v2 API, ale zapomněli jste vypnout v1. Nebo existuje /debug/ endpoint, který byl užitečný během vývoje a stále běží v produkci. Nebo staging server, který je veřejně přístupný s produkčními daty.

Toto není oprava v kódu — je to disciplína provozu. Udržujte seznam všech API endpointů, všech nasazených verzí a všech prostředí. Použijte automatizované skenování k nalezení exponovaných služeb. Zabijte to, co nepotřebujete.

API10: Unsafe Consumption of APIs#

Vaše API konzumuje API třetích stran. Validujete jejich odpovědi? Co se stane, pokud webhook payload od Stripe je ve skutečnosti od útočníka?

typescript
import crypto from "crypto";
 
// Ověření podpisů Stripe webhooků
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;
 
  // Odmítnutí starých timestampů (prevence replay útoků)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 minut tolerance
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Vždy ověřujte podpisy webhooků. Vždy validujte strukturu odpovědí API třetích stran. Vždy nastavujte timeouty na odchozí požadavky. Nikdy nevěřte datům jen proto, že přišla od „důvěryhodného partnera."

Audit Logging#

Když se něco pokazí — a pokazí se — audit logy jsou způsob, jak zjistíte, co se stalo. Ale logování je dvousečná zbraň. Logujte příliš málo a jste slepí. Logujte příliš mnoho a vytváříte povinnost ochrany soukromí.

Co logovat#

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>; // Dodatečný kontext
  requestId: string;        // Pro korelaci s aplikačními logy
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Zápis do samostatného datového úložiště, pouze pro přidávání
  // Toto by NEMĚLA být stejná databáze, kterou vaše aplikace používá
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Pro kritické akce také zapsat do neměnného externího logu
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Logujte tyto události:

  • Autentizace: přihlášení, odhlášení, neúspěšné pokusy, obnovení tokenů
  • Autorizace: události odepření přístupu (ty jsou často indikátory útoku)
  • Modifikace dat: vytváření, aktualizace, mazání — kdo co změnil, kdy
  • Admin akce: změny rolí, správa uživatelů, změny konfigurace
  • Bezpečnostní události: spuštění rate limitu, porušení CORS, poškozené požadavky

Co NELOGOVAT#

Nikdy nelogujte:

  • Hesla (ani hashovaná — hash je přihlašovací údaj)
  • Úplná čísla kreditních karet (logujte pouze poslední 4 číslice)
  • Rodná čísla nebo vládní průkazy
  • API klíče nebo tokeny (logujte nanejvýš prefix: sk_live_...abc)
  • Osobní zdravotní informace
  • Úplná těla požadavků, která mohou obsahovat 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;
}

Logy odolné proti manipulaci#

Pokud útočník získá přístup k vašemu systému, jedna z prvních věcí, které udělá, je modifikace logů, aby zakryl své stopy. Logování odolné proti manipulaci toto činí detekovatelným:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // Počáteční hash v řetězu
 
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 };
}
 
// Pro ověření integrity řetězu:
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; // Řetěz je přerušen — s logy bylo manipulováno
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Toto je stejný koncept jako blockchain — hash každého záznamu v logu závisí na předchozím záznamu. Pokud někdo modifikuje nebo smaže záznam, řetěz se přeruší.

Bezpečnost závislostí#

Váš kód může být bezpečný. Ale co těch 847 npm balíčků ve vašich node_modules? Problém dodavatelského řetězce je reálný a za posledních pár let se zhoršil.

npm audit je naprosté minimum#

bash
# Spusťte toto v CI, selhání buildu při high/critical zranitelnostech
npm audit --audit-level=high
 
# Opravte, co lze automaticky opravit
npm audit fix
 
# Podívejte se, co skutečně stahujete
npm ls --all

Ale npm audit má omezení. Kontroluje pouze databázi npm advisories a jeho hodnocení závažnosti nejsou vždy přesná. Přidejte další nástroje:

Automatické skenování závislostí#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Seskupení minor a patch aktualizací pro snížení šumu PR
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Lockfile je bezpečnostní nástroj#

Vždy commitujte svůj package-lock.json (nebo pnpm-lock.yaml, nebo yarn.lock). Lockfile fixuje přesné verze každé závislosti, včetně tranzitivních. Bez něj npm install může stáhnout jinou verzi, než kterou jste testovali — a ta jiná verze může být kompromitovaná.

bash
# V CI používejte ci místo install — striktně respektuje lockfile
npm ci

npm ci selže, pokud lockfile neodpovídá package.json, místo tichého aktualizování. To zachytí případy, kdy někdo modifikoval package.json, ale zapomněl aktualizovat lockfile.

Zhodnoťte před instalací#

Než přidáte závislost, zeptejte se:

  1. Opravdu ji potřebuji? Mohu to napsat ve 20 řádcích místo přidávání balíčku?
  2. Kolik má stažení? Nízký počet stažení není nutně špatný, ale znamená méně očí kontrolujících kód.
  3. Kdy byl naposledy aktualizovaný? Balíček, který nebyl aktualizovaný 3 roky, může mít neopravené zranitelnosti.
  4. Kolik závislostí stahuje? is-odd závisí na is-number, který závisí na kind-of. To jsou tři balíčky k provedení něčeho, co zvládne jeden řádek kódu.
  5. Kdo ho spravuje? Jeden správce je jeden bod kompromitace.
typescript
// Na tohle nepotřebujete balíček:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Ani na tohle:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Ani na tohle:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

Checklist před nasazením#

Toto je skutečný checklist, který používám před každým produkčním deploymentem. Není vyčerpávající — bezpečnost nikdy není „hotová" — ale zachytí chyby, na kterých záleží nejvíc.

#KontrolaKritérium splněníPriorita
1AutentizaceJWT ověřené s explicitním algoritmem, issuerem a audience. Žádné alg: none.Kritická
2Expirace tokenůAccess tokeny expirují za 15 min nebo méně. Refresh tokeny se rotují při použití.Kritická
3Ukládání tokenůRefresh tokeny v httpOnly secure cookies. Žádné tokeny v localStorage.Kritická
4Autorizace na každém endpointuKaždý endpoint pro přístup k datům kontroluje oprávnění na úrovni objektu. BOLA testováno.Kritická
5Validace vstupůVšechny uživatelské vstupy validovány pomocí Zod nebo ekvivalentu. Žádné surové req.body v dotazech.Kritická
6SQL/NoSQL injectionVšechny databázové dotazy používají parametrizované dotazy nebo ORM metody. Žádné spojování řetězců.Kritická
7Rate limitingAuth endpointy: 5/15min. Obecné API: 60/min. Hlavičky rate limitu vráceny.Vysoká
8CORSExplicitní allowlist originů. Žádný wildcard s credentials. Preflight cachován.Vysoká
9Bezpečnostní hlavičkyCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy — vše přítomno.Vysoká
10Zpracování chybProdukční chyby vrací generické zprávy. Žádné stack traces, žádné SQL chyby vystaveny.Vysoká
11SecretsŽádné secrets v kódu nebo git historii. .env v .gitignore. Validovány při startu.Kritická
12Závislostinpm audit čistý (žádné high/critical). Lockfile commitnutý. npm ci v CI.Vysoká
13Pouze HTTPSHSTS povolen s preload. HTTP přesměrovává na HTTPS. Příznak secure cookie nastaven.Kritická
14LogováníAuth události, odepřený přístup a datové mutace logovány. Žádné PII v logech.Střední
15Limity velikosti požadavkůBody parser omezený (1MB výchozí). Nahrávání souborů limitováno. Stránkování dotazů vynuceno.Střední
16SSRF ochranaURL poskytnuté uživatelem validovány. Privátní IP blokovány. Přesměrování zakázána nebo validována.Střední
17Uzamčení účtuNeúspěšné pokusy o přihlášení spouští uzamčení po 5 pokusech. Uzamčení zalogováno.Vysoká
18Ověření webhookůVšechny příchozí webhooky ověřeny podpisy. Ochrana proti replay přes timestamp.Vysoká
19Admin endpointyŘízení přístupu na základě rolí na všech admin routách. Pokusy logovány.Kritická
20Mass assignmentUpdate endpointy používají Zod schema s allowlist polí. Žádné surové rozšiřování body.Vysoká

Vedu to jako šablonu GitHub issue. Před tagováním releasu musí někdo z týmu zaškrtnout každý řádek a podepsat. Není to okázalé, ale funguje to.

Změna myšlení#

Bezpečnost není funkce, kterou přidáte na konci. Není to sprint, který děláte jednou za rok. Je to způsob, jakým přemýšlíte o každém řádku kódu, který píšete.

Když píšete endpoint, přemýšlejte: „Co když někdo pošle data, která neočekávám?" Když přidáváte parametr, přemýšlejte: „Co když někdo změní toto na ID někoho jiného?" Když přidáváte závislost, přemýšlejte: „Co se stane, pokud je tento balíček kompromitován příští úterý?"

Nezachytíte vše. Nikdo nezachytí. Ale projít si tento checklist — metodicky, před každým nasazením — zachytí věci, na kterých záleží nejvíc. Snadné výhry. Zřejmé díry. Chyby, které změní špatný den v únik dat.

Vytvořte si návyk. Projděte checklist. Nasazujte s jistotou.

Související články