Skip to content
Talk to our solutions team

Exposure

An endpoint maps a path prefix to a backend. Without the keys on this page it forwards everything the backend serves under that prefix, on every host of every route that lists it. Four sets of keys narrow that down:

QuestionKeys
Which paths of the backend does the endpoint publish?allow, deny on an endpoint or a backend
Is it for the internet or for the inside?zone on entrypoints and domainmaps, exposure on endpoints, sensitive on backends
Who may call it?cors, sources, csrf on an endpoint
Who must the backend be, and who does it see?identity on a backend, gatewayrole in the bootstrap

gateway.svc config generate exposure prints a complete example to start from.

Every check runs when the configuration loads, on boot and on every reload. A configuration that breaks a rule is refused as a whole. On boot the gateway does not start; on a reload it keeps serving the previous configuration.

endpoints:
- name: to-vault-jwks
path: { prefix: /vault/ }
backend: vault
exposure: both
allow: # only these reach vault.svc
- { path: /.well-known/jwks.json, methods: [GET] }
middlewares:
- replaceRegexPrefix: { regex: "^/vault/(.*)", replacement: "/${1}" }
- name: to-console
path: { prefix: /munin/ }
backend: munin
allow:
- "/observe/ui/**" # a bare string allows every method
- { path: "/observe/api/*/series", methods: [GET, POST] }
deny: # refused even when allowed
- "/observe/ui/admin/**"

Patterns are relative to the endpoint’s prefix. /.well-known/jwks.json under /vault/ publishes /vault/.well-known/jwks.json. When the endpoint strips its prefix, that is also the path the backend sees, so patterns read like the backend’s own routes.

PatternMatchesDoes not match
/health/health/health/, /healthz
/items/*/items/42/items/, /items/42/tags
/files/*.json/files/a.json/files/a/b.json
/observe/ui/**/observe/ui/, /observe/ui/app.js/observe/ui
/a/**/b/a/b, /a/x/y/b/a/bc
  • * matches within one path segment, ** matches any number of segments. Everything else is literal.
  • Methods: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS.
    • Listing GET allows HEAD too.
    • When the endpoint has CORS, OPTIONS is added, so browser preflight reaches the CORS answer.
    • A pattern without methods allows every method.
  • deny wins over allow. With only deny, the whole prefix is published except the listed holes. Use allow for anything sensitive: a deny list refuses only what it names.
  • On a backend, allow and deny are the default for every endpoint that targets it. An endpoint’s own allow replaces the backend’s. deny lists add up, so an endpoint never drops a backend’s deny.
  • Everything else under the prefix answers 404 page not found, the same answer as a path no route matches. A refused request does not fall through to a shorter prefix on the same host, such as a site served at /.
  • Paths are matched after /./, /../ and // are resolved, with reserved characters still encoded: /vault/.well-known%2Fjwks.json is not /vault/.well-known/jwks.json.

The configuration is refused if a pattern:

  • does not start with /;
  • puts ** inside a segment (/a**b);
  • has a . or .. segment;
  • lists an unknown method;
  • is an empty list, or allow: with no value.
