API Güvenliği En İyi Uygulamaları: Her Projede Çalıştırdığım Kontrol Listesi
Kimlik doğrulama, yetkilendirme, girdi doğrulama, rate limiting, CORS, secret yönetimi ve OWASP API Top 10. Her production deployment'ından önce kontrol ettiğim şeyler.
Sonuna kadar açık API'lar gönderdim. Kötü niyetle değil, tembellikle değil — sadece bilmediğimi bilmiyordum. Hash'lenmiş şifreler dahil user nesnesinin her alanını döndüren bir endpoint. Sadece IP adreslerini kontrol eden bir rate limiter, yani proxy arkasındaki herkes API'ı dövebilirdi. iss claim'ini doğrulamayı unuttuğum bir JWT implementasyonu, böylece tamamen farklı bir servisten gelen token'lar gayet iyi çalışıyordu.
Bu hataların her biri production'a ulaştı. Her biri yakalandı — bazıları benim tarafımdan, bazıları kullanıcılar tarafından, biri de Twitter'da paylaşmak yerine bana e-posta gönderecek kadar kibar bir güvenlik araştırmacısı tarafından.
Bu yazı, o hatalardan oluşturduğum kontrol listesi. Her production deployment'ından önce gözden geçiriyorum. Paranoyak olduğum için değil, güvenlik hatalarının en çok acıtan hatalar olduğunu öğrendiğim için. Bozuk bir buton kullanıcıları sinirlendirir. Bozuk bir auth akışı verilerini sızdırır.
Kimlik Doğrulama vs Yetkilendirme#
Bu iki kelime toplantılarda, dokümanlarda, hatta kod yorumlarında birbirinin yerine kullanılır. Aynı şey değiller.
Kimlik doğrulama (Authentication) şunu yanıtlar: "Sen kimsin?" Giriş adımıdır. Kullanıcı adı ve şifre, OAuth akışı, magic link — kimliğini kanıtlayan her ne ise.
Yetkilendirme (Authorization) şunu yanıtlar: "Ne yapmaya yetkin var?" İzin adımıdır. Bu kullanıcı bu kaynağı silebilir mi? Bu admin endpoint'ine erişebilir mi? Başka bir kullanıcının verisini okuyabilir mi?
Production API'larında gördüğüm en yaygın güvenlik hatası bozuk bir giriş akışı değil. Eksik bir yetkilendirme kontrolü. Kullanıcı kimliği doğrulanmış — geçerli bir token'ı var — ama API talep ettiği eylemi gerçekleştirmeye yetkili olup olmadığını asla kontrol etmiyor.
JWT: Anatomisi ve Önemli Hatalar#
JWT'ler her yerde. Aynı zamanda her yerde yanlış anlaşılıyor. Bir JWT'nin üç bölümü var, noktalarla ayrılmış:
header.payload.signature
Header hangi algoritmanın kullanıldığını söyler. Payload claim'leri (kullanıcı ID'si, roller, süre sonu) içerir. Signature ilk iki bölümle kimsenin oynamadığını kanıtlar.
İşte Node.js'te düzgün bir JWT doğrulaması:
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");
}
}Dikkat edilmesi gereken birkaç şey:
-
algorithms: ["HS256"]— Bu kritik. Algoritmayı belirtmezsen, bir saldırgan header'da"alg": "none"olan bir token gönderip doğrulamayı tamamen atlayabilir. Bualg: nonesaldırısı ve gerçek production sistemlerini etkilemiştir. -
issuerveaudience— Bunlar olmadan, Servis A için basılmış bir token Servis B'de çalışır. Aynı secret'ı paylaşan birden fazla servis çalıştırıyorsan (çalıştırmamalısın, ama insanlar çalıştırıyor), servisler arası token kötüye kullanımı böyle olur. -
Spesifik hata yönetimi — Her başarısızlık için "invalid token" döndürme. Süresi dolmuş ile geçersiz arasında ayrım yapmak, client'ın yenilemesi mi yoksa yeniden kimlik doğrulaması mı yapması gerektiğini bilmesine yardımcı olur.
Refresh Token Rotasyonu#
Access token'lar kısa ömürlü olmalı — 15 dakika standarttır. Ama kullanıcıların her 15 dakikada şifrelerini girmesini istemezsin. İşte refresh token'lar bunun için var.
Production'da gerçekten çalışan kalıp:
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 bulunamadı — ya süresi dolmuş ya da zaten kullanılmış.
// Zaten kullanılmışsa, bu potansiyel bir replay saldırısı.
// Tüm token ailesini geçersiz kıl.
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// Eski token'ı hemen sil — tek kullanımlık
await redis.del(`refresh:${oldRefreshToken}`);
// Yeni token'lar oluştur
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// Yeni refresh token'ı aynı aile ile sakla
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30 gün
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// Bu ailedeki tüm token'ları tara ve sil.
// Bu nükleer seçenek — biri refresh token'ı yeniden oynatırsa,
// ailedeki her token'ı öldürürüz, yeniden kimlik doğrulamaya zorlarız.
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);
}
}
}
}Token ailesi kavramı bunu güvenli yapan şey. Her refresh token bir aileye aittir (giriş sırasında oluşturulur). Rotasyon yaptığında, yeni token aileyi devralır. Bir saldırgan eski refresh token'ı yeniden oynatırsa, tekrar kullanımı tespit eder ve tüm aileyi öldürürsün. Meşru kullanıcı çıkış yapmak zorunda kalır, ama saldırgan içeri giremez.
Token Saklama: httpOnly Cookie vs localStorage Tartışması#
Bu tartışma yıllardır sürüyor ve cevap net: Refresh token'lar için her zaman httpOnly cookie.
localStorage, sayfanda çalışan herhangi bir JavaScript tarafından erişilebilir. Tek bir XSS zafiyetin varsa — ve ölçekte eninde sonunda olacaktır — saldırgan token'ı okuyup dışarı çıkarabilir. Oyun bitti.
httpOnly cookie'ler JavaScript tarafından erişilebilir değil. Nokta. Bir XSS zafiyeti kullanıcı adına hâlâ istekler yapabilir (çünkü cookie'ler otomatik gönderilir), ama saldırgan token'ın kendisini çalamaz. Bu anlamlı bir fark.
// Güvenli refresh token cookie ayarlama
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // JavaScript ile erişilemez
secure: true, // Sadece HTTPS
sameSite: "strict", // Cross-site istek yok
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 gün
path: "/api/auth", // Sadece auth endpoint'lerine gönderilir
});
}path: "/api/auth" çoğu kişinin kaçırdığı bir detay. Varsayılan olarak, cookie'ler domain'indeki her endpoint'e gönderilir. Refresh token'ının /api/users veya /api/products'a gitmesine gerek yok. Path'i kısıtla, saldırı yüzeyini azalt.
Access token'lar için bunları bellekte (bir JavaScript değişkeni) tutuyorum. localStorage'da değil, sessionStorage'da değil, cookie'de değil. Bellekte. Kısa ömürlüler (15 dakika), ve sayfa yenilendiğinde client sessizce refresh endpoint'ine istek atıp yeni bir tane alır. Evet, sayfa yüklemesinde ekstra bir istek demek. Buna değer.
Girdi Doğrulama: Client'a Asla Güvenme#
Client dostun değil. Client, evine girip "burada olmaya yetkim var" diyen bir yabancı. Yine de kimliğini kontrol edersin.
Sunucunun dışından gelen her veri parçası — istek gövdesi, query parametreleri, URL parametreleri, header'lar — güvenilmez girdi. React formunun doğrulaması olması önemli değil. Birisi curl ile atlayacak.
Tip-Güvenli Doğrulama İçin Zod#
Zod, Node.js girdi doğrulamasına olan en iyi şey. Çalışma zamanı doğrulaması ile birlikte bedavaya TypeScript tipleri verir:
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"),
// Not: "admin" burada kasıtlı olarak bir seçenek değil.
// Admin rol ataması ayrı, ayrıcalıklı bir endpoint üzerinden yapılır.
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Express handler'da kullanım
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 tam tipli CreateUserInput
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});Güvenlikle ilgili birkaç detay:
- Şifrede
max(128)— bcrypt'in 72 baytlık girdi limiti var, ve bazı implementasyonlar sessizce keser. Ama daha önemlisi, 10MB'lık bir şifreye izin verirsen, bcrypt onu hash'lemek için ciddi zaman harcar. Bu bir DoS vektörü. - E-postada
max(254)— RFC 5321, e-posta adreslerini 254 karakterle sınırlar. Daha uzun olan geçerli bir e-posta değil. - Admin olmadan enum role — Mass assignment en eski API zafiyetlerinden biri. Role'ü istek gövdesinden doğrulamadan kabul edersen, birisi
"role": "admin"gönderip şansını deneyecek.
SQL Injection Çözülmüş Değil#
"Sadece ORM kullan" performans için raw query yazdığında seni korumaz. Ve herkes eninde sonunda performans için raw query yazar.
// SAVUNMASIZ — string birleştirme
const query = `SELECT * FROM users WHERE email = '${email}'`;
// GÜVENLİ — parametreli sorgu
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);Prisma ile çoğunlukla güvendesin — ama $queryRaw hâlâ ısırabilir:
// SAVUNMASIZ — $queryRaw'da template literal
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// GÜVENLİ — parametre için Prisma.sql kullanımı
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);NoSQL Injection#
MongoDB SQL kullanmaz, ama injection'a bağışık değil. Sanitize edilmemiş kullanıcı girdisini sorgu nesnesi olarak geçirirsen, işler ters gider:
// SAVUNMASIZ — req.body.username { "$gt": "" } ise
// bu koleksiyondaki ilk kullanıcıyı döndürür
const user = await db.collection("users").findOne({
username: req.body.username,
});
// GÜVENLİ — açıkça string'e dönüştür
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// DAHA İYİ — önce Zod ile doğrula
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});Çözüm basit: girdi tiplerini veritabanı driver'ına ulaşmadan önce doğrula. username string olmalıysa, string olduğunu doğrula.
Path Traversal#
API'ın dosya sunuyorsa veya kullanıcı girdisi içeren bir path'ten okuyorsa, path traversal haftanı mahveder:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// Mutlak path'e normalize et ve çöz
const resolved = path.resolve(ALLOWED_DIR, userInput);
// Kritik: çözülmüş path'in hâlâ izin verilen dizin içinde olduğunu doğrula
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// Dosyanın gerçekten var olduğunu doğrula
await access(resolved, constants.R_OK);
return resolved;
}
// Bu kontrol olmadan:
// GET /api/files?name=../../../etc/passwd
// /etc/passwd'a çözümlenirpath.resolve + startsWith kalıbı doğru yaklaşım. ../'ı manuel olarak silmeye çalışma — URL kontolünü atlayacak çok fazla encoding hilesi var (..%2F, ..%252F, ....//).
Rate Limiting#
Rate limiting olmadan, API'ın botlar için sınırsız büfe. Kaba kuvvet saldırıları, credential stuffing, kaynak tüketimi — rate limiting hepsine karşı ilk savunma.
Token Bucket vs Sliding Window#
Token bucket: N token tutan bir kova var. Her istek bir token'a mal olur. Token'lar sabit bir hızda yenilenir. Kova boşsa istek reddedilir. Bu patlamalara izin verir — kova doluysa, anında N istek yapabilirsin.
Sliding window: Hareketli bir zaman penceresi içindeki istekleri say. Daha öngörülebilir, patlamayla aşılması daha zor.
Çoğu şey için sliding window kullanıyorum çünkü davranışı hakkında düşünmek ve ekibe açıklamak daha kolay:
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();
// Pencere dışındaki girdileri kaldır
multi.zremrangebyscore(key, 0, windowStart);
// Penceredeki girdileri say
multi.zcard(key);
// Mevcut isteği ekle (limit aşılırsa kaldıracağız)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// Key'e süre sonu ayarla
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) {
// Limit aşıldı — eklediğimiz girdiyi kaldır
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}Katmanlı Rate Limit'ler#
Tek bir global rate limit yetmez. Farklı endpoint'lerin farklı risk profilleri vardır:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// Auth endpoint'leri — sıkı limitler, kaba kuvvet hedefi
"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 },
// Veri okumaları — daha cömert
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// Veri yazmaları — orta düzey
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// Global yedek
"*": { 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}`;
}Dikkat et: kimliği doğrulanmış kullanıcılar IP ile değil, kullanıcı ID'si ile rate limit'lenir. Bu önemli çünkü birçok meşru kullanıcı IP paylaşır (kurumsal ağlar, VPN'ler, mobil operatörler). Sadece IP ile sınırlarsan, tüm ofisleri engellersin.
Rate Limit Header'ları#
Client'a her zaman neler olduğunu söyle:
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 Yapılandırması#
CORS muhtemelen web geliştirmedeki en yanlış anlaşılan güvenlik mekanizması. Stack Overflow cevaplarının yarısı "sadece Access-Control-Allow-Origin: * ayarla ve çalışır" diyor. Teknik olarak doğru. Aynı zamanda API'ını internetteki her kötü niyetli siteye açmanın yolu.
CORS Gerçekte Ne Yapıyor (ve Yapmıyor)#
CORS bir tarayıcı mekanizması. Tarayıcıya, Origin A'daki JavaScript'in Origin B'den gelen yanıtı okumaya izin verilip verilmediğini söyler. Hepsi bu.
CORS'un yapmadığı şeyler:
- API'ını curl, Postman veya sunucudan-sunucuya isteklerden korumaz
- İstekleri doğrulamaz
- Hiçbir şeyi şifrelemez
- CSRF'i tek başına engellemez (diğer mekanizmalarla birleştirildiğinde yardımcı olsa da)
CORS'un yaptığı şey:
- malicious-website.com'un kullanıcının tarayıcısında your-api.com'a fetch istekleri yapıp yanıtı okumasını engeller
- Saldırganın JavaScript'inin kurbanın kimliği doğrulanmış oturumu üzerinden veri sızdırmasını engeller
Wildcard Tuzağı#
// TEHLİKELİ — herhangi bir web sitesinin API yanıtlarını okumasına izin verir
app.use(cors({ origin: "*" }));
// AYNI DERECEDE TEHLİKELİ — bu yaygın "dinamik" yaklaşım sadece ekstra adımlı *
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // Her şeye izin verir
},
})
);Doğru Yapılandırma#
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) => {
// Origin'i olmayan isteklere izin ver (mobil uygulamalar, curl, sunucudan-sunucuya)
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, // Cookie'lere izin ver
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // Preflight'ı 24 saat önbelleğe al
})
);Önemli kararlar:
- Açık origin seti, regex değil. Regex'ler hileli —
yourapp.comregex'in düzgün anchor'lanmamışsaevilyourapp.comile eşleşebilir. credentials: trueçünkü refresh token'lar için httpOnly cookie kullanıyoruz.maxAge: 86400— Preflight istekleri (OPTIONS) gecikme ekler. Tarayıcıya CORS sonucunu 24 saat önbelleğe almasını söylemek gereksiz gidiş-gelişleri azaltır.exposedHeaders— Varsayılan olarak tarayıcı JavaScript'e yalnızca bir avuç "basit" yanıt header'ı gösterir. Client'ın rate limit header'larını okumasını istiyorsan, açıkça expose etmelisin.
Güvenlik Header'ları#
Güvenlik header'ları yapabileceğin en ucuz güvenlik iyileştirmesi. Tarayıcıya güvenlik özelliklerini etkinleştirmesini söyleyen yanıt header'ları. Çoğu tek bir satır yapılandırma, ve bütün saldırı sınıflarına karşı koruma sağlıyorlar.
Önemli Header'lar#
import helmet from "helmet";
// Tek satır. Herhangi bir Express uygulamasındaki en hızlı güvenlik kazancı.
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Birçok CSS-in-JS çözümü için gerekli
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1 yıl
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);Her header ne yapar:
Content-Security-Policy (CSP) — En güçlü güvenlik header'ı. Tarayıcıya script, stil, görsel, font vb. için hangi kaynakların izinli olduğunu söyler. Bir saldırgan evil.com'dan yüklenen bir <script> etiketi enjekte ederse, CSP engeller. XSS'e karşı en etkili savunma.
Strict-Transport-Security (HSTS) — Tarayıcıya kullanıcı http:// yazsa bile her zaman HTTPS kullanmasını söyler. preload direktifi, domain'ini tarayıcının yerleşik HSTS listesine kaydetmeni sağlar, böylece ilk istek bile HTTPS'e zorlanır.
X-Frame-Options — Siteni iframe içine gömülmekten engeller. Saldırganın sayfanı görünmez elementlerle kapladığı clickjacking saldırılarını durdurur.
X-Content-Type-Options: nosniff — Tarayıcının yanıtın MIME tipini tahmin etmesini engeller. Bu olmadan, yanlış Content-Type ile bir dosya sunarsak, tarayıcı onu JavaScript olarak çalıştırabilir.
Referrer-Policy — Referer header'ında ne kadar URL bilgisi gönderildiğini kontrol eder. strict-origin-when-cross-origin same-origin istekler için tam URL gönderir ama cross-origin istekler için sadece origin gönderir.
Secret Yönetimi#
Bu apaçık olmalı, ama hâlâ pull request'lerde görüyorum: kaynak dosyalara hardcode edilmiş API anahtarları, veritabanı şifreleri ve JWT secret'ları. Ya da .gitignore'a eklenmemiş .env dosyalarında commit'lenmiş. Git geçmişine girdikten sonra, sonraki commit'te dosyayı silsen bile sonsuza dek orada.
Kurallar#
-
Secret'ları asla git'e commit'leme. Kodda değil,
.env'de değil, config dosyalarında değil, Docker Compose dosyalarında değil, "sadece test için" yorumlarında değil. -
Şablon olarak
.env.examplekullan. Hangi environment variable'ların gerekli olduğunu gerçek değerler içermeden belgeler. -
Environment variable'ları başlangıçta doğrula. Veritabanı URL'sine ihtiyaç duyan bir endpoint'e istek gelene kadar bekleme. Hızlı başarısız ol:
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); // Kötü config ile başlatma
}
return result.data;
}
export const env = validateEnv();-
Production'da secret manager kullan. Environment variable'lar basit kurulumlar için çalışır, ama sınırlamaları var: process listelerinde görünürler, bellekte kalırlar ve hata logları üzerinden sızabilirler.
-
Secret'ları düzenli olarak döndür. İki yıldır aynı JWT secret'ı kullanıyorsan, döndürme zamanı gelmiş.
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // Sadece aktif anahtar yeni token'ları imzalar
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // Tüm geçerli anahtarları döndürür
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // Sonraki anahtarı dene
}
}
throw new ApiError(401, "Invalid token");
}OWASP API Güvenliği Top 10#
OWASP API Security Top 10, API zafiyetlerinin endüstri standardı listesi. Periyodik olarak güncellenir ve listedeki her madde gerçek kod tabanlarında gördüğüm bir şey.
API1: Broken Object Level Authorization (BOLA)#
En yaygın API zafiyeti. Kullanıcının kimliği doğrulanmış, ama API talep ettiği belirli nesneye erişimi olup olmadığını kontrol etmiyor.
// SAVUNMASIZ — herhangi bir kimliği doğrulanmış kullanıcı herkesin verisine erişebilir
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// DÜZELTİLMİŞ — kullanıcının kendi verisine eriştiğini (veya admin olduğunu) doğrula
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);
});API2: Broken Authentication#
Zayıf giriş mekanizmaları, eksik MFA, asla süresi dolmayan token'lar, düz metin saklanan şifreler.
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 dakika
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))) {
// Başarısız denemeleri artır
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// Her iki durum için aynı hata mesajı — e-postanın var olup olmadığını ifşa etme
throw new ApiError(401, "Invalid email or password");
}
// Başarılı girişte başarısız denemeleri sıfırla
await redis.del(lockoutKey);
return generateTokens(user);
}API3: Broken Object Property Level Authorization#
Gerekenden fazla veri döndürmek veya kullanıcıların değiştirmemesi gereken özellikleri değiştirmelerine izin vermek.
// SAVUNMASIZ — dahili alanlar dahil tüm user nesnesini döndürür
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// Yanıt şunları içerir: passwordHash, internalNotes, billingId, ...
});
// DÜZELTİLMİŞ — döndürülen alanların açık beyaz listesi
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,
});
});Asla tüm veritabanı nesnelerini döndürme. Her zaman expose etmek istediğin alanları seç.
API4: Unrestricted Resource Consumption#
API'ın bir kaynak. CPU, bellek, bant genişliği, veritabanı bağlantıları — hepsi sınırlı.
// İstek gövde boyutunu sınırla
app.use(express.json({ limit: "1mb" }));
// Sorgu karmaşıklığını sınırla
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),
});
// Dosya yükleme boyutunu sınırla
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"));
}
},
});API5: Broken Function Level Authorization#
BOLA'dan farklı. Bu, nesnelere değil fonksiyonlara (endpoint'lere) erişimle ilgili.
// Rol tabanlı erişim kontrolü middleware'i
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)) {
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();
};
}
// Route'lara uygula
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);Endpoint'leri gizlemeye güvenme. "Obscurity yoluyla güvenlik" güvenlik değil.
API7: Server Side Request Forgery (SSRF)#
API'ın kullanıcının sağladığı URL'leri fetch ediyorsa, saldırgan sunucunun dahili kaynakları istemesini sağlayabilir:
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");
}
// Sadece HTTP(S)'e izin ver
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Only HTTP(S) URLs are allowed");
}
// Hostname'i çöz ve özel IP olup olmadığını kontrol et
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "Internal addresses are not allowed");
}
}
// Şimdi timeout ve boyut limiti ile fetch et
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // Yönlendirmeleri takip etme
});
return response;
} finally {
clearTimeout(timeout);
}
}Önemli: DNS'i önce çöz ve IP'yi isteği yapmadan önce kontrol et. Yönlendirmeleri engelle — saldırgan URL düzeyinde kontrolünü atlamak için http://169.254.169.254/'e (AWS metadata endpoint) yönlendiren bir URL barındırabilir.
Denetim Günlüğü#
Bir şeyler ters gittiğinde — ve gidecek — denetim günlükleri ne olduğunu anlaman sağlar. Ama günlük tutma çift taraflı bir kılıç. Çok az tut ve körsün. Çok fazla tut ve bir gizlilik yükümlülüğü yaratırsın.
Ne Loglanmalı#
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>;
requestId: string;
}Şu olayları logla:
- Kimlik doğrulama: girişler, çıkışlar, başarısız denemeler, token yenilemeleri
- Yetkilendirme: erişim reddedildi olayları (bunlar genellikle saldırı göstergeleri)
- Veri değişiklikleri: oluşturma, güncelleme, silme — kim neyi ne zaman değiştirdi
- Admin eylemleri: rol değişiklikleri, kullanıcı yönetimi, yapılandırma değişiklikleri
- Güvenlik olayları: rate limit tetiklenmeleri, CORS ihlalleri, hatalı biçimli istekler
Ne Loglanmamalı#
Asla loglama:
- Şifreleri (hash'lenmiş olsa bile)
- Tam kredi kartı numaralarını (sadece son 4 haneyi logla)
- Kimlik numaralarını
- API anahtarlarını veya token'ları (en fazla bir önek logla:
sk_live_...abc) - Kişisel sağlık bilgilerini
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;
}Bağımlılık Güvenliği#
Senin kodun güvenli olabilir. Ama node_modules'undaki 847 npm paketine ne demeli? Tedarik zinciri sorunu gerçek ve yıllar içinde kötüleşti.
npm audit Asgari Minimum#
# CI'da çalıştır, high/critical zafiyetlerde build'i başarısız kıl
npm audit --audit-level=high
# Otomatik düzeltilebilenleri düzelt
npm audit fix
# Gerçekte ne çektiğini gör
npm ls --allLockfile Bir Güvenlik Aracıdır#
Her zaman package-lock.json'ını (veya pnpm-lock.yaml'ını) commit'le. Lockfile, geçişli olanlar dahil her bağımlılığın tam sürümlerini sabitler. O olmadan, npm install test ettiğinden farklı bir sürüm çekebilir — ve o farklı sürüm ele geçirilmiş olabilir.
# CI'da install yerine ci kullan — lockfile'a sıkı sıkıya uyar
npm ciYüklemeden Önce Değerlendir#
Bir bağımlılık eklemeden önce sor:
- Buna gerçekten ihtiyacım var mı? Bir paket eklemek yerine 20 satırda yazabilir miyim?
- Kaç indirmesi var? Düşük indirme sayısı illaki kötü değil, ama kodu inceleyen daha az göz demek.
- En son ne zaman güncellendi? 3 yıldır güncellenmemiş bir paketin yamanmamış zafiyetleri olabilir.
- Kaç bağımlılık çekiyor? Bir satır kodun yapacağı şey için üç paket fazla.
- Kim bakım yapıyor? Tek bir bakımcı, tek bir ele geçirme noktası.
Deploy Öncesi Kontrol Listesi#
Her production deployment'ından önce kullandığım gerçek kontrol listesi bu:
| # | Kontrol | Geçme Kriteri | Öncelik |
|---|---|---|---|
| 1 | Kimlik doğrulama | JWT'ler açık algoritma, issuer ve audience ile doğrulanıyor. alg: none yok. | Kritik |
| 2 | Token süresi | Access token'lar 15 dk veya daha azda sona erer. Refresh token'lar kullanımda döner. | Kritik |
| 3 | Token saklama | Refresh token'lar httpOnly secure cookie'lerde. localStorage'da token yok. | Kritik |
| 4 | Her endpoint'te yetkilendirme | Her veri erişim endpoint'i nesne düzeyinde izinleri kontrol ediyor. | Kritik |
| 5 | Girdi doğrulama | Tüm kullanıcı girdisi Zod veya eşdeğeri ile doğrulanıyor. | Kritik |
| 6 | SQL/NoSQL injection | Tüm veritabanı sorguları parametreli sorgu veya ORM metodu kullanıyor. | Kritik |
| 7 | Rate limiting | Auth endpoint'leri: 5/15dk. Genel API: 60/dk. Rate limit header'ları dönüyor. | Yüksek |
| 8 | CORS | Açık origin beyaz listesi. Credential'la wildcard yok. | Yüksek |
| 9 | Güvenlik header'ları | CSP, HSTS, X-Frame-Options, X-Content-Type-Options hepsi mevcut. | Yüksek |
| 10 | Hata yönetimi | Production hataları genel mesaj döndürüyor. Stack trace yok. | Yüksek |
| 11 | Secret'lar | Kodda veya git geçmişinde secret yok. .env .gitignore'da. | Kritik |
| 12 | Bağımlılıklar | npm audit temiz. Lockfile commit'lenmiş. CI'da npm ci. | Yüksek |
| 13 | Sadece HTTPS | HSTS preload ile etkin. HTTP, HTTPS'e yönlendiriyor. | Kritik |
| 14 | Loglama | Auth olayları, erişim reddi ve veri mutasyonları loglanıyor. Loglarda PII yok. | Orta |
| 15 | İstek boyutu limitleri | Body parser sınırlı. Dosya yüklemeleri sınırlı. Sayfalama uygulanıyor. | Orta |
Bu listeyi GitHub issue şablonu olarak tutuyorum. Bir release etiketlemeden önce, ekipten biri her satırı kontrol edip onaylamak zorunda. Gösterişli değil ama işe yarıyor.
Zihniyet Değişimi#
Güvenlik sonradan eklenen bir özellik değil. Yılda bir yapılan bir sprint değil. Yazdığın her satır kod hakkında düşünme biçimidir.
Bir endpoint yazarken düşün: "Ya birisi beklemediğim veri gönderirse?" Bir parametre eklerken düşün: "Ya birisi bunu başkasının ID'siyle değiştirirse?" Bir bağımlılık eklerken düşün: "Bu paket gelecek salı ele geçirilirse ne olur?"
Her şeyi yakalayamayacaksın. Kimse yakalayamaz. Ama bu kontrol listesini — her deployment'tan önce, metodik olarak — gözden geçirmek en çok önemli olan şeyleri yakalar. Kolay kazançları. Bariz boşlukları. Kötü bir günü veri ihlaline çeviren hataları.
Alışkanlığı oluştur. Kontrol listesini çalıştır. Güvenle gönder.