Bezpieczeństwo API: Checklista, którą odpalam przy każdym projekcie
Uwierzytelnianie, autoryzacja, walidacja danych wejściowych, rate limiting, CORS, zarządzanie sekretami i OWASP API Top 10. Co sprawdzam przed każdym wdrożeniem produkcyjnym.
Wypuszczałem na produkcję API, które były całkowicie otwarte. Nie złośliwie, nie z lenistwa — po prostu nie wiedziałem tego, czego nie wiedziałem. Endpoint, który zwracał każde pole obiektu użytkownika, łącznie z hashowanymi hasłami. Rate limiter, który sprawdzał tylko adresy IP, co oznaczało, że każdy za proxy mógł bombardować API. Implementacja JWT, gdzie zapomniałem zweryfikować claim iss, więc tokeny z zupełnie innego serwisu działały bez problemu.
Każdy z tych błędów trafił na produkcję. Każdy z nich został wyłapany — niektóre przeze mnie, niektóre przez użytkowników, jeden przez badacza bezpieczeństwa, który był na tyle uprzejmy, żeby napisać do mnie maila zamiast publikować to na Twitterze.
Ten post to checklista, którą zbudowałem z tych błędów. Przechodzę ją przed każdym wdrożeniem produkcyjnym. Nie dlatego, że jestem paranoikiem, ale dlatego, że nauczyłem się, że błędy bezpieczeństwa bolą najbardziej. Zepsuty przycisk irytuje użytkowników. Zepsuty flow autoryzacji wycieka ich dane.
Uwierzytelnianie vs autoryzacja#
Te dwa słowa są używane zamiennie na spotkaniach, w dokumentacji, nawet w komentarzach do kodu. To nie jest to samo.
Uwierzytelnianie odpowiada na pytanie: "Kim jesteś?" To krok logowania. Nazwa użytkownika i hasło, flow OAuth, magic link — cokolwiek potwierdza twoją tożsamość.
Autoryzacja odpowiada na pytanie: "Co wolno ci robić?" To krok uprawnień. Czy ten użytkownik może usunąć ten zasób? Czy ma dostęp do endpointu admina? Czy może czytać dane innego użytkownika?
Najczęstszy błąd bezpieczeństwa, jaki widziałem w produkcyjnych API, to nie zepsuty flow logowania. To brakujące sprawdzenie autoryzacji. Użytkownik jest uwierzytelniony — ma ważny token — ale API nigdy nie sprawdza, czy ma uprawnienia do wykonania żądanej akcji.
JWT: Anatomia i błędy, które mają znaczenie#
JWT-y są wszędzie. Są też wszędzie źle rozumiane. JWT składa się z trzech części, oddzielonych kropkami:
header.payload.signature
Header mówi, jaki algorytm został użyty. Payload zawiera claimy (ID użytkownika, role, wygaśnięcie). Signature dowodzi, że nikt nie zmajstrował przy pierwszych dwóch częściach.
Oto prawidłowa weryfikacja JWT w 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"], // Never allow "none"
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // 30 seconds leeway for clock skew
}) as TokenPayload;
return payload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(401, "Token expired");
}
if (error instanceof jwt.JsonWebTokenError) {
throw new ApiError(401, "Invalid token");
}
throw new ApiError(401, "Authentication failed");
}
}Kilka rzeczy do zauważenia:
-
algorithms: ["HS256"]— To jest krytyczne. Jeśli nie określisz algorytmu, atakujący może wysłać token z"alg": "none"w headerze i całkowicie ominąć weryfikację. To jest atakalg: nonei dotknął prawdziwych systemów produkcyjnych. -
issueriaudience— Bez nich token wystawiony dla Serwisu A działa w Serwisie B. Jeśli prowadzisz wiele serwisów współdzielących ten sam sekret (czego nie powinieneś, ale ludzie to robią), tak właśnie wygląda cross-service token abuse. -
Specyficzna obsługa błędów — Nie zwracaj
"invalid token"na każdą awarię. Rozróżnienie między wygasłym a nieprawidłowym pomaga klientowi wiedzieć, czy odświeżać czy ponownie się uwierzytelniać.
Rotacja refresh tokenów#
Access tokeny powinny być krótkotrwałe — 15 minut to standard. Ale nie chcesz, żeby użytkownicy wpisywali hasło co 15 minut. Tu wchodzą refresh tokeny.
Wzorzec, który naprawdę działa na produkcji:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // Token family for rotation detection
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// Token not found — either expired or already used.
// If already used, this is a potential replay attack.
// Invalidate the entire token family.
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// Delete the old token immediately — single use only
await redis.del(`refresh:${oldRefreshToken}`);
// Generate new tokens
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// Store the new refresh token with the same family
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30 days
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// Scan for all tokens in this family and delete them.
// This is the nuclear option — if someone replays a refresh token,
// we kill every token in the family, forcing re-authentication.
const keys = await redis.keys(`refresh:*`);
for (const key of keys) {
const data = await redis.get(key);
if (data) {
const parsed = JSON.parse(data) as RefreshTokenData;
if (parsed.family === family) {
await redis.del(key);
}
}
}
}Koncept rodziny tokenów jest tym, co czyni to bezpiecznym. Każdy refresh token należy do rodziny (tworzonej przy logowaniu). Kiedy dokonujesz rotacji, nowy token dziedziczy rodzinę. Jeśli atakujący odtworzy stary refresh token, wykrywasz ponowne użycie i zabijasz całą rodzinę. Uprawniony użytkownik zostaje wylogowany, ale atakujący nie uzyskuje dostępu.
Przechowywanie tokenów: debata httpOnly Cookie vs localStorage#
Ta debata trwa od lat, a odpowiedź jest jasna: httpOnly cookies do refresh tokenów, zawsze.
localStorage jest dostępne dla każdego JavaScriptu uruchomionego na twojej stronie. Jeśli masz jedną podatność XSS — a na dużą skalę w końcu ją będziesz miał — atakujący może odczytać token i go wykraść. Koniec gry.
httpOnly cookies nie są dostępne dla JavaScriptu. Kropka. Podatność XSS nadal może wysyłać żądania w imieniu użytkownika (bo cookies są wysyłane automatycznie), ale atakujący nie może ukraść samego tokena. To istotna różnica.
// Setting a secure refresh token cookie
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: "strict", // No cross-site requests
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: "/api/auth", // Only sent to auth endpoints
});
}path: "/api/auth" to detal, który większość ludzi pomija. Domyślnie cookies są wysyłane do każdego endpointu na twojej domenie. Twój refresh token nie musi trafiać do /api/users czy /api/products. Ogranicz ścieżkę, zmniejsz powierzchnię ataku.
Dla access tokenów trzymam je w pamięci (zmienna JavaScript). Nie localStorage, nie sessionStorage, nie cookie. W pamięci. Są krótkotrwałe (15 minut), a kiedy strona się odświeży, klient po cichu uderza w endpoint odświeżania po nowy token. Tak, to oznacza dodatkowe żądanie przy ładowaniu strony. Warto.
Walidacja danych wejściowych: Nigdy nie ufaj klientowi#
Klient nie jest twoim przyjacielem. Klient to obcy, który wszedł do twojego domu i powiedział "mam prawo tu być". I tak sprawdzasz mu dowód.
Każdy fragment danych przychodzący spoza twojego serwera — body żądania, parametry query, parametry URL, nagłówki — to niezaufane dane wejściowe. Nie ma znaczenia, że twój formularz React ma walidację. Ktoś ją obejdzie za pomocą curl.
Zod do walidacji z typami#
Zod to najlepsza rzecz, jaka spotkała walidację danych wejściowych w Node.js. Daje ci walidację w runtime z typami TypeScript gratis:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z
.string()
.email("Invalid email format")
.max(254, "Email too long")
.transform((e) => e.toLowerCase().trim()),
password: z
.string()
.min(12, "Password must be at least 12 characters")
.max(128, "Password too long") // Prevent bcrypt DoS
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and a number"
),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
role: z.enum(["user", "editor"]).default("user"),
// Note: "admin" is intentionally not an option here.
// Admin role assignment goes through a separate, privileged endpoint.
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Usage in an Express handler
app.post("/api/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
// result.data is fully typed as CreateUserInput
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});Kilka detali istotnych dla bezpieczeństwa:
max(128)na haśle — bcrypt ma limit 72 bajtów na wejściu, a niektóre implementacje po cichu obcinają. Ale co ważniejsze, jeśli pozwolisz na hasło o rozmiarze 10MB, bcrypt poświęci znaczną ilość czasu na jego hashowanie. To wektor DoS.max(254)na emailu — RFC 5321 ogranicza adresy email do 254 znaków. Cokolwiek dłuższego nie jest prawidłowym emailem.- Enum dla roli, bez admina — Mass assignment to jedna z najstarszych podatności API. Jeśli akceptujesz rolę z body żądania bez walidacji, ktoś wyśle
"role": "admin"i będzie trzymał kciuki.
SQL Injection nie jest rozwiązane#
"Po prostu użyj ORM-a" nie chroni cię, jeśli piszesz surowe zapytania dla wydajności. A każdy w końcu pisze surowe zapytania dla wydajności.
// VULNERABLE — string concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// SAFE — parameterized query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);Z Prismą jesteś w większości bezpieczny — ale $queryRaw wciąż może cię ugryźć:
// VULNERABLE — template literal in $queryRaw
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// SAFE — using Prisma.sql for parameterization
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);NoSQL Injection#
MongoDB nie używa SQL, ale nie jest odporny na injection. Jeśli przekazujesz niezwalidowane dane wejściowe użytkownika jako obiekt zapytania, sprawy się psują:
// VULNERABLE — if req.body.username is { "$gt": "" }
// this returns the first user in the collection
const user = await db.collection("users").findOne({
username: req.body.username,
});
// SAFE — explicitly coerce to string
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// BETTER — validate with Zod first
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});Rozwiązanie jest proste: waliduj typy danych wejściowych zanim dotrą do twojego sterownika bazy danych. Jeśli username powinno być stringiem, upewnij się, że to string.
Path Traversal#
Jeśli twoje API serwuje pliki lub czyta ze ścieżki zawierającej dane wejściowe użytkownika, path traversal zrujnuje ci tydzień:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// Normalize and resolve to an absolute path
const resolved = path.resolve(ALLOWED_DIR, userInput);
// Critical: verify the resolved path is still within the allowed directory
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// Verify the file actually exists
await access(resolved, constants.R_OK);
return resolved;
}
// Without this check:
// GET /api/files?name=../../../etc/passwd
// resolves to /etc/passwdWzorzec path.resolve + startsWith to poprawne podejście. Nie próbuj ręcznie usuwać ../ — jest zbyt wiele sztuczek z kodowaniem (..%2F, ..%252F, ....//), które ominą twojego regexa.
Rate limiting#
Bez rate limitingu twoje API to bufet "jedz ile chcesz" dla botów. Ataki brute force, credential stuffing, wyczerpywanie zasobów — rate limiting to pierwsza obrona przed nimi wszystkimi.
Token Bucket vs Sliding Window#
Token bucket: Masz kubełek, który mieści N tokenów. Każde żądanie kosztuje jeden token. Tokeny uzupełniają się ze stałą prędkością. Jeśli kubełek jest pusty, żądanie jest odrzucane. To pozwala na bursts — jeśli kubełek jest pełny, możesz wykonać N żądań natychmiast.
Sliding window: Liczysz żądania w ruchomym oknie czasowym. Bardziej przewidywalny, trudniejszy do przebicia burstami.
Używam sliding window do większości rzeczy, bo zachowanie jest łatwiejsze do zrozumienia i wyjaśnienia zespołowi:
import { Redis } from "ioredis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function slidingWindowRateLimit(
redis: Redis,
key: string,
limit: number,
windowMs: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
// Remove entries outside the window
multi.zremrangebyscore(key, 0, windowStart);
// Count entries in the window
multi.zcard(key);
// Add the current request (we'll remove it if over limit)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// Set expiry on the key
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const count = results[1][1] as number;
if (count >= limit) {
// Over limit — remove the entry we just added
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}Warstwowe limity#
Jeden globalny rate limit nie wystarczy. Różne endpointy mają różne profile ryzyka:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// Auth endpoints — tight limits, brute force target
"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 },
// Data reads — more generous
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// Data writes — moderate
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// Global 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}`;
}Zauważ: uwierzytelnieni użytkownicy mają rate limit po ID użytkownika, nie po IP. To ważne, bo wielu legalnych użytkowników współdzieli IP (sieci korporacyjne, VPN-y, operatorzy komórkowi). Jeśli limitujesz tylko po IP, zablokujesz całe biura.
Nagłówki rate limit#
Zawsze mów klientowi, co się dzieje:
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),
});
}
}Konfiguracja CORS#
CORS to chyba najgorzej rozumiany mechanizm bezpieczeństwa w web developmencie. Połowa odpowiedzi na Stack Overflow o CORS to "po prostu ustaw Access-Control-Allow-Origin: * i działa". To technicznie prawda. Tak też otwierasz swoje API dla każdej złośliwej strony w internecie.
Co CORS tak naprawdę robi (i czego nie robi)#
CORS to mechanizm przeglądarki. Mówi przeglądarce, czy JavaScript z Origin A ma prawo odczytać odpowiedź z Origin B. Tyle.
Czego CORS nie robi:
- Nie chroni twojego API przed curl, Postman czy żądaniami serwer-serwer
- Nie uwierzytelnia żądań
- Nie szyfruje niczego
- Nie zapobiega CSRF sam z siebie (choć pomaga w połączeniu z innymi mechanizmami)
Co CORS robi:
- Uniemożliwia złośliwej-stronie.com wysyłanie żądań fetch do twojego-api.com i odczytywanie odpowiedzi w przeglądarce użytkownika
- Uniemożliwia JavaScriptowi atakującego wykradanie danych przez uwierzytelnioną sesję ofiary
Pułapka wildcard#
// DANGEROUS — allows any website to read your API responses
app.use(cors({ origin: "*" }));
// ALSO DANGEROUS — this is a common "dynamic" approach that's just * with extra steps
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // Allows everything
},
})
);Problem z * polega na tym, że czyni odpowiedzi twojego API czytelnymi dla każdego JavaScriptu na każdej stronie. Jeśli twoje API zwraca dane użytkownika i użytkownik jest uwierzytelniony przez cookies, każda strona, którą użytkownik odwiedza, może odczytać te dane.
Co gorsza: Access-Control-Allow-Origin: * nie może być łączony z credentials: true. Więc jeśli potrzebujesz cookies (do auth), dosłownie nie możesz użyć wildcarda. Ale widziałem ludzi próbujących obejść to przez odbijanie nagłówka Origin — co jest równoważne * z credentials, najgorsza z możliwych kombinacji.
Poprawna konfiguracja#
import cors from "cors";
const ALLOWED_ORIGINS = new Set([
"https://yourapp.com",
"https://www.yourapp.com",
"https://admin.yourapp.com",
]);
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.add("http://localhost:3000");
ALLOWED_ORIGINS.add("http://localhost:5173");
}
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) {
return callback(null, true);
}
if (ALLOWED_ORIGINS.has(origin)) {
return callback(null, origin);
}
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // Allow cookies
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // Cache preflight for 24 hours
})
);Kluczowe decyzje:
- Jawna lista origin, nie regex. Regexy są zdradliwe —
yourapp.commoże pasować doevilyourapp.com, jeśli twój regex nie jest prawidłowo zakotwiczony. credentials: truebo używamy httpOnly cookies do refresh tokenów.maxAge: 86400— Żądania preflight (OPTIONS) dodają opóźnienie. Powiedzenie przeglądarce, żeby cachowała wynik CORS przez 24 godziny, redukuje zbędne round tripy.exposedHeaders— Domyślnie przeglądarka udostępnia JavaScriptowi tylko garść "prostych" nagłówków odpowiedzi. Jeśli chcesz, żeby klient czytał twoje nagłówki rate limit, musisz jawnie je eksponować.
Żądania preflight#
Kiedy żądanie nie jest "proste" (używa niestandardowego nagłówka, niestandardowej metody lub niestandardowego content type), przeglądarka najpierw wysyła żądanie OPTIONS, żeby zapytać o pozwolenie. To jest preflight.
Jeśli twoja konfiguracja CORS nie obsługuje OPTIONS, żądania preflight zawiodą, a właściwe żądanie nigdy nie zostanie wysłane. Większość bibliotek CORS obsługuje to automatycznie, ale jeśli używasz frameworka, który tego nie robi, musisz to obsłużyć:
// Manual preflight handling (most frameworks do this for you)
app.options("*", (req, res) => {
res.set({
"Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
});
res.status(204).end();
});Nagłówki bezpieczeństwa#
Nagłówki bezpieczeństwa to najtańsze ulepszenie bezpieczeństwa, jakie możesz zrobić. To nagłówki odpowiedzi, które mówią przeglądarce, żeby włączyła funkcje bezpieczeństwa. Większość z nich to jedna linia konfiguracji, a chronią przed całymi klasami ataków.
Nagłówki, które mają znaczenie#
import helmet from "helmet";
// One line. This is the fastest security win in any Express app.
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Needed for many CSS-in-JS solutions
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);Co robi każdy nagłówek:
Content-Security-Policy (CSP) — Najpotężniejszy nagłówek bezpieczeństwa. Mówi przeglądarce dokładnie, które źródła są dozwolone dla skryptów, stylów, obrazów, fontów itp. Jeśli atakujący wstrzyknie tag <script>, który ładuje z evil.com, CSP to blokuje. To najskuteczniejsza obrona przed XSS.
Strict-Transport-Security (HSTS) — Mówi przeglądarce, żeby zawsze używała HTTPS, nawet jeśli użytkownik wpisze http://. Dyrektywa preload pozwala zgłosić twoją domenę do wbudowanej listy HSTS przeglądarki, więc nawet pierwsze żądanie jest wymuszone na HTTPS.
X-Frame-Options — Zapobiega osadzaniu twojej strony w iframe. Zatrzymuje ataki clickjackingowe, gdzie atakujący nakłada na twoją stronę niewidoczne elementy. Helmet ustawia to domyślnie na SAMEORIGIN. Nowoczesnym zamiennikiem jest frame-ancestors w CSP.
X-Content-Type-Options: nosniff — Zapobiega zgadywaniu (sniffowaniu) typu MIME odpowiedzi przez przeglądarkę. Bez tego, jeśli serwujesz plik z błędnym Content-Type, przeglądarka może wykonać go jako JavaScript.
Referrer-Policy — Kontroluje, ile informacji URL jest wysyłane w nagłówku Referer. strict-origin-when-cross-origin wysyła pełny URL dla żądań same-origin, ale tylko origin dla żądań cross-origin. Zapobiega to wyciekaniu wrażliwych parametrów URL do stron trzecich.
Testowanie nagłówków#
Po wdrożeniu sprawdź swój wynik na securityheaders.com. Celuj w ocenę A+. Osiągnięcie tego zajmuje około pięciu minut konfiguracji.
Możesz też zweryfikować nagłówki programistycznie:
import { describe, it, expect } from "vitest";
describe("Security headers", () => {
it("should include all required security headers", async () => {
const response = await fetch("https://api.yourapp.com/health");
expect(response.headers.get("strict-transport-security")).toBeTruthy();
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(response.headers.get("content-security-policy")).toBeTruthy();
expect(response.headers.get("referrer-policy")).toBeTruthy();
expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet removes this
});
});Sprawdzenie x-powered-by jest subtelne, ale ważne. Express domyślnie ustawia X-Powered-By: Express, mówiąc atakującym dokładnie, jakiego frameworka używasz. Helmet to usuwa.
Zarządzanie sekretami#
To powinno być oczywiste, ale wciąż widzę to w pull requestach: klucze API, hasła do baz danych i sekrety JWT zahardkodowane w plikach źródłowych. Albo commitowane w plikach .env, które nie były w .gitignore. Kiedy coś trafi do historii gita, jest tam na zawsze, nawet jeśli usuniesz plik w następnym commicie.
Zasady#
-
Nigdy nie commituj sekretów do gita. Nie w kodzie, nie w
.env, nie w plikach konfiguracyjnych, nie w plikach Docker Compose, nie w komentarzach "tylko do testów". -
Używaj
.env.examplejako szablonu. Dokumentuje, jakie zmienne środowiskowe są potrzebne, bez zawierania rzeczywistych wartości:
# .env.example — commit this
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 — NEVER commit this
# Listed in .gitignore- Waliduj zmienne środowiskowe przy starcie. Nie czekaj, aż żądanie trafi na endpoint, który potrzebuje URL bazy danych. Failuj szybko:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.format());
process.exit(1); // Don't start with bad config
}
return result.data;
}
export const env = validateEnv();- Używaj managera sekretów na produkcji. Zmienne środowiskowe działają dla prostych setupów, ale mają ograniczenia: są widoczne w listingach procesów, utrzymują się w pamięci i mogą wyciekać przez logi błędów.
Dla systemów produkcyjnych używaj właściwego managera sekretów:
- AWS Secrets Manager lub SSM Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
- Doppler (jeśli chcesz coś, co działa we wszystkich chmurach)
Wzorzec jest taki sam niezależnie od tego, którego używasz: aplikacja pobiera sekrety przy starcie z managera sekretów, nie ze zmiennych środowiskowych.
- Regularnie rotuj sekrety. Jeśli używasz tego samego sekretu JWT od dwóch lat, pora na rotację. Zaimplementuj rotację kluczy: wspieraj jednocześnie wiele ważnych kluczy podpisu, podpisuj nowe tokeny nowym kluczem, weryfikuj zarówno starym jak i nowym, i wycofaj stary klucz po wygaśnięciu wszystkich istniejących tokenów.
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // Only the active key signs new tokens
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // Returns all valid keys
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // Try the next key
}
}
throw new ApiError(401, "Invalid token");
}
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
const activeKey = getActiveSigningKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: "HS256",
expiresIn: "15m",
keyid: activeKey.id, // Include key ID in the header
});
}OWASP API Security Top 10#
OWASP API Security Top 10 to branżowy standard podatności API. Jest aktualizowany okresowo, a każdy element na liście to coś, co widziałem w prawdziwych bazach kodu. Przejdźmy przez każdy punkt.
API1: Broken Object Level Authorization (BOLA)#
Najczęstsza podatność API. Użytkownik jest uwierzytelniony, ale API nie sprawdza, czy ma dostęp do konkretnego obiektu, o który prosi.
// VULNERABLE — any authenticated user can access any user's data
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// FIXED — verify the user is accessing their own data (or is an 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);
});Podatna wersja jest wszędzie. Przechodzi każde sprawdzenie auth — użytkownik ma ważny token — ale nie weryfikuje, czy jest autoryzowany do dostępu do tego konkretnego zasobu. Zmień ID w URL i dostajesz dane kogoś innego.
API2: Broken Authentication#
Słabe mechanizmy logowania, brak MFA, tokeny, które nigdy nie wygasają, hasła przechowywane jako plaintext. To obejmuje samą warstwę uwierzytelniania.
Rozwiązanie to wszystko, co omówiliśmy w sekcji o uwierzytelnianiu: silne wymagania hasłowe, bcrypt z wystarczającą liczbą rund, krótkotrwałe access tokeny, rotacja refresh tokenów, blokada konta po nieudanych próbach.
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
async function handleLogin(email: string, password: string): Promise<AuthResult> {
const lockoutKey = `lockout:${email}`;
const attempts = await redis.get(lockoutKey);
if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
const ttl = await redis.pttl(lockoutKey);
throw new ApiError(
429,
`Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
);
}
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Increment failed attempts
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// Same error message for both cases — don't reveal whether the email exists
throw new ApiError(401, "Invalid email or password");
}
// Reset failed attempts on successful login
await redis.del(lockoutKey);
return generateTokens(user);
}Komentarz o "tym samym komunikacie błędu" jest ważny. Jeśli twoje API zwraca "nie znaleziono użytkownika" dla nieprawidłowych emaili i "złe hasło" dla prawidłowych emaili ze złym hasłem, mówisz atakującemu, które emaile istnieją w twoim systemie.
API3: Broken Object Property Level Authorization#
Zwracanie więcej danych niż potrzeba, lub pozwalanie użytkownikom na modyfikowanie właściwości, których nie powinni.
// VULNERABLE — returns the entire user object, including internal fields
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// Response includes: passwordHash, internalNotes, billingId, ...
});
// FIXED — explicit allowlist of returned fields
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,
});
});Nigdy nie zwracaj całych obiektów bazodanowych. Zawsze wybieraj pola, które chcesz eksponować. To dotyczy też zapisów — nie spreaduj całego body żądania do zapytania aktualizacji:
// VULNERABLE — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// Attacker sends: { "role": "admin", "verified": true }
});
// FIXED — pick allowed fields
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#
Twoje API jest zasobem. CPU, pamięć, przepustowość, połączenia do bazy danych — wszystkie są skończone. Bez limitów pojedynczy klient może je wszystkie wyczerpać.
To wykracza poza rate limiting. Obejmuje:
// Limit request body size
app.use(express.json({ limit: "1mb" }));
// Limit query complexity
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_PAGE_SIZE)
.default(DEFAULT_PAGE_SIZE),
});
// Limit file upload size
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Invalid file type"));
}
},
});
// Timeout long-running requests
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});API5: Broken Function Level Authorization#
Różni się od BOLA. Tu chodzi o dostęp do funkcji (endpointów), do których nie powinieneś mieć dostępu, nie o obiekty. Klasyczny przykład: zwykły użytkownik odkrywa endpointy admina.
// Middleware that checks role-based access
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!allowedRoles.includes(req.user.role)) {
// Log the attempt — this might be an attack
logger.warn("Unauthorized access attempt", {
userId: req.user.id,
role: req.user.role,
requiredRoles: allowedRoles,
endpoint: `${req.method} ${req.path}`,
ip: req.ip,
});
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// Apply to routes
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);Nie polegaj na ukrywaniu endpointów. "Security through obscurity" to nie bezpieczeństwo. Nawet jeśli URL panelu admina nie jest nigdzie podlinkowany, ktoś znajdzie /api/admin/users przez fuzzing.
API6: Unrestricted Access to Sensitive Business Flows#
Automatyczne nadużywanie legalnej funkcjonalności biznesowej. Pomyśl: boty wykupujące produkty z ograniczoną dostępnością, automatyczne tworzenie kont dla spamu, scrapowanie cen produktów.
Środki zaradcze zależą od kontekstu: CAPTCHA, fingerprinting urządzeń, analiza behawioralna, step-up authentication dla wrażliwych operacji. Nie ma jednego uniwersalnego fragmentu kodu.
API7: Server Side Request Forgery (SSRF)#
Jeśli twoje API pobiera URL-e dostarczone przez użytkownika (webhooki, URL-e avatarów, podglądy linków), atakujący może zmusić twój serwer do odpytywania zasobów wewnętrznych:
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
async function safeFetch(userProvidedUrl: string): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(userProvidedUrl);
} catch {
throw new ApiError(400, "Invalid URL");
}
// Only allow HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Only HTTP(S) URLs are allowed");
}
// Resolve the hostname and check if it's a private IP
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "Internal addresses are not allowed");
}
}
// Now fetch with a timeout and size limit
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // Don't follow redirects (they could redirect to internal IPs)
});
return response;
} finally {
clearTimeout(timeout);
}
}Kluczowe detale: najpierw rozwiąż DNS i sprawdź IP przed wykonaniem żądania. Blokuj przekierowania — atakujący może hostować URL, który przekierowuje na http://169.254.169.254/ (endpoint metadanych AWS), żeby obejść twoje sprawdzenie na poziomie URL.
API8: Security Misconfiguration#
Domyślne dane uwierzytelniające pozostawione bez zmian, niepotrzebne metody HTTP włączone, gadatliwe komunikaty błędów na produkcji, listing katalogów włączony, CORS źle skonfigurowany. To kategoria "zapomniałeś zamknąć drzwi".
// Don't leak stack traces in production
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error("Unhandled error", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (process.env.NODE_ENV === "production") {
// Generic error message — don't reveal internals
res.status(500).json({
error: "Internal server error",
requestId: req.id, // Include a request ID for debugging
});
} else {
// In development, show the full error
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// Disable unnecessary HTTP methods
app.use((req, res, next) => {
const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: "Method not allowed" });
}
next();
});API9: Improper Inventory Management#
Wdrożyłeś v2 API, ale zapomniałeś wyłączyć v1. Albo jest endpoint /debug/, który był przydatny podczas developmentu i nadal działa na produkcji. Albo serwer staging, który jest publicznie dostępny z danymi produkcyjnymi.
To nie jest naprawa kodu — to dyscyplina opsowa. Utrzymuj listę wszystkich endpointów API, wszystkich wdrożonych wersji i wszystkich środowisk. Używaj automatycznego skanowania, żeby znajdować wystawione serwisy. Zabijaj to, czego nie potrzebujesz.
API10: Unsafe Consumption of APIs#
Twoje API konsumuje API stron trzecich. Czy walidujesz ich odpowiedzi? Co się stanie, jeśli payload webhooka od Stripe jest tak naprawdę od atakującego?
import crypto from "crypto";
// Verify Stripe webhook signatures
function verifyStripeWebhook(
payload: string,
signature: string,
secret: string
): boolean {
const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
if (!timestamp || !expectedSig) return false;
// Reject old timestamps (prevent replay attacks)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false; // 5 minute tolerance
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}Zawsze weryfikuj sygnatury na webhookach. Zawsze waliduj strukturę odpowiedzi API stron trzecich. Zawsze ustawiaj timeouty na żądaniach wychodzących. Nigdy nie ufaj danym tylko dlatego, że przyszły od "zaufanego partnera".
Logowanie audytowe#
Kiedy coś pójdzie nie tak — a pójdzie — logi audytowe to sposób, w jaki dowiadujesz się, co się stało. Ale logowanie to miecz obosieczny. Loguj za mało i jesteś ślepy. Loguj za dużo i tworzysz zobowiązanie prywatności.
Co logować#
interface AuditLogEntry {
timestamp: string;
action: string; // "user.login", "post.delete", "admin.role_change"
actor: {
id: string;
ip: string;
userAgent: string;
};
target: {
type: string; // "user", "post", "setting"
id: string;
};
result: "success" | "failure";
metadata: Record<string, unknown>; // Additional context
requestId: string; // For correlating with application logs
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// Write to a separate, append-only data store
// This should NOT be the same database your application uses
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// For critical actions, also write to an immutable external log
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}Loguj te zdarzenia:
- Uwierzytelnianie: loginy, logouty, nieudane próby, odświeżenia tokenów
- Autoryzacja: zdarzenia odmowy dostępu (to często wskaźniki ataków)
- Modyfikacje danych: tworzenie, aktualizacje, usuwanie — kto co zmienił i kiedy
- Akcje administracyjne: zmiany ról, zarządzanie użytkownikami, zmiany konfiguracji
- Zdarzenia bezpieczeństwa: wyzwolenia rate limitu, naruszenia CORS, zniekształcone żądania
Czego NIE logować#
Nigdy nie loguj:
- Haseł (nawet hashowanych — hash to poświadczenie)
- Pełnych numerów kart kredytowych (loguj tylko ostatnie 4 cyfry)
- Numerów ubezpieczenia społecznego ani dokumentów tożsamości
- Kluczy API ani tokenów (loguj co najwyżej prefix:
sk_live_...abc) - Osobistych informacji zdrowotnych
- Pełnych body żądań, które mogą zawierać PII
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;
}Logi odporne na manipulację#
Jeśli atakujący uzyska dostęp do twojego systemu, jedną z pierwszych rzeczy, którą zrobi, będzie modyfikacja logów, żeby zatrzeć ślady. Logowanie odporne na manipulację czyni to wykrywalnym:
import crypto from "crypto";
let previousHash = "GENESIS"; // The initial hash in the chain
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
const content = JSON.stringify(entry) + previousHash;
const hash = crypto.createHash("sha256").update(content).digest("hex");
previousHash = hash;
return { ...entry, hash };
}
// To verify the chain integrity:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
let expectedPreviousHash = "GENESIS";
for (const entry of entries) {
const { hash, ...rest } = entry;
const content = JSON.stringify(rest) + expectedPreviousHash;
const computedHash = crypto.createHash("sha256").update(content).digest("hex");
if (computedHash !== hash) {
return false; // Chain is broken — logs have been tampered with
}
expectedPreviousHash = hash;
}
return true;
}To ten sam koncept co blockchain — hash każdego wpisu logu zależy od poprzedniego wpisu. Jeśli ktoś zmodyfikuje lub usunie wpis, łańcuch się łamie.
Bezpieczeństwo zależności#
Twój kod może być bezpieczny. Ale co z 847 pakietami npm w twoim node_modules? Problem supply chain jest realny i z biegiem lat się pogarsza.
npm audit to absolutne minimum#
# Run this in CI, fail the build on high/critical vulnerabilities
npm audit --audit-level=high
# Fix what can be auto-fixed
npm audit fix
# See what you're actually pulling in
npm ls --allAle npm audit ma ograniczenia. Sprawdza tylko bazę advisory npm, a jego oceny severity nie zawsze są dokładne. Dodaj dodatkowe narzędzia:
Automatyczne skanowanie zależności#
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# Group minor and patch updates to reduce PR noise
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"Lockfile to narzędzie bezpieczeństwa#
Zawsze commituj swój package-lock.json (albo pnpm-lock.yaml, albo yarn.lock). Lockfile przypina dokładne wersje każdej zależności, łącznie z tranzytywnym. Bez niego npm install może zaciągnąć inną wersję niż ta, którą testowałeś — a ta inna wersja może być skompromitowana.
# In CI, use ci instead of install — it respects the lockfile strictly
npm cinpm ci failuje, jeśli lockfile nie pasuje do package.json, zamiast po cichu go aktualizować. To łapie przypadki, gdy ktoś zmodyfikował package.json, ale zapomniał zaktualizować lockfile.
Oceniaj zanim zainstalujessz#
Przed dodaniem zależności zapytaj:
- Czy naprawdę tego potrzebuję? Czy mogę to napisać w 20 liniach zamiast dodawać pakiet?
- Ile ma pobrań? Mała liczba pobrań niekoniecznie jest zła, ale oznacza mniej oczu przeglądających kod.
- Kiedy był ostatnio aktualizowany? Pakiet, który nie był aktualizowany od 3 lat, może mieć niezałatane podatności.
- Ile zależności ciągnie?
is-oddzależy odis-number, które zależy odkind-of. To trzy pakiety, żeby zrobić coś, co jedna linia kodu potrafi. - Kto go utrzymuje? Jeden maintainer to jeden punkt kompromitacji.
// You don't need a package for this:
const isEven = (n: number): boolean => n % 2 === 0;
// Or this:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// Or this:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;Checklista przed wdrożeniem#
To jest faktyczna checklista, której używam przed każdym wdrożeniem produkcyjnym. Nie jest wyczerpująca — bezpieczeństwo nigdy nie jest "gotowe" — ale łapie błędy, które mają największe znaczenie.
| # | Sprawdzenie | Kryteria zaliczenia | Priorytet |
|---|---|---|---|
| 1 | Uwierzytelnianie | JWT weryfikowane z jawnym algorytmem, issuerem i audience. Brak alg: none. | Krytyczny |
| 2 | Wygasanie tokenów | Access tokeny wygasają w 15 min lub krócej. Refresh tokeny rotują przy użyciu. | Krytyczny |
| 3 | Przechowywanie tokenów | Refresh tokeny w httpOnly secure cookies. Brak tokenów w localStorage. | Krytyczny |
| 4 | Autoryzacja na każdym endpoincie | Każdy endpoint dostępu do danych sprawdza uprawnienia na poziomie obiektu. BOLA przetestowane. | Krytyczny |
| 5 | Walidacja danych wejściowych | Wszystkie dane użytkownika walidowane z Zod lub odpowiednikiem. Brak surowego req.body w zapytaniach. | Krytyczny |
| 6 | SQL/NoSQL injection | Wszystkie zapytania bazodanowe używają sparametryzowanych zapytań lub metod ORM. Brak konkatenacji stringów. | Krytyczny |
| 7 | Rate limiting | Endpointy auth: 5/15min. Ogólne API: 60/min. Nagłówki rate limit zwracane. | Wysoki |
| 8 | CORS | Jawna lista dozwolonych origin. Brak wildcarda z credentials. Preflight cachowany. | Wysoki |
| 9 | Nagłówki bezpieczeństwa | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy wszystkie obecne. | Wysoki |
| 10 | Obsługa błędów | Błędy produkcyjne zwracają ogólne komunikaty. Brak stack trace'ów, brak eksponowanych błędów SQL. | Wysoki |
| 11 | Sekrety | Brak sekretów w kodzie ani historii gita. .env w .gitignore. Walidowane przy starcie. | Krytyczny |
| 12 | Zależności | npm audit czyste (brak high/critical). Lockfile commitowany. npm ci w CI. | Wysoki |
| 13 | Tylko HTTPS | HSTS włączone z preload. HTTP przekierowuje na HTTPS. Flaga secure cookie ustawiona. | Krytyczny |
| 14 | Logowanie | Zdarzenia auth, odmowy dostępu i mutacje danych logowane. Brak PII w logach. | Średni |
| 15 | Limity rozmiaru żądań | Body parser ograniczony (1MB domyślnie). Uploady plików limitowane. Paginacja zapytań wymuszona. | Średni |
| 16 | Ochrona SSRF | URL-e dostarczone przez użytkownika walidowane. Prywatne IP blokowane. Przekierowania wyłączone lub walidowane. | Średni |
| 17 | Blokada konta | Nieudane próby logowania wyzwalają blokadę po 5 próbach. Blokada logowana. | Wysoki |
| 18 | Weryfikacja webhooków | Wszystkie przychodzące webhooki weryfikowane sygnaturami. Ochrona przed replay przez timestamp. | Wysoki |
| 19 | Endpointy admina | Kontrola dostępu oparta na rolach na wszystkich trasach admina. Próby logowane. | Krytyczny |
| 20 | Mass assignment | Endpointy aktualizacji używają schematu Zod z listą dozwolonych pól. Brak surowego spreadu body. | Wysoki |
Trzymam to jako szablon issue na GitHub. Przed tagowaniem releasu ktoś z zespołu musi przejść każdy wiersz i podpisać. To nie jest glamour, ale działa.
Zmiana sposobu myślenia#
Bezpieczeństwo to nie funkcja, którą dodajesz na końcu. To nie sprint, który robisz raz w roku. To sposób myślenia o każdej linii kodu, którą piszesz.
Kiedy piszesz endpoint, pomyśl: "Co jeśli ktoś wyśle dane, których nie oczekuję?" Kiedy dodajesz parametr, pomyśl: "Co jeśli ktoś zmieni to na ID kogoś innego?" Kiedy dodajesz zależność, pomyśl: "Co się stanie, jeśli ten pakiet zostanie skompromitowany w przyszły wtorek?"
Nie złapiesz wszystkiego. Nikt tego nie robi. Ale przechodzenie przez tę checklistę — metodycznie, przed każdym wdrożeniem — łapie rzeczy, które mają największe znaczenie. Łatwe wygrane. Oczywiste dziury. Błędy, które zamieniają zły dzień w wyciek danych.
Zbuduj nawyk. Przejdź checklistę. Wdrażaj z pewnością.