Skip to content
ENTERPRISE Enterprise technology · Engineered for your business Enterprise Software · Managed Servers · Business E-mail · LiteSpeed + CloudLinux · Imunify360 Security · cPanel Control · Network profile up to 3 Gbps on eligible plans
AIOR

Web security in 2026: OWASP Top 10, but practical, with what we actually use

Aior

Administrator

Staff member
Joined
Apr 2, 2023
Messages
929
Reaction score
2
Points
18
Age
41
Location
Turkey
Website
aior.com
Thread owner

OWASP Top 10, web uygulamalarındaki en kritik risk kategorilerini sıralayan ve neredeyse her güvenlik denetiminde atıf yapılan bir farkındalık belgesi. Sorun, listenin soyut kalması: ekipler okuyor, başını sallıyor ama kendi kodunda neyi, nasıl test edeceğini bilmiyor. Bu yazıyı OWASP Top 10:2025 sürümüne göre baştan yazdık. PHP, WordPress, XenForo, Node.js veya Python ile uygulama çalıştıran küçük ekipler için her kategoride üç şey bulacaksınız: gerçekçi bir örnek, kendi başınıza yapabileceğiniz bir test ve kısa bir düzeltme kalıbı. Yazının başında 2021'den 2025'e değişiklik tablosu, sonunda ise öncelik sıralı 30 günlük kontrol listesi ve uyarlayabileceğiniz bir CI (sürekli entegrasyon) iş akışı var.

Önemli: Aşağıdaki testleri yalnızca size ait ya da yazılı test izni aldığınız sistemlerde, tercihen canlı ortamın bir kopyası olan test ortamında (staging) çalıştırın.

OWASP Top 10:2025: 2021'den bu yana ne değişti?​

Güncel liste top10.owasp.org/2025 adresinde yayımlanıyor. OWASP, on kategoriden sekizini kuruluşların paylaştığı test verisinden, ikisini topluluk anketinden seçtiğini belirtiyor. Ankette en çok öne çıkan risk A03 oldu; A09 da listeye yine anket katılımcılarının oylarıyla girdi. Ekip, mümkün olan her yerde belirtiye (symptom) değil kök nedene (root cause) odaklandığını da yazıyor.

20252021'deki karşılığıDeğişiklik
A01 Broken Access ControlA01 (aynı)Yine 1. sırada; 2021'in A10'u olan SSRF bu kategoriye katıldı
A02 Security MisconfigurationA055. sıradan 2. sıraya çıktı
A03 Software Supply Chain FailuresA06 Vulnerable and Outdated ComponentsKapsam genişledi: bağımlılıklar, derleme sistemleri, dağıtım
A04 Cryptographic FailuresA022. sıradan 4. sıraya indi
A05 InjectionA033. sıradan 5. sıraya indi; XSS hâlâ bu kategoride
A06 Insecure DesignA044. sıradan 6. sıraya indi
A07 Authentication FailuresA07 Identification and Authentication FailuresAd kısaldı, sıra aynı
A08 Software or Data Integrity FailuresA08 Software and Data Integrity FailuresSıra aynı, adda küçük değişiklik
A09 Security Logging and Alerting FailuresA09 Security Logging and Monitoring FailuresAd alarmı vurguluyor: log tutmak yetmez, birinin haberi olmalı
A10 Mishandling of Exceptional ConditionsYokYeni kategori (24 CWE): yanlış hata yönetimi, hata anında açık kalma (fail open), mantık hataları

Pratik sonuç: 2021'e göre hazırladığınız kontrol listesi çöpe gitmiyor. SSRF maddesini A01'in altına taşıyın, bağımlılık taramasını tedarik zinciri kontrolüne (A03) genişletin, hata yönetimi (A10) ve alarm üretimi (A09) için yeni maddeler ekleyin.

Küçük ekip için test araç çantası​

Kategorilerde sürekli şu beş yönteme döneceğiz:
  • Manuel IDOR kontrolü: İki test hesabıyla birbirinin kayıtlarına erişmeyi denemek. Otomatik tarayıcıların en zayıf, insan gözünün en güçlü olduğu test budur.
  • ZAP baseline taraması: Eski adıyla OWASP ZAP. Baseline modu siteyi varsayılan olarak bir dakika dolaşır (spider) ve yalnızca pasif kuralları çalıştırır; saldırı yükü göndermez.
  • Bağımlılık denetimi: composer audit, npm audit ve pip-audit.
  • SBOM: CycloneDX veya SPDX formatında yazılım malzeme listesi (Software Bill of Materials).
  • Başlık (header) kontrolü: curl ve tarayıcının geliştirici araçları.
Bash:
# ZAP baseline: pasif tarama, HTML rapor ve kural dosyası şablonu (gen.conf)
docker run --rm -v "$(pwd)":/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://staging.example.com -g gen.conf -r zap-rapor.html

# Güvenlik başlıklarını tek komutta görmek
curl -sI https://staging.example.com | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|x-frame-options'
ZAP'ın çıkış kodları CI için önemli: 0 sorun yok, 1 en az bir FAIL, 2 yalnızca WARN, 3 başka bir hata. Varsayılan olarak tüm bulgular WARN sayılır; -g ile üretilen dosyada kuralları FAIL veya IGNORE yapıp -c ile taramaya geri verirsiniz.

A01: Bozuk erişim kontrolü (Broken Access Control)​

Kategori birinci sıradaki yerini koruyor. OWASP'a göre kuruluşların paylaştığı test verisinde en sık rastlanan kategori bu ve 40 CWE'yi kapsıyor.

Örnek: Bir WordPress eklentisi /wp-json/shop/v1/order/1043 gibi bir REST rotası ekliyor ve permission_callback olarak __return_true veriyor, yani rota herkese açık. ID'yi 1042 yapan herkes başkasının siparişini görüyor. Aynı ailede yalnızca menüden gizlenmiş ama adresi bilen herkesin açabildiği yönetici sayfaları, dosya yolu parametresiyle sunucudaki her dosyayı indiren uç noktalar (path traversal), CSRF koruması olmayan formlar ve fazla serbest CORS ayarları var. 2025'te SSRF (CWE-918) de burada: kullanıcının verdiği URL'yi sunucuda çeken bir "bağlantı önizleme" özelliği, 169.254.169.254 gibi bulut metadata adreslerine ya da iç servislere istek atmak için kullanılabilir.

Test (manuel IDOR kontrolü):
  1. Test ortamında A ve B adlı iki normal kullanıcı açın; her biriyle birer kayıt (sipariş, fatura, özel mesaj, ek dosya) oluşturun.
  2. A ile oturum açın ve kaydı yükleyen isteği geliştirici araçlarının Network sekmesinden "Copy as cURL" ile kopyalayın.
  3. Çerezi B'nin oturum çereziyle değiştirip isteği yeniden gönderin. Aynı denemeyi PUT, PATCH ve DELETE çağrıları ile JSON gövdesindeki ve gizli form alanlarındaki ID'ler için de yapın.
  4. Beklenen yanıt 403 veya 404. A'nın verisi 200 koduyla dönüyorsa IDOR bulgunuz var.
Bash:
# Çerez adı uygulamaya göre değişir: PHPSESSID, xf_session, wordpress_logged_in_...
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Cookie: oturum=B_KULLANICISININ_CEREZI" \
  https://staging.example.com/api/faturalar/1043
SSRF için URL kabul eden alanlara http://127.0.0.1/ ve http://169.254.169.254/ gibi adresler verin; yanıt içeriği, hata mesajı veya yanıt süresi değişiyorsa sunucu iç adreslere istek atıyor demektir.

