File size: 4,643 Bytes
903a3b6
 
 
 
4a82fee
 
903a3b6
 
 
4a82fee
 
903a3b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a82fee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903a3b6
 
 
fdc48e0
 
903a3b6
fdc48e0
 
 
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
"""Security tests β€” input validation, output escaping, and SSRF resistance.

These back the findings recorded in SECURITY_TEST.md.
"""
from urllib.parse import urlparse

import pytest
from fastapi.testclient import TestClient

import app.data_sources as ds
from app.data_sources import get_percentage_diff
from app.main import app

client = TestClient(app)


async def fake_diffs(country1, city1, country2, city2):
    return {"city_from": city1, "city_to": city2,
            "col_excl_rent": {"valuePct": 10.0, "direction": "higher"},
            "rent": {"valuePct": 10.0, "direction": "higher"}}


@pytest.fixture
def patched(monkeypatch):
    monkeypatch.setattr("app.main.get_percentage_diff", fake_diffs)


# ── Input validation (rejects injection-shaped input) ─────────────

@pytest.mark.parametrize("payload", [
    "<script>alert(1)</script>",
    "'; DROP TABLE x;--",
    "../../etc/passwd",
    "Kuala${IFS}Lumpur",
    "city|whoami",
])
def test_place_field_rejects_dangerous_input(patched, payload):
    r = client.post("/compare", data={
        "country1": "Malaysia", "city1": payload,
        "country2": "Singapore", "city2": "Singapore",
        "net_home": "1000", "net_new": "2000",
    })
    assert r.status_code == 200
    assert "invalid characters" in r.text


def test_reflected_input_is_html_escaped(patched):
    # Even though validation rejects it, the echoed value must never appear
    # as live markup β€” Jinja autoescaping must neutralise it.
    r = client.post("/compare", data={
        "country1": "Malaysia", "city1": "<script>alert(1)</script>",
        "country2": "Singapore", "city2": "Singapore",
        "net_home": "1000", "net_new": "2000",
    })
    assert "<script>alert(1)</script>" not in r.text     # not raw
    assert "&lt;script&gt;" in r.text                    # escaped form present


def test_numeric_fields_reject_non_numeric(patched):
    r = client.post("/compare", data={
        "country1": "Malaysia", "city1": "Kuala Lumpur",
        "country2": "Singapore", "city2": "Singapore",
        "net_home": "not-a-number", "net_new": "2000",
    })
    assert r.status_code == 200
    assert "must be a number" in r.text


@pytest.mark.parametrize("field,value", [
    ("savings_ratio", "150"),
    ("savings_ratio", "-5"),
    ("rent_share", "999"),
])
def test_slider_values_bounded(patched, field, value):
    data = {
        "country1": "Malaysia", "city1": "Kuala Lumpur",
        "country2": "Singapore", "city2": "Singapore",
        "net_home": "1000", "net_new": "2000",
    }
    data[field] = value
    r = client.post("/compare", data=data)
    assert "between 0 and 100" in r.text


def test_negative_or_zero_salary_rejected(patched):
    r = client.post("/compare", data={
        "country1": "Malaysia", "city1": "Kuala Lumpur",
        "country2": "Singapore", "city2": "Singapore",
        "net_home": "0", "net_new": "2000",
    })
    assert "positive number" in r.text


# ── SSRF resistance β€” user input never changes the request host ───

async def test_scraper_only_hits_numbeo_host(monkeypatch):
    seen = []

    class _Resp:
        status_code = 200
        text = (
            '<table class="table_indices_diff">'
            "<tr><td>Cost of Living in B is 10.0% higher than in A</td></tr>"
            "<tr><td>Rent Prices in B are 10.0% higher than in A</td></tr></table>"
        )

    class _Session:
        async def __aenter__(self):
            return self

        async def __aexit__(self, *exc):
            return False

        async def get(self, url, params=None, **kwargs):
            seen.append((url, params))
            return _Resp()

    monkeypatch.setattr(ds, "_new_session", lambda: _Session())

    # A hostile "city" value must ride along as a query param β€” never the host.
    evil = "http://evil.example/@numbeo"
    await get_percentage_diff("Malaysia", evil, "Singapore", "Singapore")

    hosts = {urlparse(url).hostname for url, _ in seen}
    assert hosts == {"www.numbeo.com"}  # every request went to Numbeo, incl. warm-up
    # the malicious input stayed in the params, not the URL
    compare_params = [p for _, p in seen if p]
    assert compare_params and evil in compare_params[0].values()


def test_missing_required_field_is_handled(patched):
    # Omitting a required field β†’ a graceful HTML validation error (not a raw
    # JSON 422, and not a 500/stack trace).
    r = client.post("/compare", data={"country1": "Malaysia"})
    assert r.status_code == 200
    assert "text/html" in r.headers["content-type"]
    assert "must not be empty" in r.text