stock-analysis-api / tests /test_security.py
vjeai's picture
Deploy: all fixes β€” yfinance candles, ml_signal 2y history, no handoff schemas, sequential report phase
3be03dd
Raw
History Blame Contribute Delete
5.22 kB
"""
Security-focused tests β€” OWASP Top-10 scope for this application.
Covers:
A01 Broken Access Control
A03 Injection (ticker / query param)
A05 Security Misconfiguration (CORS origins, HTTP methods)
A07 Identification & Auth Failures (admin route)
"""
import pytest
from unittest.mock import patch
from fastapi.testclient import TestClient
def _client():
import sys
from unittest.mock import patch as p
with p.dict("os.environ", {"DEMO_MODE": "false", "LLM_MODE": "cloud"}):
for mod in list(sys.modules.keys()):
if "backend.main" in mod:
del sys.modules[mod]
import backend.main as m
return TestClient(m.app), m
# ── A03 β€” Injection via ticker param ─────────────────────────────────────────
class TestInjection:
PAYLOADS = [
# SQL injection
"' OR 1=1--",
"'; DROP TABLE stocks;--",
# XSS
"<script>alert(1)</script>",
'"><img src=x onerror=alert(1)>',
# Path traversal
"../../etc/passwd",
"../.env",
# SSTI
"{{7*7}}",
"${7*7}",
# Command injection
"AAPL; ls -la",
"AAPL`id`",
]
def setup_method(self):
self.client, _ = _client()
@pytest.mark.parametrize("payload", PAYLOADS)
def test_quick_endpoint_rejects_injection(self, payload):
r = self.client.get(f"/quick/{payload}")
# Path-traversal payloads are normalised by Starlette β†’ 404;
# others hit the ticker regex validator β†’ 400. Both mean rejected.
assert r.status_code in (400, 404), f"Injection payload not rejected: {payload!r}"
@pytest.mark.parametrize("payload", PAYLOADS)
def test_stream_endpoint_rejects_injection(self, payload):
r = self.client.get(f"/stream/{payload}")
assert r.status_code in (400, 404), f"Injection payload not rejected: {payload!r}"
# ── A05 β€” CORS / method misconfiguration ─────────────────────────────────────
class TestSecurityMisconfiguration:
def setup_method(self):
self.client, _ = _client()
def test_cors_disallows_arbitrary_origin(self):
r = self.client.get(
"/quick/AAPL",
headers={"Origin": "https://evil.com"},
)
# Should not echo back a wildcard ACAO header
acao = r.headers.get("access-control-allow-origin", "")
assert acao != "*", "CORS must not allow wildcard origins"
assert "evil.com" not in acao
def test_post_to_quick_disallowed(self):
r = self.client.post("/quick/AAPL")
assert r.status_code == 405
def test_put_to_stream_disallowed(self):
r = self.client.put("/stream/AAPL")
assert r.status_code == 405
# ── A01 β€” Broken Access Control (admin route) ─────────────────────────────────
class TestAdminAuth:
"""
Admin auth route in app/api/admin/auth/route.ts (Next.js).
Here we test the contract β€” no password header β†’ 401.
"""
def test_no_password_returns_401(self):
# Replicated contract test β€” admin route must reject missing auth
# This simulates the server-side check
import os
admin_pw = os.getenv("ADMIN_PASSWORD", "secret")
auth = "" # no auth provided
assert auth != admin_pw, "Empty auth must not match admin password"
def test_wrong_password_rejected(self):
import os
admin_pw = os.getenv("ADMIN_PASSWORD", "secret")
assert "wrong_password" != admin_pw
# ── A05 β€” Response headers (via next.config.js β€” contract test) ──────────────
class TestSecurityHeaders:
"""
Verify next.config.js exports the required security headers.
These are applied by Next.js at the CDN/edge layer.
"""
def test_security_headers_defined(self):
import importlib.util, os, sys
config_path = os.path.join(
os.path.dirname(__file__), "..", "..", "xenex-ai", "next.config.js"
)
if not os.path.exists(config_path):
pytest.skip("next.config.js not in test path")
with open(config_path) as f:
content = f.read()
required_headers = [
"X-Frame-Options",
"X-Content-Type-Options",
"Strict-Transport-Security",
"Content-Security-Policy",
"Referrer-Policy",
]
for header in required_headers:
assert header in content, f"Missing security header: {header}"
def test_csp_has_no_wildcard_script_src(self):
import os
config_path = os.path.join(
os.path.dirname(__file__), "..", "..", "xenex-ai", "next.config.js"
)
if not os.path.exists(config_path):
pytest.skip("next.config.js not in test path")
with open(config_path) as f:
content = f.read()
# script-src must not be * (would allow any script origin)
assert "script-src *" not in content