Skip to content

Commit 782922f

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
Security hardening (v0.2.0)
- CSRF middleware now verifies the submitted token HMAC signature (CWE-352) - HTML-escape OpenAPI UI title and JSON-encode URL in JS context; pin CDN asset versions (CWE-79) - DebugMiddleware defaults to enabled=False (CWE-489) - TrustedProxyMiddleware whitelists X-Forwarded-Proto to http/https - StructuredLoggingMiddleware sanitizes client request IDs (CWE-117) - parse_multipart enforces a max_parts limit (CWE-770) - SecurityHeadersMiddleware sets HSTS by default - Scaffold no longer ships allow_origins=["*"]
1 parent f3bbabc commit 782922f

13 files changed

Lines changed: 113 additions & 30 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,5 @@ coverage.xml
3434
site/
3535
.remember/
3636

37-
# Local Claude worktrees / state
37+
# Local tooling worktrees / state
3838
.claude/

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/
44

55
## [Unreleased]
66

7+
## 0.2.0 — 2026-06-10
8+
9+
Security hardening.
10+
11+
### Security
12+
13+
- CSRF middleware now verifies the HMAC signature of the submitted token before the constant-time comparison against the cookie value. The signature was never checked before, leaving the secret unused and protection degraded to a naive double-submit cookie (CWE-352).
14+
- OpenAPI UI templates (Swagger UI, Scalar, ReDoc) HTML-escape the title and JSON-encode the OpenAPI URL in JS contexts; CDN assets pinned to exact versions (`swagger-ui-dist@5.17.14`, `@scalar/api-reference@1.25.28`, `redoc@2.1.5`) instead of floating `@5` / `@latest` tags (CWE-79).
15+
- `DebugMiddleware` now defaults to `enabled=False` (was `True`); the route map and request stats are no longer exposed by default (CWE-489).
16+
- `TrustedProxyMiddleware` only rewrites the scheme for `X-Forwarded-Proto` values of `http` or `https`; any other value leaves the existing scheme untouched.
17+
- `StructuredLoggingMiddleware` validates client-supplied request IDs (length ≤ 128, ASCII, no control characters) before logging, falling back to a generated UUID otherwise (log injection — CWE-117).
18+
- `parse_multipart` enforces a `max_parts` limit (default 1000), raising `ValueError` on bodies with more parts, to prevent part-flooding denial of service (CWE-770).
19+
- `SecurityHeadersMiddleware` sets HSTS by default (`max-age=31536000; includeSubDomains`); pass `hsts=None` to disable for local HTTP development.
20+
- `ErrorHandlerMiddleware` documents that `debug=True` leaks tracebacks and must never be enabled in production; the default remains `False`.
21+
22+
### Changed
23+
24+
- Project scaffold no longer ships `CORSMiddleware` with `allow_origins=["*"]`; it emits a placeholder origin and a `TODO` instructing operators to set real origins before production.
25+
726
## [0.1.7] - 2026-05-16
827

928
Static-response cache. Plaintext throughput jumped from #2 to #1 in the competitive suite, and we lead all six scenarios on both throughput and p99 latency now.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "hawkapi"
7-
version = "0.1.7"
7+
version = "0.2.0"
88
description = "High-performance Python web framework — faster alternative to FastAPI"
99
readme = "README.md"
1010
license = { file = "LICENSE" }

src/hawkapi/_scaffold/templates.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ async def get_greeting_service() -> GreetingService:
5353
5454
app = HawkAPI(title="{name}", container=container)
5555
56-
# CORSMiddleware: allows cross-origin requests from any origin.
57-
app.add_middleware(CORSMiddleware, allow_origins=["*"])
56+
# CORSMiddleware: restrict cross-origin requests to known origins.
57+
# TODO: set real origins before production. Never ship allow_origins=["*"].
58+
app.add_middleware(CORSMiddleware, allow_origins=["https://example.com"])
5859
5960
# RequestIDMiddleware: assigns a unique ID to every request (useful for tracing).
6061
app.add_middleware(RequestIDMiddleware)

