Spaces:
Sleeping
Sleeping
render a friendly html error instead of a json 422 when the compare or tax form is submitted empty
fdc48e0 | """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"}} | |
| def patched(monkeypatch): | |
| monkeypatch.setattr("app.main.get_percentage_diff", fake_diffs) | |
| # ββ Input validation (rejects injection-shaped input) βββββββββββββ | |
| 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 | |
| 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 | |