Files
Auto-Trader/agent/tests/test_sse_ticket_and_headers.py
T
Haozhe Wu 4e3c91c77a security: address all 10 findings from external audit (issue #476)
Implements VT-001 through VT-010 from the 2026-07-10 external security
audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment):

- VT-001 (Critical): AST hardening in backtest/runner.py rejects
  network/subprocess/eval/os.environ/unsafe-open calls reachable from
  generated SignalEngine method bodies (previously only checked
  top-level statements). Defense-in-depth: ephemeral per-run HOME,
  RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox
  user (currently inert under Docker's cap model - see session doc;
  accepted as documented residual gap, primary AST defense unaffected).
- VT-002: multi-stage Dockerfile split so build-essential never
  reaches the runtime image.
- VT-003: SSE auth moved from a long-lived API key in the URL query
  string to short-lived single-use tickets; access-log redaction for
  any remaining query secrets.
- VT-004: agent/.env split-brain fixed (bind mount + atomic 0600
  writes) between docker-compose and Web UI settings writes.
- VT-005: /correlation now requires auth + per-IP rate limit; generic
  exceptions no longer leak raw text to clients.
- VT-006: CSP-Report-Only + security headers middleware; Google Fonts
  replaced with self-hosted @fontsource packages.
- VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add,
  no-new-privileges, read_only rootfs, resource limits).
- VT-008: /health split into /live (liveness) and /ready (real
  provider-config readiness check); /health kept as a compat alias.
- VT-009: base images pinned by digest, GitHub Actions pinned by
  commit SHA, dependabot.yml added, hash-verified requirements-lock.txt
  now actually wired into the Docker build via --require-hashes.
- VT-010: universe-panel pickle cache sidecar upgraded from an
  unkeyed SHA-256 (forgeable by anyone with cache-dir write access)
  to a keyed HMAC.