# bootstrap
entrypoints:
- { name: websecure, address: ":443", zone: external }
- { name: websecureinternal, address: "10.0.0.5:8443", zone: internal, trustedips: [10.0.0.0/24] }
tlsoptions:
- kisaitlsinternal:
clientAuth: { clientAuthType: RequireAndVerifyClientCert }
# routing
domainmaps:
- { name: acme, zone: external, domains: [app.acme.com], customer: acme, environment: prod, product: forge, tenant: main }
- { name: acme-internal, zone: internal, domains: [app.acme.internal], customer: acme, environment: prod, product: forge, tenant: main }
backends:
- backend: { name: config, urls: [https://127.0.0.1:7123] }
- backend: { name: forge-api, urls: [https://10.0.0.11:7102] }
endpoints:
- { name: to-config, path: { prefix: /config/ }, backend: config } # internal: config.svc is sensitive
- { name: to-api, path: { prefix: /api/ }, backend: forge-api, exposure: both }
routes:
- { name: acme-public, domainmap: acme, entrypoint: websecure, certresolver: letsencrypt, endpoints: [to-api] }
- { name: acme-internal, domainmap: acme-internal, entrypoint: websecureinternal, tlsoptions: kisaitlsinternal, endpoints: [to-api, to-config] }
KeyOnValuesDefault
zoneentrypoint, domainmapexternal, internalnone
exposureendpointinternal, external, bothboth, or internal when the backend is sensitive
sensitivebackendtrue, falsetrue for backends named vault, horus, config or meta, false otherwise

A route’s zone is its entrypoint’s, else its domainmap’s. With no entrypoints: block, the listeners built from port and internal.port are websecure (external) and websecureinternal (internal). Sensitive backends are matched by name, including a name qualified by a routing file’s namespace (app.vault).

The configuration is refused when:

  • an endpoint’s exposure does not include the zone of a route that lists it. A sensitive backend’s endpoint on an external route is the common case. To publish part of one, write exposure: both and an allow list, as to-vault-jwks does above;
  • an endpoint writes exposure: internal or external on a route whose entrypoint and domainmap declare no zone. A sensitive endpoint without a written exposure on such a route is a warning;
  • an internal domainmap is on an external entrypoint, or the reverse;
  • an internal entrypoint binds every interface (:8443, 0.0.0.0:8443, [::]:8443). Bind the private or loopback address;
  • a route on an internal entrypoint has no tlsoptions, has a profile whose clientAuth.clientAuthType is not RequireAndVerifyClientCert, or uses tlspassthrough.

Routes in the internal zone pass the caller’s verified client certificate on to the backend and are not anonymously tracked.

endpoints:
- name: to-console
path: { prefix: /munin/ }
backend: munin
cors: false # or a map: replaces the domainmap's corsconfig
sources: [10.0.0.0/8, 203.0.113.7] # client addresses or CIDRs
- name: to-iam
path: { prefix: /account/ }
backend: iam
csrf:
trustedorigins: [https://partner.example.com]
bypass: [/saml/acs, /oidc/callback]
- name: to-public-api
path: { prefix: /public/ }
backend: public-api
csrf: false

cors takes the same keys as a domainmap’s corsconfig and replaces it for this endpoint. cors: false sends no CORS headers. Without cors, the endpoint answers with its route’s domainmap corsconfig: an endpoint listed on several routes answers each with that route’s own.

sources lets through only the listed addresses and CIDRs. Everyone else gets the same 404 as a refused path.

  • The client is the connecting address.
  • When the connecting address is one of the entrypoint’s trustedips (a proxy in front of the gateway), the client is instead the right-most X-Forwarded-For entry that is not a trusted proxy.
  • X-Forwarded-For from an untrusted peer is discarded before this check, so a client cannot write its way in.

The check is on for every endpoint. A browser request that changes state (any method but GET, HEAD and OPTIONS) from another origin is refused with 403.

  • A request is same-origin when Sec-Fetch-Site says so, or, from browsers without it, when Origin matches the request’s host.
  • Requests that carry neither header, from curl, SDKs, services and webhooks, always pass.
  • Trusted origins are the endpoint’s CORS origins (exact and regex) plus csrf.trustedorigins.
  • csrf.bypass takes path patterns, written like allow patterns, for cross-site form posts that are expected: a SAML assertion consumer, an OIDC form_post callback.
  • csrf: false turns the check off. CORS that allows every origin (*) also turns it off for that endpoint, with a warning at load.

With zerotrust: true the gateway dials every backend over mutual TLS and accepts any server certificate from the installation’s CA. identity pins one backend:

backends:
- backend:
name: vault
urls: [http://127.0.0.1:9999]
identity: "spiffe://example.com/dc1/c1/vault/*"
  • The backend’s server certificate must carry this URI SAN. A trailing /* matches exactly one more path segment, the instance.
  • A server with another identity fails the handshake: the request is answered 502 and nothing reaches the server.
  • identity needs zerotrust: true. Without it the configuration is refused.

Services read their caller from the client certificate of the connection. Through the gateway, that connection is the gateway’s:

  • Inbound certificate headers never pass. Any cert_* or cert-meta header a client sends is removed on every route. In the internal zone the gateway then sets them from the client certificate it verified.
  • A gateway role tells the backend the request was relayed. With a credential whose URI SAN is spiffe://<trust-domain>/gateway/<name>, a service takes the caller from the gateway’s certificate headers: the verified client in the internal zone, nobody in the external zone. Without one, the service sees the gateway itself as the caller, and the gateway warns at boot under zerotrust.
# bootstrap
gatewayrole:
cert: /etc/gateway/role.pem
key: /etc/gateway/role.key

The files are re-read when they change.

Terminal window
gateway.svc config exposure -f .gateway.yaml
zone=external route=acme-public entrypoint=websecure hosts=app.acme.com
to-api -> forge-api /api/**
to-vault-jwks -> vault GET,HEAD /vault/.well-known/jwks.json
zone=internal route=acme-internal entrypoint=websecureinternal hosts=app.acme.internal
to-api -> forge-api /api/**
to-config -> config /config/**

The command loads the bootstrap and its routing directory, applies every check on this page, and prints each route’s endpoints and paths by zone. Nothing is started. It exits non-zero when the gateway would refuse the configuration, so it can run in a pipeline before a deploy.

The same table is in the log after every load, and two metrics follow it:

MetricTypeAttributes
meili.gateway.exposuregauge, one series per published surface (value 1)zone, route, entrypoint, hosts, endpoint, backend, paths
meili.gateway.refused_requestscounterroute, endpoint, reason (path, source, csrf)

A configuration that loaded on an earlier gateway can be refused by this one. Before upgrading:

  1. Run gateway.svc config exposure -f <bootstrap> with the new binary against the live configuration.
  2. If it reports a sensitive backend on an external route, remove that endpoint from the external routes. To keep part of it public, give the endpoint exposure: both and an allow list.
  3. Declare zone on every entrypoint and domainmap. Bind internal entrypoints to a private or loopback address, and give their routes a tlsoptions profile with RequireAndVerifyClientCert.
  4. Review the boot warnings: sensitive endpoints without allow, routes with no zone, and a missing gatewayrole under zerotrust.