Skip to content

Commit badcb30

Browse files
committed
refactor!: moved allowed hosts to a plugin
1 parent eef291c commit badcb30

File tree

11 files changed

+232
-234
lines changed

11 files changed

+232
-234
lines changed

README.md

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ WebSpark provides a simple yet powerful architecture for handling HTTP requests
1818
- **Optimized JSON Handling**: Automatically uses the fastest available JSON library (`orjson`, `ujson`, or `json`).
1919
- **Built-in File Uploads**: Seamlessly handle multipart form data and file uploads.
2020
- **Comprehensive Error Handling**: A simple `HTTPException` system for clear and consistent error responses.
21-
- **Security Features**: Built-in protection against HTTP Host header attacks and proxy support.
21+
- **Proxy Support**: Built-in support for running behind a reverse proxy.
2222
- **Environment Configuration**: Helper utilities for managing configuration via environment variables.
2323
- **Extensive Testing**: 90% test coverage ensuring reliability and stability.
2424

@@ -271,7 +271,7 @@ app.add_paths([
271271
WebSpark includes a CORS (Cross-Origin Resource Sharing) plugin that implements the full CORS specification. It supports both simple and preflighted requests with configurable origins, methods, headers, and credentials.
272272

273273
```python
274-
from webspark.contrib import CORSPlugin
274+
from webspark.contrib.plugins import CORSPlugin
275275

276276
# Create a CORS plugin with a specific configuration
277277
cors_plugin = CORSPlugin(
@@ -302,6 +302,29 @@ The CORS plugin supports the following configuration options:
302302
- `expose_headers` - List of headers that browsers are allowed to access.
303303
- `vary_header` - Whether to add Vary header for preflight requests.
304304

305+
#### AllowedHosts Plugin
306+
307+
To prevent HTTP Host header attacks, WebSpark provides an `AllowedHostsPlugin`. This plugin checks the request's `Host` header against a list of allowed hostnames.
308+
309+
```python
310+
from webspark.contrib.plugins import AllowedHostsPlugin
311+
312+
# Allow requests only to "mydomain.com" and any subdomain of "api.mydomain.com"
313+
allowed_hosts_plugin = AllowedHostsPlugin(
314+
allowed_hosts=["mydomain.com", ".api.mydomain.com"]
315+
)
316+
317+
# Register the plugin globally
318+
app = WebSpark(plugins=[allowed_hosts_plugin])
319+
```
320+
321+
- **Behavior**:
322+
- If `allowed_hosts` is not set, all requests will be rejected with a `400 Bad Request` error, ensuring that only requests from specified hosts are processed.
323+
- **Matching**:
324+
- `"mydomain.com"`: Matches the exact domain.
325+
- `".mydomain.com"`: Matches `mydomain.com` and any subdomain (e.g., `api.mydomain.com`).
326+
- `"*"`: Matches any host.
327+
305328
### 7. Error Handling
306329

307330
Gracefully handle errors using `HTTPException`. When raised, the framework will catch it and generate a standardized JSON error response.
@@ -372,27 +395,7 @@ The framework checks for the following headers when `TRUST_PROXY` is enabled:
372395
- `X-Forwarded-Proto` for the request scheme (`http` or `https`).
373396
- `X-Forwarded-Host` for the original host.
374397

375-
### 10. Allowed Hosts
376-
377-
To prevent HTTP Host header attacks, WebSpark checks the request's `Host` header against a list of allowed hostnames. This is configured via the `ALLOWED_HOSTS` setting on the configuration object.
378-
379-
```python
380-
class AppConfig:
381-
# Allow requests only to "mydomain.com" and any subdomain of "api.mydomain.com"
382-
ALLOWED_HOSTS = ["mydomain.com", ".api.mydomain.com"]
383-
384-
app = WebSpark(config=AppConfig())
385-
```
386-
387-
- **Behavior**:
388-
- If `debug=True`, `ALLOWED_HOSTS` defaults to `["*"]` (allowing all hosts).
389-
- If `debug=False` and `ALLOWED_HOSTS` is not set, all requests will be rejected with a `400 Bad Request` error.
390-
- **Matching**:
391-
- `"mydomain.com"`: Matches the exact domain.
392-
- `".mydomain.com"`: Matches `mydomain.com` and any subdomain (e.g., `api.mydomain.com`).
393-
- `"*"`: Matches any host.
394-
395-
### 11. Environment Variable Helper
398+
### 10. Environment Variable Helper
396399

397400
WebSpark includes a convenient `env` helper function in `webspark.utils` to simplify reading and parsing environment variables.
398401

@@ -411,7 +414,7 @@ DEBUG = env("DEBUG", default=False, parser=bool)
411414

412415
This helper streamlines configuration management, making it easy to handle different data types and required settings.
413416

414-
### 12. File Uploads
417+
### 11. File Uploads
415418

416419
WebSpark makes handling file uploads simple with built-in multipart form data parsing. Uploaded files are accessible through the `ctx.files` attribute.
417420

@@ -473,6 +476,7 @@ pdm run ruff format .
473476
```
474477
webspark/
475478
├── core/ # Core components (WSGI app, router, views, schemas)
479+
├── contrib/ # Optional plugins (CORS, AllowedHosts)
476480
├── http/ # HTTP abstractions (request, response, cookies)
477481
├── schema/ # Data validation schemas and fields
478482
├── utils/ # Utilities (exceptions, JSON handling, env vars)
@@ -490,6 +494,10 @@ webspark/
490494
- `path` - Routing helper function
491495
- `Plugin` - Base class for middleware
492496

497+
- **contrib/** - Optional plugins:
498+
- `CORSPlugin` - Handles Cross-Origin Resource Sharing.
499+
- `AllowedHostsPlugin` - Validates incoming Host headers.
500+
493501
- **http/** - HTTP abstractions:
494502
- `Context` - Request/response context object
495503
- `Cookie` - Cookie handling utilities

examples/config_example.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Environment variable usage
88
"""
99

10+
from webspark.contrib.plugins import AllowedHostsPlugin
1011
from webspark.core import View, WebSpark, path
1112
from webspark.http import Context
1213
from webspark.utils import env
@@ -30,12 +31,18 @@ class AppConfig:
3031

3132
# Allowed hosts for security (in production, specify your domain)
3233
ALLOWED_HOSTS = env(
33-
"ALLOWED_HOSTS", default=["*"] if DEBUG else ["localhost", "127.0.0.1"]
34+
"ALLOWED_HOSTS",
35+
default=["*"] if DEBUG else ["localhost", "127.0.0.1"],
36+
parser=lambda x: x.split(",") if x else [],
3437
)
3538

3639

3740
# Create the app with configuration
38-
app = WebSpark(config=AppConfig(), debug=AppConfig.DEBUG)
41+
app = WebSpark(
42+
config=AppConfig(),
43+
debug=AppConfig.DEBUG,
44+
plugins=[AllowedHostsPlugin(allowed_hosts=AppConfig.ALLOWED_HOSTS)],
45+
)
3946

4047

4148
# Sample view to show configuration

examples/cors_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Example demonstrating how to use the CORS plugin with WebSpark.
33
"""
44

5-
from webspark.contrib.cors import CORSPlugin
5+
from webspark.contrib.plugins import CORSPlugin
66
from webspark.core import View, WebSpark, path
77
from webspark.http import Context
88

tests/contrib/plugins/__init__.py

Whitespace-only changes.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
5+
from webspark.contrib.plugins.allowed_hosts import AllowedHostsPlugin
6+
from webspark.utils import HTTPException
7+
8+
9+
@pytest.fixture
10+
def mock_handler():
11+
"""Fixture for a mock handler."""
12+
return Mock()
13+
14+
15+
@pytest.fixture
16+
def mock_context():
17+
"""Fixture for a mock context."""
18+
return Mock()
19+
20+
21+
def test_allowed_hosts_valid_host(mock_handler, mock_context):
22+
plugin = AllowedHostsPlugin(allowed_hosts=["test.com"])
23+
mock_context.host = "test.com"
24+
wrapped_handler = plugin.apply(mock_handler)
25+
26+
wrapped_handler(mock_context)
27+
28+
mock_handler.assert_called_once_with(mock_context)
29+
30+
31+
def test_allowed_hosts_invalid_host(mock_handler, mock_context):
32+
plugin = AllowedHostsPlugin(allowed_hosts=["test.com"])
33+
mock_context.host = "invalid.com"
34+
wrapped_handler = plugin.apply(mock_handler)
35+
36+
with pytest.raises(HTTPException) as exc_info:
37+
wrapped_handler(mock_context)
38+
39+
assert exc_info.value.status_code == 400
40+
assert "Host not allowed" in exc_info.value.details
41+
mock_handler.assert_not_called()
42+
43+
44+
def test_allowed_hosts_wildcard_subdomain(mock_handler, mock_context):
45+
plugin = AllowedHostsPlugin(allowed_hosts=[".test.com"])
46+
mock_context.host = "sub.test.com"
47+
wrapped_handler = plugin.apply(mock_handler)
48+
49+
wrapped_handler(mock_context)
50+
51+
mock_handler.assert_called_once_with(mock_context)
52+
53+
54+
def test_allowed_hosts_wildcard_root_domain(mock_handler, mock_context):
55+
plugin = AllowedHostsPlugin(allowed_hosts=[".test.com"])
56+
mock_context.host = "test.com"
57+
wrapped_handler = plugin.apply(mock_handler)
58+
59+
wrapped_handler(mock_context)
60+
61+
mock_handler.assert_called_once_with(mock_context)
62+
63+
64+
def test_allowed_hosts_wildcard_invalid_domain(mock_handler, mock_context):
65+
plugin = AllowedHostsPlugin(allowed_hosts=[".test.com"])
66+
mock_context.host = "invalid.com"
67+
wrapped_handler = plugin.apply(mock_handler)
68+
69+
with pytest.raises(HTTPException) as exc_info:
70+
wrapped_handler(mock_context)
71+
72+
assert exc_info.value.status_code == 400
73+
assert "Host not allowed" in exc_info.value.details
74+
mock_handler.assert_not_called()
75+
76+
77+
def test_allowed_hosts_star_allows_all(mock_handler, mock_context):
78+
plugin = AllowedHostsPlugin(allowed_hosts=["*"])
79+
mock_context.host = "any.host.com"
80+
wrapped_handler = plugin.apply(mock_handler)
81+
82+
wrapped_handler(mock_context)
83+
84+
mock_handler.assert_called_once_with(mock_context)
85+
86+
87+
def test_allowed_hosts_missing_host_header(mock_handler, mock_context):
88+
plugin = AllowedHostsPlugin(allowed_hosts=["example.com"])
89+
mock_context.host = ""
90+
wrapped_handler = plugin.apply(mock_handler)
91+
92+
with pytest.raises(HTTPException) as exc_info:
93+
wrapped_handler(mock_context)
94+
95+
assert exc_info.value.status_code == 400
96+
assert "Invalid or missing host header" in exc_info.value.details
97+
mock_handler.assert_not_called()
98+
99+
100+
def test_allowed_hosts_strips_port(mock_handler, mock_context):
101+
plugin = AllowedHostsPlugin(allowed_hosts=["test.com"])
102+
mock_context.host = "test.com:8000"
103+
wrapped_handler = plugin.apply(mock_handler)
104+
105+
wrapped_handler(mock_context)
106+
107+
mock_handler.assert_called_once_with(mock_context)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from webspark.contrib.cors import CORSPlugin
5+
from webspark.contrib.plugins.cors import CORSPlugin
66
from webspark.http import Context
77
from webspark.utils import HTTPException
88

0 commit comments

Comments
 (0)