WebSocket Origin Guard — CSWSH Koruması
PR #276 ile telemetry WebSocket endpoint'ine Cross-Site WebSocket Hijacking (CSWSH) koruması eklendi. Bu doküman tehdit modelini, çözümü, davranış matrisini ve smoke testi anlatır.
Tehdit Modeli — CSWSH
WebSocket spec'i (RFC 6455) Same-Origin Policy'yi browser tarafında tam olarak uygulamaz. Saldırgan senaryolar:
- Token leak + cross-origin connect: Saldırgan kullanıcının access token'ını ele geçirir (XSS, network sniffing, log leak vb.). Kendi kontrolündeki bir sayfadan
new WebSocket("wss://enerji.kepmark.com/ws/telemetry?token=...")ile bağlanır. Browser bunu engellemez (CORS WebSocket'lere uygulanmaz). - Drive-by exfiltration: Saldırgan kullanıcıyı kötü amaçlı bir sayfaya yönlendirir; sayfa hedef WS endpoint'ine bağlanmaya çalışır ve dönen telemetri verisini exfiltrate eder.
Token query string olarak taşındığı için bu vektör özellikle kritik. Origin guard, bağlantı handshake aşamasında (auth'tan ÖNCE) browser kaynaklı isteklerin sadece izin verilen origin'lerden gelmesini garanti eder.
Çözüm — is_allowed_websocket_origin()
backend/app/core/websocket/security.py modülünde tanımlı stateless helper:
def is_allowed_websocket_origin(
origin: str | None,
allowed_origins: list[str],
) -> bool:
"""WebSocket handshake'de Origin header doğrulama (RFC 6455 §10.2)."""
if not origin:
return False
if not allowed_origins:
return False
needle = _normalize_origin(origin)
if not needle:
return False
for entry in allowed_origins:
if not entry:
continue
if _normalize_origin(entry) == needle:
return True
return False
Normalize: whitespace + trailing slash temizliği (https://enerji.kepmark.com/ → https://enerji.kepmark.com).
Telemetry endpoint kullanımı
backend/app/core/websocket/router.py:78-95 (auth'tan önce):
allowed_origins = settings.cors_origins_list
if allowed_origins:
origin = websocket.headers.get("origin")
if not is_allowed_websocket_origin(origin, allowed_origins):
logger.warning(
"websocket_origin_rejected",
origin=origin,
endpoint="telemetry",
allowed_count=len(allowed_origins),
)
await websocket.close(code=4003, reason="Forbidden origin")
return
settings.cors_origins_list (backend/app/core/config/settings.py:65-78) CORS_ORIGINS env değişkenini parse eder, normalize uygular.
Davranış Matrisi
| Origin | Allowed liste | Davranış | HTTP/Close kod |
|---|---|---|---|
https://enerji.kepmark.com | [https://enerji.kepmark.com] | Kabul (auth aşamasına geç) | 101 Switching Protocols |
https://attacker.com | [https://enerji.kepmark.com] | Reddet | Close 4003 (Forbidden origin) |
(yok / boş) | [https://enerji.kepmark.com] | Reddet (browser context'te eksik Origin suspicious) | Close 4003 |
(yok / boş) | [] (dev fallback, CORS_ORIGINS="") | Origin guard skip → auth'a geç | 101 (auth başarılıysa) |
https://enerji.kepmark.com/ (trailing slash) | [https://enerji.kepmark.com] | Kabul (normalize) | 101 |
CORS_ORIGINS="" development'ta (örn. localhost) gerekli — Origin guard skip olur. Production'da bu env değişkeni mutlaka dolu olmalı.
RFC 6455 İstisnası — Native client'lar
Browser dışı WS client'ları (mobile app, ESP32 charger, OCPP charge point) Origin header göndermek zorunda değil. Bu sebeple Zeus 2.0'daki Origin guard yalnızca browser-facing endpoint'te uygulanır:
| Endpoint | Origin guard | Kullanım |
|---|---|---|
/ws/telemetry | Aktif | Tarayıcı (Frontend dashboard) |
/ocpp/1.6/{cpid} | Skip | OCPP 1.6J Charge Point (Beny BCP-AT2N-P vb.) |
/ocpp/2.0.1/{cpid} | Skip (stub) | OCPP 2.0.1 (henüz tam implement değil) |
OCPP charger endpoint'lerinde Origin doğrulamak yerine HTTP Basic Auth + bcrypt + Redis rate-limit + XFF + fail-closed pattern uygulanır (Faz 1+2 — bkz. Saha Entegrasyon Kılavuzu).
Token Query String — TODO Faz 2
Mevcut implementasyon JWT'yi query string olarak taşır (?token=...). Bu yaklaşım:
- TLS altında network seviyesinde güvenli (encrypted).
- Ancak leak vektörleri açık:
- Nginx access log (
$request_uri) - Browser history
- Referer header (3rd-party iframe vb.)
- Nginx access log (
backend/app/core/websocket/router.py:97-101 TODO yorumu:
# TODO(security): Faz 2 — Token'i query string yerine
# `Sec-WebSocket-Protocol` subprotocol icinde veya HttpOnly+Secure+
# SameSite=Lax cookie ile tasi.
İki alternatif:
Sec-WebSocket-Protocolsubprotocol — token handshake header içinde taşınır, log/Referer leak yok. FrontendWebSocketconstructor'ı subprotocol'u 2. parametre olarak alır.- HttpOnly + Secure + SameSite=Lax cookie — XSS'e dirençli, otomatik cookie taşıma. CSRF için ek
Sec-WebSocket-Protocoltoken tag eklenebilir.
Tercih edilen seçim Faz 2'de yapılacak; bu doküman geçiş sırasında güncellenmeli.
Smoke Test Komutları
1. İzin verilen origin (kabul beklenir)
# wscat ile telemetry endpoint'e bağlan (Origin başlığı doğru)
wscat \
-H "Origin: https://enerji.kepmark.com" \
-c "wss://enerji.kepmark.com/ws/telemetry?token=$ACCESS_TOKEN"
# Beklenen: connected; ping/pong çalışır
2. Yasak origin (reddedilir)
wscat \
-H "Origin: https://attacker.example.com" \
-c "wss://enerji.kepmark.com/ws/telemetry?token=$ACCESS_TOKEN"
# Beklenen: close 4003 — Forbidden origin
3. Eksik Origin (reddedilir — production)
wscat \
-c "wss://enerji.kepmark.com/ws/telemetry?token=$ACCESS_TOKEN"
# Beklenen: close 4003 — Forbidden origin (allowed liste dolu)
4. Curl ile handshake denetimi (101 vs 4xx)
# Doğru origin → 101 Switching Protocols
curl -i -N \
-H "Origin: https://enerji.kepmark.com" \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"https://enerji.kepmark.com/ws/telemetry?token=$ACCESS_TOKEN"
5. Backend log kontrolü (ret durumu)
Reddedilen istekler structured log üretir:
docker logs zeus-backend 2>&1 | grep websocket_origin_rejected
# Beklenen örnek:
# {"event": "websocket_origin_rejected", "origin": "https://attacker.example.com",
# "endpoint": "telemetry", "allowed_count": 1, ...}
Kod Referansları
| Dosya | Satır | İçerik |
|---|---|---|
backend/app/core/websocket/security.py | 1-89 | is_allowed_websocket_origin() helper + normalize |
backend/app/core/websocket/router.py | 78-101 | telemetry_websocket Origin guard kullanımı |
backend/app/core/config/settings.py | 65-78 | cors_origins_list property (normalize) |
frontend/src/config/site.ts | 42-76 | getWsUrl() host-relative + protokol uyum |
frontend/src/lib/websocket/client.ts | 59-81 | Mixed-content guard + ctor try/catch |