Düzeltme kalıbı: Varsayılan olarak reddedin (deny by default) ve sahiplik kontrolünü sorgunun kendisine yazın. Kaydı çekip sonra if ile kontrol etmek yerine kullanıcı kimliğini WHERE koşuluna eklemek, unutulmuş bir kontrolün açık bırakacağı kapıyı kapatır:
PHP:
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ? AND user_id = ?');
$stmt->execute([$invoiceId, $currentUserId]);
$invoice = $stmt->fetch();
if ($invoice === false) {
    http_response_code(404);   // başkasının kaydı da "yok" görünür
    exit;
}
WordPress'te her REST rotasının permission_callback'inde yetkiyi current_user_can() ile, sorguda da kaydın o kullanıcıya ait olduğunu kontrol edin; WordPress 5.5'ten beri bu argüman eksikse çekirdek _doing_it_wrong uyarısı veriyor. XenForo eklentilerinde varlığı (entity) yükledikten sonra canView() ve canEdit() gibi izin metotlarını çağırın. Formlarda platformun yerleşik CSRF korumasını kullanın (WordPress'te nonce, XenForo'da _xfToken). SSRF'de kural basit: kullanıcı kontrolündeki URL'leri izin listesi (allowlist) olmadan çekmeyin, yönlendirmeleri takip etmeyin, sunucunun dışa giden trafiğini (egress) güvenlik duvarında sınırlayın; AWS'de metadata servisi için IMDSv2'yi zorunlu kılın. Bağlantı önizleme gibi her siteye gitmesi gereken özelliklerde alan adını değil çözümlenen IP adresini kontrol edin, isteği de o adrese yapın; 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 ve 169.254.0.0/16 aralıklarını (IPv6'da ::1, fc00::/7 ve fe80::/10) reddedin.

A02: Güvenlik yapılandırma hataları (Security Misconfiguration)​

2021'de beşinci sıradaydı, 2025'te ikinciliğe yükseldi.

Örnek: Canlı sunucuda unutulmuş phpinfo.php, web'den indirilebilen .env dosyası veya .git dizini, açık kalmış hata ayıklama modu (WordPress'te WP_DEBUG ve hataları ekrana basan WP_DEBUG_DISPLAY, XenForo'da src/config.php içindeki $config['debug'], Django'da DEBUG, Flask'ta debugger), değiştirilmemiş varsayılan yönetici parolası, eksik güvenlik başlıkları.

Test: ZAP baseline eksik başlıkları ve sürüm bilgisi sızdıran sunucu başlıklarını raporlar. Açıkta kalan dosyalar için kısa bir döngü yeterli:
Bash:
for p in .env .git/HEAD phpinfo.php info.php wp-config.php.bak internal_data/ ; do
  printf "%s %s\n" "$(curl -s -o /dev/null -w '%{http_code}' https://staging.example.com/$p)" "$p"
done
# Beklenen: 403 veya 404. .env ya da .git/HEAD için 200 acil bulgudur.

Düzeltme kalıbı: Ortamları betikle ve birbirinin aynısı olacak şekilde kurun, kullanılmayan bileşenleri kaldırın, güvenlik başlıklarını sunucu seviyesinde verin. Nginx örneği:
Code:
# HSTS: önce kısa süre; sorun çıkmazsa max-age=31536000
add_header Strict-Transport-Security "max-age=300" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
Dikkat edilecek noktalar:
  • Nginx'te bir location bloğu kendi add_header satırlarını tanımlarsa, varsayılan davranışta üst seviyedeki başlıklar o blokta kaybolur. Nginx 1.29.3 ve sonrasında üst seviyeye add_header_inherit merge; yazarsanız alt bloklar bu başlıkları kendi başlıklarına ekler. Önemli yolları curl ile ayrı ayrı kontrol edin; uygulama aynı başlığı zaten gönderiyorsa çift başlık oluşmasın.
  • HSTS'de includeSubDomains'i yalnızca tüm alt alan adlarınız HTTPS sunuyorsa ekleyin. Preload için hstspreload.org en az 31536000 saniye, includeSubDomains ve preload ister; listeden çıkmak mümkün ama değişikliğin kullanıcılara ulaşması aylar sürer.
  • CSP'yi önce Report-Only modunda yayınlayın ve ihlalleri tarayıcı konsolunda izleyin. Report-Only modu hiçbir şeyi engellemez, frame-ancestors da buna dahil; bu yüzden geçiş döneminde X-Frame-Options satırını tutun. WordPress ve XenForo'da eklenti ve temalardaki satır içi (inline) betikler yüzünden sıkı bir politika emek ister; object-src 'none', base-uri 'self' ve frame-ancestors gibi düşük riskli yönergelerle başlayın. Express'te helmet paketi, Django 6.0 ve sonrasında yerleşik SECURE_CSP ayarı işi kolaylaştırır.

A03: Yazılım tedarik zinciri hataları (Software Supply Chain Failures)​

2021'deki "Vulnerable and Outdated Components" maddesinin genişletilmiş hali: yalnızca eski kütüphaneler değil, derleme sistemleri, CI/CD ve dağıtım süreci de kapsamda.

Örnek: Resmî olmayan bir siteden indirilmiş "nulled" WordPress teması veya XenForo eklentisi; yıllardır güncellenmeyen bir eklenti; kurulum betiğiyle (postinstall) geliştirici token'larını çalan bir npm paketi. OWASP bu kategoride 2025'teki Shai-Hulud npm solucanını örnek veriyor: zararlı paket sürümleri kurulum betikleriyle veri çalmış, ele geçirilen npm token'larıyla da yeni zararlı sürümler yayımlamış.

Test: Her ekosistemin kendi denetim komutu var ve üçü de bulgu olduğunda sıfırdan farklı bir kodla çıktığı için CI'da doğrudan kullanılabilir:
Bash:
composer audit --locked --no-dev     # audit komutu Composer 2.4 ile geldi
npm audit --audit-level=high         # yalnızca high ve critical bulgularda hata verir
npm audit signatures                 # kayıt imzalarını ve provenance bilgisini doğrular
pip-audit -r requirements.txt        # kurulum: pip install pip-audit

# SBOM (CycloneDX veya SPDX)
npm sbom --sbom-format cyclonedx > sbom-npm.cdx.json
cyclonedx-py requirements requirements.txt -o sbom-py.cdx.json
syft . -o cyclonedx-json=sbom.cdx.json -o spdx-json=sbom.spdx.json
PHP projelerinde CycloneDX çıktısını cyclonedx/cyclonedx-php-composer eklentisi (composer CycloneDX:make-sbom) üretir; Composer, eklentiye izin vermenizi (allow-plugins) ister. Syft ise kilit dosyalarını okuyarak tek komutla birden çok ekosistemi kapsar.

