A security-focused API Gateway with a lightweight approach, built with Quarkus.
|
Important
|
0.2.4 is an ALPHA release. The configuration surface and the APIs may change without a
migration path. Per this project’s pre-1.0 rules, breaking changes are made freely: keys are
renamed or removed, values change meaning, and no deprecation shim, compatibility alias or
upgrade tooling is provided. A What alpha does not mean. It does not mean the code is untested or the security posture is provisional — the gateway is boot-validated fail-closed, covered by unit and containerised integration suites, and gated on a blocking quality gate. It means the surface is not frozen. Intended use: evaluation, integration and feedback. Try it, build against it, and report what breaks. Do not deploy it where an unannounced breaking change between releases would be costly. See Known Limitations for what is known to be missing or constrained at this cut. |
<dependency>
<groupId>de.cuioss.sheriff.gateway</groupId>
<artifactId>api-sheriff</artifactId>
</dependency>The reactor declared in the root pom.xml:
| Module | Content |
|---|---|
|
The deployable Quarkus application: core library, CDI producers, REST endpoints and native executable target. |
|
Integration-test coordinator: Docker infrastructure, native container builds and the HTTPS IT suites. |
|
k6 HTTP load-testing benchmarks against a containerised stack. |
|
Demo SPA and Playwright end-to-end suite (no Java; npm work runs only in the |
|
Production-shaped Docker Compose sample (no Java; started only by |
|
Published build parent for downstream deployers (no Java; builds no artifact). |
Build the native executable and production Docker image:
# Build native executable
./mvnw clean install -Pnative -pl api-sheriff -am -DskipTests
# Build production Docker image
docker build -f api-sheriff/src/main/docker/Dockerfile.native -t api-sheriff:latest api-sheriff/Run with TLS certificates mounted at runtime:
docker run -p 8443:8443 -p 127.0.0.1:9000:9000 \
-v /path/to/certs:/certs:ro \
-e QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/certs/server.crt \
-e QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/certs/server.key \
-e QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/certs/server.crt \
-e QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/certs/server.key \
api-sheriff:latest-
Port
8443(HTTPS) — the data-plane edge. Routes are deny-by-default: only the paths declared by the mountedgateway.yamlanchors andendpoints/*.yamlare served, and every other request is rejected with404. The image ships no built-in application endpoints of its own -
Port
9000(HTTPS) — management endpoints (health and metrics), served beneath the image’s management context path. The management interface has exactly one port, so supplyingQUARKUS_MANAGEMENT_SSL_CERTIFICATE_*above converts 9000 itself to HTTPS; there is no simultaneous plain-HTTP listener. It is additionally bound to127.0.0.1(loopback only) so the unauthenticated management interface is not exposed on all host interfaces, matching the security posture documented indoc/architecture/observability-and-errors.adoc
Both context paths — the data-plane one and the management one — are fixed when the image is
built, so the matching QUARKUS_*_ROOT_PATH environment variables are routing-inert against a
published image — they move nothing, though a divergent value may draw a startup
build-time-mismatch warning. Moving either takes a rebuild through the Maven seam; see
The Context Path.
-
Rebuilding under your own context path without cloning this repository is the supported customisation path: inherit the published build parent
de.cuioss.sheriff.gateway:api-sheriff-build-parent, set one property, and build — see Rebuilding Under Your Own Context Path
Verified as still open at the 0.2.4 cut. Each is a deliberate boundary or a known gap, not a surprise waiting to be discovered.
- A route with no
forwardblock forwards every header and query parameter -
The
forwardblock is a three-mode policy:headers_allow/query_allowis a positive-list,headers_deny/query_denyis a negative-list, and declaring neither means everything crosses. A route that omits the block, or one of its two dimensions, forwards that dimension in full.The bound. The policy decides what crosses within a route the operator exposed; it never decides which routes are reachable. Route matching stays deny-by-default — a request matching no route still reaches no upstream. Nor does it leak credentials: no forward mode copies the browser
Cookie— it crosses only as the route’scookiesblock admits it, and the gateway’s own cookies never do — an inboundAuthorization, like a client-sent provenance or URL-override header (X-Original-URL,X-Forwarded-Server, …), is re-admitted only where a route declares a positive-list naming it, and the hop-by-hop, framing and regenerated forwarding headers the gateway owns are withheld regardless (see the never-forward set). Note also that a declared-empty list is not the absent state:headers_allow: []is a positive-list naming nothing, and keeps forwarding nothing.Mitigation, one line: declare a positive-list (
headers_allow/query_allow) on any route that must withhold client input. See Architecture — Forward Policy and ADR-0036. - No cookie crosses a route by default
-
The
cookiesblock decides both directions per route. A route that resolves no block runsallow: none: its upstream receives no browser cookie, and no upstreamSet-Cookiereaches the browser. The gateway’s own cookies cross under no block in either direction.Mitigation, one line: declare
cookies: { allow: [<the names>] }on every route whose upstream relies on its own cookies. See Configuration —cookiesand ADR-0037. - Upstream response headers are relayed except for gateway-owned names
-
Both directions carry a gateway-owned never-relay set — the connection-specific and framing names, plus, on the request side, the credentials and provenance headers the gateway owns. The response side also strips the upstream’s identity headers,
ServerandX-Powered-By, by default — on the HTTP relay, the gRPC relay and an asset route withsource: upstream. A route keeps them only by settingkeep_identity_headers: true(see Configuration — the identity set). Beyond those sets the two directions differ: a route can narrow what crosses on the request side (headers_deny/headers_allow), while on the HTTP and gRPC relays every other upstream response header — a framework header outside the identity set, such asX-AspNet-Version— reaches the client unchanged. Two places do change origin headers: an asset route withsource: upstreamanswers through the asset envelope, which sets its ownContent-Type,X-Content-Type-Optionsand, for an authenticated asset,Cache-Control: no-store, and dropsSet-Cookie; and asecurity_headersentry insetmode overwrites the origin’s value. Only a few named route keys act on a response header:keep_identity_headers(above), the cookie policy forSet-Cookie(see above),not_modified: false, which stripsETagandLast-Modified, andupstream.rewrite_location: true, which rewritesLocation. There is no response-header allowlist or denylist. Strip such headers at the upstream, or in front of the gateway, if they matter to you.The TLS and HTTP/2 fingerprint of the listener is a separate residual that no gateway key disguises (TLS and HTTP/2 fingerprints).
- The server-mode session store is single-node and in-memory
-
InMemorySessionStoreis the only implementation of theSessionStoreseam, andsession.storeaccepts exactly one value,memory. There is no distributed or persistent option. A server-mode BFF deployment is therefore single-node, or several replicas behind sticky sessions in the orchestration layer, and a replica that restarts or fails over ends the sessions it held. A session ends at its absolute lifetime (oidc.session.ttl_seconds) and, earlier, at its idle timeout (oidc.session.idle_timeout_seconds). An expired session is removed by a periodic sweep within a minute of expiring, so it does not hold a slot until the store fills. Sizeoidc.session.max_sessionsagainst expected peak concurrency — at the ceiling a new login is refused fail-closed, and that happens only when that many sessions are still live. See Running more than one replica. Cookie mode is the stateless alternative by design. - Cookie-mode refresh fits the cookie budget for a measured claim set, not for any claim set
-
A cookie-mode session that refreshes seals a live access, refresh and ID token into one cookie.
SealedSessionCookieCodecdeflates before it seals, andSealedSessionCookieCodecTestmeasures a three-token session shaped like the integration realm’s at roughly 2,700 bytes against the 4019-byte browser-safe value budget (BROWSER_SAFE_COOKIE_VALUE_BUDGET) — a margin of roughly 1,300 bytes. That margin belongs to one realm’s claim set: an identity provider minting larger tokens spends it first, and a seal past the budget is refused (ApiSheriff-114). Two things stay unmeasured on a pull request: the browser lane that would observe a dropped cookie gates nothing, and no Chromium storage threshold has been recorded. See Route 4 — package and compress and ADR-0043. - Integration-test coverage has recorded gaps
-
Three contributor documents enumerate them rather than leaving them implicit: Declared-Limit Assertion Coverage records which declared gateway limits have a boundary assertion and which do not, Test-Corpus Integrity records the assertion shapes that cannot support the behaviour a test’s name claims and the methods still to strengthen, and Cookie-Deliverability Blindness records the properties the integration lane is structurally incapable of observing at all, however strong its assertions. Read all three before treating a green suite as proof of a specific bound.
- The FAPI 2.0 controls hold in key mode only, and no conformance-suite run exists
-
In key mode the BFF implements the FAPI 2.0 controls (DPoP route). It pushes every authorization request (PAR), binds every access token it obtains to one DPoP key, refuses a token response that is not bound to that key, and authenticates to the identity provider with
private_key_jwt. Two limits belong to that statement:-
No conformance-suite run exists. The controls are implemented and evidenced; no conformance claim is made.
-
It holds in key mode only. With
oidc.client_secretconfigured the client-authentication requirement is not met. Pushed requests and the DPoP sender constraint stay in effect in that mode.How a BFF is configured. Key-based client authentication is the default: an
oidcblock that declares noclient_secretauthenticates withprivate_key_jwt, signed with the keyoidc.client_authentication.key_filenames or with a key generated at startup. A client secret remains an operator choice, selected by configuringoidc.client_secret. Client-secret authentication is not FAPI 2.0 conformant and may weaken security, and the gateway says so at every boot (ApiSheriff-133).What every BFF deployment needs from its identity provider. A pushed-authorization-request endpoint and DPoP-bound access tokens, in both modes; without them every login is refused. In key mode the identity provider additionally fetches the gateway’s client key from its JWKS endpoint. See FAPI 2.0 Status (including its open gaps) and the BFF setup guide.
-
- Per-route rate limiting is planned, not implemented
-
The per-route
rate_limitfield is parsed and bound intoRateLimitConfig, but nothing on the request path reads it: it applies no limit and causes no429. Every route that declares the key is named at boot by the WARNApiSheriff-140, so a configuration that relies on it does not read as a control in force. The gateway’s429answers all come fromenumeration_detection, the per-source detection substrate: the opt-inblockaction, and, under the opt-indelayaction, a tripped source whose share of the held-answer sub-budget is used up — see Per-Source Detection Substrate. - From the first two-platform release on, the published image serves linux/amd64 and linux/arm64, each at GraalVM’s default CPU baseline
-
From the first release the two-platform lane cuts,
ghcr.io/cuioss/api-sheriffis one image index over two images,linux/amd64andlinux/arm64, each built natively on its own architecture and run through the full integration-test suite before it is published. Tags up to and including0.2.4arelinux/amd64only. Every tag from that release on resolves to the image for the pulling host’s platform, so no--platformflag is needed and an arm64 host — AWS Graviton, Apple Silicon — runs the arm64 image natively rather than under emulation. The native executable in each image is compiled for GraalVM’s default target —quarkus.native.additional-build-argscarries no-march:-
linux/amd64requires the x86-64-v3 instruction set (AVX2, BMI2, FMA); -
linux/arm64requires GraalVM’s AArch64 default, which AWS Graviton 2 and every later Graviton, and Apple Silicon, implement.A CPU below either baseline — an x86-64 CPU without AVX2, or an older AArch64 core such as Graviton 1 or the Raspberry Pi 4 — cannot start the published image, and neither can an amd64 image forced onto an arm64 host under emulation: the process exits at once with "The current machine does not support all of the following CPU features that are required by the image". A default local build does not serve a CPU below either baseline: it compiles for the same GraalVM default target as the published image, not for the host it runs on. An arm64 host that would otherwise force an amd64 image, and anyone running local changes, can build the image locally — see Route B — a locally built image in the compose sample. The decision and the undecided
-march=compatibilityalternative are recorded in ADR-0070.
-