Route each request to a different upstream, decided at the gateway. Gravitee-style dynamic endpoint routing for Kong Gateway OSS 3.x — first-match-wins header rules targeting Kong Upstreams or URL templates (built from request and consumer variables), with SSRF-safe allowlisting. No database, no custom entities: DB-less and Ingress Controller friendly.
rules:
- condition:
header: { name: "X-Tenant", value: "bankxyz" }
target:
upstream: bankxyz-cluster # Kong Upstream entity
- condition:
header: { name: "X-Region" } # presence check
target:
url: "https://$(header.X-Region).svc.cluster.local:443$(uri)"
preserve_host: falseRules are evaluated in order (first match wins); a rule can point at a
Kong Upstream entity (load balancing + health checks) or at a templated
URL built from request variables — the same pattern as Gravitee's
gravitee.attribute.request.endpoint override, brought to Kong OSS.
- Kong OSS has no per-request upstream override.
route-by-headeris Enterprise-only, and request-transformer cannot change the target. - Gravitee's dynamic endpoint policy is the closest prior art, but it has no Kong OSS equivalent — and no host allowlisting either.
route-by-header (Kong Enterprise) |
Gravitee dynamic endpoint | dynamic-upstream | |
|---|---|---|---|
| Available on | Kong Enterprise | Gravitee gateway | Kong OSS 3.x, Apache-2.0 |
| Target: Kong Upstream entity (LB, health checks) | ✅ | — | ✅ |
| Target: templated URL | ❌ | ✅ | ✅ |
| Host allowlist for templated targets | n/a | ❌ | ✅ two-layer, plus port pinning |
flowchart LR
A[request] --> B{rules<br/>first match wins}
B -- no match --> C[passthrough<br/>or 503]
B -- upstream --> D[Kong Upstream entity<br/>LB + health checks]
B -- url --> E[substitute variables<br/>fail closed: 503]
E --> F{host from<br/>client input?}
F -- "yes, not on allowlist" --> G[403]
F -- "on allowlist / literal" --> H[set target,<br/>Host + SNI]
Every behavior claimed here is asserted by tests: 32 unit tests (plain Lua 5.1) and a 20-case e2e suite against real Kong 3.9 (routing, allowlist 403, port pinning, fail-closed 503, preserve_host, query handling, TLS/SNI).
Requires Docker and jq:
git clone https://github.com/davidgrldo/kong-dynamic-upstream
cd kong-dynamic-upstream
KEEP=1 e2e/run.sh # runs the e2e suite, leaves the stack up
# then play with it:
curl -s localhost:18000/api/hello | jq .os.hostname
# "echo-default" <- no rule matched: route's own service
curl -s -H 'X-Tenant: bankxyz' localhost:18000/api/hello | jq .os.hostname
# "echo-a" <- routed via the Kong Upstream entity
curl -s -H 'X-Region: echo-b.internal' localhost:18000/api/hello | jq .os.hostname
# "echo-b" <- host built from the client header
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'X-Region: evil.com' localhost:18000/api/hello
# 403 <- SSRF guard: host not on the allowlist
docker compose -f e2e/docker-compose.yml down -v # tear downluarocks install kong-dynamic-upstream
# or from a checkout:
# cd plugins/dynamic-upstream && luarocks make kong-dynamic-upstream-0.2.0-1.rockspec
# then enable it:
export KONG_PLUGINS=bundled,dynamic-upstreamplugins:
- name: dynamic-upstream
config:
on_no_match: passthrough # or reject_503
allowed_hosts: # required (with a literal :port) if any url host is templated
- "*.internal"
- "*.svc.cluster.local"
rules:
- condition:
header: { name: "X-Tenant", value: "bankxyz" }
target:
upstream: bankxyz-cluster # Kong Upstream entity
- condition:
header: { name: "X-Env", regex: "^sandbox" }
target:
url: "https://sandbox-api.internal:8443$(uri)"
preserve_host: false
- condition:
header: { name: "X-Region" } # presence check
target:
url: "https://$(header.X-Region).svc.cluster.local:443$(uri)"
preserve_host: false| Field | Type | Default | Description |
|---|---|---|---|
rules |
array | required | Ordered rules; first match wins. |
rules[].condition.header.name |
string | required | Header to inspect. |
rules[].condition.header.value |
string | (none) | Exact match. Mutually exclusive with regex. |
rules[].condition.header.regex |
string | (none) | PCRE match (ngx.re, JIT + cache). |
rules[].target.upstream |
string | (none) | Kong Upstream name. Exactly one of upstream/url. |
rules[].target.url |
string | (none) | Literal or templated http(s) URL. |
rules[].target.preserve_host |
boolean | true |
false rewrites the Host header to the target host. url targets only; must be set explicitly for https urls (SNI follows the chosen Host). |
allowed_hosts |
array | [] |
Host allowlist: exact names or *.suffix. Required (together with a literal :port in the template) when a url host is templated. |
on_no_match |
string | passthrough |
passthrough to the route's service, or reject_503. |
On DB-backed deployments the schema renders natively in Kong Manager — this is the create form on a real Kong 3.9.3 hybrid cluster:
| Status | Body | When |
|---|---|---|
403 |
Upstream host not allowed |
Resolved templated host is not on allowed_hosts. |
503 |
Upstream resolution failed |
Unresolved/empty template variable, the resolved URL fails re-validation (e.g. an injected :port), or set_upstream fails. |
503 |
No upstream rule matched |
No rule matched and on_no_match: reject_503. |
$(uri), $(header.NAME), $(query.NAME), $(consumer.username),
$(consumer.custom_id). Substitution fails closed: an unresolved or empty
variable yields 503, never an empty string spliced into the URL.
This is deliberately not a full expression language — five variables cover the routing use cases without the attack surface of an evaluator.
- A path in the target URL replaces the upstream path; use
$(uri)to forward the client path. - A query string in the target URL replaces the client's query string; without one, the client's query passes through untouched.
- Fragments (
#) are rejected. preserve_host: true(default) keeps the client'sHostheader;falserewrites it to the target host, with:portappended when the port is not the scheme default. It applies tourltargets only — the schema rejects it onupstreamtargets.
Kong derives the upstream SNI from the upstream Host header
(proxy_ssl_name $upstream_host), so for https targets SNI follows
whichever host preserve_host selects: the target host when false, the
client host when true — verified in the e2e suite. For https targets
you almost always want preserve_host: false: with true, the upstream
handshake carries the client's SNI, which the target's certificate
usually doesn't cover. That's why the schema refuses an https url
without an explicit preserve_host — the foot-gun can't be configured by
accident. Upstream certificate verification is not plugin config; it
follows the Service entity (tls_verify, ca_certificates), and the
verified name is the same $upstream_host, so verification composes with
dynamic targets.
A templated host means a client header can decide where Kong connects. Without
a boundary, X-Region: 169.254.169.254 walks straight into your metadata
service. Two layers prevent that:
- Config time — the schema rejects any
target.urlwhose host portion contains a variable unlessallowed_hostsis non-empty and the template pins a literal:port— so a header value can never choose the port on an allowlisted host (no internal port-scanning through the gateway). ($(uri)and variables after the first/are path-only and don't trigger this.) - Request time — after substitution, the resolved host must match the
allowlist or the request gets
403. Resolved URLs are also re-parsed and validated (scheme, no userinfo, no fragments, hostname charset), so header values cannot smuggle ports, paths, or a second host — an injectedhost:9090fails the hostname charset check with503.
Literal hosts written in config are operator-controlled and trusted as-is.
PRIORITY = 750: runs after authentication plugins (so $(consumer.*) is
populated) and after request-transformer (801), so header rewrites settle
before rules are evaluated.
Verified against Kong OSS 3.9 — that's what the e2e suite runs in CI. It uses only stable PDK calls and plain Lua (no custom entities, no database migrations), so other 3.x releases are expected to work, but only 3.9 is tested. Pre-1.0: the config schema may tighten between minor versions, as it did in 0.2.0 — see the release notes.
- Condition types beyond header:
query,consumer(username/group) - Weighted targets per rule (canary-lite)
-
KongPluginCRD examples for Kong Ingress Controller
# unit tests (plain Lua 5.1+, kong/ngx are mocked)
LUA_PATH="./?.lua;./plugins/dynamic-upstream/?.lua;;" lua spec/run.lua
# e2e (docker + jq): Kong 3.9 DB-less + echo upstreams
e2e/run.sh # KEEP=1 e2e/run.sh leaves the stack runningconfig/kong.yml is a runnable DB-less example exercising both target modes.
CI runs luacheck, the unit suite and the e2e suite on every push.