src/hawkapi/middleware/csrf.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ async def replay_receive() -> dict[str, Any]:
218218
await response(scope, receive, send)
219219
return
220220

221+
# Verify the HMAC signature of the submitted token before (and in
222+
# addition to) the constant-time comparison against the cookie value.
223+
# Without this, the secret is unused and protection degrades to a naive
224+
# double-submit cookie.
225+
if not self._verify_token(submitted_token):
226+
response = self._forbidden_response("CSRF token signature invalid.")
227+
await response(scope, receive, send)
228+
return
229+
221230
if not hmac.compare_digest(submitted_token, cookie_token):
222231
response = self._forbidden_response("CSRF token mismatch.")
223232
await response(scope, receive, send)

src/hawkapi/middleware/debug.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(
2525
app: ASGIApp,
2626
*,
2727
prefix: str = "/_debug",
28-
enabled: bool = True,
28+
enabled: bool = False,
2929
) -> None:
3030
super().__init__(app)
3131
self._prefix = prefix

src/hawkapi/middleware/error_handler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ class ErrorHandlerMiddleware(Middleware):
1818
"""Catch exceptions and return structured error responses."""
1919

2020
def __init__(self, app: ASGIApp, *, debug: bool = False) -> None:
21+
"""Initialize the error handler.
22+
23+
WARNING: ``debug=True`` returns the full exception traceback in the
24+
HTTP response body. This leaks source paths, code, and internal state.
25+
It is a development-only aid and MUST NEVER be enabled in production.
26+
The default is ``False``.
27+
"""
2128
super().__init__(app)
2229
self.debug = debug
2330

src/hawkapi/middleware/security_headers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ class SecurityHeadersMiddleware(Middleware):
1515
- X-Content-Type-Options: nosniff
1616
- X-Frame-Options: DENY
1717
- X-XSS-Protection: 1; mode=block
18-
- Strict-Transport-Security (via ``hsts`` param, off by default)
18+
- Strict-Transport-Security (via ``hsts`` param; defaults to one year with
19+
``includeSubDomains``). Pass ``hsts=None`` to disable it — necessary for
20+
local HTTP development, since browsers will otherwise force HTTPS.
1921
- Referrer-Policy: strict-origin-when-cross-origin
2022
- Permissions-Policy (via ``permissions_policy`` param)
2123
- Content-Security-Policy (via ``content_security_policy`` param)
@@ -28,7 +30,7 @@ def __init__(
2830
content_type_options: str = "nosniff",
2931
frame_options: str = "DENY",
3032
xss_protection: str = "1; mode=block",
31-
hsts: str | None = None,
33+
hsts: str | None = "max-age=31536000; includeSubDomains",
3234
referrer_policy: str = "strict-origin-when-cross-origin",
3335
permissions_policy: str | None = None,
3436
content_security_policy: str | None = None,

src/hawkapi/middleware/structured_logging.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
6363
if key == self._request_id_header:
6464
request_id = value.decode("latin-1")
6565
break
66-
if request_id is None:
66+
# Reject overly long, injection-prone, or non-printable request IDs
67+
# (mirrors RequestIDMiddleware) to avoid log injection.
68+
if (
69+
request_id is None
70+
or len(request_id) > 128
71+
or not request_id.isascii()
72+
or any(ord(c) < 0x20 for c in request_id)
73+
):
6774
request_id = str(uuid.uuid4())
6875

6976
start = time.monotonic()

src/hawkapi/middleware/trusted_proxy.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
9090
if real_ip is not None:
9191
new_scope["client"] = (real_ip, 0)
9292

93-
# Rewrite scheme from X-Forwarded-Proto
93+
# Rewrite scheme from X-Forwarded-Proto (only accept known schemes;
94+
# otherwise leave the existing scheme untouched)
9495
if forwarded_proto:
95-
new_scope["scheme"] = forwarded_proto.strip().lower()
96+
proto = forwarded_proto.strip().lower()
97+
if proto in ("http", "https"):
98+
new_scope["scheme"] = proto
9699

97100
# Rewrite host header from X-Forwarded-Host
98101
if forwarded_host:

0 commit comments

Comments
 (0)