File size: 5,224 Bytes
3be03dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""
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