Spaces:
Sleeping
Sleeping
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 "<script>" 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
|