Middleware¶
A reusable policy object attached to a proxy host or a location by name. One
object carries exactly one type and its matching spec.
Types¶
type |
Spec | Purpose |
|---|---|---|
auth |
AuthMiddleware | Require authentication. |
headers |
HeadersMiddleware | Add/remove request/response headers. |
guard |
GuardMiddleware | Conditionally deny requests. |
rate-limit |
RateLimitMiddleware | Per-host rate limiting. |
rewrite |
RewriteMiddleware | Request-path replacement (upstream-facing): exact, prefix or regex rules. |
bouncer |
BouncerMiddleware | Deny hook: ask an external bouncer (CrowdSec LAPI or any HTTP endpoint) whether the client IP is banned. |
AuthMiddleware (auth)¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
identityProvider |
string | none | yes, except in client-cert and basic mode |
Name of an IdentityProvider. Naming one in client-cert or basic mode is an error. A dangling name is a load-time error, and the proxy listeners fail closed with 503 if one ever reaches it. |
mode |
string | the provider's type |
no | oidc | forward-auth | auth-request | client-cert | basic. See the mode table below. |
requiredRoles |
[]string | none | no | Roles from the provider's roleMapping that may pass. Refused in auth-request mode (the IdP application binding does authorization) and in basic mode (a username carries no roles). |
allowFrom |
[]string | none | no | CIDRs exempt from this gate, compared against the derived client IP. Valid in auth-request, client-cert and basic mode only; refused in oidc and forward-auth mode, where the gate has no bypass to honour. With mode unset the effective mode is the provider's type, so an exemption against an oidc or forward-auth provider is refused too: set mode explicitly rather than relying on the default. See Which IP allowFrom compares. |
clientCertRoles |
map[string]string | none | no | client-cert mode only. Maps a certificate subject (RFC 2253, or its bare common name) to a role that requiredRoles is checked against. requiredRoles without a mapping is refused at validation. |
basic |
BasicAuthSpec | none | yes in basic mode |
Local credentials for basic mode. Table below. Setting it in any other mode is an error. |
Auth modes¶
mode |
Identity source | Notes |
|---|---|---|
oidc |
The IdP, via a browser redirect | gpm is the relying party for the host; a signed SSO cookie admits later requests. |
forward-auth |
A trusted upstream proxy's identity headers | Only headers asserted by the provider's trustedProxies are believed. |
auth-request |
An external auth_request endpoint |
The auth server does authorization; requiredRoles is refused. |
client-cert |
The TLS handshake | Names no identityProvider; pair with tls.clientAuth.mode: optional. |
basic |
The middleware's own basic.users |
Names no identityProvider; HTTP basic auth against local bcrypt hashes. |
BasicAuthSpec (auth.basic, basic mode only)¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
users |
[]BasicAuthUser | none | yes | Accepted credentials. At least one, at most 64. |
users[].username |
string | none | yes | Compared in full; no : or line break. |
users[].passwordHash |
string | none | yes | bcrypt hash ($2a$/$2b$/$2y$, 60 chars). Anything else is refused at write time. |
realm |
string | the host name | no | Realm in the WWW-Authenticate challenge. Printable ASCII, no " or \, at most 128 chars. |
name: internal-basic
type: auth
auth:
mode: basic
allowFrom: [10.0.0.0/8] # the LAN skips the password
basic:
realm: Internal
users:
- username: admin
passwordHash: $2a$12$D4G5f18o7aMMfwasBL7GpuQWuP3pkrZrOAnqP.bmezbMng.QwJ/Bu
Generate a hash with htpasswd -nbB admin 'hunter2' (the part after the colon),
or POST a plaintext password field instead of passwordHash and gpm hashes it
server-side, on a type: auth middleware and on an inline auth block on a
proxy host or one of its locations alike. A password is never stored and never
returned: only the hash reaches the git-backed config and the API response.
The gate behaves exactly like the deprecated access-list basic auth it replaces:
same 401 and WWW-Authenticate challenge, same per-client-IP lockout after 5
failures for 15 minutes (answered identically to a wrong password, so the response
is no oracle), same bounded bcrypt work. What it adds is the treatment every other
auth mode has: it sits at the auth position of the chain (so a rate limit, an
access list and a bouncer all still run outside it), the host's custom error pages
render the refusal, denials are counted, and allowFrom exempts trusted networks.
In client-cert mode the identity comes from the TLS handshake, so no
identityProvider is named (setting one is an error). The gate admits a request
only when the handshake verified a client certificate for this host, i.e.
the host runs tls.clientAuth and the trust anchor (and its CRL, if configured)
accepted the certificate; otherwise it replies 401. clientCertRoles maps a
certificate subject to a role: the key is the RFC 2253 subject (CN=ops,O=Corp)
or its bare common name, the value is the role requiredRoles is checked
against. With no mapping, any verified certificate passes; with a mapping, an
unmapped subject or an insufficient role gets 403. requiredRoles without a
mapping is refused at validation (it could never match).
Pair it with tls.clientAuth.mode: optional so certless clients still reach the
chain and this middleware is what refuses them, leaving an SSO middleware free to
cover other hosts or locations.
allowFrom works here exactly as it does in auth-request mode: a client whose
resolved IP falls in one of the listed CIDRs is proxied straight through: no
certificate requirement and no clientCertRoles check at all. That is the "the LAN
does not need a client certificate, the internet does" shape: run
tls.clientAuth.mode: optional, list the LAN in allowFrom, and every other client
gets 401 without a verified certificate. An exempt, certless request necessarily
reaches the upstream with no client-certificate identity headers: those are set
only from a handshake-verified certificate, and it has none.
name: cert-or-lan
type: auth
auth:
mode: client-cert
allowFrom: [10.0.0.0/8] # LAN skips the certificate requirement
In oidc mode gpm is itself the OIDC relying party for the host: an
unauthenticated request is redirected to the IdP, the reserved callback path
/__gpm/oidc/callback exchanges the code (PKCE + nonce) and sets a signed,
stateless SSO session cookie, and requiredRoles (via the IdP's roleMapping)
is enforced; with no role mapping or required roles the gate just requires a
valid login. Register https://<host>/__gpm/oidc/callback as a redirect URI on
the IdP. The signing key is auto-generated and persisted at
<cert-dir>/sso_signing.key (0600) on first use, so sessions survive restarts
without any operator action. Set GPM_SSO_SIGNING_KEY explicitly to supply your
own key (useful when rotating or sharing a key across instances).
HeadersMiddleware (headers)¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
setRequest |
map[string]string | none | no | Request headers set on the way to the upstream, overwriting whatever the client sent. |
setResponse |
map[string]string | none | no | Response headers set on the way back, overwriting the upstream's value. Not set-if-absent, unlike securityHeaders. |
removeRequest |
[]string | none | no | Request headers deleted before the upstream sees them. |
removeResponse |
[]string | none | no | Response headers deleted, only where this middleware is attached and from inside the chain, so a response an auth gate wrote ahead of it is untouched. For edge-wide removal of leaked backend headers prefer stripResponseHeaders, which runs in the reverse proxy on the upstream's own response for every response that upstream returns. |
At least one of the four must be set.
GuardMiddleware (guard)¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
triggers |
[]GuardTrigger | none | yes | At least one. A trigger fires when every field it sets matches; any firing trigger denies. |
allowFrom |
[]string | none | no | CIDRs exempt from this guard's deny, compared against the derived client IP. See Which IP allowFrom compares. |
denyStatus |
int | 403 |
no | Status returned when a trigger fires and the client is not exempt. |
GuardTrigger (guard.triggers[])¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
paths |
[]string | none | no | Exact request paths this trigger covers. Matching is exact, see the four path mechanisms. |
methods |
[]string | none | no | Upper-case HTTP methods this trigger covers. |
queryEquals |
map[string]string | none | no | Query parameters that must equal these values for the trigger to fire. See the ; note below. |
A trigger with no field set matches every request, which is a deny-all guard;
that is legal and occasionally what you want behind an allowFrom.
queryEqualsand;. A guard carrying anyqueryEqualstrigger rejects a request whose raw query string contains a;, with 400 (before the allow/deny decision, soallowFromdoes not exempt it). gpm parses the query the modern way, where only&separates parameters, so?a=1;direct=1is one parameterawith the value1;direct=1and adirect: "1"trigger would not fire, but the raw query is forwarded to the upstream unchanged, and a backend still honouring the legacy;separator would readdirect=1and act on it. Rather than evaluate a query it cannot read the same way the upstream will, the guard fails closed. This mirrors the same rule for;in request paths. Guards with noqueryEqualstrigger are unaffected, as is every other middleware.
RewriteMiddleware (rewrite)¶
Replaces the request path on the way to the upstream. It carries three kinds of rule; at least one rule of any kind is required.
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
replacePath |
map[string]string | none | one of | Exact match: the incoming path must equal the key. Key and value must be absolute paths that satisfy the to rules below; a key may not map to itself. |
prefixRules |
[]RewriteRule | none | one of | Prefix match on a segment boundary (the path equals from, or continues with /), so /reports never captures /reports-evil. The longest matching from wins. Max 32 rules. |
regexRules |
[]RewriteRule | none | one of | Regex match, implicitly anchored with ^. to may reference capture groups as $1 / ${name}. Max 32 rules, max 256 characters per pattern. |
RewriteRule (prefixRules[] / regexRules[])¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
from |
string | none | yes | The prefix (for prefixRules) or the pattern (for regexRules) to match. |
to |
string | none | yes | The replacement path, absolute and confined by the rules below. |
Every to is confined, on the same rules as upstream.path. It must be an
absolute path with no . or .. segment, no backslash, no ;, and no query
string or fragment. The rule holds for a regex replacement template too: $1
expands at request time, so the composed path is re-cleaned and re-confined to
the upstream base path before it is forwarded, and a rewritten path that still
carries a path separator an upstream could re-interpret is answered 400.
The confinement is not cosmetic: a rewrite runs inside the security tiers, and
middlewares:write is a narrower scope than proxy-hosts:write, so a dot
segment here would let a middleware-scoped token escape the base path an
operator pinned a backend into.
Evaluation order is fixed: exact, then prefix (longest first), then regex (in config order). The first match wins and no rule ever sees another rule's output, so rules cannot chain into a path nobody wrote.
- Prefix rules replace the matched prefix and append the remainder: with
from: /old,to: /new,/old/thingbecomes/new/thingand/oldbecomes/new. - Regex rules replace the span matched at the start of the path and append
the remainder: with
from: /user/([0-9]+),to: /u/$1,/user/42/profilebecomes/u/42/profile. Patterns are compiled once at config load, so a pattern that does not compile is a validation error naming the rule index, never a request-time failure. Go'sregexpis RE2 (linear time, no backtracking), so a pattern cannot be made to blow up on a crafted path. - Never a redirect. The rewrite is internal: it mutates the proxied path in
place, preserving the method and body (a
POSTis forwarded unchanged) and the client sees no 3xx. The query string is forwarded untouched. - Security position. Rewrites run innermost (closest to the upstream), so rate limiting, access lists, the bouncer, auth and guards all evaluate the ORIGINAL client path; a rewrite can never move a request past a path-scoped security control.
name: legacy-paths
type: rewrite
rewrite:
replacePath:
/application/o/token: /application/o/token/
prefixRules:
- from: /old-app
to: /app
regexRules:
- from: /user/([0-9]+)
to: /u/$1
RateLimitMiddleware (rateLimit)¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
requests |
float | none | one of | "N requests per window". Use this for limits that do not reduce cleanly to a per-second rate, e.g. 100 per 1m or 5 per 1h. Set with window. |
window |
string | none | with requests |
Go duration string ("1s", "10s", "1m", "1h", ...) the requests count applies over. |
requestsPerSecond |
float | none | one of | Legacy shorthand, > 0, equivalent to requests: <value> with window: "1s". Kept for backward compatibility; new configs should prefer requests/window. Mutually exclusive with them. |
burst |
int | ceil(requests) |
no | Token-bucket capacity: how far above the steady rate a short spike may go. |
allowFrom |
[]string | none | no | CIDRs exempt from rate limiting entirely (no token consumed, no 429) compared against the derived client IP. See Which IP allowFrom compares. |
blockFor |
string | none | no | Go duration. Adds a fixed lockout on top of the bucket, see below. Omitted means the token bucket alone governs. |
Exactly one of requests+window or requestsPerSecond must be set.
Enforced as a per-host, per-client-IP token bucket (capacity = burst, refill
= requests/window in tokens/sec). Over-limit requests get 429 Too Many
Requests with a Retry-After header computed from the refill rate (a slow
limit like 5 per 1h can report a Retry-After of several minutes); the
request is not proxied. The bucket is keyed on the derived client IP (see
Client IP and the three trust tiers);
a request whose client IP cannot be resolved falls back to a
single shared bucket (fail-safe, never unlimited, and never matches
allowFrom). The middleware sits outermost in the chain (evaluated first)
so a flood is shed before it can drive an auth subrequest or any other
per-request work: rate-limit -> access-list -> bouncer -> auth -> guard -> headers ->
rewrite -> upstream.
blockFor (a Go duration string, e.g. "30s", "5m") adds an extra,
harsher penalty on top of the token bucket: the first request that exceeds
the limit blocks that client for blockFor, and every request from it is
rejected (429, Retry-After counting down to the end of the block) for the
whole period, independent of token refill, so a client that merely pauses
and resumes cannot slip back through once tokens would otherwise have
refilled. The block is fixed, not sliding: repeat requests during the
block do not push it back out, so it always expires exactly blockFor after
the trip that started it. Once it expires, ordinary token-bucket rules
resume (the bucket has been refilling in the background the whole time, up
to burst, so the client gets a normal allotment, not an instant re-burst).
Omit blockFor (the default) for today's behavior: only the token bucket
governs, and a client that outwaits the refill rate is let back through
immediately.
BouncerMiddleware (bouncer)¶
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
provider |
string | crowdsec |
no | crowdsec | http. Selects the verdict protocol. |
url |
string | none | yes | Base URL of the bouncer. For crowdsec, the LAPI root; for http, the endpoint that answers the query below. |
apiKey |
Secret | none | no | Sent as X-Api-Key. Must be a ${ENV:...} / ${FILE:...} placeholder; a literal is refused at commit. |
timeout |
string | 2s |
no | Go duration for one verdict lookup. |
cacheTTL |
string | 60s |
no | How long a verdict is cached per client IP. In stream mode this is also the delta-pull interval. A verdict derived from an error is capped at 5s regardless. |
cacheMaxEntries |
int | 10000 |
no | LRU bound on the verdict cache, so a rotating-source-IP flood cannot grow it without bound. |
onError |
string | fail-open |
no | fail-open | fail-closed. What an unanswerable lookup means, see below. |
denyStatus |
int | 403 |
no | Status returned on a deny verdict. |
denyWith |
string | error-page |
no | error-page renders the host's configured page for denyStatus; plain sends a bare status body, giving a scanner nothing to fingerprint. |
stream |
bool | false |
no | crowdsec only. Pull the whole decision set once and delta it every cacheTTL, so the request hot path never calls the LAPI. |
allowFrom |
[]string | none | no | CIDRs exempt from the external verdict, compared against the derived client IP. See Which IP allowFrom compares. |
This is a hook, not a WAF. gpm ships no rules, no signatures and no detection engine: it asks an operator-run bouncer whether the client IP is currently banned and acts on that verdict. What "banned" means lives entirely outside gpm.
It sits after the access list and before auth: an operator allow-list still wins outright (it is evaluated first, so an explicitly allowed IP is never overridden by an external feed), and a banned IP never reaches the IdP: no forward-auth subrequest, no OIDC redirect. A denial is reported to the per-host denial counter.
crowdsec provider. Per uncached client IP, gpm calls the LAPI bouncer
endpoint GET {url}/v1/decisions?ip=<client> with X-Api-Key: <apiKey>. A
null or empty body means "no decisions" (allow); any decision of type ban
or captcha denies. captcha is treated as a deny: it is the LAPI telling
the bouncer this client must prove it is human, and gpm has no captcha flow to
hand it: serving the request anyway would silently downgrade the operator's
decision to an allow. Decision types gpm does not implement are ignored rather
than guessed at. The LAPI resolves range (CIDR) decisions itself, so one ip=
query covers those too.
stream: true (crowdsec only) swaps the per-IP lookup for a local one: gpm
pulls the whole decision set once
(GET {url}/v1/decisions/stream?startup=true) and then deltas every cacheTTL,
keeping the banned IPs and CIDR ranges in memory, so the request hot path never
calls the LAPI at all. Only the very first request waits on the startup pull;
refreshes happen in the background while the current set keeps serving, and a
failed refresh logs and keeps the previous set rather than dropping it. Use it
on a busy edge or with a large decision set; leave it off for the simpler
live-lookup mode.
http provider is a generic deny hook so any custom bouncer can plug in:
GET {url}?ip=<client>&host=<host>&path=<path>
X-Forwarded-For: <client> # the RESOLVED client IP, not the inbound header
X-Original-URL: <absolute request URL>
X-Api-Key: <apiKey> # only when apiKey is set
2xx = allow, 403 = deny, anything else = no usable answer, so onError
governs. The contract is deliberately trivial: a shell script, a fail2ban shim
or a corporate threat feed can implement it in a few lines.
onError covers a timeout, a connection failure, an unexpected status, an
undecodable body, an unresolvable apiKey secret, and a client IP that cannot
be resolved at all. It defaults to fail-open (allow): an unreachable
threat feed must not take the site down, which is the opposite of the right
default for auth. Choose fail-closed when the bouncer is a hard requirement
and you would rather serve 403 than serve an unvetted client.
Verdicts are cached per middleware, keyed by client IP, for cacheTTL, bounded
at cacheMaxEntries with LRU eviction (so a rotating-source-IP flood cannot
grow it without bound). A verdict derived from an error rather than a real
answer is capped at 5s regardless of cacheTTL, so an outage cannot pin a
minute of guessed verdicts and keep guessing long after the bouncer recovered.
denyWith: error-page (the default) renders the host's configured custom error
page for denyStatus, falling back to the plain status body when none is
configured; plain opts out of the custom page deliberately:
a bare status body gives a scanner nothing to fingerprint.
CrowdSec quickstart. On the host running the CrowdSec LAPI:
cscli bouncers add gpm
Copy the printed key into the environment gpm runs with (e.g.
CROWDSEC_BOUNCER_KEY) and reference it as a placeholder, never commit the
literal:
name: crowdsec
type: bouncer
bouncer:
provider: crowdsec
url: http://crowdsec:8080
apiKey: ${ENV:CROWDSEC_BOUNCER_KEY}
stream: true # local lookups; deltas pulled every cacheTTL
cacheTTL: 60s
onError: fail-open # an unreachable LAPI must not take the site down
Verify with cscli decisions add --ip <your-test-ip> --duration 1m and confirm
the host answers 403, then cscli decisions delete --ip <your-test-ip>.
# Require SSO, but let the LAN through without it
name: require-sso
type: auth
auth:
identityProvider: authentik-outpost
mode: auth-request
allowFrom: [10.0.0.0/8]
# Block POSTs to a login path except from the LAN (break-glass guard)
name: login-lan-only
type: guard
guard:
triggers:
- {paths: [/login], methods: [POST]}
allowFrom: [10.0.0.0/8]
# Rate-limit the host to 100 requests/minute, but let the LAN through uncapped
name: api-rate-limit
type: rate-limit
rateLimit:
requests: 100
window: 1m
burst: 150
allowFrom: [10.0.0.0/8]
# Legacy shorthand (requests per second); equivalent to requests: 10, window: 1s
name: api-rate-limit-legacy
type: rate-limit
rateLimit:
requestsPerSecond: 10
burst: 20
allowFrom: [10.0.0.0/8]
# Trip the limit and the client is locked out for 5 minutes, regardless of
# token refill - not just throttled back to the steady-state rate.
name: api-rate-limit-blocked
type: rate-limit
rateLimit:
requests: 100
window: 1m
blockFor: 5m
# Generic deny hook: any endpoint answering 2xx/403. Fail closed - this bouncer
# is a hard requirement, so an outage denies rather than admits.
name: threat-feed
type: bouncer
bouncer:
provider: http
url: https://bouncer.internal/check
apiKey: ${FILE:/run/secrets/bouncer_key}
timeout: 1s
cacheTTL: 30s
onError: fail-closed
denyStatus: 403
# Add the trailing slash a client strips off Authentik's token endpoint, so the
# request reaches Django as /application/o/token/ (POST + body preserved) instead
# of getting a 405. Exact-match, upstream-facing only.
name: authentik-token-slash
type: rewrite
rewrite:
replacePath:
/application/o/token: /application/o/token/