IP allow-list
Per-route allow-list of client IPs and CIDR ranges. Requests from a client IP that is not in the list are rejected with 403 Forbidden before they reach auth, rate-limit, or the backend.
Labels
labels: - "sozune.http.<svc>.ipAllowList=<ip-or-cidr>,<ip-or-cidr>,…"
The value is a comma-separated list of:
- IPv4 or IPv6 addresses (auto-promoted to
/32or/128) - IPv4 or IPv6 CIDR ranges
If the label is absent or empty, the filter is disabled and every client is served.
Example
labels: - "sozune.http.api.host=api.example.com" - "sozune.http.api.ipAllowList=10.0.0.0/8,192.168.1.5,2001:db8::/32"
A request from 10.5.7.99 is served. A request from 203.0.113.7 gets a 403 Forbidden with body sozune: client IP not allowed for host 'api.example.com'..
Behaviour
- The middleware runs first in the route's stack — a denied client never reaches request-match, auth, rate-limit, or the backend.
- All
403rejects are logged atWARNlevel with the denied IP and host. - A successful allow is logged at
DEBUG. - Allow-list entries that don't parse are logged and dropped silently: a typo can only narrow access, never widen it.
- If every entry of an allow-list is invalid, the middleware is not installed and the route stays reachable. The per-entry warnings remain in the logs so the operator can fix the configuration.
Client-IP resolution
This is the hard part of any IP allow-list — and the one most implementations get wrong. X-Forwarded-For is set by whoever speaks last to Sōzune. If that's an attacker on the public internet, the header is attacker-controlled. A naïve "trust the leftmost XFF entry" resolver can be bypassed by anyone with curl:
curl -H "X-Forwarded-For: 10.0.0.1" https://api.example.com/ # pretends to be 10.0.0.1
Sōzune solves this by requiring you to declare which reverse-proxies are trusted to set X-Forwarded-For. The list lives in config.yaml under proxy.trusted_proxies:
proxy: http: listen_address: 80 https: listen_address: 443 trusted_proxies: - 10.0.0.0/8 # your internal load balancer / k8s ingress - 172.16.0.0/12
With this setup, the client IP is resolved like this:
trusted_proxiesis empty (the default):X-Forwarded-Foris ignored entirely. The direct TCP peer is the client. Safe to deploy Sōzune publicly without any further configuration.trusted_proxiesis set but the TCP peer isn't in it:X-Forwarded-Foris still ignored — an untrusted peer can't speak about who is behind it.trusted_proxiesis set and the TCP peer is one of them: walkX-Forwarded-Forright to left, skipping every entry that is itself a trusted proxy. The first non-trusted entry is the client. If every XFF entry is trusted (a long internal chain), fall back to the TCP peer.
This is the standard rightmost trusted algorithm — the same model Cloudflare, NGINX with set_real_ip_from, and HAProxy use. It is the only safe way to honour X-Forwarded-For without letting an attacker forge their identity.
IPv6 / dual-stack note
An IPv4-mapped IPv6 address (::ffff:1.2.3.4) is normalised to its IPv4 form before matching. An allow-list entry of 1.2.3.4 therefore matches a client that arrived on a dual-stack IPv6 socket holding that mapping. A pure IPv6 address like ::1.2.3.4 does not match an IPv4 rule — the two address families remain distinct.
Resolution failure
If neither the TCP peer nor any usable X-Forwarded-For entry yields an IP, the request is denied. The middleware fails closed: gating on identity requires identity.
Combining with other middleware
The allow-list runs before request-match, forward-auth, and rate-limit. A denied client therefore never consumes a rate-limit token, never triggers a forward-auth call, and never reaches the backend. This is the right order for an IP filter: a blocked client must cost as little as possible.
REST / YAML surface
The same field is also available on the entrypoint payload directly, for the file provider, the HTTP provider, and the REST API:
{ "name": "api", "protocol": "Http", "config": { "hostnames": ["api.example.com"], "ip_allow_list": ["10.0.0.0/8", "192.168.1.5", "2001:db8::/32"], // … } }
An empty array (or omitting the field entirely) disables the filter.