## 變更摘要 - Primary: http://34.143.170.20:11434 (GCP-A SSD, 9x 載速 + 2x 推理) - Secondary: http://34.21.145.224:11434 (GCP-B SSD) - Fallback: http://192.168.0.111:11434 (M1 Pro Local HDD,最後防線) - 廢止 ADR-105「111 唯一鐵律」,新建 ADR-110 ## 核心改動 - config.py: 新增 OLLAMA_SECONDARY_URL;validator 加 GCP IP 白名單(34.143.170.20, 34.21.145.224) - ollama_failover_manager.py: 三層 Ollama 決策矩陣;並行健康檢查三台;health_111 → health_gcp_a - ollama_health_monitor.py: host label 萃取改為通用版(支援 GCP 公網 IP) - failover_alerter.py: 故障/恢復主機動態顯示,不再硬編碼「Ollama 111 (GPU)」 - ollama_auto_recovery.py: notify_recovery 改為 ollama_gcp_a;recovered_host 動態 - k8s/awoooi-prod: configmap + deployment + network-policy 同步更新(egress 加 GCP /32) - 服務層: 10 個服務檔案硬編碼 192.168.0.111 改為讀 settings.OLLAMA_URL - 測試: URL 常數更新,新增三層容災場景,GCP IP 白名單驗證測試 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.2 KiB
Python
167 lines
6.2 KiB
Python
"""
|
||
OLLAMA URL endpoint poisoning 防護測試 — Wave8-X2 + ADR-110
|
||
|
||
vuln #1:OLLAMA_URL / OLLAMA_FALLBACK_URL 缺 IP allowlist 校驗
|
||
修法:pydantic field_validator 拒絕非 private/loopback/known-hostname
|
||
|
||
2026-04-27 Wave8-X2 by Claude — vuln #1 + B14 + alerter memory dedup
|
||
2026-05-03 ogt: ADR-110 GCP 三層容災,更新 fixture 預設值為 GCP-A,新增 GCP 白名單測試
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
|
||
# =============================================================================
|
||
# Helpers
|
||
# =============================================================================
|
||
|
||
|
||
def _make_settings(**kwargs):
|
||
"""建立 Settings 實例,只覆蓋指定欄位,其餘用安全預設值。"""
|
||
from src.core.config import Settings
|
||
|
||
base = {
|
||
"DATABASE_URL": "postgresql://u:p@localhost:5432/test",
|
||
"OLLAMA_URL": "http://34.143.170.20:11434", # GCP-A Primary(ADR-110 2026-05-03)
|
||
"OLLAMA_FALLBACK_URL": "",
|
||
}
|
||
base.update(kwargs)
|
||
return Settings(**base)
|
||
|
||
|
||
# =============================================================================
|
||
# #1: 公網 IP 應被拒絕
|
||
# =============================================================================
|
||
|
||
|
||
def test_public_ip_rejected_in_ollama_url():
|
||
"""8.8.8.8 是公網 IP,應被 validator 拒絕(端點中毒攻擊情境)"""
|
||
with pytest.raises(ValidationError, match="公網"):
|
||
_make_settings(OLLAMA_URL="http://8.8.8.8:11434")
|
||
|
||
|
||
def test_public_ip_rejected_in_ollama_fallback_url():
|
||
"""FALLBACK_URL 也受同一 validator 保護"""
|
||
with pytest.raises(ValidationError, match="公網"):
|
||
_make_settings(OLLAMA_FALLBACK_URL="http://1.1.1.1:11434")
|
||
|
||
|
||
# =============================================================================
|
||
# #2: 外部域名應被拒絕
|
||
# =============================================================================
|
||
|
||
|
||
def test_external_domain_rejected():
|
||
"""attacker.com 是外部域名(非 IP,非白名單),應被拒絕"""
|
||
with pytest.raises(ValidationError, match="外部域名"):
|
||
_make_settings(OLLAMA_URL="http://attacker.com:11434")
|
||
|
||
|
||
def test_external_domain_fallback_rejected():
|
||
"""FALLBACK_URL 外部域名也應被拒絕"""
|
||
with pytest.raises(ValidationError, match="外部域名"):
|
||
_make_settings(OLLAMA_FALLBACK_URL="http://evil.example.com:11434")
|
||
|
||
|
||
# =============================================================================
|
||
# #3: GCP 白名單公網 IP 應通過(ADR-110 2026-05-03)
|
||
# =============================================================================
|
||
|
||
|
||
def test_gcp_a_public_ip_accepted():
|
||
"""34.143.170.20 是 GCP-A 核准公網 IP(ADR-110 白名單),應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://34.143.170.20:11434")
|
||
assert s.OLLAMA_URL == "http://34.143.170.20:11434"
|
||
|
||
|
||
def test_gcp_b_public_ip_accepted():
|
||
"""34.21.145.224 是 GCP-B 核准公網 IP(ADR-110 白名單),應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://34.21.145.224:11434")
|
||
assert s.OLLAMA_URL == "http://34.21.145.224:11434"
|
||
|
||
|
||
def test_gcp_b_as_secondary_url_accepted():
|
||
"""GCP-B 作為 OLLAMA_SECONDARY_URL 也應通過白名單"""
|
||
s = _make_settings(
|
||
OLLAMA_URL="http://34.143.170.20:11434",
|
||
OLLAMA_SECONDARY_URL="http://34.21.145.224:11434",
|
||
)
|
||
assert s.OLLAMA_SECONDARY_URL == "http://34.21.145.224:11434"
|
||
|
||
|
||
def test_arbitrary_public_ip_rejected():
|
||
"""8.8.8.8 非 GCP 白名單公網 IP,仍應被拒絕(端點中毒攻擊防護)"""
|
||
with pytest.raises(ValidationError, match="公網"):
|
||
_make_settings(OLLAMA_URL="http://8.8.8.8:11434")
|
||
|
||
|
||
# =============================================================================
|
||
# #4: 私網 IP 應通過
|
||
# =============================================================================
|
||
|
||
|
||
def test_private_ip_192_168_accepted():
|
||
"""192.168.0.111 是 RFC1918 私網 IP(Local HDD 後備主機),應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://192.168.0.111:11434")
|
||
assert s.OLLAMA_URL == "http://192.168.0.111:11434"
|
||
|
||
|
||
def test_private_ip_10_x_accepted():
|
||
"""10.x.x.x 是 RFC1918 私網 IP,應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://10.0.0.5:11434")
|
||
assert s.OLLAMA_URL == "http://10.0.0.5:11434"
|
||
|
||
|
||
def test_private_ip_172_16_accepted():
|
||
"""172.16.x.x 是 RFC1918 私網 IP,應通過"""
|
||
s = _make_settings(OLLAMA_FALLBACK_URL="http://172.16.0.10:11434")
|
||
assert s.OLLAMA_FALLBACK_URL == "http://172.16.0.10:11434"
|
||
|
||
|
||
# =============================================================================
|
||
# #5: localhost / loopback 應通過
|
||
# =============================================================================
|
||
|
||
|
||
def test_localhost_accepted():
|
||
"""localhost 在 known hostname 白名單,應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://localhost:11434")
|
||
assert s.OLLAMA_URL == "http://localhost:11434"
|
||
|
||
|
||
def test_loopback_ip_accepted():
|
||
"""127.0.0.1 是 loopback IP,應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://127.0.0.1:11434")
|
||
assert s.OLLAMA_URL == "http://127.0.0.1:11434"
|
||
|
||
|
||
# =============================================================================
|
||
# #6: 已知 K8s Service hostname 應通過
|
||
# =============================================================================
|
||
|
||
|
||
def test_known_k8s_svc_ollama_svc_accepted():
|
||
"""ollama-svc 在 K8s Service 白名單,應通過"""
|
||
s = _make_settings(OLLAMA_URL="http://ollama-svc:11434")
|
||
assert s.OLLAMA_URL == "http://ollama-svc:11434"
|
||
|
||
|
||
def test_known_k8s_svc_ollama_fallback_svc_accepted():
|
||
"""ollama-fallback-svc 在白名單,應通過"""
|
||
s = _make_settings(OLLAMA_FALLBACK_URL="http://ollama-fallback-svc:11434")
|
||
assert s.OLLAMA_FALLBACK_URL == "http://ollama-fallback-svc:11434"
|
||
|
||
|
||
# =============================================================================
|
||
# #7: 空字串應通過(OLLAMA_FALLBACK_URL 預設值)
|
||
# =============================================================================
|
||
|
||
|
||
def test_empty_string_fallback_url_accepted():
|
||
"""OLLAMA_FALLBACK_URL 預設空字串(未設定),應通過"""
|
||
s = _make_settings(OLLAMA_FALLBACK_URL="")
|
||
assert s.OLLAMA_FALLBACK_URL == ""
|