address backend security and test suggestions

This commit is contained in:
Christian Krakau-Louis
2026-05-18 16:44:06 +02:00
parent 8f7c41193f
commit b4191e956c
11 changed files with 320 additions and 35 deletions
+16 -1
View File
@@ -145,6 +145,20 @@ def _get_domain_selectors_from_db(db: Session, domain_name: str) -> List[str]:
return []
def _get_domain_selectors_map_from_db(db: Session, domain_names: List[str]) -> Dict[str, List[str]]:
"""Return manually configured DKIM selectors for all requested domains."""
if not domain_names:
return {}
rows = db.query(Domain.name, Domain.dkim_selectors).filter(Domain.name.in_(domain_names)).all()
selectors_by_domain: Dict[str, List[str]] = {}
for name, selectors in rows:
selectors_by_domain[name] = [
selector.strip() for selector in (selectors or "").split(",") if selector.strip()
]
return selectors_by_domain
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary(db: Session = Depends(get_db)):
"""
@@ -161,9 +175,10 @@ async def get_domains_summary(db: Session = Depends(get_db)):
# Perform DNS checks concurrently for all domains
provider = get_default_provider()
manual_selectors_by_domain = _get_domain_selectors_map_from_db(db, domains)
async def _dns_for_domain(domain_name: str) -> DomainDNSResult:
manual_selectors = _get_domain_selectors_from_db(db, domain_name)
manual_selectors = manual_selectors_by_domain.get(domain_name, [])
report_selectors = _get_selectors_from_reports(store, domain_name)
combined = list(dict.fromkeys(manual_selectors + report_selectors))
try:
+25 -3
View File
@@ -1,6 +1,11 @@
from fastapi import APIRouter, HTTPException, status
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from app.core.security import api_key_header, require_admin_auth, security_bearer
router = APIRouter()
# Simple in-memory storage for setup status (for Milestone 1)
@@ -33,6 +38,17 @@ class SystemConfigRequest(BaseModel):
base_url: str
async def require_setup_write_auth(
request: Request,
api_key: Optional[str] = Security(api_key_header),
bearer: Optional[HTTPAuthorizationCredentials] = Security(security_bearer),
) -> dict:
"""Allow unauthenticated first-time setup writes, then require admin auth."""
if not setup_status["is_setup_complete"]:
return {"auth_type": "initial_setup"}
return await require_admin_auth(request=request, api_key=api_key, bearer=bearer)
@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status():
"""Get the current setup status"""
@@ -43,7 +59,10 @@ async def get_setup_status():
@router.post("/admin", status_code=201)
async def setup_admin(request: AdminSetupRequest):
async def setup_admin(
request: AdminSetupRequest,
_auth: dict = Depends(require_setup_write_auth),
):
"""
Setup admin user during initial system configuration.
For Milestone 1, this simply stores the admin email in memory.
@@ -60,7 +79,10 @@ async def setup_admin(request: AdminSetupRequest):
@router.post("/system", status_code=200)
async def setup_system(request: SystemConfigRequest):
async def setup_system(
request: SystemConfigRequest,
_auth: dict = Depends(require_setup_write_auth),
):
"""
Setup system configuration.
For Milestone 1, this simply stores the app name in memory.
+4 -5
View File
@@ -71,15 +71,14 @@ class Settings(BaseSettings):
# LOGTO_APP_SECRET: the Client Secret of the same application.
# LOGTO_REDIRECT_URI (optional): override the default callback URL.
# Defaults to <base_url>/api/v1/auth/callback.
# LOGTO_SKIP_SSL_VERIFY (optional): set to false to enable SSL certificate
# verification when connecting to the Logto OIDC endpoint.
# Defaults to true (verification disabled) to support
# self-signed certificates out of the box.
# LOGTO_SKIP_SSL_VERIFY (optional): set to true only when connecting to a
# self-hosted Logto endpoint with a self-signed certificate.
# Defaults to false so TLS certificates are verified.
LOGTO_ENDPOINT: Optional[str] = None
LOGTO_APP_ID: Optional[str] = None
LOGTO_APP_SECRET: Optional[str] = None
LOGTO_REDIRECT_URI: Optional[str] = None
LOGTO_SKIP_SSL_VERIFY: bool = True
LOGTO_SKIP_SSL_VERIFY: bool = False
@property
def logto_configured(self) -> bool:
+24 -14
View File
@@ -20,7 +20,6 @@ from app.core.security import add_api_key, generate_api_key, require_admin_auth
from app.middleware.auth import AuthRedirectMiddleware
from app.middleware.security import SecurityHeadersMiddleware
from app.models.mail_source import MailSource # noqa: F401 ensure table is registered
from app.models.user import User # noqa: F401 ensure User mapper is registered
from app.services.gmail_client import GmailClient
from app.services.imap_client import IMAPClient
from app.services.report_store import ReportStore
@@ -136,7 +135,7 @@ def _poll_single_gmail_source(source: MailSource) -> None:
)
def _poll_all_enabled_sources() -> None:
def _poll_all_enabled_sources() -> list[MailSource]:
"""Iterate over all enabled mail sources and poll each one."""
db = SessionLocal()
try:
@@ -148,7 +147,7 @@ def _poll_all_enabled_sources() -> None:
if not enabled_sources:
logger.info("No enabled mail sources configured polling skipped")
return
return enabled_sources
for source in enabled_sources:
if source.method == "GMAIL_API":
@@ -167,19 +166,23 @@ def _poll_all_enabled_sources() -> None:
source.id,
source.method,
)
return enabled_sources
def _next_sleep_seconds(min_sleep: int = 60) -> int:
def _next_sleep_seconds(
min_sleep: int = 60, enabled_sources: list[MailSource] | None = None
) -> int:
"""Return how many seconds to sleep until the next polling cycle."""
try:
db = SessionLocal()
try:
intervals = [
s.polling_interval or 60
for s in db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712
]
finally:
db.close()
if enabled_sources is None:
db = SessionLocal()
try:
enabled_sources = (
db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712
)
finally:
db.close()
intervals = [s.polling_interval or 60 for s in enabled_sources]
return max(min_sleep, min(intervals, default=3600) * 60)
except Exception: # pylint: disable=broad-exception-caught
return 3600
@@ -191,11 +194,18 @@ async def scheduled_imap_polling():
while True:
logger.info("Starting scheduled IMAP polling for DMARC reports")
try:
_poll_all_enabled_sources()
enabled_sources = _poll_all_enabled_sources()
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error in IMAP polling task: %s", str(e))
enabled_sources = None
await asyncio.sleep(_next_sleep_seconds())
try:
await asyncio.sleep(_next_sleep_seconds(enabled_sources=enabled_sources))
except asyncio.CancelledError:
raise
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error sleeping in IMAP polling task: %s", str(e))
await asyncio.sleep(3600)
except asyncio.CancelledError:
logger.info("IMAP polling task cancelled")
+3 -9
View File
@@ -53,19 +53,14 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Content Security Policy (CSP)
# Restricts sources of content that can be loaded
#
# SECURITY TODO: Current CSP includes 'unsafe-inline' and 'unsafe-eval' which
# weaken XSS protection. To remove these:
# SECURITY TODO: Current CSP includes 'unsafe-inline' which weakens
# XSS protection. To remove it:
#
# For script-src 'unsafe-inline':
# 1. Move all inline <script> tags from templates to external .js files
# 2. OR implement CSP nonces for inline scripts (requires template changes)
# 3. Convert any inline event handlers (onclick, etc.) to addEventListener
#
# For script-src 'unsafe-eval':
# 1. Verify no code uses eval(), Function(), setTimeout/setInterval with strings
# 2. If using libraries that require eval, consider alternatives
# 3. Current scan shows no eval usage - can likely remove this directive
#
# For style-src 'unsafe-inline':
# 1. Move inline styles to CSS files or use style tags with nonces
# 2. Replace style="" attributes with CSS classes
@@ -79,8 +74,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
csp_directives = [
"default-src 'self'",
# TODO: Remove 'unsafe-inline' - requires moving inline scripts to external files # pylint: disable=fixme
# TODO: Remove 'unsafe-eval' - no eval usage detected, safe to remove after testing # pylint: disable=fixme
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
"script-src 'self' 'unsafe-inline'"
" https://cdn.tailwindcss.com https://cdn.jsdelivr.net",
# TODO: Remove 'unsafe-inline' - requires moving inline styles to CSS or using nonces # pylint: disable=fixme
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"
+13
View File
@@ -170,3 +170,16 @@ class TestAdminApiKeySetting:
monkeypatch.setenv("ADMIN_API_KEY", long_key)
settings = Settings()
assert settings.ADMIN_API_KEY == long_key
class TestLogtoSettings:
def test_ssl_verification_is_enabled_by_default(self):
settings = Settings()
assert settings.LOGTO_SKIP_SSL_VERIFY is False
def test_ssl_verification_can_be_skipped_explicitly(self, monkeypatch):
monkeypatch.setenv("LOGTO_SKIP_SSL_VERIFY", "true")
settings = Settings()
assert settings.LOGTO_SKIP_SSL_VERIFY is True
+28 -1
View File
@@ -64,7 +64,7 @@ class TestDMARCParser:
def test_invalid_xml(self):
"""Test that invalid XML raises a ValueError."""
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="Error parsing DMARC XML"):
DMARCParser.parse_file(b"not xml at all", "report.xml")
def test_parse_xml_report_with_namespace(self):
@@ -102,3 +102,30 @@ class TestDMARCParser:
"""Test that an unsupported file extension raises ValueError."""
with pytest.raises(ValueError, match="Could not extract XML"):
DMARCParser.parse_file(b"some content", "report.pdf")
def test_bad_zip_file_returns_no_xml_content(self):
"""A corrupt ZIP should be handled as no extractable XML content."""
with pytest.raises(ValueError, match="Could not extract XML"):
DMARCParser.parse_file(b"not a zip file", "report.zip")
def test_bad_gzip_file_returns_no_xml_content(self):
"""A corrupt GZIP should be handled as no extractable XML content."""
with pytest.raises(ValueError, match="Could not extract XML"):
DMARCParser.parse_file(b"not a gzip file", "report.gz")
def test_parse_xml_without_report_metadata(self):
"""Missing report_metadata should not crash parsing otherwise valid XML."""
xml = b"""
<feedback>
<policy_published>
<domain>example.com</domain>
<p>none</p>
</policy_published>
</feedback>
"""
result = DMARCParser.parse_file(xml, "report.xml")
assert result["domain"] == "example.com"
assert result["records"] == []
assert result["summary"]["total_count"] == 0
@@ -0,0 +1,63 @@
import socket
from unittest.mock import patch
from app.utils.domain_validator import (
DomainValidationError,
validate_domain,
validate_domain_config,
)
class TestValidateDomain:
def test_valid_domain_without_dns_check(self):
assert validate_domain("example.com", check_dns=False) == (True, None, None)
def test_domain_longer_than_253_characters_is_rejected(self):
domain = f"{'a' * 250}.com"
valid, message, code = validate_domain(domain, check_dns=False)
assert valid is False
assert "too long" in message
assert code == DomainValidationError.TOO_LONG
def test_dns_resolution_failure_is_reported(self):
with patch("app.utils.domain_validator.socket.gethostbyname", side_effect=socket.gaierror):
valid, message, code = validate_domain("example.com", check_dns=True)
assert valid is False
assert "could not be resolved" in message
assert code == DomainValidationError.DNS_RESOLUTION_FAILED
class TestValidateDomainConfig:
def test_valid_config(self):
result = validate_domain_config(
{"name": "example.com", "description": "Primary reporting domain"}
)
assert result == {"valid": True, "errors": {}}
def test_missing_name_is_invalid(self):
result = validate_domain_config({"description": "No name"})
assert result["valid"] is False
assert result["errors"]["name"] == "Domain name is required"
def test_invalid_domain_name_is_reported(self):
result = validate_domain_config({"name": "bad domain"})
assert result["valid"] is False
assert "whitespace" in result["errors"]["name"]
def test_unsafe_description_is_rejected(self):
result = validate_domain_config({"name": "example.com", "description": "<script></script>"})
assert result["valid"] is False
assert "unsafe HTML" in result["errors"]["description"]
def test_description_length_limit(self):
result = validate_domain_config({"name": "example.com", "description": "a" * 501})
assert result["valid"] is False
assert "too long" in result["errors"]["description"]
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
class TestNextSleepSeconds:
def test_uses_shortest_enabled_source_interval_without_db_query(self):
from app.main import _next_sleep_seconds
slow = SimpleNamespace(polling_interval=30)
fast = SimpleNamespace(polling_interval=5)
with patch("app.main.SessionLocal") as mock_session:
result = _next_sleep_seconds(enabled_sources=[slow, fast])
assert result == 300
mock_session.assert_not_called()
def test_respects_min_sleep(self):
from app.main import _next_sleep_seconds
source = SimpleNamespace(polling_interval=0)
assert _next_sleep_seconds(min_sleep=120, enabled_sources=[source]) == 3600
def test_queries_database_when_sources_not_supplied(self):
from app.main import _next_sleep_seconds
source = SimpleNamespace(polling_interval=2)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [source]
with patch("app.main.SessionLocal", return_value=mock_db):
result = _next_sleep_seconds()
assert result == 120
mock_db.close.assert_called_once()
def test_database_exception_falls_back_to_one_hour(self):
from app.main import _next_sleep_seconds
with patch("app.main.SessionLocal", side_effect=RuntimeError("db unavailable")):
assert _next_sleep_seconds() == 3600
@pytest.mark.asyncio
async def test_scheduled_imap_polling_sleep_exception_falls_back_then_cancels():
from app.main import scheduled_imap_polling
with (
patch("app.main._poll_all_enabled_sources", return_value=[]),
patch("app.main._next_sleep_seconds", return_value=60),
patch(
"app.main.asyncio.sleep",
side_effect=[RuntimeError("sleep failed"), asyncio.CancelledError()],
),
):
await scheduled_imap_polling()
+33
View File
@@ -58,6 +58,39 @@ class TestReportStore:
assert len(reports) == 1
assert reports[0]["report_id"] == "rpt-001"
def test_get_report_by_id_returns_matching_report(self):
store = ReportStore.get_instance()
report = _sample_report("test.com")
store.add_report(report)
assert store.get_report_by_id("rpt-001") is report
def test_get_report_by_id_returns_none_for_missing_report(self):
store = ReportStore.get_instance()
store.add_report(_sample_report("test.com"))
assert store.get_report_by_id("rpt-missing") is None
def test_get_domain_sources_returns_sources_sorted_by_count(self):
store = ReportStore.get_instance()
report = _sample_report("test.com")
report["records"].append(
{
"source_ip": "203.0.113.2",
"count": 12,
"disposition": "none",
"dkim_result": "fail",
"spf_result": "pass",
"header_from": "test.com",
}
)
store.add_report(report)
sources = store.get_domain_sources("test.com")
assert [source["source_ip"] for source in sources] == ["203.0.113.2", "203.0.113.1"]
assert [source["count"] for source in sources] == [12, 5]
def test_clear(self):
store = ReportStore.get_instance()
store.add_report(_sample_report("test.com"))
+51 -2
View File
@@ -8,6 +8,7 @@ import pytest
from fastapi.testclient import TestClient
from app.api.api_v1.endpoints.setup import setup_status
from app.core.security import _api_keys, add_api_key
@pytest.fixture(autouse=True)
@@ -57,8 +58,7 @@ class TestSetupAdmin:
assert response.status_code == 201
assert "message" in response.json()
def test_admin_setup_fails_if_already_complete(self, client: TestClient):
# Mark setup as complete
def test_admin_setup_requires_auth_if_already_complete(self, client: TestClient):
setup_status["is_setup_complete"] = True
response = client.post(
@@ -69,6 +69,28 @@ class TestSetupAdmin:
"password": "pass",
},
)
assert response.status_code == 401
def test_admin_setup_fails_if_already_complete(self, client: TestClient):
# Mark setup as complete
setup_status["is_setup_complete"] = True
api_key = "a" * 64
add_api_key(api_key)
try:
response = client.post(
"/api/v1/setup/admin",
json={
"email": "admin2@example.com",
"username": "admin2",
"password": "pass",
},
headers={"X-API-Key": api_key},
)
finally:
_api_keys.discard(api_key)
assert response.status_code == 400
assert "already completed" in response.json()["detail"]
@@ -104,3 +126,30 @@ class TestSetupSystem:
json={"app_name": "Test", "base_url": "https://test.example.com"},
)
assert setup_status["is_setup_complete"] is True
def test_system_setup_requires_auth_if_already_complete(self, client: TestClient):
setup_status["is_setup_complete"] = True
response = client.post(
"/api/v1/setup/system",
json={"app_name": "Changed", "base_url": "https://changed.example.com"},
)
assert response.status_code == 401
def test_system_setup_allows_authenticated_updates_after_complete(self, client: TestClient):
setup_status["is_setup_complete"] = True
api_key = "b" * 64
add_api_key(api_key)
try:
response = client.post(
"/api/v1/setup/system",
json={"app_name": "Changed", "base_url": "https://changed.example.com"},
headers={"X-API-Key": api_key},
)
finally:
_api_keys.discard(api_key)
assert response.status_code == 200
assert setup_status["app_name"] == "Changed"