austial-security 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- austial/security/__init__.py +73 -0
- austial/security/config.py +31 -0
- austial/security/csrf_interceptor.py +94 -0
- austial/security/decorators.py +40 -0
- austial/security/envelope_encryptor.py +93 -0
- austial/security/errors.py +31 -0
- austial/security/idempotency_guard.py +124 -0
- austial/security/idempotency_store.py +70 -0
- austial/security/pii_redactor.py +132 -0
- austial/security/rate_limit_guard.py +82 -0
- austial/security/rate_limit_store.py +64 -0
- austial/security/secure_headers_middleware.py +49 -0
- austial/security/security_module.py +91 -0
- austial_security-0.2.0.dist-info/METADATA +8 -0
- austial_security-0.2.0.dist-info/RECORD +16 -0
- austial_security-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``austial.security`` -- baseline security primitives for Austial apps.
|
|
2
|
+
|
|
3
|
+
Ships:
|
|
4
|
+
|
|
5
|
+
- ``SecurityModule.for_root(config)`` -- DI wiring.
|
|
6
|
+
- ``SecureHeadersMiddleware`` -- CSP, HSTS, ``X-Content-Type-Options``,
|
|
7
|
+
``Referrer-Policy``, ``X-Frame-Options``, ``Permissions-Policy``.
|
|
8
|
+
- ``RateLimitGuard`` + ``@RateLimit(limit, period_seconds)`` -- fixed-window
|
|
9
|
+
rate limiting keyed on ``(client_ip, route)`` by default, with a pluggable
|
|
10
|
+
``RateLimitStore`` (in-memory reference impl included).
|
|
11
|
+
- ``IdempotencyKeyGuard`` -- ``Idempotency-Key`` replay/conflict handling for
|
|
12
|
+
``POST``/``PUT``/``PATCH``/``DELETE``, backed by a pluggable
|
|
13
|
+
``IdempotencyStore``.
|
|
14
|
+
- ``CsrfInterceptor`` -- double-submit cookie CSRF check for cookie-authenticated
|
|
15
|
+
flows; skipped when a bearer token is present.
|
|
16
|
+
- ``PIIRedactor`` -- masks email, phone, PAN, Aadhaar, passport, IBAN and
|
|
17
|
+
Luhn-verified card numbers.
|
|
18
|
+
- ``EnvelopeEncryptor`` -- AES-256-GCM app-level column encryption with an
|
|
19
|
+
embedded KEK version.
|
|
20
|
+
|
|
21
|
+
Aligned with OWASP ASVS V5 (validation), V6 (secrets/crypto), V8 (data protection),
|
|
22
|
+
V9 (transport), and OWASP API Top-10 API4 (unrestricted resource consumption),
|
|
23
|
+
API6 (unrestricted access to business flows), API8 (security misconfiguration).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from austial.security.config import SecurityConfig
|
|
27
|
+
from austial.security.csrf_interceptor import (
|
|
28
|
+
CSRF_COOKIE_NAME,
|
|
29
|
+
CSRF_HEADER_NAME,
|
|
30
|
+
CsrfInterceptor,
|
|
31
|
+
)
|
|
32
|
+
from austial.security.decorators import RATE_LIMIT_METADATA, RateLimit, get_rate_limit
|
|
33
|
+
from austial.security.envelope_encryptor import EnvelopeEncryptor
|
|
34
|
+
from austial.security.errors import (
|
|
35
|
+
EncryptionKeyError,
|
|
36
|
+
IdempotencyConflictError,
|
|
37
|
+
RateLimitExceededError,
|
|
38
|
+
)
|
|
39
|
+
from austial.security.idempotency_guard import IdempotencyKeyGuard
|
|
40
|
+
from austial.security.idempotency_store import (
|
|
41
|
+
IdempotencyRecord,
|
|
42
|
+
IdempotencyStore,
|
|
43
|
+
InMemoryIdempotencyStore,
|
|
44
|
+
)
|
|
45
|
+
from austial.security.pii_redactor import PIIRedactor
|
|
46
|
+
from austial.security.rate_limit_guard import RateLimitGuard
|
|
47
|
+
from austial.security.rate_limit_store import InMemoryRateLimitStore, RateLimitStore
|
|
48
|
+
from austial.security.secure_headers_middleware import SecureHeadersMiddleware
|
|
49
|
+
from austial.security.security_module import SecurityModule
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"CSRF_COOKIE_NAME",
|
|
53
|
+
"CSRF_HEADER_NAME",
|
|
54
|
+
"CsrfInterceptor",
|
|
55
|
+
"EncryptionKeyError",
|
|
56
|
+
"EnvelopeEncryptor",
|
|
57
|
+
"IdempotencyConflictError",
|
|
58
|
+
"IdempotencyKeyGuard",
|
|
59
|
+
"IdempotencyRecord",
|
|
60
|
+
"IdempotencyStore",
|
|
61
|
+
"InMemoryIdempotencyStore",
|
|
62
|
+
"InMemoryRateLimitStore",
|
|
63
|
+
"PIIRedactor",
|
|
64
|
+
"RATE_LIMIT_METADATA",
|
|
65
|
+
"RateLimit",
|
|
66
|
+
"RateLimitExceededError",
|
|
67
|
+
"RateLimitGuard",
|
|
68
|
+
"RateLimitStore",
|
|
69
|
+
"SecureHeadersMiddleware",
|
|
70
|
+
"SecurityConfig",
|
|
71
|
+
"SecurityModule",
|
|
72
|
+
"get_rate_limit",
|
|
73
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""``SecurityConfig`` -- configuration passed to ``SecurityModule.for_root(...)``.
|
|
2
|
+
|
|
3
|
+
Held in its own module so ``EnvelopeEncryptor``/``RateLimitGuard``/etc. can read
|
|
4
|
+
individual fields without importing the module wiring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class SecurityConfig:
|
|
14
|
+
"""Every field has a safe default; only ``encryption_keys`` + ``active_encryption_key``
|
|
15
|
+
must be supplied for the ``EnvelopeEncryptor`` to be usable."""
|
|
16
|
+
|
|
17
|
+
csp: str = "default-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
|
|
18
|
+
hsts_max_age: int = 31536000
|
|
19
|
+
permissions_policy: str = "geolocation=(), microphone=(), camera=()"
|
|
20
|
+
encryption_keys: dict[str, bytes] = field(default_factory=dict)
|
|
21
|
+
active_encryption_key: str | None = None
|
|
22
|
+
default_rate_limit: tuple[int, int] = (60, 60)
|
|
23
|
+
idempotency_ttl_seconds: int = 86400
|
|
24
|
+
trusted_proxy_hops: int = 0
|
|
25
|
+
"""How many reverse proxies you operate in front of the app, for client-IP resolution.
|
|
26
|
+
|
|
27
|
+
``0`` (default): ``X-Forwarded-For`` is ignored and the socket peer is the client -- correct when
|
|
28
|
+
the app is exposed directly, and safe by default because the header is client-controlled. ``n > 0``:
|
|
29
|
+
the client is the ``n``-th entry counted from the *right* of ``X-Forwarded-For`` (each trusted proxy
|
|
30
|
+
appends the address it received the request from, so the right-most ``n`` entries are trustworthy
|
|
31
|
+
and everything to their left is attacker-controlled)."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``CsrfInterceptor`` -- double-submit cookie CSRF check for cookie-authenticated
|
|
2
|
+
flows.
|
|
3
|
+
|
|
4
|
+
The pattern:
|
|
5
|
+
|
|
6
|
+
- The server sets a random token in the ``csrf_token`` cookie at session start.
|
|
7
|
+
- Every state-changing request must echo the same token back in the
|
|
8
|
+
``X-CSRF-Token`` header.
|
|
9
|
+
- The interceptor compares the two values in constant time and rejects on any
|
|
10
|
+
mismatch.
|
|
11
|
+
|
|
12
|
+
Bearer-token flows are exempt: an ``Authorization: Bearer ...`` header cannot
|
|
13
|
+
be attached cross-origin without the caller possessing the credential outright,
|
|
14
|
+
which defeats the CSRF threat model. Read-only methods (``GET``/``HEAD``/
|
|
15
|
+
``OPTIONS``) are exempt as well; they should not carry side effects.
|
|
16
|
+
|
|
17
|
+
Raises ``ForbiddenException`` (HTTP 403) on a mismatch so a proxy or WAF can
|
|
18
|
+
distinguish CSRF failures from generic 401s.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hmac
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from austial.common.exceptions import ForbiddenException
|
|
27
|
+
from austial.common.guards.base import ExecutionContext
|
|
28
|
+
from austial.common.interceptors.base import CallHandler, NestInterceptor
|
|
29
|
+
|
|
30
|
+
CSRF_HEADER_NAME = "X-CSRF-Token"
|
|
31
|
+
CSRF_COOKIE_NAME = "csrf_token"
|
|
32
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CsrfInterceptor(NestInterceptor):
|
|
36
|
+
"""Enforce the double-submit cookie invariant before the handler runs.
|
|
37
|
+
|
|
38
|
+
Product code sets the cookie itself (usually at login); this interceptor is
|
|
39
|
+
the read-side check."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
header_name: str = CSRF_HEADER_NAME,
|
|
45
|
+
cookie_name: str = CSRF_COOKIE_NAME,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._header = header_name
|
|
48
|
+
self._cookie = cookie_name
|
|
49
|
+
|
|
50
|
+
async def intercept(self, context: ExecutionContext, next: CallHandler) -> Any:
|
|
51
|
+
request = context.get_request()
|
|
52
|
+
method = getattr(request, "method", "GET").upper()
|
|
53
|
+
|
|
54
|
+
if method in _SAFE_METHODS:
|
|
55
|
+
return await next.handle()
|
|
56
|
+
|
|
57
|
+
# Bearer-authenticated request => skip CSRF (not the relevant threat model).
|
|
58
|
+
auth = _header(getattr(request, "headers", {}) or {}, "Authorization")
|
|
59
|
+
if auth and auth.lower().startswith("bearer "):
|
|
60
|
+
return await next.handle()
|
|
61
|
+
|
|
62
|
+
header_token = _header(getattr(request, "headers", {}) or {}, self._header)
|
|
63
|
+
cookie_token = _cookie(request, self._cookie)
|
|
64
|
+
|
|
65
|
+
if not header_token or not cookie_token:
|
|
66
|
+
raise ForbiddenException("CSRF token missing")
|
|
67
|
+
|
|
68
|
+
if not hmac.compare_digest(header_token, cookie_token):
|
|
69
|
+
raise ForbiddenException("CSRF token mismatch")
|
|
70
|
+
|
|
71
|
+
return await next.handle()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _header(headers: Any, name: str) -> str | None:
|
|
75
|
+
try:
|
|
76
|
+
value = headers.get(name)
|
|
77
|
+
except AttributeError:
|
|
78
|
+
return None
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
try:
|
|
82
|
+
return headers.get(name.lower())
|
|
83
|
+
except AttributeError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cookie(request: Any, name: str) -> str | None:
|
|
88
|
+
cookies = getattr(request, "cookies", None)
|
|
89
|
+
if cookies is None:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return cookies.get(name)
|
|
93
|
+
except AttributeError:
|
|
94
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Decorators: ``@RateLimit(limit, period_seconds)``.
|
|
2
|
+
|
|
3
|
+
Attaches per-route metadata that ``RateLimitGuard`` reads to override
|
|
4
|
+
``SecurityConfig.default_rate_limit`` for that handler.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from austial.core.metadata import get_metadata, set_metadata
|
|
12
|
+
|
|
13
|
+
RATE_LIMIT_METADATA = "austial:security:rate_limit"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def RateLimit(limit: int, period_seconds: int) -> Callable: # noqa: N802 - decorator naming
|
|
17
|
+
"""Override the rate-limit budget for a single handler or controller.
|
|
18
|
+
|
|
19
|
+
``limit`` is the maximum number of requests inside a fixed window of
|
|
20
|
+
``period_seconds`` seconds. Raises ``ValueError`` at decoration time on a
|
|
21
|
+
non-positive value so misconfiguration surfaces at import."""
|
|
22
|
+
|
|
23
|
+
if limit <= 0:
|
|
24
|
+
raise ValueError("RateLimit limit must be positive")
|
|
25
|
+
if period_seconds <= 0:
|
|
26
|
+
raise ValueError("RateLimit period_seconds must be positive")
|
|
27
|
+
|
|
28
|
+
def decorator(target):
|
|
29
|
+
set_metadata(RATE_LIMIT_METADATA, (limit, period_seconds))(target)
|
|
30
|
+
return target
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_rate_limit(target, default: tuple[int, int]) -> tuple[int, int]:
|
|
36
|
+
"""Return the rate-limit tuple attached to ``target`` (or ``default``)."""
|
|
37
|
+
value = get_metadata(RATE_LIMIT_METADATA, target, None)
|
|
38
|
+
if value is None:
|
|
39
|
+
return default
|
|
40
|
+
return value
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""``EnvelopeEncryptor`` -- app-level column encryption with AES-256-GCM.
|
|
2
|
+
|
|
3
|
+
The wire format is a URL-safe base64 encoding of::
|
|
4
|
+
|
|
5
|
+
b"v1:<key_version>:" || nonce (12 bytes) || ciphertext || tag (16 bytes)
|
|
6
|
+
|
|
7
|
+
The key version prefix lets a rotation swap the active KEK without a bulk
|
|
8
|
+
re-encrypt: old rows keep decrypting with their embedded version, new writes
|
|
9
|
+
use ``SecurityConfig.active_encryption_key``.
|
|
10
|
+
|
|
11
|
+
Callers pass a ``dict[str, bytes]`` of ``version -> 32-byte key``. A key that
|
|
12
|
+
is not exactly 32 bytes is rejected at construction (``EncryptionKeyError``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from cryptography.exceptions import InvalidTag
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
22
|
+
|
|
23
|
+
from austial.common.decorators.injectable import Injectable
|
|
24
|
+
from austial.security.errors import EncryptionKeyError
|
|
25
|
+
|
|
26
|
+
_VERSION_PREFIX = b"v1:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@Injectable()
|
|
30
|
+
class EnvelopeEncryptor:
|
|
31
|
+
"""Symmetric app-level encryption for individual column values.
|
|
32
|
+
|
|
33
|
+
``encrypt`` writes with ``active_key``. ``decrypt`` reads whichever version
|
|
34
|
+
the ciphertext advertises so historical rows stay readable across a key
|
|
35
|
+
rotation."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, keys: dict[str, bytes], active_key: str | None) -> None:
|
|
38
|
+
if not keys:
|
|
39
|
+
raise EncryptionKeyError("At least one encryption key must be supplied")
|
|
40
|
+
if active_key is None:
|
|
41
|
+
raise EncryptionKeyError("active_encryption_key must be supplied")
|
|
42
|
+
if active_key not in keys:
|
|
43
|
+
raise EncryptionKeyError(f"active_encryption_key '{active_key}' is not present in keys")
|
|
44
|
+
for version, material in keys.items():
|
|
45
|
+
if not isinstance(material, (bytes, bytearray)) or len(material) != 32:
|
|
46
|
+
raise EncryptionKeyError(f"Key '{version}' must be exactly 32 bytes (AES-256)")
|
|
47
|
+
self._keys: dict[str, bytes] = {v: bytes(m) for v, m in keys.items()}
|
|
48
|
+
self._active_key = active_key
|
|
49
|
+
|
|
50
|
+
def encrypt(self, plaintext: str) -> str:
|
|
51
|
+
if not isinstance(plaintext, str):
|
|
52
|
+
raise TypeError("plaintext must be str")
|
|
53
|
+
aesgcm = AESGCM(self._keys[self._active_key])
|
|
54
|
+
nonce = os.urandom(12)
|
|
55
|
+
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
|
56
|
+
header = _VERSION_PREFIX + self._active_key.encode("ascii") + b":"
|
|
57
|
+
blob = header + nonce + ciphertext_with_tag
|
|
58
|
+
return base64.urlsafe_b64encode(blob).decode("ascii")
|
|
59
|
+
|
|
60
|
+
def decrypt(self, ciphertext: str) -> str:
|
|
61
|
+
try:
|
|
62
|
+
blob = base64.urlsafe_b64decode(ciphertext.encode("ascii"))
|
|
63
|
+
except (ValueError, TypeError) as exc:
|
|
64
|
+
raise EncryptionKeyError("Ciphertext is not valid base64") from exc
|
|
65
|
+
|
|
66
|
+
if not blob.startswith(_VERSION_PREFIX):
|
|
67
|
+
raise EncryptionKeyError("Ciphertext is missing the version prefix")
|
|
68
|
+
remainder = blob[len(_VERSION_PREFIX) :]
|
|
69
|
+
try:
|
|
70
|
+
version_end = remainder.index(b":")
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise EncryptionKeyError("Ciphertext version delimiter not found") from exc
|
|
73
|
+
|
|
74
|
+
version = remainder[:version_end].decode("ascii", errors="replace")
|
|
75
|
+
payload = remainder[version_end + 1 :]
|
|
76
|
+
if len(payload) < 12 + 16: # nonce + minimum-sized tag
|
|
77
|
+
raise EncryptionKeyError("Ciphertext payload is truncated")
|
|
78
|
+
|
|
79
|
+
key = self._keys.get(version)
|
|
80
|
+
if key is None:
|
|
81
|
+
raise EncryptionKeyError(f"Unknown encryption key version '{version}'")
|
|
82
|
+
|
|
83
|
+
nonce, ciphertext_bytes = payload[:12], payload[12:]
|
|
84
|
+
aesgcm = AESGCM(key)
|
|
85
|
+
try:
|
|
86
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext_bytes, None)
|
|
87
|
+
except InvalidTag as exc:
|
|
88
|
+
raise EncryptionKeyError("Ciphertext failed authentication") from exc
|
|
89
|
+
return plaintext.decode("utf-8")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def active_key(self) -> str:
|
|
93
|
+
return self._active_key
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Security-specific exceptions.
|
|
2
|
+
|
|
3
|
+
Extend Austial's typed HTTP exceptions so ``AllExceptionsFilter`` maps them to
|
|
4
|
+
the correct status codes without a translation layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from austial.common.exceptions import ConflictException, TooManyRequestsException
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RateLimitExceededError(TooManyRequestsException):
|
|
13
|
+
"""Raised by ``RateLimitGuard`` when a caller exceeds their allowance
|
|
14
|
+
inside the current fixed window (HTTP 429)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Rate limit exceeded", *, retry_after: int | None = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyConflictError(ConflictException):
|
|
22
|
+
"""Raised by ``IdempotencyKeyGuard`` when the same ``Idempotency-Key`` is
|
|
23
|
+
replayed with a different request body inside the TTL (HTTP 409)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "Idempotency key reused with different request"):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EncryptionKeyError(ValueError):
|
|
30
|
+
"""Raised by ``EnvelopeEncryptor`` when the active KEK is missing, a key
|
|
31
|
+
length is wrong, or a stored ciphertext references an unknown key version."""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""``IdempotencyKeyGuard`` -- enforces ``Idempotency-Key`` on write methods.
|
|
2
|
+
|
|
3
|
+
For ``POST``/``PUT``/``PATCH``/``DELETE`` the guard reads the header
|
|
4
|
+
``Idempotency-Key``:
|
|
5
|
+
|
|
6
|
+
- Missing header: guard is a no-op (allow through). Product code can require it
|
|
7
|
+
by decorating the route with a companion validator.
|
|
8
|
+
- Header present, no cached record: allow through; the framework's response
|
|
9
|
+
interceptor stores the outcome (product code calls
|
|
10
|
+
``store.put(IdempotencyRecord(...))`` from a middleware/interceptor once the
|
|
11
|
+
handler returns).
|
|
12
|
+
- Header present, cached record with the same body hash: replay the cached
|
|
13
|
+
response by short-circuiting the pipeline. This is done by attaching the
|
|
14
|
+
record to ``request.state.idempotent_replay``; the response interceptor is
|
|
15
|
+
expected to render it.
|
|
16
|
+
- Header present, cached record with a *different* body hash: 409 conflict.
|
|
17
|
+
|
|
18
|
+
Read-only methods (``GET``/``HEAD``/``OPTIONS``) are allowed unconditionally --
|
|
19
|
+
they are already idempotent by definition and rarely mutate state.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from austial.common.decorators.injectable import Injectable
|
|
29
|
+
from austial.common.guards.base import CanActivate, ExecutionContext
|
|
30
|
+
from austial.security.errors import IdempotencyConflictError
|
|
31
|
+
from austial.security.idempotency_store import IdempotencyStore
|
|
32
|
+
|
|
33
|
+
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@Injectable()
|
|
37
|
+
class IdempotencyKeyGuard(CanActivate):
|
|
38
|
+
def __init__(self, store: IdempotencyStore) -> None:
|
|
39
|
+
self._store = store
|
|
40
|
+
|
|
41
|
+
async def can_activate(self, context: ExecutionContext) -> bool:
|
|
42
|
+
request = context.get_request()
|
|
43
|
+
method = getattr(request, "method", "GET").upper()
|
|
44
|
+
if method not in _WRITE_METHODS:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
headers = getattr(request, "headers", {}) or {}
|
|
48
|
+
key = _header(headers, "Idempotency-Key")
|
|
49
|
+
if not key:
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
request_hash = await self._hash_request(request, method)
|
|
53
|
+
cached = self._store.get(key)
|
|
54
|
+
if cached is None:
|
|
55
|
+
# cache miss: allow through; product code stores after handler runs
|
|
56
|
+
request.state.idempotency_key = key
|
|
57
|
+
request.state.idempotency_request_hash = request_hash
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
if cached.request_hash != request_hash:
|
|
61
|
+
raise IdempotencyConflictError(
|
|
62
|
+
f"Idempotency-Key '{key}' has already been used with a different request payload"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# cache hit: signal replay
|
|
66
|
+
request.state.idempotent_replay = cached
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
async def _hash_request(self, request: Any, method: str) -> str:
|
|
70
|
+
path = getattr(getattr(request, "url", None), "path", "") or getattr(request, "path", "/")
|
|
71
|
+
body_bytes = await _body(request)
|
|
72
|
+
digest = hashlib.sha256()
|
|
73
|
+
digest.update(method.encode("ascii"))
|
|
74
|
+
digest.update(b"\0")
|
|
75
|
+
digest.update(path.encode("utf-8"))
|
|
76
|
+
digest.update(b"\0")
|
|
77
|
+
digest.update(body_bytes)
|
|
78
|
+
return digest.hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _header(headers: Any, name: str) -> str | None:
|
|
82
|
+
try:
|
|
83
|
+
value = headers.get(name)
|
|
84
|
+
except AttributeError:
|
|
85
|
+
return None
|
|
86
|
+
if value:
|
|
87
|
+
return value
|
|
88
|
+
try:
|
|
89
|
+
return headers.get(name.lower())
|
|
90
|
+
except AttributeError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _body(request: Any) -> bytes:
|
|
95
|
+
"""Read the request body. Starlette exposes ``await request.body()``; a test
|
|
96
|
+
double may attach ``body_bytes`` directly or provide a dict."""
|
|
97
|
+
# cached body written by prior guards/middleware
|
|
98
|
+
cached = getattr(getattr(request, "state", None), "raw_body", None)
|
|
99
|
+
if isinstance(cached, (bytes, bytearray)):
|
|
100
|
+
return bytes(cached)
|
|
101
|
+
|
|
102
|
+
body_attr = getattr(request, "body", None)
|
|
103
|
+
if callable(body_attr):
|
|
104
|
+
result = body_attr()
|
|
105
|
+
if hasattr(result, "__await__"):
|
|
106
|
+
result = await result
|
|
107
|
+
if isinstance(result, (bytes, bytearray)):
|
|
108
|
+
return bytes(result)
|
|
109
|
+
if isinstance(result, str):
|
|
110
|
+
return result.encode("utf-8")
|
|
111
|
+
if isinstance(body_attr, (bytes, bytearray)):
|
|
112
|
+
return bytes(body_attr)
|
|
113
|
+
if isinstance(body_attr, str):
|
|
114
|
+
return body_attr.encode("utf-8")
|
|
115
|
+
|
|
116
|
+
body_bytes = getattr(request, "body_bytes", None)
|
|
117
|
+
if isinstance(body_bytes, (bytes, bytearray)):
|
|
118
|
+
return bytes(body_bytes)
|
|
119
|
+
|
|
120
|
+
payload = getattr(request, "json_body", None)
|
|
121
|
+
if payload is not None:
|
|
122
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
123
|
+
|
|
124
|
+
return b""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""``IdempotencyStore`` protocol + ``InMemoryIdempotencyStore`` reference impl.
|
|
2
|
+
|
|
3
|
+
An idempotency record captures the response produced for a mutation so the
|
|
4
|
+
guard can replay it verbatim if the client retries with the same
|
|
5
|
+
``Idempotency-Key``. Records expire after ``ttl_seconds``; a replay with a
|
|
6
|
+
different request body while the record is still alive is a conflict.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from austial.common.decorators.injectable import Injectable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class IdempotencyRecord:
|
|
21
|
+
"""Snapshot of a response bound to an ``Idempotency-Key``."""
|
|
22
|
+
|
|
23
|
+
key: str
|
|
24
|
+
request_hash: str
|
|
25
|
+
status_code: int
|
|
26
|
+
body: Any
|
|
27
|
+
stored_at: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class IdempotencyStore(Protocol):
|
|
32
|
+
"""Pluggable backend for ``IdempotencyKeyGuard``."""
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
35
|
+
"""Return the record for ``key`` if it is still within TTL, else ``None``."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def put(self, record: IdempotencyRecord) -> None:
|
|
39
|
+
"""Persist a record (overwriting any earlier record for the same key)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@Injectable()
|
|
44
|
+
class InMemoryIdempotencyStore:
|
|
45
|
+
"""Thread-safe, process-local record cache with lazy TTL expiry."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, ttl_seconds: int = 86400) -> None:
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._ttl = ttl_seconds
|
|
50
|
+
self._records: dict[str, IdempotencyRecord] = {}
|
|
51
|
+
|
|
52
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
53
|
+
now = time.time()
|
|
54
|
+
with self._lock:
|
|
55
|
+
record = self._records.get(key)
|
|
56
|
+
if record is None:
|
|
57
|
+
return None
|
|
58
|
+
if now - record.stored_at > self._ttl:
|
|
59
|
+
self._records.pop(key, None)
|
|
60
|
+
return None
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
def put(self, record: IdempotencyRecord) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._records[record.key] = record
|
|
66
|
+
|
|
67
|
+
def reset(self) -> None:
|
|
68
|
+
"""Clear all records. Useful in tests."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._records.clear()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``PIIRedactor`` -- masks common personally-identifying data before it hits logs.
|
|
2
|
+
|
|
3
|
+
Recognises:
|
|
4
|
+
|
|
5
|
+
- Email addresses (RFC-5322-ish, pragmatic subset).
|
|
6
|
+
- International phone numbers (``+`` optional, 7-15 digits, common separators).
|
|
7
|
+
- Indian PAN (``AAAAA9999A``), Aadhaar (12 digits, spaces optional; Verhoeff
|
|
8
|
+
check-digit heuristic keeps the false-positive rate low).
|
|
9
|
+
- Passport numbers (Indian format ``[A-PR-WY]\\d{7}``).
|
|
10
|
+
- IBAN (2 letters + 2 digits + up to 30 alphanumerics with a mod-97 check).
|
|
11
|
+
- Credit card numbers (13-19 digits, spaces/dashes tolerated, Luhn-verified).
|
|
12
|
+
|
|
13
|
+
Redaction preserves the first and last character for emails and the last four
|
|
14
|
+
digits for cards so operators can still correlate incidents. Values that fail
|
|
15
|
+
their per-pattern check (Luhn, mod-97) are left untouched to avoid destroying
|
|
16
|
+
useful log data.
|
|
17
|
+
|
|
18
|
+
Instances are safe to share across threads; no state is mutated after init.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from austial.common.decorators.injectable import Injectable
|
|
27
|
+
|
|
28
|
+
_EMAIL_RE = re.compile(r"(?P<user>[A-Za-z0-9._%+-]+)@(?P<host>[A-Za-z0-9.-]+\.[A-Za-z]{2,})")
|
|
29
|
+
_PHONE_RE = re.compile(r"(?<![A-Za-z0-9])(?P<phone>\+?\d[\d\s().-]{6,}\d)(?![A-Za-z0-9])")
|
|
30
|
+
_PAN_RE = re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")
|
|
31
|
+
_AADHAAR_RE = re.compile(r"(?<!\d)\d{4}[\s-]?\d{4}[\s-]?\d{4}(?!\d)")
|
|
32
|
+
_PASSPORT_RE = re.compile(r"\b[A-PR-WY][0-9]{7}\b")
|
|
33
|
+
_IBAN_RE = re.compile(r"\b[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}\b")
|
|
34
|
+
_CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@Injectable()
|
|
38
|
+
class PIIRedactor:
|
|
39
|
+
"""Redact PII patterns from strings or nested dict/list structures.
|
|
40
|
+
|
|
41
|
+
``redact`` accepts a string (returns a string) or a mapping (returns a new
|
|
42
|
+
mapping with the same shape, values redacted recursively). Non-string
|
|
43
|
+
scalar values pass through unchanged."""
|
|
44
|
+
|
|
45
|
+
_placeholder = "[REDACTED]"
|
|
46
|
+
|
|
47
|
+
def redact(self, record: dict | list | str | Any) -> Any:
|
|
48
|
+
if isinstance(record, str):
|
|
49
|
+
return self._redact_str(record)
|
|
50
|
+
if isinstance(record, dict):
|
|
51
|
+
return {k: self.redact(v) for k, v in record.items()}
|
|
52
|
+
if isinstance(record, (list, tuple)):
|
|
53
|
+
result = [self.redact(v) for v in record]
|
|
54
|
+
return type(record)(result) if isinstance(record, tuple) else result
|
|
55
|
+
return record
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _redact_str(self, text: str) -> str:
|
|
60
|
+
# Order matters: longer/more-specific patterns first so a card's leading
|
|
61
|
+
# digits cannot be swallowed by the 12-digit Aadhaar regex, and IBAN's
|
|
62
|
+
# alphanumerics cannot be nibbled by PAN.
|
|
63
|
+
text = _EMAIL_RE.sub(_mask_email, text)
|
|
64
|
+
text = _PAN_RE.sub(self._placeholder, text)
|
|
65
|
+
text = _PASSPORT_RE.sub(self._placeholder, text)
|
|
66
|
+
text = _IBAN_RE.sub(lambda m: self._placeholder if _iban_ok(m.group(0)) else m.group(0), text)
|
|
67
|
+
text = _CARD_RE.sub(_mask_card, text)
|
|
68
|
+
text = _AADHAAR_RE.sub(lambda m: self._placeholder if _digits_only(m.group(0), 12) else m.group(0), text)
|
|
69
|
+
text = _PHONE_RE.sub(_mask_phone, text)
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- helpers ------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _mask_email(match: re.Match[str]) -> str:
|
|
77
|
+
user = match.group("user")
|
|
78
|
+
host = match.group("host")
|
|
79
|
+
masked_user = user[0] + "***" + user[-1] if len(user) >= 2 else "***"
|
|
80
|
+
return f"{masked_user}@{host}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _mask_phone(match: re.Match[str]) -> str:
|
|
84
|
+
phone = match.group("phone")
|
|
85
|
+
digits = re.sub(r"\D", "", phone)
|
|
86
|
+
if not 7 <= len(digits) <= 15:
|
|
87
|
+
return phone
|
|
88
|
+
return "[REDACTED-PHONE]"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _mask_card(match: re.Match[str]) -> str:
|
|
92
|
+
raw = match.group(0)
|
|
93
|
+
digits = re.sub(r"\D", "", raw)
|
|
94
|
+
if not 13 <= len(digits) <= 19:
|
|
95
|
+
return raw
|
|
96
|
+
if not _luhn_ok(digits):
|
|
97
|
+
return raw
|
|
98
|
+
return f"[REDACTED-CARD-{digits[-4:]}]"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _luhn_ok(digits: str) -> bool:
|
|
102
|
+
total = 0
|
|
103
|
+
reverse = digits[::-1]
|
|
104
|
+
for i, ch in enumerate(reverse):
|
|
105
|
+
d = ord(ch) - 48
|
|
106
|
+
if i % 2 == 1:
|
|
107
|
+
d *= 2
|
|
108
|
+
if d > 9:
|
|
109
|
+
d -= 9
|
|
110
|
+
total += d
|
|
111
|
+
return total % 10 == 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _digits_only(text: str, expected: int) -> bool:
|
|
115
|
+
return len(re.sub(r"\D", "", text)) == expected
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _iban_ok(value: str) -> bool:
|
|
119
|
+
value = value.upper()
|
|
120
|
+
rearranged = value[4:] + value[:4]
|
|
121
|
+
digits = []
|
|
122
|
+
for ch in rearranged:
|
|
123
|
+
if ch.isalpha():
|
|
124
|
+
digits.append(str(ord(ch) - 55))
|
|
125
|
+
elif ch.isdigit():
|
|
126
|
+
digits.append(ch)
|
|
127
|
+
else:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
return int("".join(digits)) % 97 == 1
|
|
131
|
+
except ValueError:
|
|
132
|
+
return False
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""``RateLimitGuard`` -- fixed-window rate limiter keyed on ``(client_ip, route)``.
|
|
2
|
+
|
|
3
|
+
Reads the per-route budget from ``@RateLimit(limit, period_seconds)`` metadata
|
|
4
|
+
if present, otherwise falls back to ``SecurityConfig.default_rate_limit``. On a
|
|
5
|
+
breach raises ``RateLimitExceededError`` (HTTP 429) carrying a ``Retry-After``
|
|
6
|
+
hint that the framework's exception filter can surface on the response.
|
|
7
|
+
|
|
8
|
+
The default key is ``f"{client_ip}:{method}:{route}"``. Override
|
|
9
|
+
``build_key(request)`` in a subclass to use a session id, api key, or tenant id
|
|
10
|
+
where those are stronger identifiers. The client IP honours ``X-Forwarded-For`` only
|
|
11
|
+
when ``SecurityConfig.trusted_proxy_hops`` says how many proxies you operate.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from austial.common.decorators.injectable import Injectable
|
|
19
|
+
from austial.common.guards.base import CanActivate, ExecutionContext
|
|
20
|
+
from austial.security.config import SecurityConfig
|
|
21
|
+
from austial.security.decorators import get_rate_limit
|
|
22
|
+
from austial.security.errors import RateLimitExceededError
|
|
23
|
+
from austial.security.rate_limit_store import RateLimitStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@Injectable()
|
|
27
|
+
class RateLimitGuard(CanActivate):
|
|
28
|
+
def __init__(self, store: RateLimitStore, config: SecurityConfig) -> None:
|
|
29
|
+
self._store = store
|
|
30
|
+
self._config = config
|
|
31
|
+
|
|
32
|
+
async def can_activate(self, context: ExecutionContext) -> bool:
|
|
33
|
+
handler = context.get_handler()
|
|
34
|
+
controller = context.get_class()
|
|
35
|
+
|
|
36
|
+
# handler-level metadata wins over class-level, both win over the default
|
|
37
|
+
limit, period = get_rate_limit(handler, get_rate_limit(controller, self._config.default_rate_limit))
|
|
38
|
+
request = context.get_request()
|
|
39
|
+
key = self.build_key(request)
|
|
40
|
+
|
|
41
|
+
count, reset_at = self._store.hit(key, period)
|
|
42
|
+
if count > limit:
|
|
43
|
+
import time
|
|
44
|
+
|
|
45
|
+
retry_after = max(1, reset_at - int(time.time()))
|
|
46
|
+
raise RateLimitExceededError(
|
|
47
|
+
f"Rate limit exceeded: {limit} requests per {period}s",
|
|
48
|
+
retry_after=retry_after,
|
|
49
|
+
)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
def build_key(self, request: Any) -> str:
|
|
53
|
+
"""Return the counter key for ``request``. Override to key on session,
|
|
54
|
+
api key or tenant. Default is ``f"{client_ip}:{method}:{path}"``, where the client IP is the
|
|
55
|
+
socket peer, or (behind ``SecurityConfig.trusted_proxy_hops`` proxies) the trusted
|
|
56
|
+
``X-Forwarded-For`` entry."""
|
|
57
|
+
client_ip = self._client_ip(request)
|
|
58
|
+
method = getattr(request, "method", "GET")
|
|
59
|
+
path = getattr(getattr(request, "url", None), "path", "") or getattr(request, "path", "/")
|
|
60
|
+
return f"{client_ip}:{method}:{path}"
|
|
61
|
+
|
|
62
|
+
def _client_ip(self, request: Any) -> str:
|
|
63
|
+
hops = self._config.trusted_proxy_hops
|
|
64
|
+
if hops > 0:
|
|
65
|
+
headers = getattr(request, "headers", {}) or {}
|
|
66
|
+
forwarded = None
|
|
67
|
+
try:
|
|
68
|
+
forwarded = headers.get("X-Forwarded-For") or headers.get("x-forwarded-for")
|
|
69
|
+
except AttributeError: # pragma: no cover - dict-like headers
|
|
70
|
+
forwarded = None
|
|
71
|
+
if forwarded:
|
|
72
|
+
entries = [e.strip() for e in forwarded.split(",") if e.strip()]
|
|
73
|
+
# The n-th from the right is the address our outermost trusted proxy saw. If the chain is
|
|
74
|
+
# shorter than the configured hop count the header is not from our proxy: ignore it.
|
|
75
|
+
if len(entries) >= hops:
|
|
76
|
+
return entries[-hops]
|
|
77
|
+
client = getattr(request, "client", None)
|
|
78
|
+
if client is not None:
|
|
79
|
+
host = getattr(client, "host", None)
|
|
80
|
+
if host:
|
|
81
|
+
return host
|
|
82
|
+
return "unknown"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""``RateLimitStore`` protocol + ``InMemoryRateLimitStore`` reference impl.
|
|
2
|
+
|
|
3
|
+
The store is the pluggable backend for ``RateLimitGuard``. A production system
|
|
4
|
+
supplies a Redis-backed implementation; the in-memory default keeps tests and
|
|
5
|
+
single-process deployments honest.
|
|
6
|
+
|
|
7
|
+
The store's contract is a fixed-window counter: given a ``key`` and a ``period``
|
|
8
|
+
in seconds, ``hit`` returns ``(count, reset_at_epoch)`` for the current window,
|
|
9
|
+
incrementing the count as a side effect. Windows are aligned to wall-clock
|
|
10
|
+
seconds so a fleet of workers converges on the same buckets.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from austial.common.decorators.injectable import Injectable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class RateLimitStore(Protocol):
|
|
24
|
+
"""Pluggable backend for the ``RateLimitGuard``.
|
|
25
|
+
|
|
26
|
+
Implementations must be safe to call from multiple asyncio tasks (and,
|
|
27
|
+
for a shared backend, from multiple workers)."""
|
|
28
|
+
|
|
29
|
+
def hit(self, key: str, period_seconds: int) -> tuple[int, int]:
|
|
30
|
+
"""Increment the counter for ``key`` inside its current window and
|
|
31
|
+
return ``(count, reset_at_epoch)``."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@Injectable()
|
|
36
|
+
class InMemoryRateLimitStore:
|
|
37
|
+
"""Thread-safe, process-local fixed-window counter.
|
|
38
|
+
|
|
39
|
+
Not shared across workers -- swap for a Redis-backed store in a
|
|
40
|
+
multi-process deployment. Windows are aligned to wall-clock seconds
|
|
41
|
+
(``epoch // period``)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
# key -> (window_start_epoch, count)
|
|
46
|
+
self._counters: dict[str, tuple[int, int]] = {}
|
|
47
|
+
|
|
48
|
+
def hit(self, key: str, period_seconds: int) -> tuple[int, int]:
|
|
49
|
+
now = int(time.time())
|
|
50
|
+
window_start = (now // period_seconds) * period_seconds
|
|
51
|
+
reset_at = window_start + period_seconds
|
|
52
|
+
with self._lock:
|
|
53
|
+
existing = self._counters.get(key)
|
|
54
|
+
if existing is None or existing[0] != window_start:
|
|
55
|
+
self._counters[key] = (window_start, 1)
|
|
56
|
+
return 1, reset_at
|
|
57
|
+
count = existing[1] + 1
|
|
58
|
+
self._counters[key] = (window_start, count)
|
|
59
|
+
return count, reset_at
|
|
60
|
+
|
|
61
|
+
def reset(self) -> None:
|
|
62
|
+
"""Clear all counters. Useful in tests."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._counters.clear()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``SecureHeadersMiddleware`` -- sets baseline security response headers.
|
|
2
|
+
|
|
3
|
+
Applied module-wide via ``consumer.apply(SecureHeadersMiddleware).for_routes("*")``.
|
|
4
|
+
Reads its policy from ``SecurityConfig``; product code overrides the CSP string
|
|
5
|
+
(or any other header) by passing a customised config to ``SecurityModule.for_root``.
|
|
6
|
+
|
|
7
|
+
Headers set on every response:
|
|
8
|
+
|
|
9
|
+
- ``Content-Security-Policy`` -- from ``config.csp``.
|
|
10
|
+
- ``Strict-Transport-Security`` -- ``max-age=<hsts_max_age>; includeSubDomains``.
|
|
11
|
+
- ``X-Content-Type-Options: nosniff``.
|
|
12
|
+
- ``Referrer-Policy: strict-origin-when-cross-origin``.
|
|
13
|
+
- ``X-Frame-Options: DENY``.
|
|
14
|
+
- ``Permissions-Policy`` -- from ``config.permissions_policy``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
from starlette.responses import Response
|
|
23
|
+
|
|
24
|
+
from austial.common.middleware.base import NestMiddleware
|
|
25
|
+
from austial.security.config import SecurityConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SecureHeadersMiddleware(NestMiddleware):
|
|
29
|
+
"""Constructor takes a ``SecurityConfig`` (supplied by the DI container).
|
|
30
|
+
|
|
31
|
+
Middleware is instantiated per binding; the config is snapshotted at
|
|
32
|
+
startup so the header set is stable for the process lifetime."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: SecurityConfig | None = None) -> None:
|
|
35
|
+
self._config = config or SecurityConfig()
|
|
36
|
+
|
|
37
|
+
async def use(self, request: Request, call_next: Callable[[], Awaitable[Response]]) -> Response:
|
|
38
|
+
response = await call_next()
|
|
39
|
+
headers = response.headers
|
|
40
|
+
headers.setdefault("Content-Security-Policy", self._config.csp)
|
|
41
|
+
headers.setdefault(
|
|
42
|
+
"Strict-Transport-Security",
|
|
43
|
+
f"max-age={self._config.hsts_max_age}; includeSubDomains",
|
|
44
|
+
)
|
|
45
|
+
headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
46
|
+
headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
47
|
+
headers.setdefault("X-Frame-Options", "DENY")
|
|
48
|
+
headers.setdefault("Permissions-Policy", self._config.permissions_policy)
|
|
49
|
+
return response
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``SecurityModule.for_root(...)`` -- wires the security primitives into the DI
|
|
2
|
+
container.
|
|
3
|
+
|
|
4
|
+
Provides ``SecurityConfig``, ``SecureHeadersMiddleware``, ``RateLimitGuard``,
|
|
5
|
+
``IdempotencyKeyGuard``, ``CsrfInterceptor``, ``PIIRedactor``, and (when
|
|
6
|
+
encryption keys are configured) ``EnvelopeEncryptor``. Registered ``@Global``
|
|
7
|
+
by default so any module can inject them.
|
|
8
|
+
|
|
9
|
+
Callers may override the store implementations by passing a class or a
|
|
10
|
+
pre-constructed instance for ``rate_limit_store`` / ``idempotency_store``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from austial.common.decorators.modules import Module
|
|
18
|
+
from austial.core.dynamic_module import DynamicModule
|
|
19
|
+
from austial.security.config import SecurityConfig
|
|
20
|
+
from austial.security.csrf_interceptor import CsrfInterceptor
|
|
21
|
+
from austial.security.envelope_encryptor import EnvelopeEncryptor
|
|
22
|
+
from austial.security.idempotency_guard import IdempotencyKeyGuard
|
|
23
|
+
from austial.security.idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
|
|
24
|
+
from austial.security.pii_redactor import PIIRedactor
|
|
25
|
+
from austial.security.rate_limit_guard import RateLimitGuard
|
|
26
|
+
from austial.security.rate_limit_store import InMemoryRateLimitStore, RateLimitStore
|
|
27
|
+
from austial.security.secure_headers_middleware import SecureHeadersMiddleware
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@Module()
|
|
31
|
+
class SecurityModule:
|
|
32
|
+
"""Import via ``SecurityModule.for_root(config, ...)`` in ``AppModule``'s
|
|
33
|
+
``imports=[...]``. Registered ``@Global`` by default."""
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def for_root(
|
|
37
|
+
config: SecurityConfig | None = None,
|
|
38
|
+
*,
|
|
39
|
+
rate_limit_store: type | RateLimitStore | None = None,
|
|
40
|
+
idempotency_store: type | IdempotencyStore | None = None,
|
|
41
|
+
is_global: bool = True,
|
|
42
|
+
) -> DynamicModule:
|
|
43
|
+
config = config or SecurityConfig()
|
|
44
|
+
|
|
45
|
+
rl_store: RateLimitStore = (
|
|
46
|
+
rate_limit_store if isinstance(rate_limit_store, RateLimitStore) else InMemoryRateLimitStore()
|
|
47
|
+
)
|
|
48
|
+
idem_store: IdempotencyStore = (
|
|
49
|
+
idempotency_store
|
|
50
|
+
if isinstance(idempotency_store, IdempotencyStore)
|
|
51
|
+
else InMemoryIdempotencyStore(ttl_seconds=config.idempotency_ttl_seconds)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
secure_headers = SecureHeadersMiddleware(config)
|
|
55
|
+
redactor = PIIRedactor()
|
|
56
|
+
csrf = CsrfInterceptor()
|
|
57
|
+
|
|
58
|
+
providers: list[Any] = [
|
|
59
|
+
{"provide": SecurityConfig, "useValue": config},
|
|
60
|
+
{"provide": RateLimitStore, "useValue": rl_store},
|
|
61
|
+
{"provide": IdempotencyStore, "useValue": idem_store},
|
|
62
|
+
{"provide": SecureHeadersMiddleware, "useValue": secure_headers},
|
|
63
|
+
{"provide": CsrfInterceptor, "useValue": csrf},
|
|
64
|
+
{"provide": PIIRedactor, "useValue": redactor},
|
|
65
|
+
RateLimitGuard,
|
|
66
|
+
IdempotencyKeyGuard,
|
|
67
|
+
]
|
|
68
|
+
exports: list[Any] = [
|
|
69
|
+
SecurityConfig,
|
|
70
|
+
RateLimitStore,
|
|
71
|
+
IdempotencyStore,
|
|
72
|
+
SecureHeadersMiddleware,
|
|
73
|
+
CsrfInterceptor,
|
|
74
|
+
PIIRedactor,
|
|
75
|
+
RateLimitGuard,
|
|
76
|
+
IdempotencyKeyGuard,
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
if config.active_encryption_key is not None and config.encryption_keys:
|
|
80
|
+
encryptor = EnvelopeEncryptor(
|
|
81
|
+
keys=config.encryption_keys,
|
|
82
|
+
active_key=config.active_encryption_key,
|
|
83
|
+
)
|
|
84
|
+
providers.append({"provide": EnvelopeEncryptor, "useValue": encryptor})
|
|
85
|
+
exports.append(EnvelopeEncryptor)
|
|
86
|
+
|
|
87
|
+
return DynamicModule(
|
|
88
|
+
module=SecurityModule,
|
|
89
|
+
providers=providers,
|
|
90
|
+
exports=exports,
|
|
91
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: austial-security
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Security primitives -- secure headers, rate limiting, idempotency, CSRF, PII redaction, envelope encryption.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: austial-common
|
|
7
|
+
Requires-Dist: austial-core
|
|
8
|
+
Requires-Dist: cryptography>=43.0.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
austial/security/__init__.py,sha256=fzWdK6rMjqYxfJ1oN56F1xv2CRuGIJfqVvlXLC_kdlk,2749
|
|
2
|
+
austial/security/config.py,sha256=sWS3hyDu-b2lAnC-SCFCg1NDJLZ-bT78AiuRHrlbQQ0,1520
|
|
3
|
+
austial/security/csrf_interceptor.py,sha256=QMDjtLn1HO67-L1KHXGtT0P-Jz-z9UA5AJ6k6Re_xj4,3065
|
|
4
|
+
austial/security/decorators.py,sha256=H2f-8mQ-vF_2xwxyhFIaU7pieRbxOa7Hc5tuyo7cIqc,1363
|
|
5
|
+
austial/security/envelope_encryptor.py,sha256=oZInRv5z0_VpT2bbUTwcg69RX2gWvczzNx5LWuLd2AA,3879
|
|
6
|
+
austial/security/errors.py,sha256=d4AtPmaZVrD9zKxKli5iyqM3sGf0fSFP8VYRrr7gKJc,1170
|
|
7
|
+
austial/security/idempotency_guard.py,sha256=fzATyf1vqMM3XVS01pSMjvyGGJo6cO7x85ofK3OY_6o,4555
|
|
8
|
+
austial/security/idempotency_store.py,sha256=k4VO76Xxe92gdzoWnDGf2zp-gqUG1AuV22xRpdTaRKc,2155
|
|
9
|
+
austial/security/pii_redactor.py,sha256=H_fpk6XdIEf78cuxVvArDxg4r3UqeEi7FMyXsaK1tY8,4671
|
|
10
|
+
austial/security/rate_limit_guard.py,sha256=Ap11WCLo_SvtBgvnkv73M-8F3KgeKHzV-AAO-Qz9EWQ,3712
|
|
11
|
+
austial/security/rate_limit_store.py,sha256=MlyoW5Mm7NATAhC9e46R1i9pCVXi5eAe-VHcyFge2ZE,2338
|
|
12
|
+
austial/security/secure_headers_middleware.py,sha256=SLcwfif-f2M4sk488o-ziH9jpgeQnbnlPWAx_RNmWT4,2055
|
|
13
|
+
austial/security/security_module.py,sha256=jQozxNyVq1bXgZfGuFw-mQkoWMHzFEC9m8-xyTPetHI,3584
|
|
14
|
+
austial_security-0.2.0.dist-info/METADATA,sha256=SW3uZvjBdffFBAzROCRjCH7biEhRxNmqPLy4MM7_fAo,296
|
|
15
|
+
austial_security-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
16
|
+
austial_security-0.2.0.dist-info/RECORD,,
|