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.
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.
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 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:
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:
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.
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.
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.
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:
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.
// 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.
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 je to nejlepší, co se stalo validaci vstupů v Node.js. Dává vám runtime validaci s TypeScript typy zadarmo:
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."role": "admin" a bude doufat v nejlepší.„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ý.
// 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:
// 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}%`}`
);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í:
// 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.
Pokud vaše API servíruje soubory nebo čte z cesty, která obsahuje uživatelský vstup, path traversal vám zničí týden:
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/passwdVzor 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.
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: 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:
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,
};
}Jeden globální rate limit nestačí. Různé endpointy mají různé rizikové profily:
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.
Vždy sdělte klientovi, co se děje:
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),
});
}
}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.
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á:
Co CORS dělá:
// 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ů.
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í:
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.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:
// 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 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ů.
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.
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:
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í.
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.
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í".
Použijte .env.example jako šablonu. Dokumentuje, které proměnné prostředí jsou potřeba, aniž by obsahoval skutečné hodnoty:
# .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 .gitignoreimport { 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();Pro produkční systémy použijte řádný secret manager:
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í.
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 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.
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.
// 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.
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.
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.
Vracení více dat než je nutné, nebo umožnění uživatelům modifikovat vlastnosti, které by neměli.
// 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:
// 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);
});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:
// 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();
});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.
// 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.
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.
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:
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.
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".
// 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();
});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.
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?
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."
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í.
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:
Nikdy nelogujte:
sk_live_...abc)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;
}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:
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ší.
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.
# 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 --allAle 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:
# .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"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á.
# V CI používejte ci místo install — striktně respektuje lockfile
npm cinpm 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.
Než přidáte závislost, zeptejte se:
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.// 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;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.
| # | Kontrola | Kritérium splnění | Priorita |
|---|---|---|---|
| 1 | Autentizace | JWT ověřené s explicitním algoritmem, issuerem a audience. Žádné alg: none. | Kritická |
| 2 | Expirace tokenů | Access tokeny expirují za 15 min nebo méně. Refresh tokeny se rotují při použití. | Kritická |
| 3 | Ukládání tokenů | Refresh tokeny v httpOnly secure cookies. Žádné tokeny v localStorage. | Kritická |
| 4 | Autorizace na každém endpointu | Každý endpoint pro přístup k datům kontroluje oprávnění na úrovni objektu. BOLA testováno. | Kritická |
| 5 | Validace vstupů | Všechny uživatelské vstupy validovány pomocí Zod nebo ekvivalentu. Žádné surové req.body v dotazech. | Kritická |
| 6 | SQL/NoSQL injection | Všechny databázové dotazy používají parametrizované dotazy nebo ORM metody. Žádné spojování řetězců. | Kritická |
| 7 | Rate limiting | Auth endpointy: 5/15min. Obecné API: 60/min. Hlavičky rate limitu vráceny. | Vysoká |
| 8 | CORS | Explicitní allowlist originů. Žádný wildcard s credentials. Preflight cachován. | Vysoká |
| 9 | Bezpečnostní hlavičky | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy — vše přítomno. | Vysoká |
| 10 | Zpracování chyb | Produkční chyby vrací generické zprávy. Žádné stack traces, žádné SQL chyby vystaveny. | Vysoká |
| 11 | Secrets | Žádné secrets v kódu nebo git historii. .env v .gitignore. Validovány při startu. | Kritická |
| 12 | Závislosti | npm audit čistý (žádné high/critical). Lockfile commitnutý. npm ci v CI. | Vysoká |
| 13 | Pouze HTTPS | HSTS povolen s preload. HTTP přesměrovává na HTTPS. Příznak secure cookie nastaven. | Kritická |
| 14 | Logování | Auth události, odepřený přístup a datové mutace logovány. Žádné PII v logech. | Střední |
| 15 | Limity velikosti požadavků | Body parser omezený (1MB výchozí). Nahrávání souborů limitováno. Stránkování dotazů vynuceno. | Střední |
| 16 | SSRF ochrana | URL poskytnuté uživatelem validovány. Privátní IP blokovány. Přesměrování zakázána nebo validována. | Střední |
| 17 | Uzamčení účtu | Neúspěšné pokusy o přihlášení spouští uzamčení po 5 pokusech. Uzamčení zalogováno. | Vysoká |
| 18 | Ověření webhooků | Všechny příchozí webhooky ověřeny podpisy. Ochrana proti replay přes timestamp. | Vysoká |
| 19 | Admin endpointy | Řízení přístupu na základě rolí na všech admin routách. Pokusy logovány. | Kritická |
| 20 | Mass assignment | Update 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.
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.