Implemented via a 5-parcel Workflow (file-disjoint, independent
adversarial verification per parcel, then a holistic final audit).
The final audit caught a real regression (CI env-var-gate violation)
and confirmed a dependency-pinning gap, both fixed and re-verified
here, including a latent transitive-dependency bug the fix surfaced
(smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see
docs/sessions/2026-07-13_session_vt476_security_hardening.md (local,
gitignored) for full evidence trail.

Full suite: 5191 passed, 6 pre-existing/environmental failures
(unchanged from main). Docker build + hardened compose boot verified
end-to-end.
2026-07-13 01:40:53 +08:00

222 lines
7.8 KiB
Python

"""VT-003 / VT-006 regression tests: SSE tickets, security headers, log redaction."""
from __future__ import annotations
import logging
import pytest
from fastapi.testclient import TestClient
import api_server
from src.api import security
def _remote_client() -> TestClient:
"""Return a TestClient that simulates a non-loopback caller."""
return TestClient(api_server.app, client=("203.0.113.10", 50000))
def _local_client() -> TestClient:
"""Return a TestClient that simulates a loopback caller."""
return TestClient(api_server.app, client=("127.0.0.1", 50000))
@pytest.fixture(autouse=True)
def _clean_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Start each test from dev-mode auth with an empty ticket store."""
monkeypatch.delenv("API_AUTH_KEY", raising=False)
monkeypatch.delenv("VIBE_TRADING_TRUST_DOCKER_LOOPBACK", raising=False)
monkeypatch.setattr(api_server, "_API_KEY", "")
with security._sse_tickets_lock:
security._sse_tickets.clear()
# ---------------------------------------------------------------------------
# SSE ticket store (unit)
# ---------------------------------------------------------------------------
def test_minted_ticket_is_unguessable_and_urlsafe() -> None:
ticket = security._mint_sse_ticket()
assert isinstance(ticket, str)
assert len(ticket) >= 32
# token_urlsafe alphabet only
assert all(c.isalnum() or c in "-_" for c in ticket)
def test_ticket_is_single_use() -> None:
ticket = security._mint_sse_ticket()
assert security._consume_sse_ticket(ticket) is True
# Second use is rejected — the ticket was invalidated on first use.
assert security._consume_sse_ticket(ticket) is False
def test_unknown_ticket_is_rejected() -> None:
assert security._consume_sse_ticket("never-minted") is False
assert security._consume_sse_ticket("") is False
def test_expired_ticket_is_rejected() -> None:
import time
ticket = security._mint_sse_ticket()
# Force the stored expiry into the past without waiting the full TTL.
with security._sse_tickets_lock:
security._sse_tickets[ticket] = time.monotonic() - 1.0
assert security._consume_sse_ticket(ticket) is False
# ---------------------------------------------------------------------------
# POST /auth/sse-ticket endpoint
# ---------------------------------------------------------------------------
def test_sse_ticket_endpoint_mints_in_loopback_dev_mode() -> None:
response = _local_client().post("/auth/sse-ticket")
assert response.status_code == 200
assert isinstance(response.json().get("ticket"), str)
assert response.json()["ticket"]
def test_sse_ticket_endpoint_requires_bearer_when_key_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_AUTH_KEY", "secret")
monkeypatch.setattr(api_server, "_API_KEY", "secret")
# Remote caller without the header is rejected — minting requires the key
# in an Authorization header (never a URL).
assert _remote_client().post("/auth/sse-ticket").status_code == 401
ok = _remote_client().post("/auth/sse-ticket", headers={"Authorization": "Bearer secret"})
assert ok.status_code == 200
assert ok.json()["ticket"]
# ---------------------------------------------------------------------------
# require_event_stream_auth (integration via /sessions/{id}/events)
# ---------------------------------------------------------------------------
def test_event_stream_accepts_ticket_once_then_rejects_reuse(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_AUTH_KEY", "secret")
monkeypatch.setattr(api_server, "_API_KEY", "secret")
ticket = api_server._mint_sse_ticket()
client = _remote_client()
first = client.get(f"/sessions/missing/events?ticket={ticket}")
# Auth passed: the 404/501 comes from the missing session / disabled runtime.
assert first.status_code in {404, 501}
# The same ticket cannot be replayed.
second = client.get(f"/sessions/missing/events?ticket={ticket}")
assert second.status_code == 401
def test_event_stream_still_accepts_bearer_header_for_non_browser_callers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_AUTH_KEY", "secret")
monkeypatch.setattr(api_server, "_API_KEY", "secret")
response = _remote_client().get(
"/sessions/missing/events",
headers={"Authorization": "Bearer secret"},
)
assert response.status_code in {404, 501}
# ---------------------------------------------------------------------------
# VT-006: security headers
# ---------------------------------------------------------------------------
def test_security_headers_present_on_response() -> None:
response = _local_client().get("/health")
assert response.headers.get("X-Content-Type-Options") == "nosniff"
assert response.headers.get("X-Frame-Options") == "DENY"
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
assert "geolocation=()" in response.headers.get("Permissions-Policy", "")
csp = response.headers.get("Content-Security-Policy-Report-Only", "")
assert "default-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
# Ships Report-Only first, never as an enforcing policy.
assert response.headers.get("Content-Security-Policy") is None
# No HSTS from the app — that belongs at the TLS-terminating proxy.
assert response.headers.get("Strict-Transport-Security") is None
def test_security_headers_present_on_error_response(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Headers must wrap error responses from inner middleware too."""
monkeypatch.setenv("API_AUTH_KEY", "secret")
monkeypatch.setattr(api_server, "_API_KEY", "secret")
response = _local_client().get("/runs", headers={"Host": "attacker.example:8899"})
assert response.status_code == 403 # rebound-host guard short-circuits
assert response.headers.get("X-Content-Type-Options") == "nosniff"
# ---------------------------------------------------------------------------
# VT-003: access-log secret redaction
# ---------------------------------------------------------------------------
def test_redact_query_secrets_masks_ticket_and_api_key_values() -> None:
line = 'GET /sessions/x/events?ticket=abc123&foo=bar HTTP/1.1'
red = security._redact_query_secrets(line)
assert "abc123" not in red
assert "ticket=***REDACTED***" in red
assert "foo=bar" in red
line2 = "/alpha/bench/1/stream?api_key=SUPERSECRET"
red2 = security._redact_query_secrets(line2)
assert "SUPERSECRET" not in red2
assert "api_key=***REDACTED***" in red2
def test_access_log_filter_redacts_record_args() -> None:
filt = security._AccessLogRedactionFilter()
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg='%s - "%s %s HTTP/%s" %d',
args=("127.0.0.1:5000", "GET", "/sessions/x/events?ticket=LEAKME", "1.1", 404),
exc_info=None,
)
assert filt.filter(record) is True
formatted = record.getMessage()
assert "LEAKME" not in formatted
assert "ticket=***REDACTED***" in formatted
def test_install_access_log_redaction_filter_is_idempotent() -> None:
access = logging.getLogger("uvicorn.access")
before = [f for f in access.filters if isinstance(f, security._AccessLogRedactionFilter)]
for f in list(before):
access.removeFilter(f)
security.install_access_log_redaction_filter()
security.install_access_log_redaction_filter()
installed = [f for f in access.filters if isinstance(f, security._AccessLogRedactionFilter)]
assert len(installed) == 1
# Restore prior state (leave exactly what was there before this test).
for f in installed:
access.removeFilter(f)
for f in before:
access.addFilter(f)