Security
- Start here: Security guide
- Common recipes:
resolve_client_address(request.scope, trusted)returns the address a trusted proxy vouched for.ClientAddressMiddlewareresolves it once per request so every consumer reads one value.
grelmicro.security
Security.
Checks a service runs on an inbound request. grelmicro validates what arrives, it never issues credentials.
TrustedProxies names your own proxies and resolve_client_address
returns the address one of them vouched for, so a spoofed
X-Forwarded-For never becomes a rate limiter key or an audit record.
ClientAddressMiddleware resolves it once per request.
Read more in the Security docs.
TrustedProxies
TrustedProxies(
networks: Iterable[str | IPAddress | IPNetwork],
/,
*,
max_hops: int | None = None,
max_entries: int = 64,
max_header_bytes: int = 8192,
)
The proxies whose X-Forwarded-For entries may be believed.
Build one at startup and reuse it. Every entry is parsed strictly, so a typo fails here rather than becoming a rule that silently matches nothing.
There is no wildcard. To trust every peer, pass ["0.0.0.0/0", "::/0"],
which is visible in a configuration review in a way a * is not.
Compile the trusted set, rejecting anything unparsable.
| PARAMETER | DESCRIPTION |
|---|---|
networks
|
Addresses and CIDR ranges of your own proxies. Required, with no default. An empty iterable is legal and means trust nothing, so resolution always returns the transport peer.
TYPE:
|
max_hops
|
Most trusted proxies to walk past. Bounds a forged chain. None means no cap.
TYPE:
|
max_entries
|
Most header entries to parse before giving up.
TYPE:
|
max_header_bytes
|
Most header bytes to parse before giving up.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
ValueError
|
If a bound is outside its usable range. |
ClientAddress
dataclass
ClientAddress(
ip: IPAddress,
port: int | None,
reason: ClientAddressReason,
hops: int,
)
A resolved client address, with how it was reached.
ip
instance-attribute
ip: IPAddress
The address. Always parsed, never raw header text.
port
instance-attribute
port: int | None
The source port, when one was given.
reason
instance-attribute
reason: ClientAddressReason
Why this address was chosen. RESOLVED is the trusted case.
hops
instance-attribute
hops: int
Trusted proxies skipped before landing on this address.
forwarded
property
forwarded: bool
Whether a trusted proxy vouched for this address.
The test to use behind a proxy for anything that treats the
address as the caller's identity, such as an allowlist or a
private network check. It is True for RESOLVED alone.
degraded
property
degraded: bool
Whether ip is one of your own proxies rather than a caller.
True when the peer was a trusted proxy but its chain could not be
walked to a client, so ip is that proxy's own address and is
never the caller's. Any check that treats it as the caller must
refuse.
False is not the same as vouched for. It also covers the two
cases where the peer is the caller only if nothing unlisted sits
in front of the app: UNTRUSTED_PEER and NO_FORWARDED_HEADER.
Use forwarded when the address has to carry an identity behind
a proxy.
key
property
key: str
Canonical form, safe to use as a rate limiter key.
An IPv4-mapped address folds onto its IPv4 form, so one caller never occupies two buckets.
It is a bucket, not an identity. When degraded is True every
caller behind the proxy shares one key, so anything that has to
keep callers apart, such as an idempotency key or an allowlist,
checks forwarded first.
ClientAddressReason
Bases: StrEnum
Why ClientAddress.ip holds the address it holds.
RESOLVED is the one value the library can vouch for on its own: a
trusted proxy wrote the entry. Every other value means ip is the
verified transport peer, and they split in two.
With NO_FORWARDED_HEADER and UNTRUSTED_PEER the peer is the
caller, as long as nothing sits in front of the app that is missing
from the trusted set. That is a claim about your topology, not one
this module can check.
Every remaining value means the peer is itself a trusted proxy whose
chain could not be walked to a client, so ip is one of your own
proxies and never the caller. ClientAddress.degraded marks exactly
those.
RESOLVED
class-attribute
instance-attribute
RESOLVED = 'resolved'
A trusted proxy vouched for this address.
NO_FORWARDED_HEADER
class-attribute
instance-attribute
NO_FORWARDED_HEADER = 'no-forwarded-header'
A trusted peer sent no X-Forwarded-For.
UNTRUSTED_PEER
class-attribute
instance-attribute
UNTRUSTED_PEER = 'untrusted-peer'
The peer is not a trusted proxy, so the header was ignored.
CHAIN_EXHAUSTED
class-attribute
instance-attribute
CHAIN_EXHAUSTED = 'chain-exhausted'
Every entry in the chain was a trusted proxy.
HOP_LIMIT
class-attribute
instance-attribute
HOP_LIMIT = 'hop-limit'
The walk passed more trusted proxies than max_hops.
MALFORMED_ENTRY
class-attribute
instance-attribute
MALFORMED_ENTRY = 'malformed-entry'
An entry was not an address, which stops the walk.
HEADER_TOO_LARGE
class-attribute
instance-attribute
HEADER_TOO_LARGE = 'header-too-large'
The header was longer than max_header_bytes.
TOO_MANY_ENTRIES
class-attribute
instance-attribute
TOO_MANY_ENTRIES = 'too-many-entries'
More entries than max_entries, and every one read was trusted.
ClientAddressMiddleware
ClientAddressMiddleware(
app: ASGIApp,
*,
trusted: TrustedProxies,
overwrite_scope_client: bool = False,
)
Resolve the client address once and cache it on the request.
Stores a ClientAddress under scope["state"]["client_address"], so
every handler and every other middleware reads one value instead of
each resolving its own and drifting apart.
from grelmicro.security import ClientAddressMiddleware, TrustedProxies
app.add_middleware(
ClientAddressMiddleware, trusted=TrustedProxies(["10.0.0.0/8"])
)
Pure ASGI, so it works on any ASGI server. It acts on http and
websocket scopes and passes every other scope through untouched.
Initialize the middleware with the trusted set.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The next ASGI application in the chain.
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed.
TYPE:
|
overwrite_scope_client
|
Also rewrite Off by default, because it discards the verified transport
peer that an audit record wants, and because a server that
already rewrites it would then be running the walk twice.
Turn it on for code that reads
TYPE:
|
app
instance-attribute
app = app
trusted
instance-attribute
trusted = trusted
overwrite_scope_client
instance-attribute
overwrite_scope_client = overwrite_scope_client
resolve_client_address
resolve_client_address(
scope: Scope, trusted: TrustedProxies
) -> ClientAddress | None
Resolve the client address a trusted proxy vouched for.
Returns None when the transport peer is absent or unparsable, which a caller must handle rather than being handed a fabricated address.
The header is read only when the peer itself is trusted, and the walk runs right to left, because the rightmost entries are the ones your own proxies wrote. The first entry that is not a trusted proxy is the client. A malformed entry stops the walk rather than being skipped, since skipping lets padding shift which entry is chosen.
When every entry is trusted, the peer is returned with
CHAIN_EXHAUSTED rather than the leftmost entry. No trusted proxy
vouched for that entry against an untrusted peer, so it is exactly
the value that must not become a rate limiter key. The same walk
ending inside a header the max_entries cap truncated returns
TOO_MANY_ENTRIES, since the entries beyond the cap were never read.
An untrusted peer that sends a non-empty X-Forwarded-For is logged
on the grelmicro.security.clientip logger, once per peer and for at most
eight peers. A caller probing the header therefore cannot flood the
log, nor take the report a misconfigured proxy of yours needs.
Nothing is logged when the trusted set is empty, which is the
deployment that means trust nothing.
| PARAMETER | DESCRIPTION |
|---|---|
scope
|
The ASGI scope of the request.
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed.
TYPE:
|