Düzeltme kalıbı:
  • Kilit dosyalarını (composer.lock, package-lock.json, hash'li requirements.txt) depoya ekleyin; canlıda composer install --no-dev, npm ci ve pip install --require-hashes kullanın.
  • Bileşenleri yalnızca resmî kaynaklardan alın; nulled tema ve eklenti kullanmayın, terk edilmiş (abandoned) paketleri değiştirin.
  • Yeni yayımlanan sürümleri bir süre bekletin: npm 11.10 ve sonrasında min-release-age (gün), pnpm'de minimumReleaseAge (dakika) ayarı var. Bağımlılıkların kurulum betiklerini de denetim altında tutun: npm 12 ve sonrası ile güncel pnpm sürümleri bu betikleri varsayılan olarak engelliyor ve yalnızca onayladığınız paketlerde çalıştırıyor (npm'de package.json içindeki allowScripts alanı ve npm approve-scripts komutu, pnpm'de allowBuilds). Daha eski npm sürümlerinde gerekmeyen betikleri ignore-scripts ile kapatın.
  • npm, GitHub ve paket kayıtlarındaki hesaplarda MFA zorunlu olsun; CI ortamı, dağıtım yaptığı sistemlerden daha zayıf korunmasın.
  • Her sürümün SBOM'unu saklayın. Bir sonraki büyük açık duyurulduğunda "bu kütüphane bizde var mı, hangi sürümde?" sorusunu dakikalar içinde yanıtlarsınız; OWASP Dependency-Track bu listeleri sürekli izleyebilir.

A04: Kriptografik hatalar (Cryptographic Failures)​

Örnek: Eski bir üyelik tablosunda MD5 ile saklanan parolalar, HTTP'ye düşürülebilen (downgrade) giriş sayfası, depoya girmiş API anahtarları, tahmin edilebilir oturum veya parola sıfırlama token'ları.

Test: Veritabanındaki parola sütununa bakın: $2y$ (veya $2b$) ile başlayan değer bcrypt, $argon2id$ ile başlayan değer Argon2id hash'idir; 32 karakterlik onaltılık (hex) değer büyük olasılıkla MD5, 40 karakterlik olan SHA-1'dir. WordPress'te $P$ ile başlayan değerler 6.8 öncesinin phpass hash'leridir; 6.8 ve sonrası $wp$2y$ önekiyle bcrypt kullanır ve eski hash'ler kullanıcı giriş yaptıkça yenilenir. TLS yapılandırmasını testssl.sh veya SSL Labs ile, depo geçmişindeki sırları gitleaks git . komutuyla tarayın.

Düzeltme kalıbı: OWASP'ın parola saklama rehberi (Password Storage Cheat Sheet) en az 19 MiB bellek, 2 iterasyon ve paralellik 1 ayarlarıyla Argon2id algoritmasını öneriyor; bcrypt eski sistemlerde en az 10 maliyet değeri ve 72 baytlık girdi sınırı gözetilerek kabul görüyor. Kendi algoritmanızı yazmayın:
PHP:
$hash = password_hash($password, PASSWORD_ARGON2ID); // PHP Argon2 desteğiyle derlenmişse

// Girişte: doğrula, parametreler değiştiyse yeniden hash'le
if (password_verify($password, $stored) && password_needs_rehash($stored, PASSWORD_ARGON2ID)) {
    $newHash = password_hash($password, PASSWORD_ARGON2ID); // veritabanına kaydedin
}

$token = bin2hex(random_bytes(32)); // sıfırlama ve API token'ları için
Eski MD5 kayıtlarını, kullanıcı doğru parolayla giriş yaptığı anda yeni algoritmayla hash'leyerek taşıyabilirsiniz. Uzun süre giriş yapmayan hesapların MD5 değerleri tabloda kalmasın diye OWASP'ın rehberi iki yol öneriyor: bu hesapların parolasını geçersiz kılıp sıfırlama istemek ya da eski hash'i yeni algoritmayla sarmak (ör. bcrypt(md5(parola))) ve kullanıcının ilk girişinde parolanın doğrudan hash'ine geçmek. Yalnızca TLS 1.2 ve üstünü kabul edin, HSTS'yi açın, sırları koddan çıkarıp ortam değişkenine veya bir sır yöneticisine (secret manager) taşıyın. Depoya sızmış bir anahtarı silmek yetmez; geçmişte durmaya devam ettiği için mutlaka yenisiyle değiştirin (rotate).

A05: Enjeksiyon (Injection)​

Örnek: Özel bir arama filtresinde ' OR '1'='1 girdisiyle tüm kayıtları döndüren, metin birleştirmeyle kurulmuş SQL; kullanıcının seçtiği sütun adıyla oluşturulan ORDER BY; yorumlarda kaçışlanmamış (escape edilmemiş) çıktıdan doğan XSS; dosya adını doğrudan kabuk komutuna ekleyen bir görsel dönüştürme betiği. NoSQL, LDAP ve şablon (template) enjeksiyonu da bu ailede. LLM tabanlı özelliklerde OWASP, prompt injection için ayrı yayımladığı OWASP LLM Top 10'a yönlendiriyor.

Test: Parametrelere tek ve çift tırnak ekleyin; SQL hata mesajı ya da beklenmedik sonuç bulgudur. Kod tarafında sorgu metnine değişken eklenen satırları grep ile arayın. sqlmap gibi saldırı araçlarını yalnızca test izniniz olan ortamlarda kullanın.

Düzeltme kalıbı: Parametreli sorgu (prepared statement); her yığında aynı fikir:
PHP:
// PDO
$stmt = $pdo->prepare('SELECT * FROM posts WHERE author_id = ? AND status = ?');
$stmt->execute([$authorId, 'published']);

// WordPress: %d tam sayı, %s metin, %i tablo/sütun adı (%i için WordPress 6.2+)
$rows = $wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}orders WHERE customer_id = %d", $customerId));

// XenForo eklentisi
$user = \XF::db()->fetchRow('SELECT * FROM xf_user WHERE user_id = ?', [$userId]);
Code:
// Node.js (node-postgres)
const { rows } = await pool.query(
  'SELECT * FROM orders WHERE id = $1 AND user_id = $2', [orderId, userId]);
Python:
# Python (psycopg); sqlite3'te yer tutucu ? işaretidir
cur.execute("SELECT * FROM orders WHERE id = %s AND user_id = %s", (order_id, user_id))
Prepared statement'larda tablo ve sütun adları parametre olamaz; ORDER BY için kullanıcı girdisini sabit bir izin listesine eşleyin. HTML çıktısını bağlama uygun şekilde kaçışlayın (WordPress'te esc_html ve esc_attr), kabuk komutlarında argümanları PHP'de escapeshellarg ile, Python'da subprocess.run'a liste olarak verin. CSP'yi XSS'e karşı ikinci savunma hattı olarak düşünün.

A06: Güvensiz tasarım (Insecure Design)​

Bu kategori, kod doğru yazılsa bile tasarımda hiç düşünülmemiş kontrollerin eksikliğini anlatır. OWASP ayrımı net koyuyor: kusursuz kodlama, tasarımda hiç yer almamış bir savunmayı yerine koyamaz.

Örnek: Tek kullanımlık indirim kodu, aynı anda gönderilen on istekle on kez uygulanıyor. Altı haneli parola sıfırlama kodunda deneme sınırı yok. Ödeme adımı atlanıp doğrudan "sipariş tamamlandı" adresine gidilebiliyor. Parola sıfırlama, bağlantı yerine parolanın kendisini e-postayla gönderiyor.

Test: Her kritik akış için kötüye kullanım senaryoları (misuse cases) yazın ve deneyin; adımları atlayın, sırayı değiştirin, aynı isteği paralel gönderin:
Bash:
for n in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Cookie: oturum=TEST_CEREZI" \
    -d "code=HOSGELDIN10" https://staging.example.com/sepet/kupon &
done; wait
# Yalnızca bir istek başarılı olmalı.

Düzeltme kalıbı: Kuralı veritabanında atomik hale getirin: UPDATE coupons SET used = used + 1 WHERE id = ? AND used < max_uses sorgusundan sonra etkilenen satır sayısı 0 ise kuponu reddedin; kullanıcı başına tek kullanım için (coupon_id, user_id) üzerinde benzersiz kısıt (unique constraint) tanımlayın. Çok adımlı akışları sunucu tarafında bir durum makinesiyle yönetin, OTP ve sıfırlama kodlarında deneme sayısını ve geçerlilik süresini sınırlayın. Önemli her özellik için yarım saatlik bir tehdit modelleme oturumu iyi bir başlangıçtır: "Bu özelliği kim, nasıl kötüye kullanır?"

A07: Kimlik doğrulama hataları (Authentication Failures)​

Örnek: wp-login.php veya forum giriş sayfasına sızdırılmış kullanıcı adı ve parola listeleriyle toplu deneme (credential stuffing); girişten sonra değişmeyen oturum kimliği (session fixation); çıkışta sunucuda geçersiz kılınmayan oturum; "kullanıcı bulunamadı" ve "parola yanlış" için farklı mesajlarla kullanıcı adı keşfi (enumeration).

Test: Test hesabına 20 kez yanlış parola girin; yavaşlatma, geçici kilit veya CAPTCHA devreye girmeli. Var olan ve olmayan kullanıcı için hata mesajlarını karşılaştırın. Girişten önce ve sonra oturum çerezinin değerini not edin; değişmiş olmalı. Çıkış yaptıktan sonra eski çerezi curl ile yeniden gönderin; reddedilmeli. Çerezlerdeki Secure, HttpOnly ve SameSite işaretlerini geliştirici araçlarından kontrol edin.

