97 lines
No EOL
3.7 KiB
Python
97 lines
No EOL
3.7 KiB
Python
"""Tests for the ALLOWED_HOSTS host validation."""
|
|
|
|
import pytest
|
|
|
|
|
|
def _run_before_request(app, path: str, host: str):
|
|
"""Invoke the registered before_request hooks for the given path/host.
|
|
|
|
Returns the Flask response tuple (response, status) if a hook blocked the
|
|
request, or None if the request passed through.
|
|
|
|
We test the hook directly (instead of via the HTTP test client) because
|
|
the test app config sets SERVER_NAME="localhost.localdomain", which makes
|
|
Flask's routing layer reject foreign Host headers with 404 before any
|
|
before_request hook runs.
|
|
"""
|
|
with app.test_request_context(path, headers={"Host": host}):
|
|
for func in app.before_request_funcs.get(None, []):
|
|
result = func()
|
|
if result is not None:
|
|
return result
|
|
return None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_allowed_hosts(app):
|
|
"""Restore the original ALLOWED_HOSTS after each test.
|
|
|
|
The app fixture is session-scoped, so mutations to app.config persist
|
|
across tests/files. This ensures host validation doesn't leak into
|
|
other test files.
|
|
"""
|
|
original = app.config.get("ALLOWED_HOSTS")
|
|
yield
|
|
app.config["ALLOWED_HOSTS"] = original
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestHostValidation:
|
|
"""Test the before_request ALLOWED_HOSTS enforcement."""
|
|
|
|
def test_allowed_host_accepted(self, app):
|
|
"""Requests with an allowed Host header pass through."""
|
|
app.config["ALLOWED_HOSTS"] = ["example.com", "www.example.com"]
|
|
result = _run_before_request(app, "/api/boards", "example.com")
|
|
assert result is None
|
|
|
|
def test_allowed_host_with_port_accepted(self, app):
|
|
"""Host header with a port is stripped and validated."""
|
|
app.config["ALLOWED_HOSTS"] = ["example.com"]
|
|
result = _run_before_request(app, "/api/boards", "example.com:8000")
|
|
assert result is None
|
|
|
|
def test_disallowed_host_rejected(self, app):
|
|
"""Requests with a disallowed Host header are rejected with 404."""
|
|
app.config["ALLOWED_HOSTS"] = ["example.com"]
|
|
result = _run_before_request(app, "/api/boards", "192.168.1.135:8000")
|
|
assert result is not None
|
|
assert result[1] == 404
|
|
assert result[0].get_json()["error"] == "Not found"
|
|
|
|
def test_unknown_domain_rejected(self, app):
|
|
"""Unknown domains are rejected, not just IPs."""
|
|
app.config["ALLOWED_HOSTS"] = ["example.com"]
|
|
result = _run_before_request(app, "/api/boards", "evil-site.com")
|
|
assert result is not None
|
|
assert result[1] == 404
|
|
|
|
def test_no_allowed_hosts_permissive(self, app):
|
|
"""Empty ALLOWED_HOSTS disables the check (backwards compatible)."""
|
|
app.config["ALLOWED_HOSTS"] = []
|
|
result = _run_before_request(app, "/api/boards", "192.168.1.135:8000")
|
|
assert result is None
|
|
|
|
def test_case_insensitive_host_match(self, app):
|
|
"""Host matching is case-insensitive."""
|
|
app.config["ALLOWED_HOSTS"] = ["example.com"]
|
|
result = _run_before_request(app, "/api/boards", "EXAMPLE.COM")
|
|
assert result is None
|
|
|
|
def test_health_endpoint_exempt(self, app):
|
|
"""Health checks are exempt from host validation."""
|
|
app.config["ALLOWED_HOSTS"] = ["example.com"]
|
|
result = _run_before_request(app, "/health/", "localhost:8000")
|
|
assert result is None
|
|
|
|
def test_parse_list_helper(self):
|
|
"""_parse_list splits, trims, lowercases, and drops empties."""
|
|
from app.config import _parse_list
|
|
|
|
assert _parse_list("") == []
|
|
assert _parse_list(None) == []
|
|
assert _parse_list("Example.com, WWW.Example.com , ,other.org") == [
|
|
"example.com",
|
|
"www.example.com",
|
|
"other.org",
|
|
] |