Skip to content

Security

  • Start here: Security guide
  • Common recipes: resolve_client_address(request.scope, trusted) returns the address a trusted proxy vouched for. ClientAddressMiddleware resolves 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: Iterable[str | IPAddress | IPNetwork]

max_hops

Most trusted proxies to walk past. Bounds a forged chain. None means no cap.

TYPE: int | None DEFAULT: None

max_entries

Most header entries to parse before giving up.

TYPE: int DEFAULT: 64

max_header_bytes

Most header bytes to parse before giving up.

TYPE: int DEFAULT: 8192

RAISES DESCRIPTION
ValueError

If a bound is outside its usable range. max_entries and max_header_bytes cap work, so zero or less caps nothing. max_hops counts proxies to walk past, so it may be zero but never negative.

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

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: ASGIApp

trusted

The proxies whose forwarded entries may be believed.

TYPE: TrustedProxies

overwrite_scope_client

Also rewrite scope["client"] with the resolved address.

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 request.client.host and cannot be changed.

TYPE: bool DEFAULT: False

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: Scope

trusted

The proxies whose forwarded entries may be believed.

TYPE: TrustedProxies