Düzeltme kalıbı:
INI:
; php.ini: oturum çerezleri
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = "Lax"
session.use_strict_mode = 1
Kendi giriş kodunuzda başarılı girişten hemen sonra session_regenerate_id(true) çağırın. Yönetici ve moderatörler için MFA'yı zorunlu kılın; XenForo'da iki adımlı doğrulama yerleşik, WordPress'te eklentiyle gelir. Yeni parolaları sızıntı listelerine karşı kontrol edin: Have I Been Pwned hizmetinin Pwned Passwords aralık (range) API'si, parolanın SHA-1 hash'inin yalnızca ilk beş karakterini göndererek sorgu yapmanızı sağlar. OWASP, NIST SP 800-63B ile uyumlu biçimde periyodik zorunlu parola değişikliği yerine uzun parolaları ve sızıntı kontrolünü öneriyor. SSO, MFA ve rol tabanlı erişim (RBAC) tasarımı için kimlik ve erişim yönetimi yazımıza bakabilirsiniz.

A08: Yazılım veya veri bütünlüğü hataları (Software or Data Integrity Failures)​

Örnek: Çerezdeki veriyi PHP unserialize() ile açan bir eklenti; CDN'den bütünlük kontrolü olmadan yüklenen bir JavaScript dosyası; imzası doğrulanmayan bir webhook çağrısıyla "ödendi" durumuna geçen sipariş; HTTP üzerinden zip indirip açan, kendi yazdığınız bir otomatik güncelleyici.

