Skip to content

Commit 2567774

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
Security hardening (v0.1.1)
- Header- and API-key-derived identities are SHA-256 hashed before use as Redis keys, so tokens are not observable in the keyspace (CWE-312) - Fail-open path logs explicitly that the request was allowed despite the Redis error
1 parent c4e4e7d commit 2567774

5 files changed

Lines changed: 31 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.1.1 — 2026-06-10
4+
5+
Security hardening.
6+
7+
- Header- and API-key-derived rate-limit identities (`header_key()`, `api_key()`) are now SHA-256 hashed before use as Redis keys, so bearer tokens and API keys are no longer observable in the keyspace (CWE-312). Note: this changes the key format produced by these identity functions.
8+
- The fail-open path (`RedisLimiter` Redis error with `fail_closed=False`) now logs explicitly that the request was allowed despite the error; security-sensitive routes (admin / payment) should set `fail_closed=True` to deny instead.
9+
310
## 0.1.0 — 2026-05-17
411

512
Initial release.

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-ratelimit"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Rate limiting for HawkAPI — token bucket + sliding window, Redis + in-memory backends"
99
readme = "README.md"
1010
license = { file = "LICENSE" }

src/hawkapi_ratelimit/_identity.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,21 @@
22

33
from __future__ import annotations
44

5+
import hashlib
56
import ipaddress
67
from collections.abc import Callable
78
from typing import Any
89

910

11+
def _hash_value(value: str) -> str:
12+
"""Hash a user-controlled identity value before it lands in the keyspace.
13+
14+
Defense-in-depth: bearer tokens / API keys must not be observable verbatim
15+
in Redis keys. Truncated SHA-256 is collision-resistant for keyspace sizing.
16+
"""
17+
return hashlib.sha256(value.encode()).hexdigest()[:32]
18+
19+
1020
def _parse_xff(raw: str) -> str:
1121
"""Return the left-most ``X-Forwarded-For`` token, IP-only."""
1222
first = raw.split(",", 1)[0].strip()
@@ -99,7 +109,7 @@ def _fn(request: Any) -> str:
99109
value = headers.get(h, "") or ""
100110
except Exception:
101111
value = ""
102-
return f"{prefix}:{value}"
112+
return f"{prefix}:{_hash_value(value)}"
103113

104114
return _fn
105115

@@ -117,7 +127,7 @@ def _fn(request: Any) -> str:
117127
value = ""
118128
if value.lower().startswith("bearer "):
119129
value = value[7:].strip()
120-
return f"apikey:{value}"
130+
return f"apikey:{_hash_value(value)}"
121131

122132
return _fn
123133

src/hawkapi_ratelimit/_redis.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,10 @@ async def hit(self, key: str, limit: RateLimit, *, now: float | None = None) ->
170170
import logging
171171

172172
logging.getLogger("hawkapi_ratelimit").warning(
173-
"redis limiter failed (fail-open allowing): %s", exc
173+
"redis limiter error — request ALLOWED despite Redis failure "
174+
"(fail_closed=False; set fail_closed=True on admin/payment routes "
175+
"to deny instead): %s",
176+
exc,
174177
)
175178
# Fail open — count the request as allowed but report 0 remaining.
176179
return LimitResult(allowed=True, remaining=0, reset_at=now + limit.per)

tests/test_identity.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77

88
from hawkapi_ratelimit import api_key, composite_key, header_key, ip_key, user_key
9+
from hawkapi_ratelimit._identity import _hash_value
910

1011

1112
@dataclass
@@ -84,19 +85,22 @@ class _U:
8485
def test_header_key_reads_header() -> None:
8586
fn = header_key("x-api-key", prefix="api")
8687
req = _Request(headers={"x-api-key": "abc123"})
87-
assert fn(req) == "api:abc123"
88+
# Value is hashed (defense-in-depth) — raw header must not leak into keyspace.
89+
assert fn(req) == f"api:{_hash_value('abc123')}"
90+
assert "abc123" not in fn(req)
8891

8992

9093
def test_api_key_strips_bearer_prefix() -> None:
9194
fn = api_key()
9295
req = _Request(headers={"authorization": "Bearer my-token"})
93-
assert fn(req) == "apikey:my-token"
96+
assert fn(req) == f"apikey:{_hash_value('my-token')}"
97+
assert "my-token" not in fn(req)
9498

9599

96100
def test_api_key_passes_through_non_bearer() -> None:
97101
fn = api_key()
98102
req = _Request(headers={"authorization": "Basic dXNlcjpwYXNz"})
99-
assert fn(req) == "apikey:Basic dXNlcjpwYXNz"
103+
assert fn(req) == f"apikey:{_hash_value('Basic dXNlcjpwYXNz')}"
100104

101105

102106
def test_composite_key_combines() -> None:

0 commit comments

Comments
 (0)