Test: Kod tabanında unserialize(, pickle.loads ve yaml.load( çağrılarını arayın, girdinin nereden geldiğine bakın. WordPress'te wp core verify-checksums ve wp plugin verify-checksums --all komutları dosyaları WordPress.org'daki sağlama toplamlarıyla (checksum) karşılaştırır; ikincisi yalnızca WordPress.org dizinindeki eklentiler için çalışır. Sayfa kaynağında harici betik etiketlerinin integrity özniteliği taşıyıp taşımadığına bakın.

Düzeltme kalıbı: İstemciden gelen her veride dilin yerel serileştirmesi yerine JSON kullanın (PHP'de json_decode ve json_encode). PHP kılavuzu bu konuda net: güvenilmeyen girdiyi, allowed_classes seçeneği verilmiş olsa bile unserialize() ile açmayın. Önbellek veya eski bir tablo gibi sizin sakladığınız serileştirilmiş veriyi açmanız gerekiyorsa önce hash_hmac ile doğrulayın, ek önlem olarak da ['allowed_classes' => false] verin. Python'da YAML için yaml.safe_load kullanın, güvenilmeyen veriyi asla pickle ile açmayın. İstemciye durum (state) taşımanız gerekiyorsa veriyi HMAC ile imzalayıp sunucuda doğrulayın. Webhook alıcılarında imza, zaman damgası ve tekrar (replay) korumasını güvenli webhook alıcısı yazımızda ayrıntılı anlattık. CDN dosyaları için Subresource Integrity (SRI) hash'ini üretip betik etiketine integrity="sha384-..." ve crossorigin="anonymous" öznitelikleriyle ekleyin:
Bash:
openssl dgst -sha384 -binary kutuphane.min.js | openssl base64 -A
CI tarafında ana dala doğrudan push'u kapatın, her değişikliği incelemeden (code review) geçirin, derleme ve dağıtım yetkilerini ayırın.

A09: Güvenlik loglama ve alarm hataları (Security Logging and Alerting Failures)​

OWASP, kategorinin adını değiştirirken alarmı öne çıkarıyor: kimsenin bakmadığı log, saldırıyı yakalamaz.

Örnek: Gece 03.00'te yeni bir yönetici hesabı açılıyor ve kimse fark etmiyor; loglar yalnızca ele geçirilen sunucunun kendi diskinde durduğu için saldırgan onları siliyor.

Test: Test ortamında 10 başarısız giriş yapın ve bir test yönetici hesabı oluşturun. Bu olaylar zaman damgası, IP ve kullanıcı adıyla loglandı mı? Belirlediğiniz süre içinde bir insana alarm ulaştı mı? İkinci sorunun cevabı "hayır" ise A09 bulgunuz var.

Düzeltme kalıbı:
  • Şunları loglayın: başarılı ve başarısız girişler, erişim reddi (403) olayları, sunucu tarafı doğrulama hataları, rol ve yetki değişiklikleri, parola ve MFA değişiklikleri, yapılandırma değişiklikleri, toplu veri dışa aktarımları.
  • Logları JSON gibi makinenin işleyebileceği bir formatta yazın; kullanıcı girdisindeki satır sonu karakterlerini kodlayarak log enjeksiyonunu önleyin.
  • Parola, token ve kart numarası loglamayın; kişisel veriyi en aza indirin. Saklama süreleri ve erişim yetkileri KVKK açısından da değerlendirilmeli; güncel yükümlülükleri resmî kaynaktan kontrol edin.
  • Logları sunucunun dışına gönderin: merkezi bir log sunucusu, Loki ve Grafana, ELK veya OpenSearch ya da barındırılan bir servis.
  • Üç ila beş net alarm kuralıyla başlayın: dakikada belirli bir sayının üzerinde başarısız giriş, yeni yönetici hesabı, yönetici girişinde yeni ülke veya IP, 5xx hatalarında ani artış.

A10: İstisnai durumların hatalı yönetimi (Mishandling of Exceptional Conditions)​

2025'in yeni kategorisi (24 CWE). Uygulamanın olağan dışı durumları önleyememesini, fark edememesini ya da bunlara yanlış tepki vermesini kapsıyor.

Örnek: Veritabanı hatasında SQL sorgusunu ve dosya yollarını gösteren hata sayfası; yetki servisi zaman aşımına uğradığında catch bloğunun isteği geçirmesi (fail open); para transferinde borç kaydı yazılıp alacak kaydı yazılmadan işlemin kesilmesi; yükleme hatasında geçici dosyaların ve bağlantıların serbest bırakılmaması.

Test: Beklenmeyen girdi gönderin: eksik parametre, yanlış tip (PHP'de ?id[]=1 gibi dizi), bozuk JSON, çok büyük istek gövdesi. Test ortamında veritabanını veya bağımlı bir servisi durdurun. Yanıtta yığın izi (stack trace), sorgu, sürüm ya da dosya yolu görünmemeli; yetki gerektiren işlemler reddedilmeli; olay log'da bir korelasyon kimliğiyle (correlation ID) yer almalı.

Düzeltme kalıbı:
INI:
; php.ini (canlı ortam)
display_errors = Off
log_errors = On
expose_php = Off
PHP:
set_exception_handler(function (Throwable $e) {
    $id = bin2hex(random_bytes(8));
    error_log("[$id] " . $e);              // ayrıntı yalnızca sunucu logunda
    http_response_code(500);
    echo "Beklenmeyen bir hata oluştu. Destek kodu: $id";
});

try {
    $allowed = $authz->canEdit($user, $post);
} catch (Throwable $e) {
    error_log((string) $e);
    $allowed = false;                       // fail closed: hata anında reddet
}

$pdo->beginTransaction();                   // PHP 8'de PDO varsayılan olarak istisna fırlatır
try {
    $debit->execute([$amount, $fromId]);
    $credit->execute([$amount, $toId]);
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();                       // yarım işlem bırakma
    throw $e;
}
Node.js'te Express hata ara katmanını (dört parametreli, tüm rotalardan sonra) tanımlayın ve uygulamayı NODE_ENV=production ile çalıştırın; Express 5'te async handler'lardaki hatalar bu katmana kendiliğinden ulaşır, Express 4'te next(err) çağırmanız gerekir. İşlenmemiş bir Promise reddi süreci sonlandırıyorsa bunu bastırmayın; systemd veya PM2 gibi bir süreç yöneticisi uygulamayı temiz durumda yeniden başlatsın. Django'da canlı ortam DEBUG = False ile çalışmalı ve python manage.py check --deploy uyarıları temizlenmeli. Her yığında aynı ilke geçerli: kullanıcıya genel mesaj ve destek kodu, ayrıntı log'a; belirsizlikte reddet; yarım kalan işlemi geri al; istek boyutu, deneme sayısı ve süre gibi her kaynağa sınır koy.

Öncelik sıralı 30 günlük kontrol listesi​

  1. 1-3. gün: Varlık listesi çıkarın (alan adları, uygulamalar, yönetici panelleri, eklentiler). Çekirdek, tema, eklenti ve paketleri güncelleyin; yönetici hesaplarında MFA'yı açın; varsayılan parolaları değiştirin.
  2. 4-7. gün: Hata ayıklama modlarını kapatın, açıkta kalan dosya testini çalıştırın, .env ve .git erişimini engelleyin, bir yedeği gerçekten geri yükleyerek yedeklerin çalıştığını doğrulayın.
  3. 8-12. gün: composer audit, npm audit ve pip-audit çalıştırın; critical ve high bulguları kapatın; terk edilmiş ve nulled bileşenleri kaldırın; ilk SBOM'u üretin.
  4. 13-16. gün: gitleaks ile depo geçmişini tarayın, bulunan sırları yenileyin. Parola hash formatını kontrol edin, gerekiyorsa girişte yeniden hash'lemeyi planlayın.
  5. 17-21. gün: En çok kullanılan on uç noktada manuel IDOR testi yapın; HSTS'yi kısa max-age ile, CSP'yi Report-Only modunda yayınlayın; çerez işaretlerini düzeltin.
  6. 22-25. gün: Giriş ve parola sıfırlama formlarında istek sınırlamayı (rate limiting) test edin; log olaylarını tamamlayın ve ilk üç alarm kuralını kurun.
  7. 26-28. gün: Hata sayfalarını ve fail closed davranışını test edin; global hata yakalayıcıyı devreye alın.
  8. 29-30. gün: Aşağıdaki CI iş akışını ekleyin, haftalık zamanlanmış çalışmayı açın ve sonraki çeyrek için tehdit modelleme oturumlarını takvime koyun.

CI'da otomatik güvenlik kontrolleri​

Aşağıdaki GitHub Actions iş akışı bağımlılık denetimini, SBOM üretimini ve test ortamına ZAP baseline taramasını birleştiriyor. Kullanmadığınız ekosistemin adımlarını silin; komutların aynısı GitLab CI veya Jenkins'te de çalışır. Haftalık zamanlama önemli: kodunuz değişmese de bağımlılıklarınız için yeni açıklar yayımlanır.
YAML:
name: guvenlik
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "0 5 * * 1"            # her pazartesi 05:00 (UTC)
permissions:
  contents: read
jobs:
  bagimliliklar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
      - run: composer audit --locked --no-dev
      - uses: actions/setup-node@v7
        with:
          node-version: "lts/*"
      - run: npm ci --ignore-scripts
      - run: npm audit --audit-level=high
      - uses: actions/setup-python@v7
        with:
          python-version: "3.x"
      - run: pip install pip-audit && pip-audit -r requirements.txt
      - uses: anchore/sbom-action@v0   # SBOM'u iş akışı çıktısı (artifact) olarak yükler
        with:
          format: cyclonedx-json
  zap:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: zaproxy/[email protected]
        with:
          target: "https://staging.example.com"
          rules_file_name: ".zap/rules.tsv"
          cmd_options: "-I"
          fail_action: true
          allow_issue_writing: false
-I seçeneği yalnızca uyarı (WARN) üreten bir taramayı başarısız saymaz; fail_action: true ise kural dosyasında FAIL yaptığınız bir bulgu çıktığında işi kırar. Sekmeyle ayrılmış .zap/rules.tsv dosyası şöyle başlayabilir:
Code:
10035	FAIL	(Strict-Transport-Security Header)
10021	FAIL	(X-Content-Type-Options Header Missing)
90022	FAIL	(Application Error Disclosure)
10038	WARN	(Content Security Policy (CSP) Header Not Set)
CSP'yi zorunlu moda geçirdiğinizde 10038'i de FAIL yapın. Eşikleri ilk gün en sıkı hale getirmeyin: önce mevcut durumu görün, sonra kuralları adım adım sıkılaştırın; yoksa ekip iş akışını devre dışı bırakmanın bir yolunu bulur. Deneyimimize göre her dağıtımdan önce çalışan bu kontroller, küçük bir ekibin güvenlik için yapabileceği en verimli yatırımlardan biridir.

Sık sorulan sorular​

2021 listesine göre hazırladığımız kontrol listesi geçersiz mi oldu?
Hayır. Kategorilerin çoğu yerinde duruyor; SSRF'yi A01'e taşıyın, bileşen taramasını tedarik zinciri kontrolüne genişletin, A10 ve alarm maddelerini ekleyin.

SSRF listeden çıktı mı?
Hayır, artık ayrı bir başlık değil, A01'in içinde (CWE-918). Kullanıcının verdiği URL'yi sunucuda çeken her özellik hâlâ test edilmeli.

ZAP baseline canlı siteye zarar verir mi?
Baseline pasif kurallar çalıştırır ve saldırı göndermez, ancak siteyi dolaşırken istek üretir; yoğun sitelerde trafiği izleyin. Gerçek saldırı denemeleri yapan tam tarama (zap-full-scan.py) yalnızca test ortamında çalıştırılmalı.

Kod yazmıyoruz, WordPress ve XenForo kullanıyoruz. Bu liste bize de uyar mı?
Uyar. Riskin büyük kısmı A02 (yapılandırma), A03 (eklenti ve tema), A07 (giriş ve MFA) ve A09'da (alarm) toplanır. Özel kod veya eklenti eklediğiniz anda A01 ve A05 de devreye girer.

OWASP Top 10'a uymak yeterli mi?
Top 10 bir farkındalık belgesi, denetim standardı değil. Uyumluluk gerekli ama güvenlikle aynı şey değil; doğrulanabilir gereksinimler için OWASP ASVS'yi kullanın.

Sonuç ve sonraki adımlar​

Bu hafta üç işe odaklanın: en kritik uç noktalarınızda manuel IDOR testi, güvenlik başlıkları kontrolü ve bağımlılık denetimi. Ardından 30 günlük listeyi izleyip kontrolleri CI iş akışıyla kalıcı hale getirin. Sunucu katmanı için canlı Linux sunucuları sıkılaştırma yazımızla devam edebilirsiniz; dışarıdan bir değerlendirme isterseniz AIOR'un siber güvenlik hizmetlerine göz atabilirsiniz. Sizin yığınınızda en çok atlanan kontrol hangisi? Deneyiminizi bu konuya yazın.

The OWASP Top 10 is the awareness document that almost every security review cites. It is also abstract enough that teams read it, nod, and never turn it into tests for their own code. We have rewritten this guide for the OWASP Top 10:2025. It is aimed at small teams running PHP, WordPress, XenForo, Node.js or Python, and each category gets three things: a realistic example, a test you can run yourself and a short fix pattern. A 2021-to-2025 change table comes first; a prioritised 30-day checklist and a CI workflow you can adapt close the guide.

Important: run these tests only against systems you own or have written permission to test, ideally a staging copy of production.

OWASP Top 10:2025: what changed since 2021?​

The current list is published at top10.owasp.org/2025. OWASP says it picked eight of the ten categories from contributed testing data and two from a community survey. A03 was the survey's top-ranked concern, and A09 was again voted into the list by survey participants. The authors also say they focused on root causes rather than symptoms wherever they could.

20252021 equivalentWhat changed
A01 Broken Access ControlA01 (same)Still #1; SSRF, which was A10 in 2021, is now part of it
A02 Security MisconfigurationA05Up from #5 to #2
A03 Software Supply Chain FailuresA06 Vulnerable and Outdated ComponentsWider scope: dependencies, build systems, distribution
A04 Cryptographic FailuresA02Down from #2 to #4
A05 InjectionA03Down from #3 to #5; XSS still lives here
A06 Insecure DesignA04Down from #4 to #6
A07 Authentication FailuresA07 Identification and Authentication FailuresShorter name, same rank
A08 Software or Data Integrity FailuresA08 Software and Data Integrity FailuresSame rank, small change to the name
A09 Security Logging and Alerting FailuresA09 Security Logging and Monitoring FailuresRenamed to stress alerting: logs need someone to act on them
A10 Mishandling of Exceptional ConditionsNoneNew (24 CWEs): poor error handling, failing open, logic errors

The practical takeaway: a checklist built on the 2021 list is not obsolete. Move SSRF under A01, widen dependency scanning into supply-chain control (A03), and add items for error handling (A10) and alerting (A09).

The small-team testing toolkit​

Five methods come up again and again below:
  • Manual IDOR check: two test accounts trying to reach each other's records. Automated scanners are weakest here and a human tester is strongest.
  • ZAP baseline scan: ZAP was formerly known as OWASP ZAP. Baseline mode spiders the site for one minute by default and runs passive rules only; it sends no attack payloads.
  • Dependency audits: composer audit, npm audit and pip-audit.
  • SBOM: a Software Bill of Materials in CycloneDX or SPDX format.
  • Header checks: curl and the browser's developer tools.
Bash:
# ZAP baseline: passive scan, HTML report and a rules template (gen.conf)
docker run --rm -v "$(pwd)":/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://staging.example.com -g gen.conf -r zap-report.html

# See the security headers in one command
curl -sI https://staging.example.com | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|x-frame-options'
ZAP's exit codes matter in CI: 0 means clean, 1 at least one FAIL, 2 warnings only, 3 any other failure. Every alert is a WARN by default; set rules to FAIL or IGNORE in the file generated with -g and pass it back with -c.

A01: Broken Access Control​

The category keeps the top spot. OWASP reports that it has the most occurrences in the contributed testing data, and it maps 40 CWEs.

Example: a WordPress plugin registers a REST route such as /wp-json/shop/v1/order/1043 with __return_true as its permission_callback, so the route is public. Anyone who changes the ID to 1042 sees someone else's order. The same family includes admin pages that are merely hidden from the menu but open to anyone who knows the URL, download endpoints that serve any file on the server through a path parameter (path traversal), forms without CSRF protection and over-permissive CORS. SSRF (CWE-918) lives here in 2025 too: a "link preview" feature that fetches a user-supplied URL on the server can be pointed at cloud metadata addresses such as 169.254.169.254 or at internal services.

Test (manual IDOR check):
  1. On staging, create two ordinary users, A and B, and give each one a record (an order, invoice, private message or attachment).
  2. Log in as A and copy the request that loads A's record with "Copy as cURL" in the Network tab of the developer tools.
  3. Swap in B's session cookie and replay the request. Repeat for PUT, PATCH and DELETE calls, and for IDs inside JSON bodies and hidden form fields.
  4. The expected answer is 403 or 404. A 200 carrying A's data is an IDOR finding.
Bash:
# The cookie name depends on the app: PHPSESSID, xf_session, wordpress_logged_in_...
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Cookie: session=USER_B_SESSION_COOKIE" \
  https://staging.example.com/api/invoices/1043
For SSRF, feed URL fields addresses such as http://127.0.0.1/ and http://169.254.169.254/; if the response body, the error message or the timing changes, the server is reaching internal addresses.

Fix pattern: deny by default and put ownership into the query itself. Adding the user ID to the WHERE clause, instead of loading the record and checking it afterwards with an if, closes the hole a forgotten check would leave open:
PHP:
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ? AND user_id = ?');
$stmt->execute([$invoiceId, $currentUserId]);
$invoice = $stmt->fetch();
if ($invoice === false) {
    http_response_code(404);   // someone else's record also looks "not found"
    exit;
}
In WordPress, check the capability with current_user_can() in every REST route's permission_callback and check ownership in the query; since WordPress 5.5, core raises a _doing_it_wrong notice when the argument is missing. In XenForo add-ons, call permission methods such as canView() and canEdit() after loading the entity. In forms, use the platform's built-in CSRF protection (nonces in WordPress, _xfToken in XenForo). For SSRF the rule is short: never fetch user-controlled URLs without an allowlist, don't follow redirects, restrict outbound (egress) traffic at the firewall, and on AWS require IMDSv2 for the metadata service. For features such as link previews that must reach arbitrary sites, check the resolved IP address rather than the hostname and connect to the address you checked; reject 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and 169.254.0.0/16 (::1, fc00::/7 and fe80::/10 on IPv6).

A02: Security Misconfiguration​

It was fifth in 2021 and is second in 2025.

Example: a forgotten phpinfo.php on the live server, a downloadable .env file or .git directory, debug mode left on (WP_DEBUG plus WP_DEBUG_DISPLAY in WordPress, $config['debug'] in XenForo's src/config.php, DEBUG in Django, the Flask debugger), an unchanged default admin password, missing security headers.

Test: the ZAP baseline reports missing headers and server headers that leak version information. A short loop covers exposed files:
Bash:
for p in .env .git/HEAD phpinfo.php info.php wp-config.php.bak internal_data/ ; do
  printf "%s %s\n" "$(curl -s -o /dev/null -w '%{http_code}' https://staging.example.com/$p)" "$p"
done
# Expected: 403 or 404. A 200 for .env or .git/HEAD is an urgent finding.

Fix pattern: build environments from scripts so they stay identical, remove what you don't use, and send security headers at the server level. An Nginx example:
Code:
# HSTS: start short; raise to max-age=31536000 once nothing breaks
add_header Strict-Transport-Security "max-age=300" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
Things to watch:
  • If an Nginx location block defines its own add_header lines, by default it drops the headers set at the higher level. On Nginx 1.29.3 and later, add_header_inherit merge; at the top level makes nested blocks add the inherited headers to their own. Check important paths separately with curl, and make sure you don't send a header twice when the application already sets it.
  • Add includeSubDomains to HSTS only when every subdomain serves HTTPS. For preloading, hstspreload.org requires a max-age of at least 31536000 seconds plus includeSubDomains and preload; removal is possible, but it takes months to reach users.
  • Ship CSP in Report-Only mode first and watch violations in the browser console. Report-Only mode blocks nothing, frame-ancestors included, so keep the X-Frame-Options line during the transition. On WordPress and XenForo, inline scripts from plugins and themes make a strict policy real work; start with low-risk directives such as object-src 'none', base-uri 'self' and frame-ancestors. The helmet package for Express and the built-in SECURE_CSP setting in Django 6.0 and later make this easier.

A03: Software Supply Chain Failures​

This is the 2021 "Vulnerable and Outdated Components" item, widened: build systems, CI/CD and distribution now count as well as old libraries.

Example: a "nulled" WordPress theme or XenForo add-on downloaded from an unofficial site; a plugin nobody has updated in years; an npm package whose install script (postinstall) steals developer tokens. OWASP cites the 2025 Shai-Hulud npm worm here: malicious package versions stole data through install scripts and used stolen npm tokens to publish further malicious versions.

Test: each ecosystem has its own audit command, and all three exit non-zero when they find something, so they drop straight into CI:
Bash:
composer audit --locked --no-dev     # the audit command arrived in Composer 2.4
npm audit --audit-level=high         # fails only on high and critical findings
npm audit signatures                 # verifies registry signatures and provenance
pip-audit -r requirements.txt        # install with: pip install pip-audit

# SBOM (CycloneDX or SPDX)
npm sbom --sbom-format cyclonedx > sbom-npm.cdx.json
cyclonedx-py requirements requirements.txt -o sbom-py.cdx.json
syft . -o cyclonedx-json=sbom.cdx.json -o spdx-json=sbom.spdx.json
For PHP projects, the cyclonedx/cyclonedx-php-composer plugin (composer CycloneDX:make-sbom) writes CycloneDX; Composer will ask you to allow the plugin (allow-plugins). Syft reads lock files and covers several ecosystems with one command.

Fix pattern:
  • Commit lock files (composer.lock, package-lock.json, a hashed requirements.txt) and deploy with composer install --no-dev, npm ci and pip install --require-hashes.
  • Take components only from official sources: no nulled themes or add-ons, and replace abandoned packages.
  • Let new releases age before you install them: npm 11.10 and later have min-release-age (in days) and pnpm has minimumReleaseAge (in minutes). Keep dependency install scripts under control as well: npm 12 and later, like current pnpm versions, block them by default and run them only for packages you approve (the allowScripts field in package.json, managed with npm approve-scripts; allowBuilds in pnpm). On older npm versions, switch off the scripts you don't need with ignore-scripts.
  • Require MFA on npm, GitHub and registry accounts, and don't protect CI less well than the systems it deploys to.
  • Keep an SBOM for every release. When the next big vulnerability is announced, "do we use this library, and which version?" takes minutes to answer; OWASP Dependency-Track can watch those SBOMs continuously.

A04: Cryptographic Failures​

Example: MD5 password hashes in an old membership table, a login page that can be downgraded to HTTP, API keys committed to the repository, predictable session or password-reset tokens.

Test: look at the password column: values starting with $2y$ (or $2b$) are bcrypt and $argon2id$ marks Argon2id; a 32-character hex value is most likely MD5 and a 40-character one SHA-1. In WordPress, values starting with $P$ are phpass hashes from before 6.8; since 6.8, WordPress uses bcrypt with a $wp$2y$ prefix and rehashes old passwords as users log in. Check TLS with testssl.sh or SSL Labs, and run gitleaks git . in the repository root to scan its history for secrets.

Fix pattern: OWASP's Password Storage Cheat Sheet recommends Argon2id with at least 19 MiB of memory, 2 iterations and a parallelism of 1; bcrypt remains acceptable for legacy systems with a work factor of 10 or more, keeping its 72-byte input limit in mind. Don't roll your own:
PHP:
$hash = password_hash($password, PASSWORD_ARGON2ID); // if PHP was built with Argon2

// At login: verify, then rehash when the parameters have changed
if (password_verify($password, $stored) && password_needs_rehash($stored, PASSWORD_ARGON2ID)) {
    $newHash = password_hash($password, PASSWORD_ARGON2ID); // save it to the database
}

$token = bin2hex(random_bytes(32)); // reset and API tokens
Legacy MD5 records can be migrated the moment each user logs in with the correct password. So that dormant accounts don't keep MD5 hashes forever, OWASP's cheat sheet offers two routes: expire those passwords and require a reset, or wrap the old hash in the new algorithm (for example bcrypt(md5(password))) and switch to a direct hash at the user's next login. Accept TLS 1.2 or newer only, turn on HSTS, and move secrets out of code into environment variables or a secret manager. Deleting a leaked key from the repository is not enough: it lives on in history, so rotate it.

A05: Injection​

Example: a custom search filter built by string concatenation that returns every row for the input ' OR '1'='1; an ORDER BY built from a user-chosen column name; XSS from unescaped output in comments; an image conversion script that pastes the file name straight into a shell command. NoSQL, LDAP and template injection belong to the same family. For LLM features, OWASP points to its separate OWASP LLM Top 10 for prompt injection.

Test: add single and double quotes to parameters; an SQL error or an unexpected result is a finding. Grep the code for queries assembled from variables. Use offensive tools such as sqlmap only on a staging system you are authorised to test.

Fix pattern: parameterised queries (prepared statements), the same idea in every stack:
PHP:
// PDO
$stmt = $pdo->prepare('SELECT * FROM posts WHERE author_id = ? AND status = ?');
$stmt->execute([$authorId, 'published']);

// WordPress: %d integer, %s string, %i table/column name (%i needs WordPress 6.2+)
$rows = $wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}orders WHERE customer_id = %d", $customerId));

// XenForo add-on
$user = \XF::db()->fetchRow('SELECT * FROM xf_user WHERE user_id = ?', [$userId]);
Code:
// Node.js (node-postgres)
const { rows } = await pool.query(
  'SELECT * FROM orders WHERE id = $1 AND user_id = $2', [orderId, userId]);
Python:
# Python (psycopg); sqlite3 uses ? as the placeholder
cur.execute("SELECT * FROM orders WHERE id = %s AND user_id = %s", (order_id, user_id))
Prepared statements cannot take table or column names as parameters, so map user input for ORDER BY to a fixed allowlist. Escape HTML output for its context (esc_html and esc_attr in WordPress), pass shell arguments through escapeshellarg in PHP or as a list to subprocess.run in Python, and treat CSP as a second line of defence against XSS.

A06: Insecure Design​

This category is about controls that were never designed in, so it can exist even when every line of code is correct. OWASP draws the line clearly: a perfect implementation cannot add a defence the design never included.

Example: a single-use discount code is applied ten times by ten simultaneous requests. A six-digit password-reset code has no attempt limit. The payment step can be skipped by going straight to the "order complete" URL. Password reset emails the password itself instead of a link.

Test: write misuse cases for every critical flow and try them: skip steps, change their order, send the same request in parallel:
Bash:
for n in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Cookie: session=TEST_COOKIE" \
    -d "code=WELCOME10" https://staging.example.com/cart/coupon &
done; wait
# Only one request should succeed.

Fix pattern: make the rule atomic in the database: after UPDATE coupons SET used = used + 1 WHERE id = ? AND used < max_uses, reject the coupon if zero rows changed, and for once-per-user codes add a unique constraint on (coupon_id, user_id). Drive multi-step flows with a server-side state machine, and limit attempts and lifetime for OTP and reset codes. Half an hour of threat modelling per important feature is a good start: who would abuse this, and how?

A07: Authentication Failures​

Example: credential stuffing against wp-login.php or a forum login with leaked username and password lists; a session ID that doesn't change after login (session fixation); a session that survives logout on the server; username enumeration through different messages for "user not found" and "wrong password".

Test: enter a wrong password 20 times on a test account; throttling, a temporary lock or a CAPTCHA should kick in. Compare the error messages for existing and non-existing users. Note the session cookie value before and after login; it must change. Replay the old cookie with curl after logging out; it must be rejected. Check the Secure, HttpOnly and SameSite flags on cookies in the developer tools.

Fix pattern:
INI:
; php.ini: session cookies
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = "Lax"
session.use_strict_mode = 1
In your own login code, call session_regenerate_id(true) right after a successful login. Require MFA for admins and moderators; XenForo ships two-step verification, while WordPress needs a plugin. Check new passwords against breach lists: the Have I Been Pwned Pwned Passwords range API lets you query with only the first five characters of the password's SHA-1 hash. In line with NIST SP 800-63B, OWASP recommends long passwords and breach checks over forced periodic rotation. For SSO, MFA and role-based access control (RBAC) design, see our identity and access management guide.

A08: Software or Data Integrity Failures​

Example: a plugin that runs PHP unserialize() on cookie data; a JavaScript file loaded from a CDN without an integrity check; an order marked "paid" by a webhook call whose signature nobody verifies; a home-made auto-updater that downloads and unpacks a zip over HTTP.

Test: grep the codebase for unserialize(, pickle.loads and yaml.load( and trace where the input comes from. In WordPress, wp core verify-checksums and wp plugin verify-checksums --all compare files with WordPress.org checksums; the plugin command only covers plugins from the WordPress.org directory. Check whether external script tags in the page source carry an integrity attribute.

Fix pattern: use JSON rather than the language's native serialisation for anything that comes from a client (json_decode and json_encode in PHP). The PHP manual is explicit: never pass untrusted input to unserialize(), even with the allowed_classes option. If you must unserialise data you stored yourself, such as a cache entry or a legacy table, verify it with hash_hmac first and add ['allowed_classes' => false] as a second layer. In Python, load YAML with yaml.safe_load and never unpickle untrusted data. If you have to hand state to the client, sign it with an HMAC and verify it on the server. We covered signatures, timestamps and replay protection for webhook receivers in our secure webhook receiver guide. For CDN files, generate a Subresource Integrity (SRI) hash and add it to the script tag as integrity="sha384-..." together with crossorigin="anonymous":
Bash:
openssl dgst -sha384 -binary library.min.js | openssl base64 -A
In CI, block direct pushes to the main branch, send every change through code review, and separate build and deploy permissions.

A09: Security Logging and Alerting Failures​

OWASP renamed the category to stress alerting: a log nobody looks at catches no attack.

Example: a new admin account appears at 03:00 and nobody notices; the logs sit only on the compromised server's own disk, so the attacker deletes them.

Test: on staging, make 10 failed logins and create a test admin account. Were the events logged with timestamp, IP and username? Did an alert reach a person within the time you set? If the answer to the second question is no, you have an A09 finding.

Fix pattern:
  • Log successful and failed logins, access-denied (403) events, server-side validation failures, role and permission changes, password and MFA changes, configuration changes and bulk data exports.
  • Write logs in a machine-readable format such as JSON, and encode line breaks in user input to prevent log injection.
  • Never log passwords, tokens or card numbers, and keep personal data to a minimum. Retention periods and access rights fall under data protection law too (KVKK in Türkiye, GDPR in the EU); check current obligations at the official source.
  • Ship logs off the server: a central log host, Loki and Grafana, ELK or OpenSearch, or a hosted service.
  • Start with three to five clear alert rules: failed logins above a set rate per minute, a new admin account, an admin login from a new country or IP, a sudden rise in 5xx errors.

A10: Mishandling of Exceptional Conditions​

The new category for 2025 (24 CWEs). It covers applications that fail to prevent, detect or properly respond to unusual conditions.

Example: a database error page that shows the SQL query and file paths; a catch block that lets the request through when the authorisation service times out (failing open); a money transfer interrupted after the debit is written but before the credit; an upload error path that never releases temporary files and connections.

Test: send unexpected input: missing parameters, wrong types (an array such as ?id[]=1 in PHP), broken JSON, an oversized body. On staging, stop the database or a dependent service. The response must not show a stack trace, query, version or file path; privileged actions must be denied; the event must appear in the log with a correlation ID.

Fix pattern:
INI:
; php.ini (production)
display_errors = Off
log_errors = On
expose_php = Off
PHP:
set_exception_handler(function (Throwable $e) {
    $id = bin2hex(random_bytes(8));
    error_log("[$id] " . $e);              // details only in the server log
    http_response_code(500);
    echo "Something went wrong. Support code: $id";
});

try {
    $allowed = $authz->canEdit($user, $post);
} catch (Throwable $e) {
    error_log((string) $e);
    $allowed = false;                       // fail closed: deny on error
}

$pdo->beginTransaction();                   // PDO throws exceptions by default in PHP 8
try {
    $debit->execute([$amount, $fromId]);
    $credit->execute([$amount, $toId]);
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();                       // never leave half a transaction behind
    throw $e;
}
In Node.js, define an Express error-handling middleware (four arguments, registered after all routes) and run with NODE_ENV=production; in Express 5, errors from async handlers reach it automatically, while Express 4 needs next(err). If an unhandled promise rejection ends the process, don't suppress it; let a process manager such as systemd or PM2 restart the app in a clean state. In Django, production must run with DEBUG = False and a clean python manage.py check --deploy. The principle is the same in every stack: a generic message and a support code for the user, details in the log; deny when in doubt; roll back half-finished work; put a limit on every resource, from request size to attempts and time.

A prioritised 30-day checklist​

  1. Days 1-3: build an inventory (domains, applications, admin panels, plugins). Update core, themes, plugins and packages; turn on MFA for admin accounts; change default passwords.
  2. Days 4-7: switch off debug modes, run the exposed-files test, block .env and .git, and prove your backups work by actually restoring one.
  3. Days 8-12: run composer audit, npm audit and pip-audit; close critical and high findings; remove abandoned and nulled components; produce your first SBOM.
  4. Days 13-16: scan repository history with gitleaks and rotate whatever it finds. Check the password hash format and plan rehash-on-login if needed.
  5. Days 17-21: run the manual IDOR check on your ten most used endpoints; ship HSTS with a short max-age and CSP in Report-Only mode; fix cookie flags.
  6. Days 22-25: test rate limiting on login and password-reset forms; complete the logged events and set up the first three alert rules.
  7. Days 26-28: test error pages and fail-closed behaviour; deploy a global exception handler.
  8. Days 29-30: add the CI workflow below, enable the weekly scheduled run, and book threat-modelling sessions for next quarter.

Security checks in CI​

The GitHub Actions workflow below combines dependency audits, SBOM generation and a ZAP baseline scan of staging. Delete the steps for ecosystems you don't use; the same commands run in GitLab CI or Jenkins. The weekly schedule matters: new vulnerabilities in your dependencies are published even when your code doesn't change.
YAML:
name: security
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "0 5 * * 1"            # every Monday 05:00 UTC
permissions:
  contents: read
jobs:
  dependencies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
      - run: composer audit --locked --no-dev
      - uses: actions/setup-node@v7
        with:
          node-version: "lts/*"
      - run: npm ci --ignore-scripts
      - run: npm audit --audit-level=high
      - uses: actions/setup-python@v7
        with:
          python-version: "3.x"
      - run: pip install pip-audit && pip-audit -r requirements.txt
      - uses: anchore/sbom-action@v0   # uploads the SBOM as a workflow artifact
        with:
          format: cyclonedx-json
  zap:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: zaproxy/[email protected]
        with:
          target: "https://staging.example.com"
          rules_file_name: ".zap/rules.tsv"
          cmd_options: "-I"
          fail_action: true
          allow_issue_writing: false
With -I, a scan that only produces warnings does not fail; fail_action: true fails the job when an alert you marked FAIL appears. The tab-separated .zap/rules.tsv can start like this:
Code:
10035	FAIL	(Strict-Transport-Security Header)
10021	FAIL	(X-Content-Type-Options Header Missing)
90022	FAIL	(Application Error Disclosure)
10038	WARN	(Content Security Policy (CSP) Header Not Set)
Switch 10038 to FAIL once CSP is enforced. Don't start with the strictest thresholds: see where you stand, then tighten rule by rule, or the team will find a way to switch the workflow off. In our experience, checks that run before every deployment are one of the best-value security investments a small team can make.

FAQ​

Is our checklist based on the 2021 list obsolete?
No. Most categories are still there; move SSRF to A01, widen component scanning into supply-chain control, and add A10 and alerting items.

Has SSRF left the list?
No. It is no longer a separate entry but part of A01 (CWE-918). Every feature that fetches a user-supplied URL on the server still needs testing.

Can a ZAP baseline scan harm a live site?
Baseline runs passive rules and sends no attacks, but its spider does generate requests, so watch traffic on busy sites. The full scan (zap-full-scan.py) performs real attacks; run it only against staging.

We don't write code; we run WordPress and XenForo. Does the list apply to us?
Yes. Most of your risk sits in A02 (configuration), A03 (plugins and themes), A07 (logins and MFA) and A09 (alerting). As soon as you add custom code or add-ons, A01 and A05 apply too.

Is following the OWASP Top 10 enough?
The Top 10 is an awareness document, not an audit standard. Compliance is necessary, but it is not the same thing as security; for verifiable requirements, use OWASP ASVS.

Conclusion and next steps​

This week, do three things: a manual IDOR check on your most critical endpoints, a security-header check and a dependency audit. Then work through the 30-day list and make the checks permanent with the CI workflow. For the server layer, continue with our production Linux server hardening guide; if you would like an outside review, AIOR's cyber security services are one option. Which control gets skipped most often on your stack? Share it in this thread.
 
Last edited:

Forum statistics

Threads
925
Messages
932
Members
26
Latest member
Jessicawatson

Members online

No members online now.

Featured content

AIOR
AIOR TECHNOLOGY

Get a quote for all your needs

Hosting · Domain · Servers · Design · Software · Engineering · Industry Solutions

Get a quote

24/7 Support · Quick response

Back
Top