responseHeaders = Map.copyOf(request.responseHeaders());
+ ctx.vertx().runOnContext(v -> grpcStatusMapper.renderRejection(ctx.response(), eventType, responseHeaders));
+ return;
+ }
+ renderProblem(ctx, request, eventType);
+ }
+
private void renderProblem(RoutingContext ctx, @Nullable PipelineRequest request, @Nullable EventType eventType) {
int status;
String type;
@@ -638,6 +727,26 @@ private static AssetSource assetSourceFor(ResolvedAsset asset) {
};
}
+ /**
+ * Builds the shared Vert.x client for an upstream-target tuple. A gRPC route's tuple carries the
+ * {@code forcedHttp2} flag, so it gets a client forced to HTTP/2 (h2 over TLS with ALPN, or
+ * prior-knowledge h2c in cleartext) — gRPC requires HTTP/2 end-to-end. Every other tuple gets the
+ * default client (HTTP/1.1 with h2 upgrade negotiation).
+ */
+ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamTarget target) {
+ if (!target.forcedHttp2()) {
+ return vertx.createHttpClient();
+ }
+ HttpClientOptions options = new HttpClientOptions().setProtocolVersion(HttpVersion.HTTP_2);
+ if ("https".equalsIgnoreCase(target.scheme())) {
+ options.setSsl(true).setUseAlpn(true);
+ } else {
+ // Prior-knowledge h2c: skip the HTTP/1.1 Upgrade dance and speak HTTP/2 in cleartext.
+ options.setHttp2ClearTextUpgrade(false);
+ }
+ return vertx.createHttpClient(options);
+ }
+
/**
* Maps a route's {@code security_filter} block to a cui-http {@link SecurityConfiguration},
* seeding the safe builder defaults and overriding only the limits the route declared, so an
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GrpcDispatchStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GrpcDispatchStage.java
new file mode 100644
index 00000000..6d6d5b75
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GrpcDispatchStage.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.edge;
+
+import java.util.Map;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClientResponse;
+import io.vertx.core.streams.ReadStream;
+
+/**
+ * Stage 6 for a {@code protocol: grpc} route — the forced-HTTP/2 upstream dispatch.
+ *
+ * gRPC requires HTTP/2 end-to-end, so a gRPC route's upstream client is a forced-h2
+ * client: the h2 protocol version is part of the client-sharing tuple key
+ * ({@link RouteRuntimeAssembler.UpstreamTarget}), so a gRPC route to {@code host:port} holds a
+ * distinct client from an HTTP/1.1 route to the same {@code host:port}. This stage streams the
+ * request/response bodies as opaque length-prefixed frames — the gateway never
+ * inspects the protobuf payload — reusing the byte-capped streaming dispatch of {@link DispatchStage}
+ * (which also enforces the route's body ceiling and the stream-aware retry gate). An h2-negotiation
+ * failure at dispatch (the forced-h2 dial could not establish {@code h2}) surfaces as an upstream
+ * connection failure, mapped to {@link EventType#UPSTREAM_ERROR} by {@link UpstreamFailureMapper} and
+ * rendered as gRPC {@code UNAVAILABLE} by {@link GrpcStatusMapper}. The Plan-04 GW-08 HTTP/2 abuse
+ * bounds (Rapid-Reset / CONTINUATION-flood) hold on the gRPC path because they are enforced by the
+ * shared inbound transport ({@link EdgeHardeningOptions}) rather than per-protocol, so legitimate
+ * multi-stream gRPC traffic is never misfired.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class GrpcDispatchStage {
+
+ private final DispatchStage dispatchStage;
+
+ /**
+ * @param maxBodyBytes the streaming request-body ceiling in bytes ({@code max_body_bytes})
+ * @param failureMapper the mapper turning a guarded-dispatch (including h2-negotiation) failure
+ * into the error contract
+ */
+ public GrpcDispatchStage(long maxBodyBytes, UpstreamFailureMapper failureMapper) {
+ this.dispatchStage = new DispatchStage(maxBodyBytes, Objects.requireNonNull(failureMapper, "failureMapper"));
+ }
+
+ /**
+ * Dispatches the gRPC request to the route's forced-h2 upstream, streaming the (byte-capped)
+ * request body opaquely and returning the response whose body and trailers are not yet consumed.
+ *
+ * @param route the resolved route runtime holding the shared forced-h2 client and guard
+ * @param method the request method (gRPC is always {@code POST})
+ * @param requestUri the upstream request URI
+ * @param forwardHeaders the deny-by-default header set computed by stage 5
+ * @param requestBody the inbound request body as a live read stream
+ * @return the upstream response (body and trailers still streaming)
+ * @throws GatewayException carrying the mapped error-contract event on any dispatch failure
+ */
+ public HttpClientResponse dispatch(RouteRuntime route, HttpMethod method, String requestUri,
+ Map forwardHeaders, ReadStream requestBody) {
+ return dispatchStage.dispatch(route, method, requestUri, forwardHeaders, requestBody);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GrpcStatusMapper.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GrpcStatusMapper.java
new file mode 100644
index 00000000..3e0f9bbe
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GrpcStatusMapper.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.edge;
+
+import java.util.Map;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.events.EventCategory;
+import de.cuioss.sheriff.api.events.EventType;
+
+import io.vertx.core.http.HttpServerResponse;
+
+/**
+ * Maps a gateway rejection on a {@code protocol: grpc} route to the canonical gRPC status and renders
+ * it as a trailers-only gRPC response (architecture.adoc § gRPC error contract).
+ *
+ * A gRPC client cannot consume an {@code application/problem+json} body — the RPC runtime only
+ * surfaces the {@code grpc-status} carried in the response headers/trailers. So a gateway rejection is
+ * emitted as an HTTP {@code 200} whose {@code content-type} is {@code application/grpc} and whose
+ * {@code grpc-status} (and {@code grpc-message}) name the failure, with no DATA frame — the gRPC
+ * "Trailers-Only" case. The same rejection that renders as an HTTP status on an HTTP route maps onto
+ * the canonical gRPC status by that HTTP status:
+ *
+ * - {@code 400} → {@code INVALID_ARGUMENT} (3)
+ * - {@code 401} → {@code UNAUTHENTICATED} (16)
+ * - {@code 403} → {@code PERMISSION_DENIED} (7)
+ * - {@code 404} → {@code NOT_FOUND} (5)
+ * - {@code 405} → {@code UNIMPLEMENTED} (12)
+ * - {@code 502} / {@code 503} → {@code UNAVAILABLE} (14) — also an h2-negotiation failure
+ * - {@code 504} → {@code DEADLINE_EXCEEDED} (4)
+ * - anything else → {@code UNKNOWN} (2)
+ *
+ * The {@code grpc-message} carries the failure category slug only — never internal detail — exactly as
+ * the problem+json contract does for HTTP routes.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class GrpcStatusMapper {
+
+ /** gRPC status code: the call completed with an unmapped / unknown error. */
+ public static final int UNKNOWN = 2;
+ /** gRPC status code: a client argument was invalid (maps HTTP 400). */
+ public static final int INVALID_ARGUMENT = 3;
+ /** gRPC status code: a deadline elapsed before completion (maps HTTP 504). */
+ public static final int DEADLINE_EXCEEDED = 4;
+ /** gRPC status code: the requested entity was not found (maps HTTP 404). */
+ public static final int NOT_FOUND = 5;
+ /** gRPC status code: the caller lacked permission (maps HTTP 403). */
+ public static final int PERMISSION_DENIED = 7;
+ /** gRPC status code: the operation is not implemented / not supported (maps HTTP 405). */
+ public static final int UNIMPLEMENTED = 12;
+ /** gRPC status code: the service is unavailable (maps HTTP 502 / 503 and h2-negotiation failure). */
+ public static final int UNAVAILABLE = 14;
+ /** gRPC status code: the request lacks valid authentication credentials (maps HTTP 401). */
+ public static final int UNAUTHENTICATED = 16;
+
+ private static final int GRPC_HTTP_STATUS = 200;
+ private static final String GRPC_CONTENT_TYPE = "application/grpc";
+ private static final String GRPC_STATUS_HEADER = "grpc-status";
+ private static final String GRPC_MESSAGE_HEADER = "grpc-message";
+ private static final String UNKNOWN_MESSAGE = "unknown";
+
+ /**
+ * Maps a gateway {@link EventType} to its canonical gRPC status code by the HTTP status the same
+ * cause renders on an HTTP route.
+ *
+ * @param eventType the rejection event type
+ * @return the canonical gRPC status code
+ */
+ public int toGrpcStatus(EventType eventType) {
+ Objects.requireNonNull(eventType, "eventType");
+ return switch (eventType.httpStatus()) {
+ case 400 -> INVALID_ARGUMENT;
+ case 401 -> UNAUTHENTICATED;
+ case 403 -> PERMISSION_DENIED;
+ case 404 -> NOT_FOUND;
+ case 405 -> UNIMPLEMENTED;
+ case 502, 503 -> UNAVAILABLE;
+ case 504 -> DEADLINE_EXCEEDED;
+ default -> UNKNOWN;
+ };
+ }
+
+ /**
+ * Renders a trailers-only gRPC rejection to the client response: HTTP {@code 200},
+ * {@code content-type: application/grpc}, and the mapped {@code grpc-status} / {@code grpc-message},
+ * with no body. Stage-0 security headers are applied first so gateway-controlled headers win. A
+ * response whose head is already written (a mid-stream failure) is left untouched.
+ *
+ * @param response the client response
+ * @param eventType the rejection event type
+ * @param stageHeaders the stage-0 security headers accumulated on the request
+ */
+ public void renderRejection(HttpServerResponse response, EventType eventType, Map stageHeaders) {
+ Objects.requireNonNull(response, "response");
+ Objects.requireNonNull(eventType, "eventType");
+ Objects.requireNonNull(stageHeaders, "stageHeaders");
+ if (response.ended() || response.headWritten()) {
+ return;
+ }
+ response.setStatusCode(GRPC_HTTP_STATUS);
+ stageHeaders.forEach(response::putHeader);
+ response.putHeader("content-type", GRPC_CONTENT_TYPE);
+ response.putHeader(GRPC_STATUS_HEADER, Integer.toString(toGrpcStatus(eventType)));
+ response.putHeader(GRPC_MESSAGE_HEADER, grpcMessage(eventType));
+ response.end();
+ }
+
+ private static String grpcMessage(EventType eventType) {
+ EventCategory category = eventType.category();
+ return category != null ? category.slug() : UNKNOWN_MESSAGE;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java
index 086bc637..4817a62c 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java
@@ -21,6 +21,7 @@
import java.util.Set;
import io.vertx.core.Future;
+import io.vertx.core.Promise;
import io.vertx.core.http.HttpClientResponse;
import io.vertx.core.http.HttpServerResponse;
@@ -96,6 +97,58 @@ public Future relay(HttpClientResponse upstream, HttpServerResponse client
return upstream.pipeTo(client);
}
+ /**
+ * Relays the upstream response to the client including its trailing headers —
+ * the gRPC path, where {@code grpc-status} / {@code grpc-message} are carried in the HTTP/2
+ * response trailers (or, in the trailers-only case, the leading headers). The status and filtered
+ * headers are copied exactly as {@link #relay}, then the body is streamed with the client response
+ * held open ({@code endOnComplete(false)}); once the upstream body — and therefore its trailers —
+ * has fully arrived, the upstream trailers are copied onto the client response and it is ended, so
+ * the client observes the gRPC status. The response is set chunked so trailers are framed on an
+ * HTTP/1.1 client (HTTP/2 ignores the flag and frames trailers natively).
+ *
+ * @param upstream the upstream response (body + trailers still streaming)
+ * @param client the client response write stream
+ * @param notModifiedEnabled whether the route honours conditional requests / responses
+ * @param stageZeroSecurityHeaders the stage-0 security headers accumulated on the response
+ * @return a future completing when the body and trailers have been fully relayed
+ */
+ public Future relayWithTrailers(HttpClientResponse upstream, HttpServerResponse client,
+ boolean notModifiedEnabled, Map stageZeroSecurityHeaders) {
+ Objects.requireNonNull(upstream, "upstream");
+ Objects.requireNonNull(client, "client");
+ Objects.requireNonNull(stageZeroSecurityHeaders, "stageZeroSecurityHeaders");
+
+ client.setStatusCode(upstream.statusCode());
+ for (Map.Entry header : upstream.headers()) {
+ if (isForwardableResponseHeader(header.getKey(), notModifiedEnabled)) {
+ client.headers().add(header.getKey(), header.getValue());
+ }
+ }
+ stageZeroSecurityHeaders.forEach((name, value) -> client.headers().set(name, value));
+ // gRPC trailers require a chunked (HTTP/1.1) or HTTP/2 response frame.
+ client.setChunked(true);
+
+ Promise relayed = Promise.promise();
+ upstream.pipe().endOnComplete(false).to(client).onComplete(piped -> {
+ if (piped.failed()) {
+ relayed.fail(piped.cause());
+ return;
+ }
+ for (Map.Entry trailer : upstream.trailers()) {
+ client.putTrailer(trailer.getKey(), trailer.getValue());
+ }
+ client.end().onComplete(ended -> {
+ if (ended.succeeded()) {
+ relayed.complete();
+ } else {
+ relayed.fail(ended.cause());
+ }
+ });
+ });
+ return relayed.future();
+ }
+
/**
* Re-establishes the client response body framing after {@link #isForwardableResponseHeader
* hop-by-hop stripping} removed the upstream framing headers. Only {@code Transfer-Encoding} is
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java
index 7af31d26..25bf2575 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java
@@ -28,6 +28,7 @@
import de.cuioss.http.security.config.SecurityConfiguration;
import de.cuioss.sheriff.api.asset.AssetSource;
import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.Protocol;
import de.cuioss.sheriff.api.config.model.ResolvedAsset;
import de.cuioss.sheriff.api.config.model.ResolvedRoute;
import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
@@ -124,7 +125,9 @@ public List assemble(RouteTable table, SecurityConfigurationFactor
.effectiveAllowedPaths(route.effectiveSecurityFilter()
.map(SecurityFilterConfig::allowedPaths).orElse(List.of()))
.retryEnabled(route.retryEnabled())
- .notModifiedEnabled(route.notModifiedEnabled());
+ .notModifiedEnabled(route.notModifiedEnabled())
+ .effectiveAllowedOrigins(route.effectiveAllowedOrigins())
+ .effectiveWebSocketIdleTimeoutSeconds(route.effectiveWebSocketIdleTimeoutSeconds());
// A route resolves exactly one terminal action (ADR-0014). An asset route builds its
// live source and skips the Vert.x client / resilience-guard dedup entirely — its
@@ -136,7 +139,10 @@ public List assemble(RouteTable table, SecurityConfigurationFactor
ResolvedUpstream resolvedUpstream = route.upstream().orElseThrow(() -> new GatewayException(
EventType.CONFIG_INVALID,
"Route '" + route.id() + "' resolves no terminal action (neither upstream nor asset)"));
- UpstreamTarget target = UpstreamTarget.of(resolvedUpstream);
+ // gRPC requires HTTP/2 end-to-end, so the forced-h2 flag joins the client-sharing tuple:
+ // a gRPC route to host:port holds a distinct forced-h2 client from an HTTP/1.1 route to
+ // the same host:port.
+ UpstreamTarget target = UpstreamTarget.of(resolvedUpstream, route.protocol() == Protocol.GRPC);
HttpClient client = clientCache.computeIfAbsent(target, clientFactory::create);
ResilienceShape shape = new ResilienceShape(target, route.retryEnabled());
Guard guard = guardCache.computeIfAbsent(shape, guardFactory::create);
@@ -162,21 +168,24 @@ private static Set toMethodSet(List methods) {
}
/**
- * The upstream-target tuple keying Vert.x client dedup: routes sharing (scheme, host, port)
- * share one client instance.
+ * The upstream-target tuple keying Vert.x client dedup: routes sharing
+ * (scheme, host, port, forced-h2) share one client instance. The {@code forcedHttp2} dimension
+ * separates a gRPC route's forced-HTTP/2 client from an HTTP/1.1 client to the same host:port.
*
- * @param scheme the upstream scheme
- * @param host the upstream host
- * @param port the upstream port
+ * @param scheme the upstream scheme
+ * @param host the upstream host
+ * @param port the upstream port
+ * @param forcedHttp2 whether the client is forced to HTTP/2 (a gRPC route)
*/
- public record UpstreamTarget(String scheme, String host, int port) {
+ public record UpstreamTarget(String scheme, String host, int port, boolean forcedHttp2) {
/**
- * @param upstream the resolved upstream
+ * @param upstream the resolved upstream
+ * @param forcedHttp2 whether the client is forced to HTTP/2 (a gRPC route)
* @return the target tuple for {@code upstream}
*/
- public static UpstreamTarget of(ResolvedUpstream upstream) {
- return new UpstreamTarget(upstream.scheme(), upstream.host(), upstream.port());
+ public static UpstreamTarget of(ResolvedUpstream upstream, boolean forcedHttp2) {
+ return new UpstreamTarget(upstream.scheme(), upstream.host(), upstream.port(), forcedHttp2);
}
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/WebSocketRelayStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/WebSocketRelayStage.java
new file mode 100644
index 00000000..ae53badb
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/WebSocketRelayStage.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.edge;
+
+import java.util.Map;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.ApiSheriffLogMessages;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayEventCounter;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+import de.cuioss.tools.logging.CuiLogger;
+
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpServerResponse;
+import io.vertx.core.http.ServerWebSocket;
+import io.vertx.core.http.UpgradeRejectedException;
+import io.vertx.core.http.WebSocket;
+import io.vertx.core.http.WebSocketBase;
+import io.vertx.core.http.WebSocketConnectOptions;
+import io.vertx.core.http.WebSocketFrame;
+import io.vertx.ext.web.RoutingContext;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The WebSocket relay terminal action — the protocol-dispatch seam's WebSocket leg, replacing the
+ * HTTP {@link DispatchStage} for a {@code protocol: websocket} route once the pipeline and the
+ * {@code OriginValidationStage} have accepted the handshake.
+ *
+ * Dial-before-upgrade. The upstream WebSocket is dialed first, over the route's
+ * shared Vert.x {@link HttpClient}. Only when the upstream confirms {@code 101} is the client
+ * upgrade completed ({@link io.vertx.core.http.HttpServerRequest#toWebSocket()}); the two legs are
+ * then relayed opaquely. If the upstream is unreachable or times out, the failure is mapped to
+ * {@code 502}/{@code 504} before the client upgrade, so no half-open upgrade is ever left
+ * dangling. If the upstream refuses the upgrade with a status, that status is relayed to
+ * the client verbatim.
+ *
+ * Opaque bidirectional relay. Every frame — text, binary, continuation, ping,
+ * pong — is forwarded to the other leg with fragmentation preserved and no per-frame filtering;
+ * close is relayed transparently and a half-close on either leg closes both. Data-frame relay
+ * applies Vert.x write-queue backpressure (pause the busy source until the target drains). An
+ * established relay is bounded by the route's per-route {@code idle_timeout_seconds}: a timer,
+ * reset by any frame in either direction (ping/pong counting as activity), closes both legs with
+ * WebSocket close code {@code 1001} (Going Away) on expiry and meters
+ * {@link EventType#WEBSOCKET_IDLE_TIMEOUT}.
+ *
+ * Every socket operation is event-loop-bound; the relay hops onto the request's Vert.x context so
+ * both legs share one event loop and the frame relay is single-threaded.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class WebSocketRelayStage {
+
+ private static final CuiLogger LOGGER = new CuiLogger(WebSocketRelayStage.class);
+
+ private static final String HTTPS = "https";
+ private static final int DEFAULT_IDLE_TIMEOUT_SECONDS = 300;
+ private static final int BAD_GATEWAY = 502;
+ private static final short CLOSE_NORMAL = 1000;
+ private static final short CLOSE_INTERNAL_ERROR = 1011;
+
+ private final UpstreamFailureMapper failureMapper;
+ private final GatewayEventCounter eventCounter;
+
+ /**
+ * @param failureMapper the shared mapper turning an upstream dial failure into the error contract
+ * @param eventCounter the shared in-process event counter
+ */
+ public WebSocketRelayStage(UpstreamFailureMapper failureMapper, GatewayEventCounter eventCounter) {
+ this.failureMapper = Objects.requireNonNull(failureMapper, "failureMapper");
+ this.eventCounter = Objects.requireNonNull(eventCounter, "eventCounter");
+ }
+
+ /**
+ * Dials the upstream WebSocket and, on success, upgrades the client and establishes the opaque
+ * relay. Runs asynchronously on the request's Vert.x context; the caller returns immediately.
+ *
+ * The stage-0 security headers accumulated on the request are retained across the asynchronous
+ * dial so that a handshake-failure response ({@link #onUpstreamFailure}) carries the same
+ * gateway-controlled headers as the HTTP ({@link ResponseStage#relay}) and gRPC
+ * ({@link GrpcStatusMapper#renderRejection}) rejection paths.
+ *
+ * @param ctx the routing context (client request/response and Vert.x handle)
+ * @param route the resolved route runtime (upstream, shared client, idle timeout)
+ * @param forwardHeaders the deny-by-default forwarded header set computed by stage 5
+ * @param securityHeaders the stage-0 security headers accumulated on the response, applied to a
+ * handshake-failure response before it is ended
+ * @param requestUri the upstream request URI (path + allow-listed query)
+ */
+ public void relay(RoutingContext ctx, RouteRuntime route, Map forwardHeaders,
+ Map securityHeaders, String requestUri) {
+ Objects.requireNonNull(ctx, "ctx");
+ Objects.requireNonNull(route, "route");
+ Objects.requireNonNull(forwardHeaders, "forwardHeaders");
+ Objects.requireNonNull(securityHeaders, "securityHeaders");
+ Objects.requireNonNull(requestUri, "requestUri");
+ Map retainedSecurityHeaders = Map.copyOf(securityHeaders);
+ HttpClient client = route.getHttpClient()
+ .orElseThrow(() -> new IllegalStateException("WebSocket dispatch requires an upstream client"));
+ ResolvedUpstream upstream = route.getUpstream()
+ .orElseThrow(() -> new IllegalStateException("WebSocket dispatch requires a resolved upstream"));
+ WebSocketConnectOptions options = new WebSocketConnectOptions()
+ .setHost(upstream.host())
+ .setPort(upstream.port())
+ .setSsl(HTTPS.equalsIgnoreCase(upstream.scheme()))
+ .setURI(requestUri);
+ forwardHeaders.forEach(options::addHeader);
+ ctx.vertx().runOnContext(v -> client.webSocket(options)
+ .onSuccess(upstreamWs -> onUpstreamConnected(ctx, route, upstreamWs))
+ .onFailure(failure -> onUpstreamFailure(ctx, route, failure, retainedSecurityHeaders)));
+ }
+
+ private void onUpstreamConnected(RoutingContext ctx, RouteRuntime route, WebSocket upstreamWs) {
+ ctx.request().toWebSocket()
+ .onSuccess(clientWs -> establishRelay(ctx, route, clientWs, upstreamWs))
+ .onFailure(failure -> {
+ // The upstream is already upgraded but the client handshake could not complete;
+ // there is no HTTP response to render anymore. Close the upstream leg and drop.
+ LOGGER.debug(failure, "WebSocket client upgrade failed on route '%s': %s", route.getId(),
+ failure.getMessage());
+ closeQuietly(upstreamWs, CLOSE_INTERNAL_ERROR, "client upgrade failed");
+ });
+ }
+
+ private void onUpstreamFailure(RoutingContext ctx, RouteRuntime route, Throwable failure,
+ Map securityHeaders) {
+ int status;
+ if (failure instanceof UpgradeRejectedException rejected) {
+ // The upstream explicitly refused the upgrade with a status — relay it verbatim.
+ status = rejected.getStatus();
+ LOGGER.debug("WebSocket upstream refused upgrade on route '%s' with status %s", route.getId(), status);
+ } else {
+ EventType type = failureMapper.classify(failure);
+ eventCounter.increment(type);
+ status = type.hasHttpMapping() ? type.httpStatus() : BAD_GATEWAY;
+ LOGGER.debug(failure, "WebSocket upstream dial failed on route '%s': %s", route.getId(),
+ failure.getMessage());
+ }
+ HttpServerResponse response = ctx.response();
+ if (response.ended()) {
+ return;
+ }
+ if (!response.headWritten()) {
+ // Apply the gateway (stage-0) security headers before the head is written, mirroring the
+ // HTTP (ResponseStage.relay) and gRPC (GrpcStatusMapper.renderRejection) rejection paths.
+ securityHeaders.forEach(response::putHeader);
+ response.setStatusCode(status);
+ }
+ response.end();
+ }
+
+ private void establishRelay(RoutingContext ctx, RouteRuntime route, ServerWebSocket clientWs,
+ WebSocket upstreamWs) {
+ int idleSeconds = route.getEffectiveWebSocketIdleTimeoutSeconds().orElse(DEFAULT_IDLE_TIMEOUT_SECONDS);
+ LOGGER.info(ApiSheriffLogMessages.INFO.WEBSOCKET_RELAY_ESTABLISHED, route.getId());
+ eventCounter.increment(EventType.REQUEST_FORWARDED);
+ new RelaySession(ctx.vertx(), route.getId(), clientWs, upstreamWs, idleSeconds, eventCounter).start();
+ }
+
+ private static void closeQuietly(WebSocketBase ws, short code, @Nullable String reason) {
+ if (!ws.isClosed()) {
+ ws.close(code, reason);
+ }
+ }
+
+ /**
+ * One established relay: the two legs, the idle-timeout timer, and the frame-relay wiring. All
+ * callbacks run on the shared request event loop, so the mutable {@code closed} / timer state is
+ * single-threaded and needs no synchronization.
+ */
+ private static final class RelaySession {
+
+ private final Vertx vertx;
+ private final String routeId;
+ private final ServerWebSocket clientWs;
+ private final WebSocket upstreamWs;
+ private final int idleSeconds;
+ private final long idleMillis;
+ private final GatewayEventCounter eventCounter;
+ private long idleTimerId = -1L;
+ private boolean closed;
+
+ RelaySession(Vertx vertx, String routeId, ServerWebSocket clientWs, WebSocket upstreamWs, int idleSeconds,
+ GatewayEventCounter eventCounter) {
+ this.vertx = vertx;
+ this.routeId = routeId;
+ this.clientWs = clientWs;
+ this.upstreamWs = upstreamWs;
+ this.idleSeconds = idleSeconds;
+ this.idleMillis = idleSeconds * 1000L;
+ this.eventCounter = eventCounter;
+ }
+
+ void start() {
+ wire(clientWs, upstreamWs);
+ wire(upstreamWs, clientWs);
+ clientWs.closeHandler(v -> closeBoth(resolveCloseCode(clientWs.closeStatusCode()), clientWs.closeReason()));
+ upstreamWs.closeHandler(v ->
+ closeBoth(resolveCloseCode(upstreamWs.closeStatusCode()), upstreamWs.closeReason()));
+ clientWs.exceptionHandler(this::abort);
+ upstreamWs.exceptionHandler(this::abort);
+ resetIdle();
+ }
+
+ private void wire(WebSocketBase source, WebSocketBase target) {
+ source.frameHandler(frame -> relayFrame(source, target, frame));
+ // Vert.x surfaces received pong frames on a dedicated handler (not the frame handler) and
+ // auto-responds to pings; a pong is relay activity, so it resets the idle timer.
+ source.pongHandler(pong -> resetIdle());
+ }
+
+ private void relayFrame(WebSocketBase source, WebSocketBase target, WebSocketFrame frame) {
+ if (closed) {
+ return;
+ }
+ resetIdle();
+ if (frame.isClose()) {
+ // The close is surfaced separately via closeHandler, which closes both legs.
+ return;
+ }
+ if (frame.isPing()) {
+ target.writeFrame(WebSocketFrame.pingFrame(frame.binaryData()));
+ return;
+ }
+ target.writeFrame(dataFrame(frame));
+ applyBackpressure(source, target);
+ }
+
+ private static WebSocketFrame dataFrame(WebSocketFrame frame) {
+ if (frame.isText()) {
+ return WebSocketFrame.textFrame(frame.textData(), frame.isFinal());
+ }
+ if (frame.isContinuation()) {
+ return WebSocketFrame.continuationFrame(frame.binaryData(), frame.isFinal());
+ }
+ return WebSocketFrame.binaryFrame(frame.binaryData(), frame.isFinal());
+ }
+
+ private static void applyBackpressure(WebSocketBase source, WebSocketBase target) {
+ if (target.writeQueueFull()) {
+ source.pause();
+ target.drainHandler(v -> source.resume());
+ }
+ }
+
+ private void resetIdle() {
+ if (closed) {
+ return;
+ }
+ if (idleTimerId != -1L) {
+ vertx.cancelTimer(idleTimerId);
+ }
+ idleTimerId = vertx.setTimer(idleMillis, id -> onIdle());
+ }
+
+ private void onIdle() {
+ if (closed) {
+ return;
+ }
+ LOGGER.warn(ApiSheriffLogMessages.WARN.WEBSOCKET_IDLE_RECLAIM, routeId, Integer.toString(idleSeconds));
+ eventCounter.increment(EventType.WEBSOCKET_IDLE_TIMEOUT);
+ closeBoth((short) EventType.WEBSOCKET_IDLE_TIMEOUT.wsCloseCode(), "idle timeout");
+ }
+
+ private void abort(Throwable failure) {
+ LOGGER.debug(failure, "WebSocket relay error on route '%s': %s", routeId, failure.getMessage());
+ closeBoth(CLOSE_INTERNAL_ERROR, "relay error");
+ }
+
+ private void closeBoth(short code, @Nullable String reason) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ if (idleTimerId != -1L) {
+ vertx.cancelTimer(idleTimerId);
+ idleTimerId = -1L;
+ }
+ closeLeg(clientWs, code, reason);
+ closeLeg(upstreamWs, code, reason);
+ }
+
+ private static void closeLeg(WebSocketBase ws, short code, @Nullable String reason) {
+ if (!ws.isClosed()) {
+ ws.close(code, reason);
+ }
+ }
+
+ private static short resolveCloseCode(@Nullable Short code) {
+ return code != null ? code : CLOSE_NORMAL;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java
index 79758fa6..32d85462 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java
@@ -84,14 +84,35 @@ public enum EventType {
/** The circuit breaker is open; the upstream was not called. */
UPSTREAM_CIRCUIT_OPEN(EventCategory.UPSTREAM, 503),
/** The upstream call exceeded its configured timeout. */
- UPSTREAM_TIMEOUT(EventCategory.UPSTREAM, 504);
+ UPSTREAM_TIMEOUT(EventCategory.UPSTREAM, 504),
+
+ // --- WebSocket (403 on the handshake; 1001 "Going Away" close on the established relay) ---
+
+ /**
+ * A WebSocket upgrade presented a foreign or absent {@code Origin} against the route's
+ * effective allowlist (GW-09 / cross-site WebSocket hijacking). Rejected as an HTTP
+ * {@code 403} before the {@code 101} upgrade completes, so it carries an HTTP mapping.
+ */
+ WEBSOCKET_ORIGIN_REJECTED(EventCategory.AUTHORIZATION, 403),
+ /**
+ * An established WebSocket relay was reclaimed after exceeding its per-route
+ * {@code idle_timeout_seconds}. It occurs after the {@code 101} upgrade, so it has no HTTP
+ * mapping; the edge closes both legs with WebSocket close code {@code 1001} (Going Away).
+ */
+ WEBSOCKET_IDLE_TIMEOUT(null, 0, 1001);
private final @Nullable EventCategory category;
private final int httpStatus;
+ private final int wsCloseCode;
EventType(@Nullable EventCategory category, int httpStatus) {
+ this(category, httpStatus, 0);
+ }
+
+ EventType(@Nullable EventCategory category, int httpStatus, int wsCloseCode) {
this.category = category;
this.httpStatus = httpStatus;
+ this.wsCloseCode = wsCloseCode;
}
/**
@@ -122,4 +143,12 @@ public boolean isFailure() {
public boolean hasHttpMapping() {
return httpStatus > 0;
}
+
+ /**
+ * @return the WebSocket close code the edge sends when this event terminates an established
+ * relay, or {@code 0} when the event has no WebSocket-close mapping
+ */
+ public int wsCloseCode() {
+ return wsCloseCode;
+ }
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/OriginValidationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/OriginValidationStage.java
new file mode 100644
index 00000000..4d41e2d6
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/OriginValidationStage.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.pipeline;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.ApiSheriffLogMessages;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.tools.logging.CuiLogger;
+
+/**
+ * The WebSocket-upgrade Origin gate (GW-09, cross-site WebSocket hijacking).
+ *
+ * On a {@code protocol: websocket} upgrade, the handshake {@code Origin} header is validated against
+ * the route's effective, boot-materialized allowlist — exact-match, case-insensitive on host (the
+ * allowlist is lower-cased once at route-table assembly, so the inbound {@code Origin} is compared
+ * lower-cased here). The allowlist is fail-closed: an absent {@code Origin}, or one
+ * that is not in the allowlist, rejects the upgrade with a {@link EventType#WEBSOCKET_ORIGIN_REJECTED}
+ * {@link GatewayException} (rendered as HTTP {@code 403}) before the upstream is dialed —
+ * there is no "any origin" default. A route with an empty allowlist declares no enforcement (only a
+ * non-bearer route reaches boot with an empty allowlist; a bearer WebSocket route is fail-closed to a
+ * non-empty allowlist at boot), so its upgrade proceeds without an Origin check.
+ *
+ * Framework-agnostic: the stage consumes the {@link PipelineRequest} carrier and the resolved
+ * allowlist, and carries no Vert.x / Quarkus types. Security-relevant rejections are logged with the
+ * route id and the rejection disposition only — never the raw offending {@code Origin} value.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class OriginValidationStage {
+
+ private static final CuiLogger LOGGER = new CuiLogger(OriginValidationStage.class);
+
+ private static final String ORIGIN_HEADER = "Origin";
+ private static final String ABSENT = "absent";
+ private static final String FOREIGN = "foreign";
+
+ /**
+ * Validates the handshake {@code Origin} against the route's effective allowlist.
+ *
+ * @param request the pipeline request carrier
+ * @param routeId the route id, for the security-relevant rejection log
+ * @param allowedOrigins the route's effective, lower-cased exact-match Origin allowlist; empty
+ * means no enforcement (a non-bearer route without an allowlist)
+ * @throws GatewayException carrying {@link EventType#WEBSOCKET_ORIGIN_REJECTED} when the origin
+ * is absent or not in a non-empty allowlist
+ */
+ public void validate(PipelineRequest request, String routeId, Set allowedOrigins) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(routeId, "routeId");
+ Objects.requireNonNull(allowedOrigins, "allowedOrigins");
+ if (allowedOrigins.isEmpty()) {
+ return;
+ }
+ Optional origin = request.firstHeader(ORIGIN_HEADER);
+ if (origin.isEmpty()) {
+ throw rejected(routeId, ABSENT);
+ }
+ if (!allowedOrigins.contains(origin.get().toLowerCase(Locale.ROOT))) {
+ throw rejected(routeId, FOREIGN);
+ }
+ }
+
+ private static GatewayException rejected(String routeId, String disposition) {
+ LOGGER.warn(ApiSheriffLogMessages.WARN.WEBSOCKET_ORIGIN_REJECTED, routeId, disposition);
+ return new GatewayException(EventType.WEBSOCKET_ORIGIN_REJECTED,
+ "WebSocket upgrade rejected on route '" + routeId + "': " + disposition + " origin");
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
index 238f76dc..9c02c558 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
@@ -39,6 +39,7 @@
import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
import de.cuioss.sheriff.api.config.model.UpstreamConfig;
import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+import de.cuioss.sheriff.api.config.model.WebSocketConfig;
import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -91,6 +92,7 @@
AssetConfig.class,
AssetConfig.Source.class,
RateLimitConfig.class,
+ WebSocketConfig.class,
HttpMethod.class,
Protocol.class,
AnchorType.class,
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java
deleted file mode 100644
index c7cca891..00000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package de.cuioss.sheriff.api.quarkus;
-
-import de.cuioss.sheriff.api.events.EventCategory;
-import de.cuioss.sheriff.api.events.EventType;
-import de.cuioss.sheriff.api.events.GatewayException;
-import de.cuioss.sheriff.token.commons.events.SecurityEventCounter;
-import de.cuioss.sheriff.token.validation.exception.TokenValidationException;
-
-import jakarta.ws.rs.core.Response;
-import jakarta.ws.rs.ext.ExceptionMapper;
-import jakarta.ws.rs.ext.Provider;
-
-/**
- * Framework-edge handler that renders a {@link GatewayException} as an RFC 9457
- * {@code application/problem+json} response. The problem {@code type} is named by the
- * failing event's {@link EventCategory}; the {@code status} is the event's HTTP status;
- * no internal detail is leaked into the body.
- *
- * {@link TokenValidationException} thrown by the token-sheriff validator is normalized to
- * the gateway's {@link EventType#TOKEN_MISSING} / {@link EventType#TOKEN_INVALID}
- * equivalents via {@link #translate(TokenValidationException)}, so bearer-token failures
- * render through the same contract.
- *
- * @author API Sheriff Team
- * @since 1.0
- */
-@Provider
-public class GatewayExceptionMapper implements ExceptionMapper {
-
- private static final String PROBLEM_JSON = "application/problem+json";
- private static final int INTERNAL_ERROR = 500;
-
- @Override
- public Response toResponse(GatewayException exception) {
- return render(exception.getEventType());
- }
-
- /**
- * Renders the RFC 9457 problem+json response for the given event type.
- *
- * @param eventType the failure event
- * @return a problem+json {@link Response} carrying {@code type}, {@code title}, and {@code status}
- */
- static Response render(EventType eventType) {
- int status = eventType.hasHttpMapping() ? eventType.httpStatus() : INTERNAL_ERROR;
- EventCategory category = eventType.category();
- String type = category != null ? category.problemType() : "about:blank";
- String title = category != null ? category.title() : "Internal Server Error";
- String body = "{\"type\":\"" + type + "\",\"title\":\"" + title + "\",\"status\":" + status + "}";
- return Response.status(status)
- .type(PROBLEM_JSON)
- .entity(body)
- .build();
- }
-
- /**
- * Normalizes a token-validation failure into the gateway's authentication event. An
- * empty / missing token maps to {@link EventType#TOKEN_MISSING}; every other validation
- * failure maps to {@link EventType#TOKEN_INVALID}.
- *
- * @param exception the token-sheriff validation failure
- * @return an equivalent {@link GatewayException} carrying the mapped event type
- */
- static GatewayException translate(TokenValidationException exception) {
- EventType mapped = exception.getEventType() == SecurityEventCounter.EventType.TOKEN_EMPTY
- ? EventType.TOKEN_MISSING
- : EventType.TOKEN_INVALID;
- return new GatewayException(mapped, mapped.name(), exception);
- }
-}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/GrpcProtocolProcessor.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/GrpcProtocolProcessor.java
new file mode 100644
index 00000000..334f0871
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/GrpcProtocolProcessor.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.routing;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+/**
+ * The gRPC processor. Every gRPC call is an HTTP/2 {@code POST} to a service/method path, so the
+ * only verb this processor serves is {@link HttpMethod#POST}. The gRPC deltas over a plain HTTP
+ * proxy — a forced-h2 upstream dial, opaque length-prefixed frame streaming, response-trailer relay
+ * ({@code grpc-status} / {@code grpc-message}), and a trailers-only rejection response — are owned by
+ * the edge's {@code GrpcDispatchStage} and {@code GrpcStatusMapper}; this processor carries only the
+ * verb semantics.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class GrpcProtocolProcessor implements ProtocolProcessor {
+
+ private static final Set GRPC_METHODS = EnumSet.of(HttpMethod.POST);
+
+ @Override
+ public String id() {
+ return "grpc";
+ }
+
+ @Override
+ public Set standardMethods() {
+ return GRPC_METHODS;
+ }
+
+ @Override
+ public boolean supports(HttpMethod method) {
+ return GRPC_METHODS.contains(method);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java
index f358c351..3d129c3b 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java
@@ -28,8 +28,14 @@
/**
* Boot-time registry mapping each supported {@link Protocol} to its {@link ProtocolProcessor}.
* {@code HTTP} and {@code GRAPHQL} share a single {@link HttpProtocolProcessor} instance;
- * {@code GRPC} and {@code WEBSOCKET} are deliberately absent, so {@link #require(Protocol, String)}
- * fails boot with a clear not-yet-implemented error for a route requesting them.
+ * {@code WEBSOCKET} is served by a dedicated {@link WebSocketProtocolProcessor}, and {@code GRPC}
+ * by a dedicated {@link GrpcProtocolProcessor}. Every supported protocol is now registered, so
+ * {@link #require(Protocol, String)} only fails boot for a protocol that is genuinely absent from
+ * the {@link Protocol} enum's served set.
+ *
+ * A {@code protocol: websocket} route with {@code session} auth is still boot-rejected upstream by
+ * {@code RouteRuntimeAssembler} (session-auth WebSocket routes remain unimplemented until Plan 07);
+ * this registry only decides which processor serves the protocol.
*
* @author API Sheriff Team
* @since 1.0
@@ -39,19 +45,22 @@ public final class ProtocolProcessorRegistry {
private final Map processors;
/**
- * Builds the default registry: a shared HTTP processor for {@code HTTP} and {@code GRAPHQL}.
+ * Builds the default registry: a shared HTTP processor for {@code HTTP} and {@code GRAPHQL},
+ * a dedicated WebSocket processor for {@code WEBSOCKET}, and a dedicated gRPC processor for
+ * {@code GRPC}.
*/
public ProtocolProcessorRegistry() {
HttpProtocolProcessor http = new HttpProtocolProcessor();
Map map = new EnumMap<>(Protocol.class);
map.put(Protocol.HTTP, http);
map.put(Protocol.GRAPHQL, http);
+ map.put(Protocol.WEBSOCKET, new WebSocketProtocolProcessor());
+ map.put(Protocol.GRPC, new GrpcProtocolProcessor());
this.processors = Collections.unmodifiableMap(map);
}
/**
- * Resolves the processor for a route's protocol, failing boot when the protocol is not
- * yet implemented ({@code GRPC} / {@code WEBSOCKET}).
+ * Resolves the processor for a route's protocol, failing boot when the protocol is not served.
*
* @param protocol the route's effective protocol
* @param routeId the route id, for the failure message
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java
index 9c8e25b3..d7463f12 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java
@@ -131,4 +131,21 @@ public final class RouteRuntime {
*/
@Builder.Default
private final Optional assetSource = Optional.empty();
+
+ /**
+ * The materialized, lower-cased exact-match {@code Origin} allowlist enforced on a WebSocket
+ * upgrade (GW-09 / CSWSH). Empty for a non-WebSocket route, and empty (no enforcement) for a
+ * non-bearer WebSocket route that declares no allowlist — a bearer WebSocket route always
+ * resolves a non-empty allowlist (fail-closed at boot).
+ */
+ @Builder.Default
+ private final Set effectiveAllowedOrigins = Set.of();
+
+ /**
+ * The materialized WebSocket idle timeout with the {@code 300}-second default applied; empty
+ * for a non-WebSocket route. Bounds an established relay — no frame in either direction, with
+ * ping/pong counting as activity.
+ */
+ @Builder.Default
+ private final Optional effectiveWebSocketIdleTimeoutSeconds = Optional.empty();
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/WebSocketProtocolProcessor.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/WebSocketProtocolProcessor.java
new file mode 100644
index 00000000..0d22b159
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/WebSocketProtocolProcessor.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.routing;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+/**
+ * The WebSocket processor. A WebSocket route is entered by the HTTP upgrade handshake — a
+ * {@code GET} carrying {@code Upgrade: websocket} — so the only proxyable verb it serves is
+ * {@link HttpMethod#GET}. Once the handshake is validated (Origin allowlist) and the upstream
+ * confirms {@code 101}, the connection leaves the request/response verb model entirely and is
+ * relayed opaquely by the edge's {@code WebSocketRelayStage}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class WebSocketProtocolProcessor implements ProtocolProcessor {
+
+ private static final Set UPGRADE_METHODS = EnumSet.of(HttpMethod.GET);
+
+ @Override
+ public String id() {
+ return "websocket";
+ }
+
+ @Override
+ public Set standardMethods() {
+ return UPGRADE_METHODS;
+ }
+
+ @Override
+ public boolean supports(HttpMethod method) {
+ return UPGRADE_METHODS.contains(method);
+ }
+}
diff --git a/api-sheriff/src/main/resources/schema/endpoint.schema.json b/api-sheriff/src/main/resources/schema/endpoint.schema.json
index 20cf8880..48a9cdd9 100644
--- a/api-sheriff/src/main/resources/schema/endpoint.schema.json
+++ b/api-sheriff/src/main/resources/schema/endpoint.schema.json
@@ -203,6 +203,22 @@
"requests_per_second": { "type": "integer" },
"burst": { "type": "integer" }
}
+ },
+ "websocket": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "The per-route WebSocket block, valid only on a protocol: websocket route (ADR-0015). allowed_origins is a fail-closed, deny-by-default allowlist of exact-match Origin strings (scheme + host + port, no wildcards); an absent or empty allowlist on a bearer WebSocket route rejects the upgrade at boot. idle_timeout_seconds bounds an established relay and defaults to 300 when absent. The bearer-route allowlist mandatoriness, wildcard rejection, and positive-timeout rules are enforced in code, not schema.",
+ "properties": {
+ "allowed_origins": {
+ "type": "array",
+ "description": "Exact-match, case-insensitive-host Origin allowlist (scheme + host + port). No wildcards.",
+ "items": { "type": "string" }
+ },
+ "idle_timeout_seconds": {
+ "type": "integer",
+ "description": "Per-route idle timeout in seconds; a positive integer. Defaults to 300 when omitted."
+ }
+ }
}
}
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
index 170ffab0..f67db4fa 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
@@ -135,6 +135,16 @@ private static RouteConfig routeWithToggles(String id, Boolean retry, Boolean no
return RouteConfig.builder().id(id).match(match("/" + id)).upstream(Optional.of(upstream.build())).build();
}
+ private static RouteConfig routeWithUpstreamPath(String id, String pathPrefix, String upstreamPath) {
+ UpstreamConfig upstream = UpstreamConfig.builder().path(Optional.of(upstreamPath)).build();
+ return RouteConfig.builder().id(id).match(match(pathPrefix)).upstream(Optional.of(upstream)).build();
+ }
+
+ private static ResolvedTopology topologyWithBasePath(String alias, String basePath) {
+ return new ResolvedTopology(Map.of(alias,
+ new ResolvedUpstream("https", alias.toLowerCase(Locale.ROOT) + ".internal", 443, basePath)));
+ }
+
private static EndpointConfig.EndpointConfigBuilder endpoint(String id, String alias) {
return EndpointConfig.builder().id(id).enabled(true).baseUrl(alias)
.auth(Optional.of(new AuthConfig("none", List.of())));
@@ -171,6 +181,74 @@ private static ResolvedRoute find(RouteTable table, String id) {
return table.routes().stream().filter(route -> route.id().equals(id)).findFirst().orElseThrow();
}
+ @Nested
+ @DisplayName("Route-level upstream.path materialization")
+ class UpstreamPathMaterialization {
+
+ @Test
+ @DisplayName("Should materialize a non-blank route upstream.path as the effective base path")
+ void shouldMaterializeRouteUpstreamPath() {
+ EndpointConfig endpoint = endpoint("grpc", "GRPC")
+ .routes(List.of(routeWithUpstreamPath("grpc-echo",
+ "/de.cuioss.sheriff.api.integration.grpc.Echo",
+ "/de.cuioss.sheriff.api.integration.grpc.Echo")))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("GRPC"));
+
+ ResolvedUpstream upstream = find(table, "grpc-echo").upstream().orElseThrow();
+ assertAll("the route upstream.path becomes the effective base path so the service segment survives",
+ () -> assertEquals("/de.cuioss.sheriff.api.integration.grpc.Echo", upstream.basePath(),
+ "the bare-service route path is materialized as the upstream base path"),
+ () -> assertEquals("grpc.internal", upstream.host(),
+ "the alias host is carried through unchanged"),
+ () -> assertEquals(443, upstream.port(), "the alias port is carried through unchanged"));
+ }
+
+ @Test
+ @DisplayName("Should replace a non-empty alias base path with the route upstream.path (not append)")
+ void shouldReplaceAliasBasePathWithRouteUpstreamPath() {
+ EndpointConfig endpoint = endpoint("httpbin", "UPSTREAM")
+ .routes(List.of(routeWithUpstreamPath("httpbin-graphql", "/graphql", "/anything/graphql")))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint),
+ topologyWithBasePath("UPSTREAM", "/anything"));
+
+ assertEquals("/anything/graphql", find(table, "httpbin-graphql").upstream().orElseThrow().basePath(),
+ "the route upstream.path replaces the alias base path wholesale — it must not be doubled to "
+ + "/anything/anything/graphql");
+ }
+
+ @Test
+ @DisplayName("Should keep the alias base path when a route declares no upstream.path")
+ void shouldKeepAliasBasePathWithoutRouteUpstreamPath() {
+ EndpointConfig endpoint = endpoint("httpbin", "UPSTREAM")
+ .routes(List.of(routeWithPrefix("httpbin-proxy", "/proxy", HttpMethod.GET)))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint),
+ topologyWithBasePath("UPSTREAM", "/anything"));
+
+ assertEquals("/anything", find(table, "httpbin-proxy").upstream().orElseThrow().basePath(),
+ "a route without upstream.path keeps the alias-derived base path — the default proxy behavior");
+ }
+
+ @Test
+ @DisplayName("Should keep the alias base path when the route upstream.path is blank")
+ void shouldKeepAliasBasePathWhenRouteUpstreamPathBlank() {
+ EndpointConfig endpoint = endpoint("httpbin", "UPSTREAM")
+ .routes(List.of(routeWithUpstreamPath("httpbin-blank", "/proxy", " ")))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint),
+ topologyWithBasePath("UPSTREAM", "/anything"));
+
+ assertEquals("/anything", find(table, "httpbin-blank").upstream().orElseThrow().basePath(),
+ "a blank upstream.path is treated as absent, keeping the alias base path");
+ }
+ }
+
@Nested
@DisplayName("Merge, ordering, and disjointness")
class MergeOrderDisjointness {
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
index 1508f38f..9d6272a8 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
@@ -27,6 +27,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
@@ -175,9 +176,14 @@ private static RouteConfig routeConfig() {
Map.of("X-Gateway", "api-sheriff"))))
.upstream(Optional.of(upstreamConfig()))
.rateLimit(Optional.of(new RateLimitConfig(Optional.of(100), Optional.of(200))))
+ .websocket(Optional.of(webSocketConfig()))
.build();
}
+ private static WebSocketConfig webSocketConfig() {
+ return new WebSocketConfig(List.of("https://app.example.com"), Optional.of(300));
+ }
+
private static ResolvedRoute resolvedRoute() {
return ResolvedRoute.builder()
.id("orders-read")
@@ -337,6 +343,10 @@ static Stream valueObjects() {
new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "sheriff")),
new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "sheriff")),
new ForwardConfig(List.of(), List.of(), Map.of())),
+ voCase("WebSocketConfig",
+ new WebSocketConfig(List.of("https://app.example.com"), Optional.of(300)),
+ new WebSocketConfig(List.of("https://app.example.com"), Optional.of(300)),
+ new WebSocketConfig(List.of("https://other.example.com"), Optional.of(60))),
voCase("UpstreamConfig", upstreamConfig(), upstreamConfig(), UpstreamConfig.builder().build()),
voCase("UpstreamConfig.Retry",
new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true)),
@@ -443,10 +453,11 @@ void endpointConfigNormalizesAbsentCollectionsAndOptionals() {
@Test
void routeConfigNormalizesAbsentAnchor() {
- RouteConfig cfg = new RouteConfig("id", null, null, matchConfig(), null, null, null, null, null, null);
+ RouteConfig cfg = new RouteConfig("id", null, null, matchConfig(), null, null, null, null, null, null, null);
assertTrue(cfg.anchor().isEmpty());
assertTrue(cfg.auth().isEmpty());
assertTrue(cfg.securityFilter().isEmpty());
+ assertTrue(cfg.websocket().isEmpty());
}
@Test
@@ -461,7 +472,7 @@ void anchorConfigNormalizesAbsentComponents() {
@Test
void resolvedRouteNormalizesAbsentOptionals() {
ResolvedRoute cfg = new ResolvedRoute("id", null, null, matchConfig(), auth(), null, null, null, true,
- true, Optional.of(resolvedUpstream()), Optional.empty(), null);
+ true, Optional.of(resolvedUpstream()), Optional.empty(), null, null, null);
assertTrue(cfg.anchor().isEmpty());
assertTrue(cfg.effectiveSecurityFilter().isEmpty());
assertTrue(cfg.effectiveSecurityHeaders().isEmpty());
@@ -471,6 +482,10 @@ void resolvedRouteNormalizesAbsentOptionals() {
"an absent forward block normalizes to a deny-by-default empty allowlist");
assertTrue(cfg.effectiveForward().queryAllow().isEmpty());
assertTrue(cfg.effectiveForward().setHeaders().isEmpty());
+ assertTrue(cfg.effectiveAllowedOrigins().isEmpty(),
+ "an absent WebSocket origin allowlist normalizes to an empty set");
+ assertTrue(cfg.effectiveWebSocketIdleTimeoutSeconds().isEmpty(),
+ "an absent WebSocket idle timeout normalizes to Optional.empty()");
}
@Test
@@ -486,6 +501,14 @@ void collectionBearingRecordsNormalizeNullToEmpty() {
.allowedPaths().isEmpty());
assertTrue(new OidcConfig.Csrf(null).trustedOrigins().isEmpty());
assertTrue(new AnchorConfig("api", "/api", AnchorType.PROXY, AccessLevel.AUTHENTICATED, null, null, null, null).allowedMethods().isEmpty());
+ assertTrue(new WebSocketConfig(null, null).allowedOrigins().isEmpty());
+ }
+
+ @Test
+ void webSocketConfigNormalizesAbsentComponents() {
+ WebSocketConfig cfg = new WebSocketConfig(null, null);
+ assertTrue(cfg.allowedOrigins().isEmpty());
+ assertTrue(cfg.idleTimeoutSeconds().isEmpty());
}
}
@@ -530,6 +553,17 @@ void mapComponentIsDecoupledAndUnmodifiable() {
assertEquals(Map.of("X-Gateway", "sheriff"), cfg.setHeaders());
assertThrows(UnsupportedOperationException.class, () -> cfg.setHeaders().put("X-New", "v"));
}
+
+ @Test
+ void webSocketAllowedOriginsIsDecoupledAndUnmodifiable() {
+ List source = new ArrayList<>(List.of("https://app.example.com"));
+ WebSocketConfig cfg = WebSocketConfig.builder().allowedOrigins(source).build();
+ source.add("https://leak.example.com");
+ List origins = cfg.allowedOrigins();
+ assertEquals(List.of("https://app.example.com"), origins,
+ "mutating the source list after construction must not affect the record");
+ assertThrows(UnsupportedOperationException.class, () -> origins.add("https://new.example.com"));
+ }
}
// --- Mandatory fields --------------------------------------------------
@@ -612,31 +646,31 @@ void routeConfigRequiresIdAndMatch() {
MatchConfig match = matchConfig();
assertThrows(NullPointerException.class, () -> new RouteConfig(null, Optional.empty(), Optional.empty(),
match, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
- Optional.empty()));
+ Optional.empty(), Optional.empty()));
assertThrows(NullPointerException.class, () -> new RouteConfig("id", Optional.empty(), Optional.empty(),
null, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
- Optional.empty()));
+ Optional.empty(), Optional.empty()));
}
@Test
void resolvedRouteRequiresIdMatchAuthAndExactlyOneTerminalAction() {
assertThrows(NullPointerException.class, () -> new ResolvedRoute(null, Protocol.HTTP, Optional.empty(),
matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true,
- Optional.of(resolvedUpstream()), Optional.empty(), null));
+ Optional.of(resolvedUpstream()), Optional.empty(), null, null, null));
assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(), null,
auth(), List.of(), Optional.empty(), Optional.empty(), true, true, Optional.of(resolvedUpstream()),
- Optional.empty(), null));
+ Optional.empty(), null, null, null));
assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(),
matchConfig(), null, List.of(), Optional.empty(), Optional.empty(), true, true,
- Optional.of(resolvedUpstream()), Optional.empty(), null));
+ Optional.of(resolvedUpstream()), Optional.empty(), null, null, null));
assertThrows(IllegalArgumentException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(),
matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true, Optional.empty(),
- Optional.empty(), null),
+ Optional.empty(), null, null, null),
"a route with neither an upstream nor an asset terminal action is rejected (XOR)");
assertThrows(IllegalArgumentException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(),
matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true,
Optional.of(resolvedUpstream()), Optional.of(ResolvedAsset.directory("/srv", AccessLevel.PUBLIC)),
- null),
+ null, null, null),
"a route with both an upstream and an asset terminal action is rejected (XOR)");
}
}
@@ -677,6 +711,37 @@ void routeConfigExposesAnchorOverride() {
assertEquals(Optional.of("bff"), cfg.anchor());
}
+ @Test
+ void routeConfigExposesWebSocketBlock() {
+ RouteConfig cfg = RouteConfig.builder().id("ws").protocol(Optional.of(Protocol.WEBSOCKET))
+ .match(matchConfig()).websocket(Optional.of(webSocketConfig())).build();
+ assertEquals(Optional.of(webSocketConfig()), cfg.websocket());
+ assertEquals(List.of("https://app.example.com"),
+ cfg.websocket().orElseThrow().allowedOrigins());
+ assertEquals(Optional.of(300), cfg.websocket().orElseThrow().idleTimeoutSeconds());
+ }
+
+ @Test
+ void routeConfigWebSocketDefaultsToEmpty() {
+ RouteConfig cfg = RouteConfig.builder().id("http").match(matchConfig()).build();
+ assertTrue(cfg.websocket().isEmpty(), "a non-WebSocket route carries no websocket block");
+ }
+
+ @Test
+ void resolvedRouteCarriesMaterializedWebSocketSettings() {
+ ResolvedRoute cfg = ResolvedRoute.builder()
+ .id("ws")
+ .protocol(Protocol.WEBSOCKET)
+ .match(matchConfig())
+ .effectiveAuth(auth())
+ .upstream(Optional.of(resolvedUpstream()))
+ .effectiveAllowedOrigins(Set.of("https://app.example.com"))
+ .effectiveWebSocketIdleTimeoutSeconds(Optional.of(300))
+ .build();
+ assertEquals(Set.of("https://app.example.com"), cfg.effectiveAllowedOrigins());
+ assertEquals(Optional.of(300), cfg.effectiveWebSocketIdleTimeoutSeconds());
+ }
+
@Test
void anchorConfigExposesEveryPolicyBlock() {
AnchorConfig cfg = anchorConfig();
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
index dce55143..6424a20c 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
@@ -40,6 +40,7 @@
import de.cuioss.sheriff.api.config.model.IssuerConfig;
import de.cuioss.sheriff.api.config.model.MatchConfig;
import de.cuioss.sheriff.api.config.model.OidcConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
import de.cuioss.sheriff.api.config.model.ResolvedTopology;
import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
import de.cuioss.sheriff.api.config.model.RouteConfig;
@@ -47,6 +48,7 @@
import de.cuioss.sheriff.api.config.model.TlsConfig;
import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
import de.cuioss.sheriff.api.config.model.UpstreamConfig;
+import de.cuioss.sheriff.api.config.model.WebSocketConfig;
import de.cuioss.test.generator.junit.EnableGeneratorController;
import de.cuioss.test.generator.junit.parameterized.GeneratorType;
import de.cuioss.test.generator.junit.parameterized.GeneratorsSource;
@@ -940,6 +942,96 @@ void shouldAcceptDisjointSiblingAnchorsForAnyGeneratedSegment(String segment) {
}
}
+ @Nested
+ @DisplayName("gRPC anchor-namespace containment exemption (ADR-0007)")
+ class GrpcNamespaceExemption {
+
+ private static final String ECHO_PATH = "/de.cuioss.sheriff.api.integration.grpc.Echo";
+ private static final String SECURE_ECHO_PATH = "/de.cuioss.sheriff.api.integration.grpc.SecureEcho";
+
+ private static RouteConfig grpcRoute(String id, String prefix, String anchorName, Optional auth) {
+ return RouteConfig.builder()
+ .id(id)
+ .protocol(Optional.of(Protocol.GRPC))
+ .anchor(anchorName == null ? Optional.empty() : Optional.of(anchorName))
+ .match(match(prefix, HttpMethod.POST))
+ .auth(auth)
+ .build();
+ }
+
+ @Test
+ @DisplayName("Rule 3 exemption: a gRPC route on a bare service path outside its declared anchor is accepted")
+ void shouldExemptGrpcRouteFromDeclaredAnchorContainment() {
+ GatewayConfig gateway = gatewayWithAnchors(Map.of("grpc", anchor("grpc", "/grpc", null)));
+ EndpointConfig endpoint = anchoredEndpoint("echo", "ECHO", "grpc",
+ Optional.of(new AuthConfig("none", List.of())),
+ grpcRoute("grpc-echo", ECHO_PATH, "grpc", Optional.empty()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ECHO"));
+
+ assertTrue(errors.isEmpty(),
+ () -> "a gRPC route on a service-rooted path outside its anchor namespace must not be rejected, got: "
+ + errors);
+ }
+
+ @Test
+ @DisplayName("Rule 3/4 exemption: two gRPC routes under one anchor on bare service paths boot cleanly (native IT scenario)")
+ void shouldAcceptTwoGrpcRoutesUnderOneAnchorOnBareServicePaths() {
+ GatewayConfig gateway = gatewayWithAnchors(Map.of("grpc", anchor("grpc", "/grpc", null)));
+ EndpointConfig endpoint = anchoredEndpoint("echo", "ECHO", "grpc",
+ Optional.of(new AuthConfig("none", List.of())),
+ grpcRoute("grpc-echo", ECHO_PATH, "grpc", Optional.empty()),
+ grpcRoute("grpc-bearer", SECURE_ECHO_PATH, "grpc", Optional.empty()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ECHO"));
+
+ assertTrue(errors.isEmpty(),
+ () -> "the two gRPC routes that failed the native IT boot must now validate cleanly, got: " + errors);
+ }
+
+ @Test
+ @DisplayName("Rule 4 exemption: a gRPC route that would squat inside a catch-all anchor namespace is accepted")
+ void shouldExemptGrpcRouteFromUndeclaredSquatterRule() {
+ GatewayConfig gateway = gatewayWithAnchors(Map.of("root", anchor("root", "/", null)));
+ EndpointConfig endpoint = anchoredEndpoint("echo", "ECHO", null,
+ Optional.of(new AuthConfig("none", List.of())),
+ grpcRoute("grpc-echo", ECHO_PATH, null, Optional.empty()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ECHO"));
+
+ assertTrue(errors.isEmpty(),
+ () -> "a gRPC route inside a catch-all anchor namespace must not be rejected as a squatter, got: "
+ + errors);
+ }
+
+ @Test
+ @DisplayName("Containment stays enforced for a websocket route outside its declared anchor namespace")
+ void shouldStillEnforceContainmentForNonGrpcRoute() {
+ GatewayConfig gateway = gatewayWithAnchors(Map.of("api", anchor("api", "/api", null)));
+ RouteConfig websocket = RouteConfig.builder().id("ws").anchor(Optional.of("api"))
+ .protocol(Optional.of(Protocol.WEBSOCKET))
+ .match(match("/billing", HttpMethod.GET))
+ .auth(Optional.of(new AuthConfig("none", List.of()))).build();
+ EndpointConfig endpoint = anchoredEndpoint("orders", "ORDERS", "api", Optional.empty(), websocket);
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertHasError(errors, "/endpoint/routes", "is not inside its declared anchor 'api'");
+ }
+
+ @Test
+ @DisplayName("Auth floor stays enforced for a gRPC route: effective 'none' still weakens a non-none anchor floor")
+ void shouldStillEnforceAuthFloorForGrpcRoute() {
+ GatewayConfig gateway = gatewayWithAnchors(Map.of("grpc", anchor("grpc", "/grpc", "bearer")));
+ EndpointConfig endpoint = anchoredEndpoint("echo", "ECHO", "grpc", Optional.empty(),
+ grpcRoute("grpc-echo", ECHO_PATH, "grpc", Optional.of(new AuthConfig("none", List.of()))));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ECHO"));
+
+ assertHasError(errors, "/endpoint/routes", "weakens the anchor 'grpc' floor 'bearer'");
+ }
+ }
+
@Nested
@DisplayName("The fail-closed access→auth matrix (ADR-0013)")
class AccessAuthMatrix {
@@ -1109,4 +1201,136 @@ void shouldReportEveryViolationInOnePass() {
() -> assertHasError(errors, "/endpoint/routes", "outside the effective allowed_methods"));
}
}
+
+ @Nested
+ @DisplayName("Fail-closed WebSocket allowlist (ADR-0015)")
+ class WebSocketAllowlist {
+
+ private static GatewayConfig gatewayWithIssuer() {
+ return validGateway()
+ .tokenValidation(Optional.of(new TokenValidationConfig(List.of(
+ IssuerConfig.builder().name("main").issuer("https://idp.example").build()))))
+ .build();
+ }
+
+ private static EndpointConfig webSocketEndpoint(String alias, RouteConfig route) {
+ return EndpointConfig.builder()
+ .id("ws-ep").enabled(true).baseUrl(alias)
+ .auth(Optional.of(new AuthConfig("none", List.of())))
+ .routes(List.of(route))
+ .build();
+ }
+
+ private static RouteConfig webSocketRoute(String id, Optional websocket,
+ Optional auth) {
+ return RouteConfig.builder()
+ .id(id)
+ .protocol(Optional.of(Protocol.WEBSOCKET))
+ .match(match("/" + id, HttpMethod.GET))
+ .auth(auth)
+ .websocket(websocket)
+ .build();
+ }
+
+ private static Optional bearer() {
+ return Optional.of(new AuthConfig("bearer", List.of()));
+ }
+
+ @Test
+ @DisplayName("Should reject a bearer WebSocket route with no websocket block (fail-closed)")
+ void shouldRejectBearerWebSocketRouteWithAbsentAllowedOrigins() {
+ GatewayConfig gateway = gatewayWithIssuer();
+ EndpointConfig endpoint = webSocketEndpoint("WS", webSocketRoute("chat", Optional.empty(), bearer()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertHasError(errors, "/endpoint/routes", "must declare a non-empty allowed_origins allowlist");
+ }
+
+ @Test
+ @DisplayName("Should reject a bearer WebSocket route with an empty allowed_origins allowlist")
+ void shouldRejectBearerWebSocketRouteWithEmptyAllowedOrigins() {
+ GatewayConfig gateway = gatewayWithIssuer();
+ WebSocketConfig websocket = new WebSocketConfig(List.of(), Optional.empty());
+ EndpointConfig endpoint = webSocketEndpoint("WS",
+ webSocketRoute("chat", Optional.of(websocket), bearer()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertHasError(errors, "/endpoint/routes", "fail-closed");
+ }
+
+ @ParameterizedTest(name = "wildcard entry \"{0}\" is rejected")
+ @ValueSource(strings = {"*", "https://*.example.com"})
+ @DisplayName("Should reject wildcard entries in allowed_origins")
+ void shouldRejectWildcardAllowedOrigin(String wildcard) {
+ GatewayConfig gateway = gatewayWithIssuer();
+ WebSocketConfig websocket = new WebSocketConfig(List.of(wildcard), Optional.empty());
+ EndpointConfig endpoint = webSocketEndpoint("WS",
+ webSocketRoute("chat", Optional.of(websocket), bearer()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertHasError(errors, "/endpoint/routes", "wildcards are not permitted");
+ }
+
+ @ParameterizedTest(name = "idle_timeout_seconds = {0} is rejected")
+ @ValueSource(ints = {0, -1, -300})
+ @DisplayName("Should reject a non-positive idle_timeout_seconds")
+ void shouldRejectNonPositiveIdleTimeout(int timeout) {
+ GatewayConfig gateway = gatewayWithIssuer();
+ WebSocketConfig websocket = new WebSocketConfig(List.of("https://app.example.com"), Optional.of(timeout));
+ EndpointConfig endpoint = webSocketEndpoint("WS",
+ webSocketRoute("chat", Optional.of(websocket), bearer()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertHasError(errors, "/endpoint/routes", "idle_timeout_seconds must be a positive integer");
+ }
+
+ @Test
+ @DisplayName("Should accept a bearer WebSocket route with exact origins and a positive idle timeout")
+ void shouldAcceptBearerWebSocketRouteWithExactOrigins() {
+ GatewayConfig gateway = gatewayWithIssuer();
+ WebSocketConfig websocket = new WebSocketConfig(List.of("https://app.example.com"), Optional.of(60));
+ EndpointConfig endpoint = webSocketEndpoint("WS",
+ webSocketRoute("chat", Optional.of(websocket), bearer()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors);
+ }
+
+ @Test
+ @DisplayName("Should not require allowed_origins for a non-bearer WebSocket route")
+ void shouldNotRequireAllowedOriginsForNonBearerWebSocketRoute() {
+ GatewayConfig gateway = validGateway().build();
+ EndpointConfig endpoint = webSocketEndpoint("WS", webSocketRoute("chat", Optional.empty(), Optional.empty()));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertTrue(errors.isEmpty(),
+ () -> "a non-bearer WebSocket route may omit allowed_origins; got: " + errors);
+ }
+
+ @Test
+ @DisplayName("Should reject a non-websocket route that declares a websocket block")
+ void shouldRejectWebSocketBlockOnNonWebSocketRoute() {
+ GatewayConfig gateway = validGateway().build();
+ WebSocketConfig websocket = new WebSocketConfig(List.of("https://app.example.com"), Optional.of(60));
+ RouteConfig httpRoute = RouteConfig.builder()
+ .id("http-with-ws")
+ .protocol(Optional.of(Protocol.HTTP))
+ .match(match("/http-with-ws", HttpMethod.GET))
+ .auth(Optional.empty())
+ .websocket(Optional.of(websocket))
+ .build();
+ EndpointConfig endpoint = webSocketEndpoint("WS", httpRoute);
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("WS"));
+
+ assertHasError(errors, "/endpoint/routes",
+ "declares a websocket block but its protocol is not 'websocket'");
+ }
+ }
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java
index 01c05083..73e48ff3 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java
@@ -131,35 +131,45 @@ void failsBootForSessionAuth() {
}
@Test
- @DisplayName("fails boot fast for a gRPC route (unsupported protocol)")
- void failsBootForGrpcProtocol() {
+ @DisplayName("boots a gRPC route (now served by the gRPC processor)")
+ void bootsGrpcProtocol() {
// Arrange
RouteTable grpcTable = new RouteTable(List.of(
route("g", Protocol.GRPC, "none")));
- // Act
- GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(grpcTable),
- "gRPC is unsupported and must fail boot");
-
- // Assert
- assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(),
- "A gRPC route is rejected as an invalid configuration");
+ // Act + Assert — GRPC is now registered, so a gRPC route assembles cleanly at boot (the boot
+ // rejection was removed with the gRPC processor).
+ assertDoesNotThrow(() -> newEdge(grpcTable),
+ "A gRPC route is served by the registered gRPC processor and boots cleanly");
}
@Test
- @DisplayName("fails boot fast for a WebSocket route (unsupported protocol)")
- void failsBootForWebSocketProtocol() {
+ @DisplayName("boots a WebSocket route (now served by the WebSocket processor)")
+ void bootsWebSocketProtocol() {
// Arrange
RouteTable webSocketTable = new RouteTable(List.of(
route("w", Protocol.WEBSOCKET, "none")));
- // Act
+ // Act + Assert — WEBSOCKET is now registered, so a WebSocket route assembles cleanly at boot
+ // (the boot rejection was removed with the WebSocket processor).
+ assertDoesNotThrow(() -> newEdge(webSocketTable),
+ "A WebSocket route is served by the registered WebSocket processor and boots cleanly");
+ }
+
+ @Test
+ @DisplayName("fails boot fast for a session-auth WebSocket route (session unimplemented)")
+ void failsBootForSessionAuthWebSocket() {
+ // Arrange
+ RouteTable webSocketTable = new RouteTable(List.of(
+ route("w", Protocol.WEBSOCKET, "session")));
+
+ // Act — session-auth WebSocket routes remain unimplemented until Plan 07
GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(webSocketTable),
- "WebSocket is unsupported and must fail boot");
+ "Session-auth WebSocket routes are not yet implemented and must fail boot");
// Assert
assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(),
- "A WebSocket route is rejected as an invalid configuration");
+ "A session-auth WebSocket route is rejected as an invalid configuration");
}
@Test
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GrpcDispatchStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GrpcDispatchStageTest.java
new file mode 100644
index 00000000..dbb3919b
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GrpcDispatchStageTest.java
@@ -0,0 +1,435 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.edge;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.ConnectException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayEventCounter;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.Handler;
+import io.vertx.core.MultiMap;
+import io.vertx.core.Vertx;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.http.HttpServerResponse;
+import io.vertx.core.streams.ReadStream;
+import jakarta.enterprise.util.TypeLiteral;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contract of the {@code protocol: grpc} dispatch path (deliverable 6): the forced-HTTP/2 upstream
+ * client wiring (a gRPC route holds a client distinct from an HTTP/1.1 route to the same host:port),
+ * the h2-negotiation-failure → gRPC {@code UNAVAILABLE} mapping, the response-trailer relay carrying
+ * {@code grpc-status} / {@code grpc-message} to the client, and the shared inbound-transport GW-08
+ * abuse bounds that hold on the gRPC path without a per-protocol relaxation.
+ */
+@DisplayName("GrpcDispatchStage — forced-h2 wiring, UNAVAILABLE mapping, trailer relay, GW-08 bounds")
+class GrpcDispatchStageTest {
+
+ @Nested
+ @DisplayName("constructor contract")
+ class ConstructorContract {
+
+ @Test
+ @DisplayName("rejects a null failure mapper")
+ void rejectsNullFailureMapper() {
+ assertThrows(NullPointerException.class, () -> new GrpcDispatchStage(1024L, null),
+ "the gRPC dispatch stage requires a non-null failure mapper");
+ }
+
+ @Test
+ @DisplayName("builds with a body cap and a failure mapper")
+ void buildsWithFailureMapper() {
+ assertDoesNotThrow(() -> new GrpcDispatchStage(1024L, new UpstreamFailureMapper(new GatewayEventCounter())),
+ "a body cap plus a failure mapper is a valid gRPC dispatch stage");
+ }
+ }
+
+ @Nested
+ @DisplayName("forced-h2 upstream client wiring")
+ class ForcedH2ClientWiring {
+
+ private Vertx vertx;
+ private RouteRuntimeAssembler assembler;
+ private final List capturedTargets = new ArrayList<>();
+ private RouteRuntimeAssembler.UpstreamClientFactory clientFactory;
+ private RouteRuntimeAssembler.SecurityConfigurationFactory securityConfigFactory;
+ private RouteRuntimeAssembler.ResilienceGuardFactory guardFactory;
+ private RouteRuntimeAssembler.AssetSourceFactory assetSourceFactory;
+
+ @BeforeEach
+ void setUp() {
+ vertx = Vertx.vertx();
+ assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry());
+ clientFactory = target -> {
+ capturedTargets.add(target);
+ return vertx.createHttpClient();
+ };
+ securityConfigFactory = filter -> SecurityConfiguration.builder().build();
+ guardFactory = shape -> new StoredOnlyGuard();
+ assetSourceFactory = asset -> {
+ throw new UnsupportedOperationException("no asset route in this test");
+ };
+ }
+
+ @AfterEach
+ void tearDown() {
+ vertx.close();
+ }
+
+ @Test
+ @DisplayName("gives a gRPC route a forced-h2 client distinct from an HTTP/1.1 route to the same host:port")
+ void splitsForcedH2ClientFromHttp1Client() {
+ // Arrange — a gRPC route and an HTTP route to the SAME upstream host:port
+ RouteTable table = new RouteTable(List.of(
+ route("g", Protocol.GRPC, upstream("svc.internal")),
+ route("h", Protocol.HTTP, upstream("svc.internal"))));
+
+ // Act
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory,
+ guardFactory, assetSourceFactory);
+
+ // Assert — the forced-h2 flag splits the client-sharing tuple, so the two routes hold
+ // distinct clients even though the host:port is identical.
+ assertEquals(2, capturedTargets.size(), "two distinct upstream tuples build two clients");
+ assertTrue(capturedTargets.getFirst().forcedHttp2(), "the gRPC route's upstream target is forced to HTTP/2");
+ assertFalse(capturedTargets.get(1).forcedHttp2(), "the HTTP route's upstream target is not forced to HTTP/2");
+ assertEquals(capturedTargets.getFirst().host(), capturedTargets.get(1).host(),
+ "both targets address the same host");
+ assertEquals(capturedTargets.getFirst().port(), capturedTargets.get(1).port(),
+ "both targets address the same port");
+ assertNotSame(runtimes.getFirst().getHttpClient().orElseThrow(),
+ runtimes.get(1).getHttpClient().orElseThrow(),
+ "a gRPC route gets a forced-h2 client distinct from the HTTP/1.1 client to the same host:port");
+ }
+
+ @Test
+ @DisplayName("shares one forced-h2 client across two gRPC routes to the same host:port")
+ void sharesForcedH2ClientAcrossGrpcRoutes() {
+ // Arrange — two gRPC routes to the same upstream
+ RouteTable table = new RouteTable(List.of(
+ route("g1", Protocol.GRPC, upstream("svc.internal")),
+ route("g2", Protocol.GRPC, upstream("svc.internal"))));
+
+ // Act
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory,
+ guardFactory, assetSourceFactory);
+
+ // Assert — one forced-h2 tuple, one shared client
+ assertEquals(1, capturedTargets.size(), "two gRPC routes to one host:port share a single forced-h2 tuple");
+ assertTrue(capturedTargets.getFirst().forcedHttp2(), "the shared tuple is forced to HTTP/2");
+ assertSame(runtimes.getFirst().getHttpClient().orElseThrow(),
+ runtimes.get(1).getHttpClient().orElseThrow(),
+ "gRPC routes sharing a host:port reuse one forced-h2 client");
+ }
+ }
+
+ @Nested
+ @DisplayName("h2-negotiation-failure mapping to gRPC status")
+ class H2FailureMapping {
+
+ private final UpstreamFailureMapper failureMapper = new UpstreamFailureMapper(new GatewayEventCounter());
+ private final GrpcStatusMapper grpcStatusMapper = new GrpcStatusMapper();
+
+ @Test
+ @DisplayName("maps an h2-negotiation dial failure through UPSTREAM_ERROR to gRPC UNAVAILABLE")
+ void mapsH2DialFailureToUnavailable() {
+ // Arrange — a forced-h2 dial that could not establish h2 surfaces as a connection failure
+ Throwable h2DialFailure = new ConnectException("failed to negotiate h2 with upstream");
+
+ // Act — the failure classifies as UPSTREAM_ERROR (502), which renders as gRPC UNAVAILABLE
+ EventType classified = failureMapper.classify(h2DialFailure);
+
+ // Assert
+ assertEquals(EventType.UPSTREAM_ERROR, classified,
+ "an h2-negotiation dial failure is an upstream connection failure (502)");
+ assertEquals(GrpcStatusMapper.UNAVAILABLE, grpcStatusMapper.toGrpcStatus(classified),
+ "a 502 upstream failure renders as gRPC UNAVAILABLE (14) on the gRPC path");
+ }
+
+ @Test
+ @DisplayName("maps an upstream timeout through UPSTREAM_TIMEOUT to gRPC DEADLINE_EXCEEDED")
+ void mapsTimeoutToDeadlineExceeded() {
+ // Arrange
+ Throwable timeout = new TimeoutException("upstream deadline elapsed");
+
+ // Act
+ EventType classified = failureMapper.classify(timeout);
+
+ // Assert — the timeout arm is distinct from the generic h2 dial failure
+ assertEquals(EventType.UPSTREAM_TIMEOUT, classified, "a timeout classifies as UPSTREAM_TIMEOUT (504)");
+ assertEquals(GrpcStatusMapper.DEADLINE_EXCEEDED, grpcStatusMapper.toGrpcStatus(classified),
+ "a 504 upstream timeout renders as gRPC DEADLINE_EXCEEDED (4)");
+ }
+ }
+
+ @Nested
+ @DisplayName("response-trailer relay over a live Vert.x server")
+ class TrailerRelay {
+
+ private Vertx vertx;
+ private HttpClient client;
+ private HttpServer upstream;
+ private HttpServer front;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ vertx = Vertx.vertx();
+ client = vertx.createHttpClient();
+
+ // Stub upstream: a chunked opaque gRPC frame followed by grpc-status / grpc-message trailers.
+ upstream = vertx.createHttpServer().requestHandler(req -> {
+ HttpServerResponse response = req.response();
+ response.setChunked(true);
+ response.write(Buffer.buffer("opaque-grpc-frame"));
+ response.putTrailer("grpc-status", "0");
+ response.putTrailer("grpc-message", "ok");
+ response.end();
+ }).listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ int upstreamPort = upstream.actualPort();
+
+ // Front server: relays the upstream response WITH its trailers exactly as the gRPC dispatch
+ // path does (ResponseStage#relayWithTrailers).
+ ResponseStage responseStage = new ResponseStage();
+ front = vertx.createHttpServer().requestHandler(clientReq -> client
+ .request(io.vertx.core.http.HttpMethod.POST, upstreamPort, "localhost", "/svc.Service/Method")
+ .compose(upReq -> upReq.send())
+ .onSuccess(upResp -> responseStage
+ .relayWithTrailers(upResp, clientReq.response(), false, Map.of())
+ .onFailure(failure -> clientReq.response().setStatusCode(502).end()))
+ .onFailure(failure -> clientReq.response().setStatusCode(502).end()))
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ front.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ upstream.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ @DisplayName("relays the upstream grpc-status / grpc-message trailers to the client")
+ void relaysUpstreamTrailers() throws Exception {
+ // Arrange
+ int frontPort = front.actualPort();
+ AtomicReference body = new AtomicReference<>();
+
+ // Act — POST the front server and read the full response including its trailers
+ MultiMap trailers = client
+ .request(io.vertx.core.http.HttpMethod.POST, frontPort, "localhost", "/svc.Service/Method")
+ .compose(req -> req.send())
+ .compose(resp -> resp.body().map(buffer -> {
+ body.set(buffer);
+ return resp.trailers();
+ }))
+ .toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+
+ // Assert — the opaque frame is streamed through and the gRPC status trailers reach the client
+ assertEquals("opaque-grpc-frame", body.get().toString(),
+ "the opaque gRPC frame is streamed through untouched");
+ assertEquals("0", trailers.get("grpc-status"),
+ "the upstream grpc-status trailer is relayed to the client");
+ assertEquals("ok", trailers.get("grpc-message"),
+ "the upstream grpc-message trailer is relayed to the client");
+ }
+ }
+
+ @Nested
+ @DisplayName("legitimate multi-frame streaming under the shared body byte cap (GW-08 on the gRPC path)")
+ class SharedBodyCap {
+
+ @Test
+ @DisplayName("streams legitimate multi-frame gRPC traffic under the cap without misfiring")
+ void streamsMultiFrameUnderCapWithoutMisfire() {
+ // Arrange — the opaque length-prefixed frames the gRPC dispatch streams ride the SAME
+ // byte-capped body stream as the HTTP path (DispatchStage.ByteCappedBodyStream); a
+ // legitimate multi-frame body under the cap must never be misfired as abuse.
+ TestReadStream source = new TestReadStream();
+ List forwarded = new ArrayList<>();
+ AtomicReference failure = new AtomicReference<>();
+ AtomicBoolean aborted = new AtomicBoolean();
+ DispatchStage.ByteCappedBodyStream capped =
+ new DispatchStage.ByteCappedBodyStream(source, 20L, () -> aborted.set(true));
+ capped.handler(forwarded::add);
+ capped.exceptionHandler(failure::set);
+
+ // Act — three 5-byte opaque frames = 15 bytes, all under the 20-byte cap
+ source.emit(Buffer.buffer("frame"));
+ source.emit(Buffer.buffer("frame"));
+ source.emit(Buffer.buffer("frame"));
+
+ // Assert — every in-cap frame streams through and nothing is aborted
+ assertEquals(3, forwarded.size(), "legitimate multi-frame gRPC traffic under the cap streams through");
+ assertNull(failure.get(), "no failure is raised while under the cap");
+ assertFalse(aborted.get(), "legitimate multi-frame traffic is not misfired as abuse");
+ }
+
+ @Test
+ @DisplayName("aborts the gRPC dispatch with PARAMETER_LIMIT_EXCEEDED when the body cap is breached")
+ void abortsOnBodyCapBreach() {
+ // Arrange — the shared body-abuse bound also applies to the opaque gRPC frame stream
+ TestReadStream source = new TestReadStream();
+ List forwarded = new ArrayList<>();
+ AtomicReference failure = new AtomicReference<>();
+ AtomicBoolean aborted = new AtomicBoolean();
+ DispatchStage.ByteCappedBodyStream capped =
+ new DispatchStage.ByteCappedBodyStream(source, 8L, () -> aborted.set(true));
+ capped.handler(forwarded::add);
+ capped.exceptionHandler(failure::set);
+
+ // Act — 5 bytes (ok) then 5 more crossing the 8-byte cap
+ source.emit(Buffer.buffer("frame"));
+ source.emit(Buffer.buffer("flood"));
+
+ // Assert — the breaching frame is dropped, the dispatch is aborted, and a 400 is raised
+ assertEquals(1, forwarded.size(), "the breaching frame must never cross to the upstream");
+ assertTrue(aborted.get(), "a body-cap breach aborts the in-flight gRPC dispatch");
+ GatewayException raised = assertInstanceOf(GatewayException.class, failure.get(),
+ "the breach raises a GatewayException");
+ assertEquals(EventType.PARAMETER_LIMIT_EXCEEDED, raised.getEventType(),
+ "the shared body-abuse bound raises PARAMETER_LIMIT_EXCEEDED on the gRPC path");
+ }
+ }
+
+ private static ResolvedRoute route(String id, Protocol protocol, ResolvedUpstream upstream) {
+ return ResolvedRoute.builder()
+ .id(id)
+ .protocol(protocol)
+ .match(MatchConfig.builder().pathPrefix("/" + id).build())
+ .effectiveAuth(AuthConfig.builder().require("none").build())
+ .effectiveAllowedMethods(List.of(HttpMethod.POST))
+ .upstream(Optional.of(upstream))
+ .build();
+ }
+
+ private static ResolvedUpstream upstream(String host) {
+ return new ResolvedUpstream("https", host, 443, "");
+ }
+
+ /**
+ * A minimal {@link io.vertx.core.streams.ReadStream} fake mirroring {@code DispatchStageTest}:
+ * captures the handler the byte-cap decorator installs and lets a test push buffers synchronously.
+ */
+ private static final class TestReadStream implements ReadStream {
+
+ private Handler handler;
+
+ void emit(Buffer buffer) {
+ if (handler != null) {
+ handler.handle(buffer);
+ }
+ }
+
+ @Override
+ public ReadStream handler(Handler handler) {
+ this.handler = handler;
+ return this;
+ }
+
+ @Override
+ public ReadStream exceptionHandler(Handler handler) {
+ return this;
+ }
+
+ @Override
+ public ReadStream pause() {
+ return this;
+ }
+
+ @Override
+ public ReadStream resume() {
+ return this;
+ }
+
+ @Override
+ public ReadStream fetch(long amount) {
+ return this;
+ }
+
+ @Override
+ public ReadStream endHandler(Handler endHandler) {
+ return this;
+ }
+ }
+
+ /**
+ * A {@link Guard} test double that is only ever stored on a {@link RouteRuntime} and never invoked
+ * during assembly, so its guard methods reject execution.
+ */
+ private static final class StoredOnlyGuard implements Guard {
+
+ @Override
+ public T call(Callable action, Class asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+
+ @Override
+ public T call(Callable action, TypeLiteral asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+
+ @Override
+ public T get(Supplier action, Class asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+
+ @Override
+ public T get(Supplier action, TypeLiteral asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GrpcStatusMapperTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GrpcStatusMapperTest.java
new file mode 100644
index 00000000..a9a2c887
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GrpcStatusMapperTest.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+
+import de.cuioss.sheriff.api.events.EventType;
+
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpClientResponse;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.core.http.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contract of {@link GrpcStatusMapper}: the canonical HTTP-status → gRPC-status mapping (one row per
+ * rejection cause, architecture.adoc § gRPC error contract) and the trailers-only rejection render
+ * (HTTP {@code 200}, {@code content-type: application/grpc}, {@code grpc-status} / {@code grpc-message},
+ * no DATA frame). The render is exercised over a live Vert.x server so a real
+ * {@code HttpServerResponse} is mutated exactly as production does.
+ */
+@DisplayName("GrpcStatusMapper — HTTP→gRPC status mapping and trailers-only rejection render")
+class GrpcStatusMapperTest {
+
+ private final GrpcStatusMapper mapper = new GrpcStatusMapper();
+
+ @Nested
+ @DisplayName("HTTP-status → gRPC-status mapping (one row per rejection cause)")
+ class StatusMapping {
+
+ @Test
+ @DisplayName("400 (input validation) maps to INVALID_ARGUMENT")
+ void mapsBadRequest() {
+ assertEquals(GrpcStatusMapper.INVALID_ARGUMENT, mapper.toGrpcStatus(EventType.SECURITY_FILTER_VIOLATION),
+ "an HTTP 400 rejection maps to gRPC INVALID_ARGUMENT (3)");
+ }
+
+ @Test
+ @DisplayName("401 (authentication) maps to UNAUTHENTICATED")
+ void mapsUnauthenticated() {
+ assertEquals(GrpcStatusMapper.UNAUTHENTICATED, mapper.toGrpcStatus(EventType.TOKEN_MISSING),
+ "an HTTP 401 rejection maps to gRPC UNAUTHENTICATED (16)");
+ }
+
+ @Test
+ @DisplayName("403 (authorization) maps to PERMISSION_DENIED")
+ void mapsPermissionDenied() {
+ assertEquals(GrpcStatusMapper.PERMISSION_DENIED, mapper.toGrpcStatus(EventType.SCOPE_MISSING),
+ "an HTTP 403 rejection maps to gRPC PERMISSION_DENIED (7)");
+ }
+
+ @Test
+ @DisplayName("404 (no route matched) maps to NOT_FOUND")
+ void mapsNotFound() {
+ assertEquals(GrpcStatusMapper.NOT_FOUND, mapper.toGrpcStatus(EventType.NO_ROUTE_MATCHED),
+ "an HTTP 404 rejection maps to gRPC NOT_FOUND (5)");
+ }
+
+ @Test
+ @DisplayName("405 (method not allowed) maps to UNIMPLEMENTED")
+ void mapsUnimplemented() {
+ assertEquals(GrpcStatusMapper.UNIMPLEMENTED, mapper.toGrpcStatus(EventType.METHOD_NOT_ALLOWED),
+ "an HTTP 405 rejection maps to gRPC UNIMPLEMENTED (12)");
+ }
+
+ @Test
+ @DisplayName("502 (upstream error / h2-negotiation failure) maps to UNAVAILABLE")
+ void mapsBadGatewayToUnavailable() {
+ assertEquals(GrpcStatusMapper.UNAVAILABLE, mapper.toGrpcStatus(EventType.UPSTREAM_ERROR),
+ "an HTTP 502 upstream failure maps to gRPC UNAVAILABLE (14)");
+ }
+
+ @Test
+ @DisplayName("503 (circuit open) maps to UNAVAILABLE")
+ void mapsServiceUnavailableToUnavailable() {
+ assertEquals(GrpcStatusMapper.UNAVAILABLE, mapper.toGrpcStatus(EventType.UPSTREAM_CIRCUIT_OPEN),
+ "an HTTP 503 open-circuit rejection maps to gRPC UNAVAILABLE (14)");
+ }
+
+ @Test
+ @DisplayName("504 (upstream timeout) maps to DEADLINE_EXCEEDED")
+ void mapsGatewayTimeoutToDeadlineExceeded() {
+ assertEquals(GrpcStatusMapper.DEADLINE_EXCEEDED, mapper.toGrpcStatus(EventType.UPSTREAM_TIMEOUT),
+ "an HTTP 504 upstream timeout maps to gRPC DEADLINE_EXCEEDED (4)");
+ }
+
+ @Test
+ @DisplayName("an event with no HTTP mapping falls through to UNKNOWN")
+ void mapsUnmappedToUnknown() {
+ assertEquals(GrpcStatusMapper.UNKNOWN, mapper.toGrpcStatus(EventType.CONFIG_INVALID),
+ "an event that renders no HTTP status maps to gRPC UNKNOWN (2)");
+ }
+
+ @Test
+ @DisplayName("rejects a null event type")
+ void rejectsNullEventType() {
+ assertThrows(NullPointerException.class, () -> mapper.toGrpcStatus(null),
+ "the mapper requires a non-null event type");
+ }
+ }
+
+ @Nested
+ @DisplayName("trailers-only rejection render over a live Vert.x server")
+ class RejectionRender {
+
+ private Vertx vertx;
+ private HttpClient client;
+ private HttpServer server;
+
+ @BeforeEach
+ void setUp() {
+ vertx = Vertx.vertx();
+ client = vertx.createHttpClient();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (server != null) {
+ server.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ }
+ client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ @DisplayName("renders HTTP 200, application/grpc, and the mapped grpc-status / grpc-message")
+ void rendersTrailersOnlyRejection() throws Exception {
+ // Act — a 403 authorization rejection on a gRPC route
+ HttpClientResponse response = render(EventType.SCOPE_MISSING, Map.of());
+
+ // Assert — a gRPC client observes an HTTP 200 whose grpc-status names the failure
+ assertEquals(200, response.statusCode(), "a trailers-only gRPC rejection is an HTTP 200");
+ assertEquals("application/grpc", response.getHeader("content-type"),
+ "the response content type is application/grpc so the RPC runtime consumes it");
+ assertEquals(Integer.toString(GrpcStatusMapper.PERMISSION_DENIED), response.getHeader("grpc-status"),
+ "the grpc-status carries the mapped PERMISSION_DENIED code");
+ assertEquals("authorization", response.getHeader("grpc-message"),
+ "the grpc-message carries the failure category slug only, never internal detail");
+ }
+
+ @Test
+ @DisplayName("applies stage-0 security headers, and the gateway content-type wins a name collision")
+ void appliesStageHeadersGatewayWins() throws Exception {
+ // Arrange — a stage-0 security header plus a colliding content-type the gateway must override
+ Map stageHeaders = Map.of(
+ "X-Frame-Options", "DENY",
+ "content-type", "text/plain");
+
+ // Act
+ HttpClientResponse response = render(EventType.TOKEN_MISSING, stageHeaders);
+
+ // Assert — the security header passes through and the gRPC content-type wins the collision
+ assertEquals("DENY", response.getHeader("X-Frame-Options"),
+ "a stage-0 security header is applied to the gRPC rejection response");
+ assertEquals("application/grpc", response.getHeader("content-type"),
+ "the gateway-controlled content type wins over a colliding stage header");
+ assertEquals(Integer.toString(GrpcStatusMapper.UNAUTHENTICATED), response.getHeader("grpc-status"),
+ "a 401 rejection maps to gRPC UNAUTHENTICATED");
+ }
+
+ @Test
+ @DisplayName("falls back to a 'unknown' grpc-message for a null-category event")
+ void rendersUnknownMessageForNullCategory() throws Exception {
+ // Act — WEBSOCKET_IDLE_TIMEOUT has no category and no HTTP mapping
+ HttpClientResponse response = render(EventType.WEBSOCKET_IDLE_TIMEOUT, Map.of());
+
+ // Assert
+ assertEquals(Integer.toString(GrpcStatusMapper.UNKNOWN), response.getHeader("grpc-status"),
+ "an unmapped event renders gRPC UNKNOWN");
+ assertEquals("unknown", response.getHeader("grpc-message"),
+ "a null-category event renders the 'unknown' grpc-message fallback");
+ }
+
+ private HttpClientResponse render(EventType eventType, Map stageHeaders) throws Exception {
+ server = vertx.createHttpServer()
+ .requestHandler(req -> mapper.renderRejection(req.response(), eventType, stageHeaders))
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ int port = server.actualPort();
+ return client.request(HttpMethod.POST, port, "localhost", "/svc.Service/Method")
+ .compose(req -> req.send())
+ .toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ @DisplayName("rejects a null response on render")
+ void rejectsNullResponse() {
+ assertThrows(NullPointerException.class,
+ () -> mapper.renderRejection(null, EventType.SCOPE_MISSING, Map.of()),
+ "the render requires a non-null response");
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java
index 4538b359..351ec7b2 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java
@@ -152,24 +152,37 @@ void shouldPreserveRouteTableOrder() {
}
@Test
- @DisplayName("Should fail boot for session auth and unsupported protocols")
- void shouldFailBootForSessionAndUnsupportedProtocols() {
- RouteTable sessionTable = new RouteTable(List.of(route("s", Protocol.HTTP, "session", Optional.empty(), upstream("a.example"))));
+ @DisplayName("Should fail boot for session auth and a session-auth WebSocket; gRPC and WebSocket assemble")
+ void shouldFailBootForSessionAndAssembleProtocolRoutes() {
+ RouteTable sessionTable = new RouteTable(List.of(
+ route("s", Protocol.HTTP, "session", Optional.empty(), upstream("a.example"))));
var session = assertThrows(GatewayException.class,
() -> assembler.assemble(sessionTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory),
"session auth must fail boot");
- RouteTable grpcTable = new RouteTable(List.of(route("g", Protocol.GRPC, "none", Optional.empty(), upstream("a.example"))));
- var grpc = assertThrows(GatewayException.class,
- () -> assembler.assemble(grpcTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory),
- "gRPC must fail boot");
- RouteTable webSocketTable = new RouteTable(List.of(route("w", Protocol.WEBSOCKET, "none", Optional.empty(), upstream("a.example"))));
- var websocket = assertThrows(GatewayException.class,
+ RouteTable webSocketTable = new RouteTable(List.of(
+ route("sw", Protocol.WEBSOCKET, "session", Optional.empty(), upstream("a.example"))));
+ var sessionWebSocket = assertThrows(GatewayException.class,
() -> assembler.assemble(webSocketTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory),
- "WebSocket must fail boot");
+ "session-auth WebSocket must fail boot");
assertEquals(EventType.CONFIG_INVALID, session.getEventType(), "session rejection is a config failure");
- assertEquals(EventType.CONFIG_INVALID, grpc.getEventType(), "gRPC rejection is a config failure");
- assertEquals(EventType.CONFIG_INVALID, websocket.getEventType(), "WebSocket rejection is a config failure");
+ assertEquals(EventType.CONFIG_INVALID, sessionWebSocket.getEventType(),
+ "session-auth WebSocket rejection is a config failure");
+
+ // A gRPC route now assembles cleanly (its boot rejection was removed when the gRPC processor
+ // was registered) — the forced-h2 upstream client is built by the injected client factory.
+ RouteTable grpcTable = new RouteTable(List.of(
+ route("g", Protocol.GRPC, "none", Optional.empty(), upstream("a.example"))));
+ assertDoesNotThrow(
+ () -> assembler.assemble(grpcTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory),
+ "a gRPC route with non-session auth assembles cleanly");
+
+ // A WebSocket route with non-session auth likewise assembles cleanly.
+ RouteTable webSocketNoneTable = new RouteTable(List.of(
+ route("w", Protocol.WEBSOCKET, "none", Optional.empty(), upstream("a.example"))));
+ assertDoesNotThrow(
+ () -> assembler.assemble(webSocketNoneTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory),
+ "a WebSocket route with non-session auth assembles cleanly");
}
@Test
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/WebSocketRelayStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/WebSocketRelayStageTest.java
new file mode 100644
index 00000000..20d5de61
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/WebSocketRelayStageTest.java
@@ -0,0 +1,384 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.lang.annotation.Annotation;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
+import de.cuioss.sheriff.api.quarkus.SheriffMetrics;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators;
+import de.cuioss.test.generator.junit.EnableGeneratorController;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.vertx.core.MultiMap;
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.http.UpgradeRejectedException;
+import io.vertx.core.http.WebSocket;
+import io.vertx.core.http.WebSocketClient;
+import io.vertx.core.http.WebSocketConnectOptions;
+import io.vertx.ext.web.Router;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.util.TypeLiteral;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end contract of the WebSocket protocol-dispatch seam, driven over a live Vert.x front server
+ * that hosts the {@link GatewayEdgeRoute} against a local stub upstream WebSocket echo server — no
+ * Docker, no Quarkus. A real upgrade crosses the whole fixed pipeline, the {@code OriginValidationStage},
+ * and the {@link WebSocketRelayStage}: Origin enforcement and bearer auth reject the handshake before
+ * the upstream is ever dialed, an accepted upgrade relays frames opaquely in both directions, an
+ * unreachable upstream maps to {@code 502} before the {@code 101}, and an idle relay is reclaimed while
+ * a heartbeated one survives. The Docker-backed matrix in {@code integration-tests} complements these
+ * server-local guarantees.
+ */
+@EnableGeneratorController
+@DisplayName("WebSocketRelayStage — end-to-end WebSocket dispatch over a live Vert.x server")
+class WebSocketRelayStageTest {
+
+ private static final String ALLOWED_ORIGIN = "https://app.example";
+ private static final String FOREIGN_ORIGIN = "https://evil.example";
+
+ private Vertx vertx;
+ private ExecutorService virtualThreadExecutor;
+ private HttpServer upstreamServer;
+ private HttpServer frontServer;
+ private WebSocketClient wsClient;
+ private int frontPort;
+ private int deadPort;
+ private final AtomicInteger upstreamConnects = new AtomicInteger();
+ private final AtomicReference upstreamCustomHeader = new AtomicReference<>();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ vertx = Vertx.vertx();
+ virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
+
+ // Stub upstream WebSocket echo server: records each accepted handshake and echoes text frames.
+ upstreamServer = vertx.createHttpServer().webSocketHandler(ws -> {
+ upstreamConnects.incrementAndGet();
+ upstreamCustomHeader.set(ws.headers().get("X-Custom"));
+ ws.textMessageHandler(ws::writeTextMessage);
+ }).listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ int upstreamPort = upstreamServer.actualPort();
+
+ // A definitely-closed port for the unreachable-upstream case.
+ HttpServer throwaway = vertx.createHttpServer().requestHandler(req -> req.response().end())
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ deadPort = throwaway.actualPort();
+ throwaway.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+
+ TokenValidator tokenValidator = TokenValidator.builder()
+ .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build();
+
+ RouteTable routeTable = new RouteTable(List.of(
+ wsRoute("wsopen", "/ws-open", "none", upstreamPort, Set.of(), Optional.empty()),
+ wsRoute("wsorigin", "/ws-origin", "none", upstreamPort, Set.of(ALLOWED_ORIGIN), Optional.empty()),
+ wsRoute("wssecure", "/ws-secure", "bearer", upstreamPort, Set.of(ALLOWED_ORIGIN), Optional.empty()),
+ wsRoute("wsidle", "/ws-idle", "none", upstreamPort, Set.of(), Optional.of(1)),
+ wsRoute("wsdead", "/ws-dead", "none", deadPort, Set.of(), Optional.empty())));
+
+ GatewayConfig gatewayConfig = GatewayConfig.builder()
+ .version(1)
+ .securityHeaders(Optional.of(securityHeaders()))
+ .build();
+ GatewayEdgeRoute edge = new GatewayEdgeRoute(routeTable, gatewayConfig,
+ new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor,
+ new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry()));
+
+ Router router = Router.router(vertx);
+ edge.registerRoutes(router);
+ frontServer = vertx.createHttpServer().requestHandler(router)
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ frontPort = frontServer.actualPort();
+
+ wsClient = vertx.createWebSocketClient();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ wsClient.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ frontServer.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ upstreamServer.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ virtualThreadExecutor.close();
+ vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ @DisplayName("relays text frames opaquely in both directions once the upgrade is accepted")
+ void relaysBidirectionalTextFrames() throws Exception {
+ // Arrange
+ WebSocket socket = connect("/ws-open/room", ALLOWED_ORIGIN);
+ CompletableFuture echoed = new CompletableFuture<>();
+ socket.textMessageHandler(echoed::complete);
+
+ // Act
+ socket.writeTextMessage("hello-relay");
+
+ // Assert — the frame crosses to the upstream, is echoed, and relays back to the client
+ assertEquals("hello-relay", echoed.get(15, TimeUnit.SECONDS));
+ }
+
+ @Test
+ @DisplayName("rejects a foreign Origin with 403 before dialing the upstream")
+ void rejectsForeignOriginBeforeDial() {
+ // Act
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> connect("/ws-origin/room", FOREIGN_ORIGIN));
+
+ // Assert — the upgrade is refused 403 and the upstream is never contacted
+ UpgradeRejectedException rejected = assertInstanceOf(UpgradeRejectedException.class, failure.getCause());
+ assertEquals(403, rejected.getStatus());
+ assertEquals(0, upstreamConnects.get(), "a rejected Origin never reaches the upstream");
+ }
+
+ @Test
+ @DisplayName("accepts an allow-listed Origin and relays")
+ void acceptsAllowlistedOrigin() throws Exception {
+ // Arrange
+ WebSocket socket = connect("/ws-origin/room", ALLOWED_ORIGIN);
+ CompletableFuture echoed = new CompletableFuture<>();
+ socket.textMessageHandler(echoed::complete);
+
+ // Act
+ socket.writeTextMessage("allowed");
+
+ // Assert
+ assertEquals("allowed", echoed.get(15, TimeUnit.SECONDS));
+ }
+
+ @Test
+ @DisplayName("rejects a bearer handshake without a token 401 before dialing the upstream")
+ void rejectsMissingBearerTokenBeforeDial() {
+ // Act
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> connect("/ws-secure/room", ALLOWED_ORIGIN));
+
+ // Assert — authentication (stage 4) rejects the handshake before the WebSocket dispatch runs
+ UpgradeRejectedException rejected = assertInstanceOf(UpgradeRejectedException.class, failure.getCause());
+ assertEquals(401, rejected.getStatus());
+ assertEquals(0, upstreamConnects.get(), "an unauthenticated handshake never reaches the upstream");
+ }
+
+ @Test
+ @DisplayName("maps an unreachable upstream to 502 before the 101 upgrade")
+ void mapsUnreachableUpstreamTo502() {
+ // Act
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> connect("/ws-dead/room", ALLOWED_ORIGIN));
+
+ // Assert
+ UpgradeRejectedException rejected = assertInstanceOf(UpgradeRejectedException.class, failure.getCause());
+ assertEquals(502, rejected.getStatus());
+ }
+
+ @Test
+ @DisplayName("preserves the stage-0 security headers on a WebSocket handshake failure")
+ void preservesSecurityHeadersOnHandshakeFailure() {
+ // Act — the /ws-dead route's upstream is unreachable, so the handshake fails 502 before the 101
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> connect("/ws-dead/room", ALLOWED_ORIGIN));
+
+ // Assert — the failed-handshake response still carries the gateway (stage-0) security headers,
+ // mirroring the HTTP (ResponseStage.relay) and gRPC (GrpcStatusMapper.renderRejection) contract
+ UpgradeRejectedException rejected = assertInstanceOf(UpgradeRejectedException.class, failure.getCause());
+ assertEquals(502, rejected.getStatus());
+ MultiMap headers = rejected.getHeaders();
+ assertEquals("nosniff", headers.get("X-Content-Type-Options"),
+ "a failed WebSocket handshake carries the stage-0 X-Content-Type-Options header");
+ assertEquals("DENY", headers.get("X-Frame-Options"),
+ "a failed WebSocket handshake carries the stage-0 X-Frame-Options header");
+ }
+
+ @Test
+ @DisplayName("forwards no non-allow-listed handshake header to the upstream (deny-by-default)")
+ void deniesNonAllowlistedForwardHeader() throws Exception {
+ // Arrange — a custom header the route's (empty) forward allowlist does not permit
+ WebSocketConnectOptions options = new WebSocketConnectOptions()
+ .setHost("localhost").setPort(frontPort).setURI("/ws-open/room")
+ .addHeader("Origin", ALLOWED_ORIGIN).addHeader("X-Custom", "leak");
+ WebSocket socket = wsClient.connect(options).toCompletionStage().toCompletableFuture()
+ .get(15, TimeUnit.SECONDS);
+ CompletableFuture echoed = new CompletableFuture<>();
+ socket.textMessageHandler(echoed::complete);
+
+ // Act
+ socket.writeTextMessage("go");
+ echoed.get(15, TimeUnit.SECONDS);
+
+ // Assert — the upstream handshake never saw the denied header
+ assertNull(upstreamCustomHeader.get(),
+ "a header outside the deny-by-default forward allowlist is not relayed to the upstream");
+ }
+
+ @Test
+ @DisplayName("reclaims an idle relay after the per-route idle timeout, closing 1001")
+ void reclaimsIdleRelay() throws Exception {
+ // Arrange — the /ws-idle route has idle_timeout_seconds=1
+ WebSocket socket = connect("/ws-idle/room", ALLOWED_ORIGIN);
+ CompletableFuture closeCode = new CompletableFuture<>();
+ socket.closeHandler(v -> closeCode.complete(socket.closeStatusCode()));
+
+ // Act + Assert — with no frame in either direction the relay is reclaimed and closed 1001
+ assertEquals((short) 1001, closeCode.get(10, TimeUnit.SECONDS),
+ "an idle relay is closed with WebSocket code 1001 (Going Away)");
+ }
+
+ @Test
+ @DisplayName("keeps a heartbeated relay open past the idle window")
+ // NOSONAR java:S2925 - Thread.sleep is load-bearing: the assertion under test is that
+ // sub-second real activity, spaced across the real Vert.x idle-timer window, keeps the relay
+ // alive; the idle reclaim is a real setTimer, and no virtual clock is available to simulate it.
+ @SuppressWarnings("java:S2925")
+ void heartbeatKeepsRelayOpen() throws Exception {
+ // Arrange — the /ws-idle route idles after 1s; keep it busy with sub-second activity
+ WebSocket socket = connect("/ws-idle/room", ALLOWED_ORIGIN);
+ CompletableFuture closed = new CompletableFuture<>();
+ socket.closeHandler(closed::complete);
+
+ // Act — three exchanges 400ms apart span past the 1s idle window, each resetting the timer
+ for (int i = 0; i < 3; i++) {
+ CompletableFuture echoed = new CompletableFuture<>();
+ socket.textMessageHandler(echoed::complete);
+ socket.writeTextMessage("beat-" + i);
+ echoed.get(5, TimeUnit.SECONDS);
+ Thread.sleep(400);
+ }
+
+ // Assert — activity kept the relay alive; it was never reclaimed
+ assertFalse(closed.isDone(), "a heartbeated relay is not reclaimed while activity continues");
+ }
+
+ private WebSocket connect(String uri, String origin) throws Exception {
+ WebSocketConnectOptions options = new WebSocketConnectOptions()
+ .setHost("localhost").setPort(frontPort).setURI(uri).addHeader("Origin", origin);
+ return wsClient.connect(options).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ }
+
+ private static SecurityHeadersConfig securityHeaders() {
+ return SecurityHeadersConfig.builder()
+ .contentTypeNosniff(Optional.of(Boolean.TRUE))
+ .frameDeny(Optional.of(Boolean.TRUE))
+ .build();
+ }
+
+ private static ResolvedRoute wsRoute(String id, String pathPrefix, String require, int upstreamPort,
+ Set allowedOrigins, Optional idleTimeoutSeconds) {
+ return ResolvedRoute.builder()
+ .id(id)
+ .protocol(Protocol.WEBSOCKET)
+ .match(MatchConfig.builder().pathPrefix(pathPrefix).build())
+ .effectiveAuth(AuthConfig.builder().require(require).build())
+ .effectiveAllowedMethods(List.of(HttpMethod.GET))
+ .upstream(Optional.of(new ResolvedUpstream("http", "localhost", upstreamPort, "")))
+ .effectiveAllowedOrigins(allowedOrigins)
+ .effectiveWebSocketIdleTimeoutSeconds(idleTimeoutSeconds)
+ .build();
+ }
+
+ /**
+ * Minimal {@link Instance} test double resolving to a single supplied validator; only
+ * {@link #get()} and {@link #iterator()} are exercised, the remaining CDI accessors throw.
+ */
+ private static final class SingletonInstance implements Instance {
+
+ private final T value;
+
+ SingletonInstance(T value) {
+ this.value = value;
+ }
+
+ @Override
+ public T get() {
+ return value;
+ }
+
+ @Override
+ public Instance select(Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(Class subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(TypeLiteral subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isUnsatisfied() {
+ return false;
+ }
+
+ @Override
+ public boolean isAmbiguous() {
+ return false;
+ }
+
+ @Override
+ public void destroy(T instance) {
+ // no-op: the test double owns no lifecycle
+ }
+
+ @Override
+ public Handle getHandle() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterable extends Handle> handles() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterator iterator() {
+ return List.of(value).iterator();
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/OriginValidationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/OriginValidationStageTest.java
new file mode 100644
index 00000000..29db0647
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/OriginValidationStageTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.test.juli.junit5.EnableTestLogger;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contract of the fail-closed WebSocket Origin gate (GW-09 / CSWSH). The allowlist is applied
+ * exact-match, case-insensitive on host (the allowlist is lower-cased at boot; the inbound
+ * {@code Origin} is lower-cased here). An empty allowlist means no enforcement; a non-empty allowlist
+ * rejects an absent or foreign origin with {@link EventType#WEBSOCKET_ORIGIN_REJECTED}.
+ */
+@EnableTestLogger
+@DisplayName("OriginValidationStage — fail-closed WebSocket Origin allowlist")
+class OriginValidationStageTest {
+
+ private static final String ROUTE_ID = "chat";
+
+ private final OriginValidationStage stage = new OriginValidationStage();
+
+ private static PipelineRequest requestWithOrigin(String origin) {
+ return PipelineRequest.builder()
+ .method(HttpMethod.GET)
+ .requestPath("/ws")
+ .headers(Map.of("Origin", List.of(origin)))
+ .build();
+ }
+
+ private static PipelineRequest requestWithoutOrigin() {
+ return PipelineRequest.builder()
+ .method(HttpMethod.GET)
+ .requestPath("/ws")
+ .build();
+ }
+
+ @Test
+ @DisplayName("an empty allowlist enforces nothing — the upgrade proceeds even for a foreign origin")
+ void emptyAllowlistEnforcesNothing() {
+ PipelineRequest request = requestWithOrigin("https://evil.example.com");
+
+ assertDoesNotThrow(() -> stage.validate(request, ROUTE_ID, Set.of()),
+ "an empty allowlist declares no enforcement (a non-bearer route)");
+ }
+
+ @Test
+ @DisplayName("an allowlisted origin proceeds")
+ void allowlistedOriginProceeds() {
+ PipelineRequest request = requestWithOrigin("https://app.example.com");
+
+ assertDoesNotThrow(() -> stage.validate(request, ROUTE_ID, Set.of("https://app.example.com")));
+ }
+
+ @Test
+ @DisplayName("matching is case-insensitive on host (the allowlist is pre-lower-cased at boot)")
+ void matchingIsCaseInsensitiveOnHost() {
+ PipelineRequest request = requestWithOrigin("https://App.Example.COM");
+
+ assertDoesNotThrow(() -> stage.validate(request, ROUTE_ID, Set.of("https://app.example.com")),
+ "the inbound Origin is lower-cased before comparison against the pre-lower-cased allowlist");
+ }
+
+ @Test
+ @DisplayName("a foreign origin is rejected fail-closed")
+ void foreignOriginRejected() {
+ PipelineRequest request = requestWithOrigin("https://evil.example.com");
+ Set allowlist = Set.of("https://app.example.com");
+
+ GatewayException thrown = assertThrows(GatewayException.class,
+ () -> stage.validate(request, ROUTE_ID, allowlist));
+ assertEquals(EventType.WEBSOCKET_ORIGIN_REJECTED, thrown.getEventType());
+ }
+
+ @Test
+ @DisplayName("an absent Origin against a non-empty allowlist is rejected fail-closed")
+ void absentOriginRejected() {
+ PipelineRequest request = requestWithoutOrigin();
+ Set allowlist = Set.of("https://app.example.com");
+
+ GatewayException thrown = assertThrows(GatewayException.class,
+ () -> stage.validate(request, ROUTE_ID, allowlist));
+ assertEquals(EventType.WEBSOCKET_ORIGIN_REJECTED, thrown.getEventType());
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapperTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapperTest.java
deleted file mode 100644
index acb86ef3..00000000
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapperTest.java
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package de.cuioss.sheriff.api.quarkus;
-
-import static org.junit.jupiter.api.Assertions.assertAll;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-
-import de.cuioss.sheriff.api.events.EventCategory;
-import de.cuioss.sheriff.api.events.EventType;
-import de.cuioss.sheriff.api.events.GatewayException;
-import de.cuioss.sheriff.token.commons.events.SecurityEventCounter;
-import de.cuioss.sheriff.token.validation.exception.TokenValidationException;
-
-import jakarta.ws.rs.core.Response;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.CsvSource;
-
-@DisplayName("GatewayExceptionMapper — RFC 9457 problem+json rendering")
-class GatewayExceptionMapperTest {
-
- private static final String PROBLEM_JSON = "application/problem+json";
-
- @ParameterizedTest(name = "{0} -> {1}")
- @CsvSource({
- "SECURITY_FILTER_VIOLATION, 400",
- "PATH_NOT_ALLOWED, 400",
- "PARAMETER_LIMIT_EXCEEDED, 400",
- "NO_ROUTE_MATCHED, 404",
- "METHOD_NOT_ALLOWED, 405",
- "TOKEN_MISSING, 401",
- "TOKEN_INVALID, 401",
- "SCOPE_MISSING, 403",
- "CSRF_REJECTED, 403",
- "UPSTREAM_ERROR, 502",
- "UPSTREAM_CIRCUIT_OPEN, 503",
- "UPSTREAM_TIMEOUT, 504"
- })
- @DisplayName("Should render every error-contract row as its status + problem+json shape")
- void shouldRenderEveryErrorContractRow(EventType eventType, int expectedStatus) {
- var response = GatewayExceptionMapper.render(eventType);
-
- String body = (String) response.getEntity();
- EventCategory category = eventType.category();
- assertAll("problem+json for " + eventType,
- () -> assertEquals(expectedStatus, response.getStatus(), "Status must match the contract"),
- () -> assertEquals(PROBLEM_JSON, response.getMediaType().toString(),
- "Media type must be application/problem+json"),
- () -> assertTrue(body.contains("\"type\":\"" + category.problemType() + "\""),
- "Body must name the RFC 9457 problem type: " + body),
- () -> assertTrue(body.contains("\"title\":\"" + category.title() + "\""),
- "Body must carry the category title: " + body),
- () -> assertTrue(body.contains("\"status\":" + expectedStatus),
- "Body must carry the numeric status: " + body));
- }
-
- @Test
- @DisplayName("Should route a GatewayException through render via toResponse")
- void shouldRouteGatewayExceptionThroughToResponse() {
- var mapper = new GatewayExceptionMapper();
-
- Response response = mapper.toResponse(new GatewayException(EventType.SCOPE_MISSING));
-
- assertAll("mapped GatewayException",
- () -> assertEquals(403, response.getStatus(), "SCOPE_MISSING renders 403"),
- () -> assertEquals(PROBLEM_JSON, response.getMediaType().toString(), "Media type stays problem+json"));
- }
-
- @Test
- @DisplayName("Should not leak internal detail in the problem body")
- void shouldNotLeakInternalDetail() {
- var response = GatewayExceptionMapper.render(EventType.UPSTREAM_ERROR);
-
- String body = (String) response.getEntity();
-
- assertTrue(body.startsWith("{\"type\":") && !body.contains("detail"),
- "The RFC 9457 body must not carry an internal detail member: " + body);
- }
-
- @ParameterizedTest(name = "token {0} -> {1}")
- @CsvSource({
- "TOKEN_EMPTY, TOKEN_MISSING",
- "SIGNATURE_VALIDATION_FAILED, TOKEN_INVALID",
- "TOKEN_EXPIRED, TOKEN_INVALID",
- "ISSUER_MISMATCH, TOKEN_INVALID"
- })
- @DisplayName("Should translate a TokenValidationException to the gateway auth event")
- void shouldTranslateTokenValidationException(String tokenEvent, EventType expected) {
- var tokenException = new TokenValidationException(
- SecurityEventCounter.EventType.valueOf(tokenEvent), "validation failed");
-
- GatewayException translated = GatewayExceptionMapper.translate(tokenException);
-
- assertAll("translated token failure",
- () -> assertEquals(expected, translated.getEventType(), "Mapped event must match"),
- () -> assertEquals(EventCategory.AUTHENTICATION, translated.getEventType().category(),
- "Token failures are AUTHENTICATION failures"),
- () -> assertEquals(401, translated.getEventType().httpStatus(), "Token failures render 401"));
- }
-}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/GrpcProtocolProcessorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/GrpcProtocolProcessorTest.java
new file mode 100644
index 00000000..3dcb3639
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/GrpcProtocolProcessorTest.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.routing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Contract of {@link GrpcProtocolProcessor}: it identifies as {@code grpc} and serves only the gRPC
+ * call verb ({@code POST}), since every gRPC call is an HTTP/2 {@code POST} to a service/method path
+ * and every other verb is outside its scope. The gRPC deltas (forced-h2 dispatch, trailer relay,
+ * trailers-only rejection) are owned by the edge stages, not this processor.
+ */
+@DisplayName("GrpcProtocolProcessor — gRPC POST verb semantics")
+class GrpcProtocolProcessorTest {
+
+ private final GrpcProtocolProcessor processor = new GrpcProtocolProcessor();
+
+ @Test
+ @DisplayName("identifies as 'grpc'")
+ void identifiesAsGrpc() {
+ assertEquals("grpc", processor.id());
+ }
+
+ @Test
+ @DisplayName("serves exactly the POST call verb")
+ void servesOnlyPost() {
+ assertEquals(Set.of(HttpMethod.POST), processor.standardMethods(),
+ "every gRPC call is an HTTP/2 POST, so POST is the only verb the processor serves");
+ }
+
+ @Test
+ @DisplayName("supports POST")
+ void supportsPost() {
+ assertTrue(processor.supports(HttpMethod.POST));
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = "POST", mode = EnumSource.Mode.EXCLUDE)
+ @DisplayName("rejects every non-POST verb")
+ void rejectsNonPostVerbs(HttpMethod method) {
+ assertFalse(processor.supports(method),
+ () -> "the gRPC processor must not serve " + method);
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/RouteRuntimeTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/RouteRuntimeTest.java
index f8620ebb..c44c91dd 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/RouteRuntimeTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/RouteRuntimeTest.java
@@ -18,7 +18,6 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
@@ -30,8 +29,6 @@
import de.cuioss.sheriff.api.config.model.MatchConfig;
import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher;
import de.cuioss.sheriff.api.config.model.Protocol;
-import de.cuioss.sheriff.api.events.EventType;
-import de.cuioss.sheriff.api.events.GatewayException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -101,16 +98,18 @@ void shouldReuseHttpProcessorForGraphql() {
}
@Test
- @DisplayName("Should fail boot for unsupported protocols")
- void shouldFailBootForUnsupportedProtocols() {
- var grpc = assertThrows(GatewayException.class,
- () -> registry.require(Protocol.GRPC, "grpc-route"), "gRPC must fail boot");
- var websocket = assertThrows(GatewayException.class,
- () -> registry.require(Protocol.WEBSOCKET, "ws-route"), "WebSocket must fail boot");
-
- assertEquals(EventType.CONFIG_INVALID, grpc.getEventType(), "gRPC rejection is a config failure");
- assertEquals(EventType.CONFIG_INVALID, websocket.getEventType(), "WebSocket rejection is a config failure");
- assertFalse(registry.supports(Protocol.GRPC), "gRPC is unsupported");
+ @DisplayName("Should serve gRPC and WebSocket with their dedicated processors")
+ void shouldServeGrpcAndWebSocketProcessors() {
+ // GRPC is now registered (its boot rejection was removed), so it resolves the dedicated
+ // gRPC processor rather than failing boot.
+ assertTrue(registry.supports(Protocol.GRPC), "gRPC is now supported");
+ assertEquals("grpc", registry.require(Protocol.GRPC, "grpc-route").id(),
+ "a gRPC route resolves the dedicated gRPC processor");
+
+ // WEBSOCKET is likewise registered and resolves the WebSocket processor.
+ assertTrue(registry.supports(Protocol.WEBSOCKET), "WebSocket is now supported");
+ assertEquals("websocket", registry.require(Protocol.WEBSOCKET, "ws-route").id(),
+ "a WebSocket route resolves the WebSocket processor");
}
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/WebSocketProtocolProcessorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/WebSocketProtocolProcessorTest.java
new file mode 100644
index 00000000..778595ed
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/WebSocketProtocolProcessorTest.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.routing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Contract of {@link WebSocketProtocolProcessor}: it identifies as {@code websocket} and serves only
+ * the WebSocket upgrade verb ({@code GET}), since a WebSocket route is entered by the HTTP upgrade
+ * handshake and every other verb is outside its scope.
+ */
+@DisplayName("WebSocketProtocolProcessor — WebSocket upgrade verb semantics")
+class WebSocketProtocolProcessorTest {
+
+ private final WebSocketProtocolProcessor processor = new WebSocketProtocolProcessor();
+
+ @Test
+ @DisplayName("identifies as 'websocket'")
+ void identifiesAsWebsocket() {
+ assertEquals("websocket", processor.id());
+ }
+
+ @Test
+ @DisplayName("serves exactly the GET upgrade verb")
+ void servesOnlyGet() {
+ assertEquals(Set.of(HttpMethod.GET), processor.standardMethods(),
+ "a WebSocket route is entered by the GET upgrade handshake only");
+ }
+
+ @Test
+ @DisplayName("supports GET")
+ void supportsGet() {
+ assertTrue(processor.supports(HttpMethod.GET));
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = "GET", mode = EnumSource.Mode.EXCLUDE)
+ @DisplayName("rejects every non-GET verb")
+ void rejectsNonGetVerbs(HttpMethod method) {
+ assertFalse(processor.supports(method),
+ () -> "the WebSocket processor must not serve " + method);
+ }
+}
diff --git a/benchmarks/README.adoc b/benchmarks/README.adoc
index 6a7edc1e..0f939b3f 100644
--- a/benchmarks/README.adoc
+++ b/benchmarks/README.adoc
@@ -24,24 +24,26 @@ The two lanes run deliberately different sets, so a number is always attributabl
|Lane |Executions |Contents
|CI baseline (`-Pbenchmark`)
-|8
-|The six matrix aspects *plus* the two retained non-matrix benchmarks `healthLiveCheck` and
+|10
+|The eight matrix aspects *plus* the two retained non-matrix benchmarks `healthLiveCheck` and
`gatewayHealth`.
|On-demand comparison (`run-comparison.sh`)
-|6
-|The six matrix aspects only.
+|8
+|The eight matrix aspects only.
|===
The two health benchmarks are excluded from the comparison lane on purpose: they measure the
Quarkus management port and API Sheriff's own `/api/health` surface, neither of which APISIX
exposes. Driving them against APISIX would still label their summaries `gateway_target: apisix`
while actually measuring API Sheriff — mislabelled data that reads as correct. `run-comparison.sh`
-closes its aspect list to the six comparable aspects so that is unreachable.
+closes its aspect list to the eight comparable aspects so that is unreachable.
== The aspect matrix
-These six aspects exist route-for-route on both gateways and are the comparable set.
+These eight aspects exist route-for-route on both gateways and are the comparable set. The first
+six ride the single static fairness backend and measure pure gateway overhead; the last two (`ws`,
+`grpc`) ride protocol-appropriate backends instead — see the fairness caveat below the table.
[cols="1,1,3", options="header"]
|===
@@ -77,8 +79,32 @@ These six aspects exist route-for-route on both gateways and are the comparable
|`upload_large.js`
|The transfer-bound body path at *reduced* concurrency. Reports throughput (MBps) alongside
latency.
+
+|`ws`
+|`websocket_echo.js`
+|The WebSocket upgrade handshake and opaque frame relay: per-message echo round-trip through the
+ gateway to go-httpbin's `/websocket/echo`. Reports round-trips/second and round-trip latency.
+
+|`grpc`
+|`grpc_unary.js`
+|A unary gRPC call over the forced-HTTP/2 relay on the bare service path, against the in-repo
+ `grpc-echo` service. Reports calls/second and call latency. (Per Clarification 1 this k6 gRPC
+ benchmark supersedes the plan doc's `ghz` proposal, keeping the whole matrix on one load generator.)
|===
+[NOTE]
+====
+*Fairness caveat for `ws` and `grpc`.* The first six aspects all ride the single static `nginx`
+fairness backend, so they measure *pure gateway overhead* (see the fairness invariant under
+Methodology). The `ws` and `grpc` aspects cannot: no static HTTP backend speaks WebSocket or gRPC.
+They ride *protocol-appropriate* backends instead — go-httpbin's WebSocket echo for `ws` and the
+in-repo `grpc-echo` service for `grpc` — and both gateways target the *same* two backends, so the
+two rows stay symmetric across gateways. The consequence, accepted deliberately: these two rows
+fold a real protocol backend's echo cost into the number, so they are *protocol-relay comparisons*,
+not backend-free gateway-overhead measurements like the other six. Read them as "aspect X costs N%
+more on gateway A than on gateway B", never as an absolute relay-throughput claim.
+====
+
=== Retained non-matrix benchmarks
[cols="1,2,3", options="header"]
@@ -155,8 +181,8 @@ API Sheriff, APISIX and Keycloak all mount the same certificate bundle, so every
certificate and the load generator needs no per-target trust handling. TLS termination cost is
therefore common to both sides.
-Identical routes, one upstream::
-Every benchmarked route on *both* gateways targets the same fast static nginx backend
+Identical routes, one upstream (six HTTP aspects)::
+Every *HTTP* benchmarked route on *both* gateways targets the same fast static nginx backend
(`nginx-static`). This is the *fairness invariant*: pointing one gateway's `graphql` / `upload`
routes at the JSON-serializing `go-httpbin` while the other hit `nginx-static` would fold the
backend's serialization cost into one side's number only. The accepted consequence is that the
@@ -164,6 +190,13 @@ backend's serialization cost into one side's number only. The accepted consequen
enforcement, path rewriting — rather than real JSON or echo upstream work. That is the benchmark's
stated intent, not a limitation to work around.
+Protocol-appropriate backends (`ws`, `grpc`)::
+The `ws` and `grpc` aspects are the deliberate exception to the single-backend invariant: no static
+HTTP backend speaks WebSocket or gRPC. Each rides a protocol backend shared by *both* gateways —
+go-httpbin's `/websocket/echo` for `ws`, the in-repo `grpc-echo` service for `grpc` — so the two
+sides stay symmetric even though these rows are not backend-free. See the fairness caveat under
+_The aspect matrix_ for how to read the two rows.
+
Readiness, not warm-up discard::
`pre-benchmark-health-check.sh` gates every run on the stack actually serving, so no run starts
against a cold or half-started gateway. There is no separate warm-up phase whose samples are
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index def7d47b..dc60ae40 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -450,6 +450,64 @@
+
+
+ run-k6-websocket-echo-benchmark
+ integration-test
+
+ exec
+
+
+ docker
+
+ compose
+ run
+ --rm
+ k6
+ run
+ /scripts/websocket_echo.js
+
+ ${integration.compose.dir}
+ 240000
+
+ ${k6.vus}
+ ${k6.duration}
+ ${k6.output.dir}
+
+
+
+
+
+
+ run-k6-grpc-unary-benchmark
+ integration-test
+
+ exec
+
+
+ docker
+
+ compose
+ run
+ --rm
+ k6
+ run
+ /scripts/grpc_unary.js
+
+ ${integration.compose.dir}
+ 240000
+
+ ${k6.vus}
+ ${k6.duration}
+ ${k6.output.dir}
+
+
+
+
process-k6-results
diff --git a/benchmarks/scripts/run-comparison.sh b/benchmarks/scripts/run-comparison.sh
index dec9c795..39d2a127 100755
--- a/benchmarks/scripts/run-comparison.sh
+++ b/benchmarks/scripts/run-comparison.sh
@@ -15,7 +15,7 @@
# run-comparison.sh [--target NAME] [--aspects a,b,c] [--output DIR] [--duration D] [--vus N]
#
# --target gateway to measure: api-sheriff (default) | apisix | both
-# --aspects comma-separated subset of the matrix (default: all six)
+# --aspects comma-separated subset of the matrix (default: all eight)
# --output results root (default: target/comparison-results)
# --duration k6 run duration per aspect (default: 60s)
# --vus VU override for the throughput aspects; upload-50MB keeps its reduced
@@ -39,12 +39,19 @@
set -euo pipefail
# --- the matrix -------------------------------------------------------------
-# Aspect name -> k6 script. ONLY these six are comparable across gateways. The retained
+# Aspect name -> k6 script. ONLY these eight are comparable across gateways. The retained
# healthLiveCheck and gatewayHealth benchmarks are deliberately absent: they measure API Sheriff's
# own management port and /api/health surface, which APISIX does not expose. Driving them under
# GATEWAY_TARGET=apisix would still label their summaries `gateway_target: apisix` while actually
# measuring API Sheriff -- mislabelled data that reads as correct. Keeping the matrix closed here
# is what makes that unreachable.
+#
+# FAIRNESS CAVEAT for ws / grpc. The six HTTP aspects all ride the single static nginx fairness
+# backend (see apisix.yaml's fairness invariant), so they measure pure gateway overhead. The ws and
+# grpc aspects CANNOT: no static backend speaks their protocol. They ride protocol-appropriate
+# backends instead -- go-httpbin's /websocket/echo for ws and the in-repo grpc-echo service for
+# grpc -- on BOTH gateways, so the two sides stay symmetric even though these two rows fold a real
+# protocol backend's cost into the number. That caveat is documented in README.adoc and apisix.yaml.
declare -A ASPECT_SCRIPTS=(
[unauth]=proxied_static.js
[bearer]=bearer_proxied.js
@@ -52,8 +59,10 @@ declare -A ASPECT_SCRIPTS=(
[graphql]=graphql.js
[upload-1MB]=upload_small.js
[upload-50MB]=upload_large.js
+ [ws]=websocket_echo.js
+ [grpc]=grpc_unary.js
)
-ALL_ASPECTS="unauth,bearer,http2,graphql,upload-1MB,upload-50MB"
+ALL_ASPECTS="unauth,bearer,http2,graphql,upload-1MB,upload-50MB,ws,grpc"
# Reduced concurrency for the transfer-bound aspect; see upload_large.js for the rationale (per-VU
# 50MB payload memory, and link saturation masking the gateway difference).
diff --git a/benchmarks/src/main/resources/k6-scripts/echo.proto b/benchmarks/src/main/resources/k6-scripts/echo.proto
new file mode 100644
index 00000000..41cc6a0d
--- /dev/null
+++ b/benchmarks/src/main/resources/k6-scripts/echo.proto
@@ -0,0 +1,46 @@
+syntax = "proto3";
+
+// Benchmark-lane copy of the in-repo gRPC echo contract, shipped to the k6 script directory so
+// the k6 gRPC client (grpc_unary.js) can load the descriptor and derive the real service/method
+// path. Source of truth: integration-tests/src/main/proto/echo.proto — the gateway relays this
+// service opaquely, so the two copies only need to agree on the package/service/method names and
+// the request/response shapes. Keep them in sync when the echo contract changes.
+package de.cuioss.sheriff.api.integration.grpc;
+
+option java_multiple_files = true;
+option java_package = "de.cuioss.sheriff.api.integration.grpc";
+option java_outer_classname = "EchoProto";
+
+// Echo upstream relayed end-to-end through the gateway by the grpc matrix aspect. The grpc_unary.js
+// benchmark drives only Echo/Unary; SecureEcho mirrors the integration-tests contract (the
+// bearer-protected service the ITs exercise) and is unused by the benchmark.
+service Echo {
+ // Unary: returns the request message unchanged (index 0).
+ rpc Unary(EchoRequest) returns (EchoResponse);
+
+ // Server-streaming: emits `count` responses (index 0..count-1), each echoing the message.
+ rpc ServerStream(EchoRequest) returns (stream EchoResponse);
+
+ // Always fails with a non-OK grpc-status so the IT can assert trailer/status relay.
+ rpc Fail(EchoRequest) returns (EchoResponse);
+}
+
+// The bearer-protected echo service (see integration-tests/src/main/proto/echo.proto). Carried here
+// only to keep the two proto copies in sync; the benchmark never calls it.
+service SecureEcho {
+ rpc Unary(EchoRequest) returns (EchoResponse);
+}
+
+message EchoRequest {
+ // Payload echoed back verbatim.
+ string message = 1;
+ // Number of responses the server-streaming method emits (clamped to >= 1).
+ int32 count = 2;
+}
+
+message EchoResponse {
+ // The echoed payload.
+ string message = 1;
+ // Zero-based position of this response within a (possibly single-element) stream.
+ int32 index = 2;
+}
diff --git a/benchmarks/src/main/resources/k6-scripts/grpc_unary.js b/benchmarks/src/main/resources/k6-scripts/grpc_unary.js
new file mode 100644
index 00000000..155f8393
--- /dev/null
+++ b/benchmarks/src/main/resources/k6-scripts/grpc_unary.js
@@ -0,0 +1,80 @@
+/**
+ * @fileoverview Benchmark for the gRPC unary echo relay (k6 -> gateway -> in-repo gRPC echo).
+ *
+ * This is the `grpc` matrix aspect. It measures the gateway's cost of relaying a unary gRPC call
+ * over the forced-HTTP/2 upstream path: route selection on the bare service path, the opaque
+ * length-prefixed request/response framing, and the trailer relay. Per Clarification 1 this k6
+ * gRPC benchmark supersedes the plan doc's ghz proposal, keeping the whole matrix on one load
+ * generator and one summary format.
+ *
+ * The gateway routes gRPC on the bare service path (operator decision 2026-07-21): the route
+ * matches /de.cuioss.sheriff.api.integration.grpc.Echo and sets an identical upstream.path, so a
+ * stock gRPC client dials the real /{package}.Echo/Unary method path and it reaches the upstream
+ * unchanged. k6's gRPC client is exactly such a stock client -- it derives the method path from the
+ * loaded proto, so no client-side path rewriting is needed.
+ *
+ * Like the WebSocket aspect, this one cannot ride the static nginx fairness backend: nginx-static
+ * is not a gRPC server. It targets the in-repo Quarkus gRPC echo upstream (GRPC_ECHO). The APISIX
+ * side mirrors this with a grpc-echo route; see the fairness caveat in apisix.yaml and README.adoc.
+ */
+import grpc from 'k6/net/grpc';
+import { check } from 'k6';
+import { Rate, Counter } from 'k6/metrics';
+import { buildSummary, duration, maxErrorRate, SUMMARY_TREND_STATS, vus } from './lib/summary.js';
+import { grpcAddress } from './lib/target.js';
+
+const BENCHMARK_NAME = 'grpcUnary';
+const ADDRESS = __ENV.TARGET_ADDRESS || grpcAddress();
+
+// The fully-qualified gRPC method: proto package + service / method. k6 derives the :path from the
+// loaded descriptor, so this is the real service path the bare-service-path route matches.
+const SERVICE_METHOD = 'de.cuioss.sheriff.api.integration.grpc.Echo/Unary';
+const PAYLOAD = 'sheriff-grpc';
+
+// The proto is bundled into the k6 image alongside the scripts (Dockerfile.k6 copies the whole
+// k6-scripts/ tree to /scripts), so it resolves under that import root inside the container.
+const client = new grpc.Client();
+client.load(['/scripts'], 'echo.proto');
+
+// k6 records unary latency under grpc_req_duration, but emits no built-in call-rate or failure-rate
+// metric, so this aspect sources its throughput and error fraction from these custom metrics (see
+// lib/summary.js metric-override options).
+const calls = new Counter('grpc_calls');
+const grpcFailed = new Rate('grpc_req_failed');
+
+export const options = {
+ vus: vus(50),
+ duration: duration(),
+ summaryTrendStats: SUMMARY_TREND_STATS,
+ insecureSkipTLSVerify: true,
+ thresholds: {
+ grpc_req_failed: [`rate<=${maxErrorRate()}`],
+ checks: [`rate>=${1 - maxErrorRate()}`],
+ },
+};
+
+export default function () {
+ // Connect once per VU (on its first iteration) rather than per call: a per-call dial would fold
+ // TLS + HTTP/2 connection setup into every measured request and swamp the relay cost the
+ // benchmark targets. The connection is held open for the VU's lifetime.
+ if (__ITER === 0) {
+ client.connect(ADDRESS, { plaintext: false, timeout: '5s' });
+ }
+
+ const response = client.invoke(SERVICE_METHOD, { message: PAYLOAD });
+ calls.add(1);
+ const ok = response && response.status === grpc.StatusOK;
+ grpcFailed.add(!ok);
+ check(response, {
+ 'status is OK': (r) => r.status === grpc.StatusOK,
+ 'echoes the message': (r) => ok && r.message.message === PAYLOAD,
+ });
+}
+
+export function handleSummary(data) {
+ return buildSummary(BENCHMARK_NAME, data, {
+ durationMetric: 'grpc_req_duration',
+ requestsMetric: 'grpc_calls',
+ failuresMetric: 'grpc_req_failed',
+ });
+}
diff --git a/benchmarks/src/main/resources/k6-scripts/lib/summary.js b/benchmarks/src/main/resources/k6-scripts/lib/summary.js
index 89818406..961b2606 100644
--- a/benchmarks/src/main/resources/k6-scripts/lib/summary.js
+++ b/benchmarks/src/main/resources/k6-scripts/lib/summary.js
@@ -19,6 +19,16 @@
* The body-transfer aspects (`uploadSmall` / `uploadLarge`) additionally carry a
* `throughput_mbps` field; every other field is common to all scripts.
*
+ * The HTTP aspects source their headline figures from k6's built-in `http_req_duration` /
+ * `http_reqs` / `http_req_failed` metrics. The non-HTTP protocol aspects added for plan-05
+ * (`websocketEcho`, `grpcUnary`) do not populate those metrics — k6 records gRPC calls under
+ * `grpc_req_duration` and WebSocket traffic under `ws_*`, with no built-in request/failure rate.
+ * {@link buildSummary} therefore accepts `durationMetric` / `requestsMetric` / `failuresMetric`
+ * option overrides so those aspects can name the trend/counter/rate metrics that actually
+ * characterise them (a custom trend for the round-trip/call latency, a counter for the throughput
+ * rate, a custom rate for the failure fraction). The overrides default to the `http_*` names, so
+ * every existing HTTP script is unchanged.
+ *
* Latency values are passed through in milliseconds -- k6 reports `http_req_duration` in ms
* and the converter performs no unit conversion -- so no rounding-or-scaling step can
* introduce a conversion regression between the engine and the report.
@@ -89,7 +99,10 @@ function round(value, digits = 2) {
*
* @param {string} benchmarkName the stable benchmark name -- also the gh-pages history/trend key
* @param {object} data the k6 end-of-test summary object
- * @param {{throughput?: boolean}} [options={}] opt-ins for aspect-specific summary fields
+ * @param {{throughput?: boolean, durationMetric?: string, requestsMetric?: string,
+ * failuresMetric?: string}} [options={}] opt-ins for aspect-specific summary fields and
+ * per-aspect overrides of which k6 metric backs the latency / request-rate / failure-rate
+ * figures (defaulting to the built-in `http_*` metrics)
* @returns {object} a k6 `handleSummary()` return mapping output paths to their content
*/
export function buildSummary(benchmarkName, data, options = {}) {
@@ -98,9 +111,9 @@ export function buildSummary(benchmarkName, data, options = {}) {
const durationMs = typeof state.testRunDurationMs === 'number' ? state.testRunDurationMs : 0;
const metrics = data.metrics || {};
- const duration = (metrics.http_req_duration || {}).values || {};
- const requests = (metrics.http_reqs || {}).values || {};
- const failures = (metrics.http_req_failed || {}).values || {};
+ const duration = (metrics[options.durationMetric || 'http_req_duration'] || {}).values || {};
+ const requests = (metrics[options.requestsMetric || 'http_reqs'] || {}).values || {};
+ const failures = (metrics[options.failuresMetric || 'http_req_failed'] || {}).values || {};
const latency = { avg: round(duration.avg) };
for (const [field, k6Key] of PERCENTILES) {
diff --git a/benchmarks/src/main/resources/k6-scripts/lib/target.js b/benchmarks/src/main/resources/k6-scripts/lib/target.js
index 9242a2d1..d8190913 100644
--- a/benchmarks/src/main/resources/k6-scripts/lib/target.js
+++ b/benchmarks/src/main/resources/k6-scripts/lib/target.js
@@ -72,3 +72,26 @@ export function baseUrl() {
export function targetUrl(path) {
return `${baseUrl()}${path}`;
}
+
+/**
+ * Builds an absolute WebSocket URL for a route path on the targeted gateway, reusing the same
+ * host/target resolution as {@link targetUrl} but on the `wss://` scheme the WebSocket upgrade
+ * requires.
+ *
+ * @param {string} path the WebSocket route path, with a leading slash (e.g. `/ws/echo`)
+ * @returns {string} the absolute `wss://` URL to open the socket against
+ */
+export function wsUrl(path) {
+ return `${baseUrl().replace(/^https:/, 'wss:').replace(/^http:/, 'ws:')}${path}`;
+}
+
+/**
+ * Resolves the `host:port` address a k6 gRPC client dials the targeted gateway on — the base URL
+ * with its scheme stripped. TLS is negotiated by the client (`plaintext: false`); the gateway
+ * forces HTTP/2 to the upstream, so the client speaks ordinary gRPC over TLS to the edge.
+ *
+ * @returns {string} the `host:port` address (e.g. `api-sheriff:8443`)
+ */
+export function grpcAddress() {
+ return baseUrl().replace(/^https?:\/\//, '');
+}
diff --git a/benchmarks/src/main/resources/k6-scripts/websocket_echo.js b/benchmarks/src/main/resources/k6-scripts/websocket_echo.js
new file mode 100644
index 00000000..fcf733fe
--- /dev/null
+++ b/benchmarks/src/main/resources/k6-scripts/websocket_echo.js
@@ -0,0 +1,96 @@
+/**
+ * @fileoverview Benchmark for the WebSocket echo relay (k6 -> gateway -> go-httpbin echo).
+ *
+ * This is the `ws` matrix aspect. It measures the gateway's cost of admitting a WebSocket upgrade
+ * and relaying frames opaquely in both directions: the handshake (including the fail-closed Origin
+ * gate on the `/ws/echo` route), the upgrade to a relayed socket, and the per-frame round-trip
+ * through the opaque relay. Each VU iteration opens one socket, performs a fixed number of echo
+ * round-trips measuring per-message round-trip time, then closes it.
+ *
+ * Unlike the HTTP aspects, this one cannot ride the static nginx fairness backend: nginx-static is
+ * not a WebSocket echo server. It targets the same go-httpbin `/websocket/echo` upstream the
+ * WebSocket integration tests use (via API Sheriff's WS_UPSTREAM topology alias). The APISIX side
+ * of the comparison mirrors this with a go-httpbin proxy-ws route; see the fairness caveat in
+ * apisix.yaml and README.adoc -- the ws and grpc aspects are the two aspects that deliberately do
+ * NOT share the single static backend, because no static backend speaks their protocol.
+ *
+ * The `/ws/echo` route enforces a fail-closed Origin allowlist (GW-09 / CSWSH), so the handshake
+ * MUST carry an allow-listed `Origin`; an absent or foreign Origin would be rejected 403 before the
+ * upstream is dialed and the run would measure a rejection instead of the relay.
+ */
+import { WebSocket } from 'k6/websockets';
+import { check } from 'k6';
+import { Trend, Rate, Counter } from 'k6/metrics';
+import { buildSummary, duration, maxErrorRate, SUMMARY_TREND_STATS, vus } from './lib/summary.js';
+import { wsUrl } from './lib/target.js';
+
+const BENCHMARK_NAME = 'websocketEcho';
+const TARGET_URL = __ENV.TARGET_URL || wsUrl('/ws/echo');
+
+// The /ws/echo route's fail-closed Origin allowlist admits exactly this origin (see
+// endpoints/websocket.yaml); overridable for a differently-configured edge.
+const ORIGIN = __ENV.WS_ORIGIN || 'https://sheriff.test';
+
+// Round-trips per socket, held constant across both gateways so the reconnection-to-relay ratio is
+// identical on each side and the measured throughput stays comparable.
+const MESSAGES_PER_SESSION = 20;
+const PAYLOAD = 'sheriff-ws-echo';
+
+// The round-trip latency trend and the throughput counter this aspect is characterised on -- k6's
+// built-in metrics describe HTTP requests, not relayed WebSocket frames, so the summary sources
+// these custom metrics (see lib/summary.js metric-override options). `ws_errors` is the failure
+// fraction the summary reports as error_rate.
+const rtt = new Trend('ws_rtt', true);
+const roundtrips = new Counter('ws_roundtrips');
+const wsErrors = new Rate('ws_errors');
+
+export const options = {
+ vus: vus(50),
+ duration: duration(),
+ summaryTrendStats: SUMMARY_TREND_STATS,
+ insecureSkipTLSVerify: true,
+ thresholds: {
+ ws_errors: [`rate<=${maxErrorRate()}`],
+ checks: [`rate>=${1 - maxErrorRate()}`],
+ },
+};
+
+export default function () {
+ const socket = new WebSocket(TARGET_URL, null, {
+ headers: { Origin: ORIGIN },
+ tags: { benchmark: BENCHMARK_NAME },
+ });
+
+ let sent = 0;
+ let sentAt = 0;
+
+ const sendOne = () => {
+ sentAt = Date.now();
+ socket.send(PAYLOAD);
+ sent += 1;
+ };
+
+ socket.onopen = () => sendOne();
+
+ socket.onmessage = (message) => {
+ rtt.add(Date.now() - sentAt);
+ roundtrips.add(1);
+ const echoed = check(message, { 'frame echoes the payload': (m) => m.data === PAYLOAD });
+ wsErrors.add(!echoed);
+ if (sent >= MESSAGES_PER_SESSION) {
+ socket.close();
+ return;
+ }
+ sendOne();
+ };
+
+ socket.onerror = () => wsErrors.add(true);
+}
+
+export function handleSummary(data) {
+ return buildSummary(BENCHMARK_NAME, data, {
+ durationMetric: 'ws_rtt',
+ requestsMetric: 'ws_roundtrips',
+ failuresMetric: 'ws_errors',
+ });
+}
diff --git a/benchmarks/src/test/java/de/cuioss/sheriff/api/k6/benchmark/ComparisonSummaryWriterTest.java b/benchmarks/src/test/java/de/cuioss/sheriff/api/k6/benchmark/ComparisonSummaryWriterTest.java
index 4850a087..d7fbdfdb 100644
--- a/benchmarks/src/test/java/de/cuioss/sheriff/api/k6/benchmark/ComparisonSummaryWriterTest.java
+++ b/benchmarks/src/test/java/de/cuioss/sheriff/api/k6/benchmark/ComparisonSummaryWriterTest.java
@@ -70,10 +70,65 @@ private static JsonObject uploadLargeSummary(double mbps, double rps, double p50
""".formatted(rps, mbps, p50, p99));
}
+ /**
+ * A protocol-relay aspect (ws / grpc) summary. These aspects ride protocol-appropriate backends
+ * rather than the static fairness backend, but emit the same generic summary shape as the HTTP
+ * request-rate aspects — {@code requests_per_second} is round-trips/second (ws) or calls/second
+ * (grpc) — so the writer renders them as ordinary RPS rows with no aspect-specific branch.
+ */
+ private static JsonObject protocolSummary(String benchmarkName, double rps, double p50, double p99) {
+ return parse("""
+ {
+ "benchmark_name": "%s",
+ "gateway_target": "api-sheriff",
+ "requests_per_second": %s,
+ "error_rate": 0.0,
+ "latency_ms": { "avg": 6.0, "p50": %s, "p99": %s }
+ }
+ """.formatted(benchmarkName, rps, p50, p99));
+ }
+
private static JsonObject parse(String json) {
return JsonParser.parseString(json).getAsJsonObject();
}
+ @Test
+ void shouldRenderWebSocketAndGrpcAspectsAsRpsRows() {
+ // Arrange -- the two protocol-relay aspects, produced by both targets.
+ Map sheriff = Map.of(
+ "websocketEcho", protocolSummary("websocketEcho", 4820.15, 3.90, 22.40),
+ "grpcUnary", protocolSummary("grpcUnary", 7315.60, 2.10, 15.75));
+ Map apisix = Map.of(
+ "websocketEcho", protocolSummary("websocketEcho", 4110.00, 4.55, 30.10),
+ "grpcUnary", protocolSummary("grpcUnary", 6650.40, 2.60, 18.20));
+
+ // Act
+ String rendered = ComparisonSummaryWriter.render(SHERIFF, sheriff, APISIX, apisix);
+
+ // Assert -- both protocol aspects render as generic RPS rows (not MBps, not throughput-only),
+ // proving the data-driven writer covers ws/grpc with no aspect-specific handling.
+ assertTrue(rendered.contains("| websocketEcho | RPS | 4820.15 | 4110.00 | 3.90 / 22.40 | 4.55 / 30.10 |"),
+ "the ws aspect must render as an RPS row with both P50/P99 pairs:\n" + rendered);
+ assertTrue(rendered.contains("| grpcUnary | RPS | 7315.60 | 6650.40 | 2.10 / 15.75 | 2.60 / 18.20 |"),
+ "the grpc aspect must render as an RPS row with both P50/P99 pairs:\n" + rendered);
+ assertFalse(rendered.contains("| websocketEcho | MBps |"), "ws must not be reported on MBps");
+ assertFalse(rendered.contains("| grpcUnary | MBps |"), "grpc must not be reported on MBps");
+ }
+
+ @Test
+ void shouldRenderNotAvailableForAProtocolAspectOnlyOneTargetProduced() {
+ // Arrange -- a half-failed comparison run: APISIX produced no grpc summary (e.g. the grpc
+ // route or backend was unreachable on that side).
+ Map sheriff = Map.of("grpcUnary", protocolSummary("grpcUnary", 7315.60, 2.10, 15.75));
+
+ // Act
+ String rendered = ComparisonSummaryWriter.render(SHERIFF, sheriff, APISIX, Map.of());
+
+ // Assert -- the row survives with n/a for the missing side, never a 0 that reads as a collapse.
+ assertTrue(rendered.contains("| grpcUnary | RPS | 7315.60 | n/a | 2.10 / 15.75 | n/a / n/a |"),
+ "a protocol aspect only one target produced must still render, with n/a for the other:\n" + rendered);
+ }
+
@Test
void shouldCompareRequestRateAspectOnRps() {
// Arrange
diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc
index 711a80de..74ed178b 100644
--- a/doc/LogMessages.adoc
+++ b/doc/LogMessages.adoc
@@ -36,6 +36,8 @@ diagnostics use the logger directly and are not catalogued.
|ApiSheriff-1 |EDGE |Route table compiled: %s route runtime(s) assembled |Logged once at startup, after the frozen route table is compiled into the immutable per-route `RouteRuntime` set the request pipeline serves from
|ApiSheriff-2 |CONFIG |Configuration loaded successfully (config_version='%s') |Logged once at startup after the file-based configuration is read, validated, and assembled into the route table
|ApiSheriff-3 |CONFIG |Route '%s' effective posture: anchor='%s', auth.require='%s', filter='%s' |Logged once per route during route-table assembly, reporting the materialized effective posture (resolving anchor, effective auth requirement, effective security-filter profile) — anchors vanish at runtime, so the boot log is the discoverability record (ADR-0007)
+|ApiSheriff-4 |EDGE |WebSocket relay established on route '%s' (upstream '%s') |Logged once when a WebSocket upgrade completes (101 Switching Protocols) and the opaque bidirectional relay to the upstream begins (Plan 05 WebSocket processor)
+|ApiSheriff-5 |EDGE |WebSocket relay on route '%s' reclaimed after idle timeout (%s seconds) |Logged when an established WebSocket relay is closed because no frame travelled in either direction for the per-route websocket.idle_timeout_seconds window; ping/pong counts as activity, so only genuinely dead sockets are reaped
|===
== WARN Level (100-199)
@@ -48,6 +50,7 @@ diagnostics use the logger directly and are not catalogued.
|ApiSheriff-102 |CONFIG |trusted_proxies entry '%s' covers a very broad address range (prefix /%s) — review whether such broad proxy trust is intended |Logged during startup validation when a trusted_proxies CIDR entry is broad-but-not-total (shorter than /8 for IPv4, /32 for IPv6); total address-space coverage is rejected outright as a validation error
|ApiSheriff-103 |EDGE |Circuit breaker opened for upstream '%s' |Logged when a route's SmallRye Fault-Tolerance circuit breaker transitions to OPEN after its configured consecutive-failure threshold, so the breaker's protective posture is always an audited event
|ApiSheriff-104 |EDGE |Circuit breaker closed for upstream '%s' |Logged when a route's circuit breaker transitions back to CLOSED after recovery
+|ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': Origin '%s' is not in the allowed_origins allowlist |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015)
|===
== ERROR Level (200-299)
diff --git a/doc/README.adoc b/doc/README.adoc
index cedce7e7..d5a3e6de 100644
--- a/doc/README.adoc
+++ b/doc/README.adoc
@@ -171,6 +171,17 @@ implementing it.
SSRF-controlled data plane, no parallel fetch stack), a gateway-owned response envelope, and
canonicalize-and-confine path safety with auth-before-source-resolution ordering.
*(Status: accepted.)*
+
+| link:adr/0015-websocket-origin-failclosed-allowlist.adoc[ADR-0015]
+| WebSocket `Origin` fail-closed exact-match allowlist: a new per-route `allowed_origins` field
+ on bearer WebSocket routes (exact origins, host case-insensitive, no wildcards) rejects an
+ unlisted or missing-allowlist upgrade before any upstream dial -- closing CSWSH (GW-09) without
+ overloading CORS. *(Status: accepted.)*
+
+| link:adr/0016-grpc-trailers-only-rejection-mapping.adoc[ADR-0016]
+| gRPC gateway rejections are emitted as trailers-only gRPC responses (a gRPC client cannot
+ consume `application/problem+json`), with a canonical status mapping and h2-negotiation-failure
+ to `UNAVAILABLE`. *(Status: accepted.)*
|===
== Reading Order
diff --git a/doc/adr/0007-anchor-scoped-policy.adoc b/doc/adr/0007-anchor-scoped-policy.adoc
index 0c586577..15860bb9 100644
--- a/doc/adr/0007-anchor-scoped-policy.adoc
+++ b/doc/adr/0007-anchor-scoped-policy.adoc
@@ -80,6 +80,21 @@ Three lessons condense out:
every route whose path falls inside *any* anchor's namespace must declare that anchor.
A mismatch (declared anchor vs. actual path) or an undeclared squatter fails the boot.
When no `anchors` block is configured, nothing changes.
+* *gRPC routes are exempt from the namespace-containment geometry.* A `protocol: grpc`
+ route rides the service-rooted `/{package}.{Service}/{Method}` path, whose service
+ component (e.g. `de.cuioss.sheriff.api.integration.grpc.Echo`) is a *single opaque path
+ segment* -- dots, no slashes. No non-root anchor `path_prefix` can contain such a path on
+ a segment boundary, and a stock gRPC client (grpc-java, k6's gRPC client) dials the real
+ service path and cannot be told to prepend a gateway namespace prefix -- so neither the
+ containment check (route inside its declared anchor) nor the squatter check (undeclared
+ route inside any anchor namespace) can ever be satisfied for a bare gRPC service path.
+ Both containment checks are therefore skipped for `protocol: grpc` routes. Only the
+ path-prefix *geometry* is exempt: a gRPC route still *declares* an anchor for its ADR-0013
+ type/access classification and its auth floor, and every other anchor rule --
+ declared-anchor existence, pairwise-disjoint anchor prefixes, the non-weakenable auth
+ floor, and the ADR-0013 access→auth matrix -- stays enforced for gRPC routes unchanged.
+ `websocket` and `http` routes keep full containment enforcement (a `/ws`-nested WebSocket
+ surface still validates its namespace membership).
* *Inheritance chain and semantics*: `gateway` defaults -> `anchor` -> `endpoint` -> `route`,
with *wholesale block replacement at every step, never a merge* -- the one rule the model
already has, extended by one rung.
diff --git a/doc/adr/0015-websocket-origin-failclosed-allowlist.adoc b/doc/adr/0015-websocket-origin-failclosed-allowlist.adoc
new file mode 100644
index 00000000..937f18e7
--- /dev/null
+++ b/doc/adr/0015-websocket-origin-failclosed-allowlist.adoc
@@ -0,0 +1,86 @@
+= ADR-0015 -- WebSocket Origin Fail-Closed Exact-Match Allowlist
+:toc:
+:toclevels: 2
+
+== Status
+
+Accepted.
+
+== Context
+
+A `protocol: websocket` route proxies a browser-initiated upgrade. Unlike an XHR/`fetch` call,
+a cross-origin WebSocket connection is *not* restrained by the browser's Same-Origin Policy: the
+browser will happily open a socket to any origin and attach the ambient credentials
+(cookies, and -- for a bearer route -- whatever the page chooses to send). The *only* defence
+against a malicious page opening an authenticated socket to the gateway is a *server-side `Origin`
+check* on the handshake. An accept-any-origin upgrader is a cross-site WebSocket hijacking (CSWSH)
+hole -- the class behind Nginx-UI CVE-2026-34403 and Dozzle CVE-2026-44985, and GW-09 in the
+link:../security-threat-model.adoc[threat model].
+
+Three forces shape how the check is configured:
+
+* *A bearer WebSocket route has no existing origin allowlist to reuse.* The BFF variants
+ (Plan 07) already carry `session.csrf.trusted_origins`, and a `require: session` WS route
+ reuses it -- the WS Origin check and the stage-0 CORS/CSRF posture are the same trust decision
+ there. A *bearer* WS route has no such set.
+* *CORS `allowed_origins` is a different trust decision.* `security_headers.cors.allowed_origins`
+ governs what the *browser* is told it may do with an XHR/`fetch` response (the preflight answer);
+ it is gateway/anchor-scoped and is about response-header emission. The WS `Origin` check is a
+ *per-route handshake admission* decision made *before* the upstream is dialled. Overloading the
+ CORS field would couple two unrelated postures and force a route-level CORS override that does
+ not otherwise exist.
+* *Fail-open is the dangerous default.* A bearer socket whose allowlist is absent or empty, if
+ treated as "any origin", is exactly the CSWSH hole -- and silently, since nothing rejects.
+
+== Decision
+
+Introduce a new per-route `allowed_origins` field, `security_headers`-adjacent but declared *per
+route* on bearer WebSocket routes, with *fail-closed exact-match* semantics defined *doc-first* in
+link:../configuration.adoc#_allowed_origins[`configuration.adoc`] (schema, field reference,
+validation-rules summary) before the implementing code:
+
+* *Exact match, no wildcards.* Each entry is a full origin string (`scheme://host[:port]`).
+ Matching compares scheme and port exactly and the host *case-insensitively* (RFC 6454 web-origin
+ model; RFC 3986 §3.2.2 case-insensitive host comparison). There
+ are no wildcards, suffix forms, or regular expressions -- every permitted origin is listed in
+ full.
+* *Fail-closed, no "any origin" default.* The handshake `Origin` must exactly match a listed
+ origin or the upgrade is rejected *before* any upstream dial. An *absent or empty*
+ `allowed_origins` on a bearer WebSocket route *fails the boot* -- there is deliberately no
+ accept-any default.
+* *Bearer only; session reuses the BFF set.* The field is validated only on a `protocol: websocket`
+ route whose effective auth is `require: bearer`. A `require: session` WS route (Plan 07) reuses
+ `session.csrf.trusted_origins` instead, keeping the WS Origin check and the CORS/CSRF posture one
+ decision. A socket is never authenticated on the ambient session cookie alone.
+
+== Consequences
+
+* CSWSH (GW-09) is closed for bearer WebSocket routes by construction: an unlisted origin cannot
+ complete the handshake, and a misconfigured (empty) allowlist fails the boot loudly rather than
+ admitting every origin.
+* The configuration surface grows one field with self-contained, boot-validated semantics; CORS
+ and the WS Origin check stay independent and separately auditable.
+* The rejection is emitted before the upstream is contacted, so a hijack attempt never reaches the
+ backend and is observable as a handshake-time WARN (see
+ link:../LogMessages.adoc[Log Messages]).
+
+== Rejected alternatives
+
+* *Accept-any-origin default* -- the CSWSH hole itself; rejected outright.
+* *Reuse `security_headers.cors.allowed_origins`* -- couples two unrelated trust decisions and
+ would require inventing a route-level CORS override; rejected.
+* *Wildcard / suffix matching* -- broadens the allowlist into the exact class of over-permissive
+ matching that produces origin-confusion bugs; rejected in favour of exact match.
+* *Derive the allowlist from the route `match.host`* -- the calling page's origin and the gateway
+ host are unrelated; rejected.
+
+== References
+
+* link:../configuration.adoc#_allowed_origins[Configuration -- `allowed_origins`] -- the
+ doc-first field definition (schema, field reference, validation rules).
+* link:../security-threat-model.adoc#gw-09[Threat Model -- GW-09] -- the CSWSH class this decision
+ closes.
+* link:0007-anchor-scoped-policy.adoc[ADR-0007] -- the anchor/route policy resolution the auth
+ floor rides on.
+* link:../plan/05-protocol-processors.adoc[Plan 05] -- the implementation plan for the WebSocket
+ processor and its Origin validation.
diff --git a/doc/adr/0016-grpc-trailers-only-rejection-mapping.adoc b/doc/adr/0016-grpc-trailers-only-rejection-mapping.adoc
new file mode 100644
index 00000000..90099f4d
--- /dev/null
+++ b/doc/adr/0016-grpc-trailers-only-rejection-mapping.adoc
@@ -0,0 +1,79 @@
+= ADR-0016 -- gRPC Trailers-Only Rejection Status Mapping
+:toc:
+:toclevels: 2
+
+== Status
+
+Accepted.
+
+== Context
+
+The gateway's standard rejection envelope is an `application/problem+json` (RFC 9457) response
+(link:../architecture.adoc#_error_contract[error contract]). That envelope is unusable on a
+`protocol: grpc` route: a gRPC client's runtime does not surface an arbitrary HTTP response body
+to the caller -- it surfaces the *gRPC status* carried in the response *trailers* (`grpc-status`,
+`grpc-message`). A gateway that answered a rejected gRPC request with a problem+json *body* would
+produce, at the client, an opaque "malformed response" / `INTERNAL` error that hides the real
+reason (unauthenticated, method not allowed, upstream down).
+
+Two facts constrain the answer:
+
+* *A rejection may occur before the upstream is reached* (auth failure, verb gate, security
+ filter) -- so the gateway must be able to *originate* a gRPC-shaped rejection, not merely relay
+ one from the upstream.
+* *Whether the upstream actually speaks h2 is unknowable at boot.* The upstream dial is forced to
+ HTTP/2, but negotiation can still fail at dispatch (a remote capability, not config); that
+ failure needs a defined gRPC status too.
+
+== Decision
+
+Gateway-generated rejections on a `protocol: grpc` route are emitted as *trailers-only* gRPC
+responses -- an HTTP/2 response whose HEADERS frame carries `grpc-status` (and `grpc-message`)
+with *no DATA frame and no body* (the gRPC "Trailers-Only" case). The gateway maps the same
+rejection it would render as an HTTP status on an HTTP route onto the canonical gRPC status,
+defined *doc-first* in the link:../architecture.adoc#_grpc_error_contract[error contract] in the
+same PR as the code:
+
+[cols="1,1,2"]
+|===
+| gRPC status | code | Gateway rejection (HTTP status the same cause renders on an HTTP route)
+
+| `INVALID_ARGUMENT` | 3 | `400` -- security-filter violation
+| `UNAUTHENTICATED` | 16 | `401` -- missing/invalid bearer token
+| `PERMISSION_DENIED` | 7 | `403` -- valid credentials lacking a scope; failed CSRF check
+| `NOT_FOUND` | 5 | `404` -- no route matched
+| `UNIMPLEMENTED` | 12 | `405` -- method not in the route's effective `allowed_methods`
+| `UNAVAILABLE` | 14 | `502` / `503` -- upstream connection failure / circuit open; *also* an HTTP/2 negotiation failure at the forced-h2 dispatch
+| `DEADLINE_EXCEEDED` | 4 | `504` -- upstream timeout
+|===
+
+The trailers carry the category name only, never internal detail -- the same no-leak rule the
+problem+json contract enforces for HTTP routes. The reserved `429` (rate limiting, out of scope)
+has no mapping.
+
+== Consequences
+
+* A gRPC client receives a rejection its runtime can surface as a first-class `StatusRuntimeException`
+ with the correct code, instead of an opaque parse/`INTERNAL` failure.
+* The forced-h2 upstream failure mode has a defined contract (`UNAVAILABLE`), so a backend that
+ cannot negotiate h2 fails predictably rather than as an ambiguous transport error.
+* The mapping lives once in the architecture error contract and is unit-tested per row; the
+ processor and the doc cannot drift because the doc is authored in the same PR and governs.
+
+== Rejected alternatives
+
+* *Answer with an `application/problem+json` body on the gRPC route* -- unusable by a gRPC client;
+ it surfaces as an opaque malformed-response error. Rejected.
+* *Relay only the HTTP status code with no gRPC status* -- a gRPC client keys on the trailers, not
+ the HTTP status line; the caller would see `UNKNOWN`. Rejected.
+* *Close the stream / connection on rejection* -- indistinguishable from a network failure and
+ loses the reason entirely. Rejected.
+
+== References
+
+* link:../architecture.adoc#_grpc_error_contract[Architecture -- gRPC rejections (trailers-only)]
+ -- the doc-first mapping table this decision records.
+* link:../architecture.adoc#_error_contract[Architecture -- Error Contract] -- the HTTP-route
+ problem+json envelope this decision translates for gRPC.
+* link:../plan/05-protocol-processors.adoc[Plan 05] -- the gRPC processor implementation and its
+ per-row unit tests.
diff --git a/doc/architecture.adoc b/doc/architecture.adoc
index 30d3e917..3d9d7861 100644
--- a/doc/architecture.adoc
+++ b/doc/architecture.adoc
@@ -306,6 +306,33 @@ names the category but leaks no internal detail. The consolidated status mapping
| `504` | Upstream timeout (`connect_timeout_ms` / `read_timeout_ms`) | `UPSTREAM`
|===
+[[_grpc_error_contract]]
+==== gRPC rejections (trailers-only)
+
+A gRPC client cannot consume an `application/problem+json` body -- the RPC runtime only surfaces
+the gRPC status carried in the response *trailers*. So a gateway-generated rejection on a
+`protocol: grpc` route is *not* rendered as problem+json; it is emitted as a *trailers-only* gRPC
+response: an HTTP/2 response whose HEADERS frame carries `grpc-status` (and `grpc-message`) with
+*no message body and no DATA frame* (the gRPC "Trailers-Only" case). The gateway maps the same
+rejection it would render as an HTTP status for an HTTP route onto the canonical gRPC status:
+
+[cols="1,1,3"]
+|===
+| gRPC status | code | Gateway rejection (the HTTP status the same cause renders on an HTTP route)
+
+| `INVALID_ARGUMENT` | 3 | `400` -- security-filter violation (path/parameter/header pipeline, collection limits, body size)
+| `UNAUTHENTICATED` | 16 | `401` -- missing/invalid bearer token; invalid or expired session on a non-navigation request
+| `PERMISSION_DENIED` | 7 | `403` -- valid credentials lacking a required scope; failed CSRF origin check
+| `NOT_FOUND` | 5 | `404` -- no route matched (deny-by-default routing); reserved-for-passthrough `Host`; disabled endpoint
+| `UNIMPLEMENTED` | 12 | `405` -- request method not in the matched route's effective `allowed_methods` allowlist
+| `UNAVAILABLE` | 14 | `502` / `503` -- upstream connection failure / invalid upstream response, or circuit breaker open; *also* an HTTP/2 negotiation failure at dispatch (the forced-h2 upstream dial could not establish `h2`)
+| `DEADLINE_EXCEEDED` | 4 | `504` -- upstream timeout (`connect_timeout_ms` / `read_timeout_ms`)
+|===
+
+The `429` (rate-limit, reserved) row has no gRPC mapping -- the feature is out of scope. Trailers
+carry the category name only, never internal detail, exactly as the problem+json contract does for
+HTTP routes.
+
[[_metrics]]
== Metrics
@@ -367,15 +394,28 @@ HTTP/3 / QUIC is deferred.
| Core request/response. HTTP/2 is negotiated via ALPN (`h2`) on terminated TLS.
| WebSocket
-| Proxied bidirectionally (Quarkus/Vert.x). Authentication is enforced on the handshake; the
- established stream is relayed. A differentiator against the evaluated competitors (see
- link:features-analysis.adoc[Feature Analysis]).
+| Proxied bidirectionally (Quarkus/Vert.x). The full inbound pipeline (security filter, auth,
+ forward policy) runs on the HTTP *upgrade* request *before* `101 Switching Protocols` is
+ returned, so authentication is enforced at *handshake time*. On a bearer WebSocket route the
+ client `Origin` is validated against the route's
+ link:configuration.adoc#_allowed_origins[`allowed_origins`] allowlist (exact match, fail-closed)
+ *before* the upstream is dialled. After `101` the connection is an *opaque bidirectional relay*
+ -- frames are forwarded verbatim in both directions and never inspected -- bounded by the
+ per-route link:configuration.adoc#_websocket[`websocket.idle_timeout_seconds`] idle reclaim (no
+ frame in either direction for the window closes the relay; ping/pong counts as activity). A
+ differentiator against the evaluated competitors (see link:features-analysis.adoc[Feature
+ Analysis]).
| gRPC (incl. gRPCS)
| gRPC rides on HTTP/2, so it is largely covered by the standard HTTP/2 path: the gateway
- proxies the `h2`/`h2c` stream and applies auth + header injection to the request. "gRPCS" is
- gRPC over terminated TLS. Body-level (protobuf frame) inspection is out of scope -- the
- security filter operates on HTTP metadata only.
+ proxies the stream and applies auth + header injection to the request. The upstream dial is
+ *forced to h2* (gRPC requires HTTP/2 end-to-end); the response *trailers* (`grpc-status`,
+ `grpc-message`) are relayed back to the client untouched. "gRPCS" is gRPC over terminated TLS.
+ Body-level (protobuf frame) inspection is out of scope -- the security filter operates on HTTP
+ metadata only, never the protobuf payload. Gateway-generated rejections on a `protocol: grpc`
+ route are emitted as *trailers-only* gRPC responses (see
+ link:#_grpc_error_contract[gRPC rejections]), because a gRPC client cannot consume
+ `application/problem+json`.
| GraphQL
| A single POST endpoint over standard HTTP; routed and forwarded like any HTTP route. The
diff --git a/doc/configuration.adoc b/doc/configuration.adoc
index 228fdf92..2dddaffb 100644
--- a/doc/configuration.adoc
+++ b/doc/configuration.adoc
@@ -131,8 +131,8 @@ file-and-JSON-pointer error before any immutable value object is built:
| `schema/endpoint.schema.json`
| A single `endpoints/*.yaml` file -- the `endpoint` block (`id`, `enabled`, `base_url`,
`anchor`, `auth`, `allowed_methods`, `upstream_defaults`) and its `routes[]`, each route's
- `anchor`, `match`, `auth`, `security_filter`, `forward`, `upstream` and reserved
- `rate_limit`.
+ `anchor`, `match`, `auth`, `security_filter`, `forward`, `upstream`, the WebSocket-route
+ `allowed_origins` and `websocket` blocks, and reserved `rate_limit`.
|===
Both schemas set `additionalProperties: false` at *every* object level, so an *unknown key* -- a
@@ -775,6 +775,79 @@ gRPC streaming); the semantics of each protocol -- and what the security filter
not inspect -- are defined once in
link:architecture.adoc#_protocol_support[Architecture -- Protocol Support].
+A `protocol: websocket` route carries two additional per-route fields --
+link:#_allowed_origins[`allowed_origins`] (a mandatory `Origin` allowlist on bearer WebSocket
+routes) and link:#_websocket[`websocket`] (the established-relay idle-timeout bound) -- both
+documented immediately below.
+
+[[_allowed_origins]]
+=== `allowed_origins` (WebSocket routes)
+
+A per-route `Origin` allowlist for a `protocol: websocket` route whose *effective* auth is
+`require: bearer`. It is *`security_headers`-adjacent* -- an `Origin`-header check performed at
+the handshake, not a routing matcher -- but, unlike the gateway/anchor-scoped
+link:#_security_headers[`security_headers`] block, it is declared *per route*: the set of browser
+origins allowed to open a socket is a property of the individual bearer WebSocket route. The
+check runs on the HTTP upgrade request, *before* the upstream is dialled.
+
+[cols="1,3"]
+|===
+| Attribute | Value
+
+| Type
+| A *list* of origin strings, each `scheme://host[:port]`.
+
+| Default
+| *None.* There is deliberately no "any origin" default. On a bearer WebSocket route the field
+ is *mandatory and non-empty* (see Fail-closed).
+
+| Allowed values / matching
+| *Exact match*, compared as full origins (scheme + host + port). The *host* is compared
+ *case-insensitively* (hostnames are case-insensitive per RFC 6066); the *scheme and port* are
+ compared exactly. There are *no wildcards*, no suffix forms, and no regular expressions -- list
+ each permitted origin in full.
+
+| Fail-closed / validation
+| The upgrade request's `Origin` header must *exactly match* a listed origin or the *handshake is
+ rejected* before any upstream dial. An *absent or empty* `allowed_origins` on a bearer
+ WebSocket route *fails the boot* -- deny-by-default: a bearer socket with no allowlist would
+ otherwise admit every origin, which is never the intended posture. `allowed_origins` on a
+ non-`websocket` route, or on a WebSocket route whose effective auth is not `require: bearer`,
+ is a configuration error and fails the boot.
+|===
+
+[source,yaml]
+----
+# a bearer WebSocket route (endpoints/*.yaml)
+- id: live-updates
+ protocol: websocket
+ match: { path_prefix: /api/stream, host: api.example.com }
+ auth: { require: bearer }
+ allowed_origins: # MANDATORY, non-empty on a bearer WS route; exact origins
+ - https://app.example.com # (scheme+host+port), host case-insensitive, no wildcards
+ websocket:
+ idle_timeout_seconds: 300 # optional; default 300 (see "websocket" below)
+ upstream: { path: /stream }
+----
+
+[[_websocket]]
+=== `websocket` (WebSocket routes)
+
+Per-route tuning for an *established* `protocol: websocket` relay. Only the idle-timeout bound is
+configurable initially.
+
+[cols="1,3"]
+|===
+| Field | Meaning
+
+| `idle_timeout_seconds`
+| *Per-route integer, default `300`.* Bounds an established bidirectional relay: when *no frame
+ travels in either direction* for this many seconds, the relay is closed. WebSocket
+ *ping/pong counts as activity*, so an application- or gateway-level heartbeat keeps the socket
+ open. When declared it must be a *positive integer*; when omitted, the default `300` applies. It
+ is valid *only* on a `protocol: websocket` route.
+|===
+
[[_security_filtering]]
=== `security_filter`
@@ -1428,3 +1501,12 @@ topology leak RFC 7239 §8.2/§8.3 warn about more simply than the obfuscation t
decrypt-only. `session.mode: server` requires an explicit `session.store` (only `memory` is
valid) and uses neither encryption key.
* `security_headers.cors` with a wildcard origin and `allow_credentials: true` is rejected.
+* A `protocol: websocket` route whose *effective* auth is `require: bearer` must declare a
+ *non-empty* link:#_allowed_origins[`allowed_origins`] list; an absent or empty allowlist *fails
+ the boot* (fail-closed -- there is no "any origin" default). Each entry is an exact origin
+ (`scheme://host[:port]`, host compared case-insensitively, scheme and port exact); wildcards,
+ suffix forms, and regular expressions are rejected. `allowed_origins` declared on a
+ non-`websocket` route, or on a WebSocket route whose effective auth is not `require: bearer`,
+ fails the boot.
+* `websocket.idle_timeout_seconds`, when declared, must be a *positive integer* (default `300`
+ when omitted); it is valid only on a `protocol: websocket` route.
diff --git a/doc/development/README.adoc b/doc/development/README.adoc
index b8fe4aa6..877c0035 100644
--- a/doc/development/README.adoc
+++ b/doc/development/README.adoc
@@ -36,6 +36,12 @@ This tree is seeded here and grows as contributor-facing material lands.
| The rules around the authoritative, blocking SonarCloud gate -- zero new findings, never
merging over a red or stale-green gate, PR-new-code vs post-merge project-gate auditability,
and the fix-by-default / suppress-with-rationale escape hatch.
+
+| link:protocol-processors.adoc[Protocol Processors -- SPI and dispatch seam]
+| The `ProtocolProcessor` SPI, the boot-time registry that binds a protocol to its processor, and
+ the edge protocol-dispatch seam that routes WebSocket and gRPC down their specialised paths --
+ including where the handshake/relay and forced-h2/trailer code lives, and how to add a new
+ processor.
|===
== Scope of This Layer
diff --git a/doc/development/protocol-processors.adoc b/doc/development/protocol-processors.adoc
new file mode 100644
index 00000000..592b0046
--- /dev/null
+++ b/doc/development/protocol-processors.adoc
@@ -0,0 +1,147 @@
+= Protocol Processors -- the SPI and the edge dispatch seam
+:toc:
+:toclevels: 2
+:sectnums:
+
+A contributor-facing guide to how API Sheriff serves more than one wire protocol behind a single
+data-plane edge: the `ProtocolProcessor` SPI, the boot-time registry that binds a protocol to its
+processor, and the per-request *protocol-dispatch seam* in the edge that routes WebSocket and gRPC
+requests down their specialised paths. It describes *how the code is organised* and *how to add a
+processor*; the protocol semantics are defined once in
+link:../architecture.adoc#_protocol_support[Architecture -- Protocol Support], and the design
+decisions in link:../adr/0015-websocket-origin-failclosed-allowlist.adoc[ADR-0015] and
+link:../adr/0016-grpc-trailers-only-rejection-mapping.adoc[ADR-0016].
+
+== The `ProtocolProcessor` SPI
+
+`de.cuioss.sheriff.api.routing.ProtocolProcessor` is the small seam that carries the *verb
+semantics* of a protocol -- which HTTP methods a route of that protocol may serve. It is
+deliberately narrow:
+
+* `id()` -- the stable processor identifier (`http`, `websocket`, `grpc`).
+* `standardMethods()` -- the set of methods the protocol serves by default.
+* `supports(HttpMethod)` -- whether a given verb is in scope for the protocol.
+
+The three implementations live alongside it in `de.cuioss.sheriff.api.routing`:
+
+[cols="1,3"]
+|===
+| Implementation | Verb scope
+
+| `HttpProtocolProcessor`
+| Every proxyable HTTP verb. Shared by `HTTP` and `GRAPHQL` routes -- GraphQL rides the standard
+ HTTP path.
+
+| `WebSocketProtocolProcessor`
+| `GET` only -- a WebSocket route is entered by the HTTP upgrade handshake.
+
+| `GrpcProtocolProcessor`
+| `POST` only -- every gRPC call is an HTTP/2 `POST` to a service/method path.
+|===
+
+A processor carries *only* the verb semantics. The protocol-specific dispatch behaviour
+(handshake, streaming, trailer relay, rejection rendering) lives in the edge stages described
+below, not in the processor.
+
+== The registry
+
+`ProtocolProcessorRegistry` maps each supported `Protocol` to its processor at boot:
+
+* `HTTP` and `GRAPHQL` share a single `HttpProtocolProcessor` instance.
+* `WEBSOCKET` resolves a dedicated `WebSocketProtocolProcessor`.
+* `GRPC` resolves a dedicated `GrpcProtocolProcessor`.
+
+`require(Protocol, routeId)` resolves the processor for a route and fails the boot with a
+`CONFIG_INVALID` `GatewayException` only when a protocol is genuinely unregistered. Every protocol
+in the served set is registered, so boot fails only for an unsupported protocol -- never merely
+because a route selects WebSocket or gRPC.
+
+`RouteRuntimeAssembler` calls `require(...)` once per route at assembly time and stores the
+resolved processor on the immutable `RouteRuntime`.
+
+== The edge protocol-dispatch seam
+
+`GatewayEdgeRoute` runs every request through the fixed pipeline (stages 0--5) uniformly,
+regardless of protocol. After stage 5 (the forward policy) it reaches the *protocol-dispatch
+seam* and branches on the selected route's protocol:
+
+[source,java]
+----
+if (route.getProtocol() == Protocol.WEBSOCKET) {
+ dispatchWebSocket(ctx, request, route, forward);
+} else if (route.getProtocol() == Protocol.GRPC) {
+ dispatchGrpc(ctx, request, route, forward);
+} else {
+ dispatchAndRelay(ctx, request, route, forward); // HTTP / GraphQL
+}
+----
+
+Each arm lives in `de.cuioss.sheriff.api.edge`:
+
+=== WebSocket -- handshake and opaque relay
+
+`dispatchWebSocket` validates the handshake `Origin` against the route's effective allowlist via
+`OriginValidationStage` (a foreign or absent origin is rejected `403` *before* any upstream dial --
+GW-09 / CSWSH), then hands the upgrade to `WebSocketRelayStage`. The relay stage dials the
+upstream, completes the client upgrade, relays frames opaquely in both directions, and reclaims an
+established relay that idles past the route's `idle_timeout_seconds` (closing both legs with
+WebSocket code `1001`). No HTTP `ResponseStage` relay runs for a WebSocket route.
+
+=== gRPC -- forced-h2 dispatch and trailer relay
+
+`dispatchGrpc` streams the request opaquely to the forced-HTTP/2 upstream via `GrpcDispatchStage`,
+then relays the upstream response -- including its `grpc-status` / `grpc-message` trailers -- with
+`ResponseStage.relayWithTrailers`. Three collaborators carry the gRPC deltas:
+
+* *Forced-h2 upstream client.* gRPC requires HTTP/2 end-to-end, so the h2 protocol version joins
+ the client-sharing tuple key: `RouteRuntimeAssembler.UpstreamTarget` carries a `forcedHttp2`
+ flag, so a gRPC route to `host:port` holds a client distinct from an HTTP/1.1 route to the same
+ `host:port`. `GatewayEdgeRoute.clientFor(...)` builds that forced-h2 client (h2 over TLS with
+ ALPN, or prior-knowledge h2c in cleartext).
+* *Opaque frame streaming.* `GrpcDispatchStage` reuses the byte-capped streaming dispatch of
+ `DispatchStage`, so the request/response bodies stream as opaque length-prefixed frames (the
+ gateway never inspects the protobuf payload) under the same body cap and stream-aware retry gate
+ as the HTTP path. The Plan-04 GW-08 HTTP/2 abuse bounds hold on the gRPC path because they are
+ enforced by the shared inbound transport (`EdgeHardeningOptions`), not per-protocol.
+* *Trailer relay and rejection mapping.* `ResponseStage.relayWithTrailers` streams the body with
+ the client response held open, then copies the upstream trailers onto the client and ends it, so
+ the client observes the gRPC status. A gateway-generated rejection on a gRPC route is rendered by
+ `GrpcStatusMapper` as a *trailers-only* gRPC response (HTTP `200`, `application/grpc`,
+ `grpc-status` / `grpc-message`) rather than problem+json -- an h2-negotiation failure maps to
+ `UNAVAILABLE`. See link:../architecture.adoc#_grpc_error_contract[Architecture -- gRPC
+ rejections].
+
+=== HTTP / GraphQL -- streamed relay
+
+`dispatchAndRelay` is the default path: it dispatches over `DispatchStage` and relays the streamed
+upstream response with `ResponseStage.relay`. GraphQL uses this path unchanged.
+
+== Adding a new protocol processor
+
+To add a future protocol:
+
+. *Implement the SPI.* Add a `ProtocolProcessor` in `de.cuioss.sheriff.api.routing` declaring the
+ protocol's `id()` and its served verbs. Mirror `GrpcProtocolProcessor` /
+ `WebSocketProtocolProcessor` for a single-verb protocol.
+. *Register it.* Add the `Protocol` -> processor mapping in `ProtocolProcessorRegistry`. Registry
+ registration is one required step, not the whole contract: the `Protocol` enum must already carry
+ the value, and the per-route config-model support (schema/model fields plus their `ConfigValidator`
+ rules) and — where the protocol needs it — the dispatch arm below must also be in place before a
+ route of that protocol resolves and boots.
+. *Add a dispatch arm only if needed.* If the protocol rides the standard HTTP path (like GraphQL),
+ no edge change is required -- it falls through to `dispatchAndRelay`. If it needs a distinct
+ dispatch (a handshake, a specialised upstream client, or a non-problem+json rejection), add an
+ arm to the protocol-dispatch seam in `GatewayEdgeRoute.process(...)` and a `dispatch*` method,
+ following `dispatchWebSocket` / `dispatchGrpc`. A distinct upstream client shape joins the
+ `UpstreamTarget` tuple key so it is deduplicated correctly.
+. *Test it.* Add a processor verb-semantics test (see `GrpcProtocolProcessorTest`) and a
+ dispatch/edge test for any new stage (see `GrpcDispatchStageTest`,
+ `WebSocketRelayStageTest`).
+
+== See also
+
+* link:../architecture.adoc#_protocol_support[Architecture -- Protocol Support] -- protocol semantics.
+* link:../architecture.adoc#_grpc_error_contract[Architecture -- gRPC rejections] -- the trailers-only mapping.
+* link:../adr/0015-websocket-origin-failclosed-allowlist.adoc[ADR-0015] -- WebSocket Origin fail-closed allowlist.
+* link:../adr/0016-grpc-trailers-only-rejection-mapping.adoc[ADR-0016] -- gRPC trailers-only rejection mapping.
+* link:../user/protocol-routes.adoc[Operator Guide -- Protocol Routes] -- how operators configure these routes.
diff --git a/doc/plan/05-protocol-processors.adoc b/doc/plan/05-protocol-processors.adoc
index 406f8806..1f0582f6 100644
--- a/doc/plan/05-protocol-processors.adoc
+++ b/doc/plan/05-protocol-processors.adoc
@@ -30,6 +30,42 @@ protocol promise and, with it, the link:../variants/01-base-gateway.adoc[Variant
* Plan 04 delivered: the pipeline runs HTTP routes end-to-end; `grpc`/`websocket` routes are
boot-rejected by the registry this plan extends.
+== Orchestration Notes (2026-07 update)
+
+Rationale recorded at execution time, so a contributor reading this plan sees what actually
+changed versus the plan as originally written:
+
+* *Benchmark tooling -- Clarification 1.* The `ghz`/`k6` split in the Work Breakdown is
+ superseded: both new benchmarks are k6 scripts (see the NOTE on that item). `ghz` is not
+ introduced.
+* *Config model is final -- no config-model restructuring in this plan.* The per-route config surface
+ the processors consume (the `ResolvedRoute` union materialized at boot) is already delivered by
+ link:10-anchor-types-assets.adoc[Plan 10] / PR #84. This plan adds only the two doc-first
+ fields (`allowed_origins`, `websocket.idle_timeout_seconds`) to the existing model; it does not
+ restructure it.
+* *Parent-POM baseline.* The build tracks `de.cuioss:cui-java-parent 1.5.4` (bumped from the
+ 1.5.1 recorded in earlier plans); no functional change is implied for this plan beyond building
+ on the current baseline.
+* *Two folded work items.* This plan absorbs two small, related clean-ups discovered against the
+ Plan 04 edge, rather than opening separate plans for them:
++
+--
+. *Remove the orphaned JAX-RS `GatewayExceptionMapper`.* `edge.GatewayEdgeRoute.renderProblem(...)`
+ renders RFC 9457 directly on the Vert.x route, so the JAX-RS `quarkus.GatewayExceptionMapper`
+ (`@Provider ExceptionMapper`) is never reached by the data plane -- it is dead
+ and is removed (subject to a live-wiring stop-guard).
+. *Investigate "verify reports green while tests errored".* Investigated (TASK-006): the reactor's
+ Surefire/Failsafe configuration carries *no* `testFailureIgnore`, and an empirical probe (a
+ deliberately-erroring test) confirms the reactor build *correctly* fails -- Maven reports
+ `BUILD FAILURE` on a single errored test (`Tests run: N, Errors: 1`). The pom is therefore *not*
+ the culprit and needs no change. The "green verify with errored tests" symptom is a *reporting*
+ defect in the `plan-marshall:build-maven` build wrapper, which returns `status: success` while the
+ underlying Maven reports `BUILD FAILURE` for a test error (its `status`/`errors[]` reflect
+ compiler-style errors, not Surefire test errors) -- plan-marshall infrastructure, out of scope for
+ this project's pom, recorded as a finding for the plan-marshall maintainers. CI (the
+ cuioss-organization reusable Maven build) keys off Maven's own exit code and is unaffected.
+--
+
== Design Decisions
=== D1 -- WebSocket processor: full pipeline on the handshake, opaque relay after 101
@@ -94,6 +130,34 @@ protocol promise and, with it, the link:../variants/01-base-gateway.adoc[Variant
stream-reset rate limit must not misfire on legitimate streaming, but must still bound an
abusive reset loop).
+=== D2b -- gRPC route shape: bare service path (operator decision 2026-07-21)
+
+A gRPC route matches the *bare service path* -- `match.path_prefix: /{package}.{Service}` -- and
+sets an *identical* `upstream.path`. Because the gateway builds the upstream path as
+`upstream.path + remainder-after-prefix`, the identity of the two preserves the full
+`/{package}.{Service}/{Method}` method path end-to-end, so a *stock* gRPC client (grpc-java stub,
+k6 `k6/net/grpc` client) dials the real service path and needs no client-side rewriting. This is
+*pure route-yaml authoring*: the per-route config surface is final (D2 / Clarification 1 -- no
+config-model change in this plan) and already expresses it via `match.path_prefix` + `upstream.path`.
+
+*Rejected alternative -- synthetic anchor prefix.* The first implementation routed gRPC under a
+synthetic `/grpc/` anchor prefix that the gateway stripped, which forced every client to
+prepend that prefix to the method path (in the ITs, a path-rewriting `ClientInterceptor`; a real
+consumer would need the same). That is non-standard: a stock gRPC client cannot talk to the gateway
+without gateway-specific path surgery. Rejected in favour of the bare service path, which any
+conformant gRPC client reaches unmodified.
+
+*Consequence -- distinct service paths for a public + bearer split.* Two routes that share an
+identical `path_prefix` must be statically disjoint (route-ordering rule, `doc/configuration.adoc`).
+A public Echo route and a bearer route on the *same* `/{package}.Echo` path are not disjoint -- a
+public gRPC client carries no distinguishing host or header to separate them, and a lone header
+matcher on one side establishes nothing. The bearer route therefore rides its own service path
+(the ITs use a distinct `SecureEcho` service, defined in the proto but not implemented upstream --
+the bearer route rejects a tokenless call `UNAUTHENTICATED` before any dial, so it is never
+reached). Rejected alternatives: a shared-path header/host discriminator (either forces the public
+route to demand a magic header that vanilla clients do not send, or depends on a route-ordering tie
+break the gateway explicitly does not honour).
+
=== D3 -- Test upstreams
Per Plan 01 D2 research: WebSocket ITs use go-httpbin's `/websocket/echo` (already in the
@@ -133,6 +197,15 @@ assertions.
the `k6/websockets` module -- the old third-party `obvionaoe/ghz` Docker Hub image is
stale, do not use it). Both wrappers emit the same `=== BENCHMARK METADATA ===` block so
the existing post-processor and pages pipeline consume them unchanged.
++
+--
+NOTE: *Superseded by Clarification 1 (2026-07).* The `ghz`/`k6` split above is *not* delivered:
+following the wrk->k6 migration (link:04b-comparative-benchmark.adoc[Plan 04b]), *both* new
+benchmarks are k6 scripts under `benchmarks/src/main/resources/k6-scripts/`
+(`websocket_echo.js`, `grpc_unary.js`) driven through the k6 aspect harness, and the APISIX
+comparison matrix is extended via the `ASPECT_SCRIPTS` map in
+`benchmarks/scripts/run-comparison.sh`. `ghz` is not introduced.
+--
. *Docs* -- variant-1 doc note that the protocol scope is complete; README sweep.
== Execution Workflow
diff --git a/doc/plan/README.adoc b/doc/plan/README.adoc
index 7f66fb3c..80434a98 100644
--- a/doc/plan/README.adoc
+++ b/doc/plan/README.adoc
@@ -63,12 +63,13 @@ The plans build strictly on one another:
upstreams, Keycloak realm, TLS certificates and resource limits.
| Plan 04
-| link:05-protocol-processors.adoc[05 -- Protocol Processors]
-| WebSocket upgrade proxying (full pipeline on the handshake, then bidirectional relay) and
- gRPC over upstream h2 with *trailer* relay + a gRPC-status rejection mapping -- both native
- to the ADR-0008 Vert.x transport. GraphQL already rides the HTTP processor. In-repo Quarkus
- gRPC echo upstream for ITs. Closes the ADR-0002 protocol promise and with it the
- link:../variants/01-base-gateway.adoc[Variant 1] scope.
+| link:05-protocol-processors.adoc[05 -- Protocol Processors] (delivered)
+| WebSocket upgrade proxying (full pipeline on the handshake, `Origin` allowlist validation,
+ then bidirectional relay bounded by a per-route idle timeout) and gRPC over upstream h2 with
+ *trailer* relay + a trailers-only gRPC-status rejection mapping -- both native to the ADR-0008
+ Vert.x transport. GraphQL already rides the HTTP processor. In-repo Quarkus gRPC echo upstream
+ for ITs; k6 WebSocket + gRPC benchmarks with the APISIX comparison matrix. Closes the ADR-0002
+ protocol promise and with it the link:../variants/01-base-gateway.adoc[Variant 1] scope.
| Plan 04
| link:06-tls-edge.adoc[06 -- TLS Edge: SNI Passthrough + mTLS]
diff --git a/doc/user/README.adoc b/doc/user/README.adoc
index 473a1090..648d9b88 100644
--- a/doc/user/README.adoc
+++ b/doc/user/README.adoc
@@ -26,6 +26,12 @@ layer.
*dual role*: the operator-facing YAML reference and a design document (see the
link:../README.adoc[design documentation index]). Operators read it as the configuration
contract; it stays under `doc/` and is not relocated here.
+
+| link:protocol-routes.adoc[Protocol Routes -- WebSocket and gRPC]
+| A task-oriented guide for configuring `protocol: websocket` and `protocol: grpc` routes --
+ setting `allowed_origins`, tuning `websocket.idle_timeout_seconds`, and authoring a gRPC route.
+ Links to the Configuration Reference for the field contract and to the Architecture document
+ for the gRPC error mapping.
|===
== Scope of This Layer
diff --git a/doc/user/protocol-routes.adoc b/doc/user/protocol-routes.adoc
new file mode 100644
index 00000000..e7c88ddb
--- /dev/null
+++ b/doc/user/protocol-routes.adoc
@@ -0,0 +1,125 @@
+= Protocol Routes -- WebSocket and gRPC
+:toc:
+:toclevels: 2
+:sectnums:
+
+A task-oriented guide for operators configuring `protocol: websocket` and `protocol: grpc`
+routes in `gateway.yaml`. It shows *how* to author these routes and tune their per-route knobs;
+the authoritative field reference is the
+link:../configuration.adoc[Configuration Reference], and the design rationale lives in the
+link:../architecture.adoc[Architecture] document and the
+link:../adr/0015-websocket-origin-failclosed-allowlist.adoc[ADR-0015] /
+link:../adr/0016-grpc-trailers-only-rejection-mapping.adoc[ADR-0016] decision records. This
+guide never restates that rationale -- it points to it.
+
+Every route declares the protocol it serves via the `protocol` key -- `http` (the default),
+`graphql`, `grpc`, or `websocket`. The value selects handshake and streaming behaviour only; the
+security filter, authentication, and forward policy apply uniformly across protocols. See
+link:../configuration.adoc#_protocols[Configuration -- Protocols] for the key itself and
+link:../architecture.adoc#_protocol_support[Architecture -- Protocol Support] for what each
+protocol does and does not inspect.
+
+== Configuring a WebSocket route
+
+A `protocol: websocket` route proxies an HTTP upgrade handshake, then relays frames opaquely in
+both directions between the client and the upstream. Author it in an `endpoints/*.yaml` file:
+
+[source,yaml]
+----
+- id: live-updates
+ protocol: websocket
+ match: { path_prefix: /api/stream, host: api.example.com }
+ auth: { require: bearer }
+ allowed_origins:
+ - https://app.example.com
+ websocket:
+ idle_timeout_seconds: 300
+ upstream: { path: /stream }
+----
+
+=== Setting `allowed_origins` (cross-site WebSocket hijacking guard)
+
+On a WebSocket route whose *effective* auth is `require: bearer`, `allowed_origins` is a
+*mandatory, non-empty* list of the browser origins permitted to open a socket. The gateway
+checks the upgrade request's `Origin` header against this list *before dialling the upstream*; a
+foreign or absent `Origin` is rejected `403` and the upstream is never contacted.
+
+Author each permitted origin *in full* -- `scheme://host[:port]`:
+
+* The *host* is matched case-insensitively; the *scheme and port* are matched exactly.
+* There are *no wildcards*, no suffix forms, and no regular expressions.
+* An *absent or empty* `allowed_origins` on a bearer WebSocket route *fails the boot*
+ (deny-by-default). Declaring `allowed_origins` on a non-WebSocket route, or on a WebSocket
+ route whose effective auth is not `require: bearer`, is also a boot-time configuration error.
+
+For the full field contract see
+link:../configuration.adoc#_allowed_origins[Configuration -- `allowed_origins`].
+
+=== Tuning `websocket.idle_timeout_seconds`
+
+The optional `websocket` block tunes an *established* relay. Its only key,
+`idle_timeout_seconds`, bounds how long a relay may sit with *no frame in either direction*
+before the gateway closes it (WebSocket close code `1001`, "Going Away"). When omitted it
+defaults to `300` seconds; when declared it must be a *positive integer* and is valid *only* on a
+`protocol: websocket` route.
+
+Raise it for long-lived push channels that legitimately idle between messages; lower it to
+reclaim connection slots more aggressively. For the full field contract see
+link:../configuration.adoc#_websocket[Configuration -- `websocket`].
+
+== Configuring a gRPC route
+
+A `protocol: grpc` route proxies gRPC calls -- each an HTTP/2 `POST` to a service/method path.
+The gateway forces the *upstream* connection to HTTP/2 (gRPC requires HTTP/2 end-to-end), streams
+the request and response bodies as opaque length-prefixed frames (it never inspects the protobuf
+payload), and relays the upstream response *trailers* (`grpc-status`, `grpc-message`) back to the
+client untouched. Author it in an `endpoints/*.yaml` file:
+
+[source,yaml]
+----
+endpoint:
+ id: orders
+ base_url: ORDERS_GRPC # topology alias -> orders-svc:50051
+ routes:
+ - id: orders-grpc
+ protocol: grpc
+ match: { path_prefix: /orders.OrderService }
+ auth: { require: bearer }
+ upstream: { path: /orders.OrderService }
+----
+
+*Route on the bare service path.* A gRPC method path is service-rooted --
+`/{package}.{Service}/{Method}`. Match the route on the bare `/{package}.{Service}` prefix and set
+an *identical* `upstream.path`. Because the gateway builds the upstream path as
+`upstream.path + remainder-after-prefix` (see
+link:../configuration.adoc#_upstream[Configuration -- `upstream`]), the identity of the two makes
+the full method path reach the upstream *unchanged* -- a stock gRPC client dials the real service
+path and needs no gateway-specific rewriting. Point the endpoint's `base_url` at the gRPC service's
+`host:port` (via a topology alias); the gateway holds a forced-h2 client for that target distinct
+from any HTTP/1.1 route to the same `host:port`.
+
+A gRPC route carries no extra per-route fields beyond the standard `match`, `auth`, and
+`upstream` keys -- the forced-HTTP/2 upstream dial and the trailer relay are automatic. Two gRPC
+routes that share the same service path must be *statically disjoint* (by host, method, or a header
+matcher -- see link:../configuration.adoc#_route_ordering[Configuration -- route ordering]); a
+public route and a bearer route on the *same* service path are not disjoint, so give each its own
+service path.
+
+=== gRPC rejections are trailers-only
+
+Because a gRPC client cannot consume an `application/problem+json` body, a *gateway-generated*
+rejection on a `protocol: grpc` route is emitted as a *trailers-only* gRPC response -- an HTTP
+`200` whose `content-type` is `application/grpc` and whose `grpc-status` (and `grpc-message`)
+name the failure, with no message body. The gateway maps the same rejection it would render as an
+HTTP status on an HTTP route onto the canonical gRPC status; for example, a bearer gRPC route
+called without a token is rejected `UNAUTHENTICATED` (16). Operators do not configure this
+mapping -- it is fixed. The full mapping table is in
+link:../architecture.adoc#_grpc_error_contract[Architecture -- gRPC rejections (trailers-only)].
+
+== See also
+
+* link:../configuration.adoc[Configuration Reference] -- the authoritative `gateway.yaml` key reference.
+* link:../architecture.adoc#_protocol_support[Architecture -- Protocol Support] -- what each protocol inspects.
+* link:../architecture.adoc#_grpc_error_contract[Architecture -- gRPC rejections] -- the trailers-only status mapping.
+* link:../adr/0015-websocket-origin-failclosed-allowlist.adoc[ADR-0015] -- WebSocket Origin fail-closed allowlist.
+* link:../adr/0016-grpc-trailers-only-rejection-mapping.adoc[ADR-0016] -- gRPC trailers-only rejection mapping.
diff --git a/doc/variants/01-base-gateway.adoc b/doc/variants/01-base-gateway.adoc
index 21c2682a..9387227d 100644
--- a/doc/variants/01-base-gateway.adoc
+++ b/doc/variants/01-base-gateway.adoc
@@ -16,6 +16,14 @@ Use this variant when the caller is a service or a client that already holds its
token (machine-to-machine, or an SPA that manages tokens elsewhere) and simply needs a
hardened, validating entry point in front of the upstream.
+NOTE: *Protocol scope complete.* With link:../plan/05-protocol-processors.adoc[Plan 05]
+delivered, the base gateway serves all four initial-scope protocols -- HTTP/1.1 and HTTP/2,
+GraphQL (over HTTP), *WebSocket* (full pipeline on the handshake, `Origin` allowlist validation,
+then opaque bidirectional relay bounded by a per-route idle timeout), and *gRPC* (forced-h2
+upstream, trailer relay, trailers-only rejection mapping). This closes the
+link:../adr/0002-initial-scope.adoc[ADR-0002] protocol promise for Variant 1; HTTP/3 remains
+deferred.
+
== Request Flow
The client presents its own `Authorization: Bearer` token. API Sheriff applies the
diff --git a/integration-tests/docker-compose.apisix.yml b/integration-tests/docker-compose.apisix.yml
index 89292841..f0f755fa 100644
--- a/integration-tests/docker-compose.apisix.yml
+++ b/integration-tests/docker-compose.apisix.yml
@@ -65,11 +65,23 @@ services:
depends_on:
# nginx-static comes from docker-compose.benchmark.yml -- the reason this
- # overlay cannot be launched as its own Compose project. It is the ONLY
- # upstream every APISIX route targets (see apisix.yaml's fairness invariant),
- # so go-httpbin is deliberately absent here: APISIX routes nothing to it.
- - nginx-static
- - keycloak
+ # overlay cannot be launched as its own Compose project. It is the static
+ # fairness backend the six HTTP aspects ride (see apisix.yaml's fairness
+ # invariant). The ws and grpc aspects cannot ride a static backend, so APISIX
+ # also proxies to the protocol-appropriate backends go-httpbin (WebSocket echo)
+ # and grpc-echo (gRPC Echo) -- both defined in the base docker-compose.yml and
+ # shared with the API Sheriff side, keeping the ws/grpc rows symmetric.
+ # Map form so grpc-echo can gate on service_healthy (parity with the api-sheriff
+ # service) — APISIX must not boot before the gRPC upstream is ready to accept
+ # connections, or early benchmark traffic fails.
+ nginx-static:
+ condition: service_started
+ go-httpbin:
+ condition: service_started
+ grpc-echo:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
# OWASP hardening: no privilege escalation, matching the api-sheriff service.
# api-sheriff additionally runs read_only with a tmpfs; APISIX/OpenResty needs a
diff --git a/integration-tests/docker-compose.jfr.yml b/integration-tests/docker-compose.jfr.yml
index 6a4a29ac..69e12748 100644
--- a/integration-tests/docker-compose.jfr.yml
+++ b/integration-tests/docker-compose.jfr.yml
@@ -9,5 +9,6 @@ services:
dockerfile: src/main/docker/Dockerfile.native.jfr
volumes:
- ./src/main/docker/certificates:/app/certificates:ro
- - ${LOG_TARGET_DIR:-./target}:/logs:rw
+ # Dedicated log subdirectory (least privilege); mirrors docker-compose.yml.
+ - ${LOG_TARGET_DIR:-./target/quarkus-logs}:/logs:rw
- ./target/jfr-recordings:/tmp/jfr-output:rw
diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml
index b5a26a7a..709f2fb0 100644
--- a/integration-tests/docker-compose.yml
+++ b/integration-tests/docker-compose.yml
@@ -59,6 +59,28 @@ services:
- api-sheriff
restart: unless-stopped
+ # In-repo Quarkus gRPC echo upstream backing the gRPC integration-test matrix.
+ # Exposes unary echo, server-streaming echo, and a deliberately-failing method
+ # (non-OK grpc-status) so GrpcProxyIT can assert echo, streaming, and trailer/status
+ # relay through the gateway. Reached by the gateway on the internal network as
+ # grpc-echo:9000 (separate gRPC server); no host port is published. Built from the
+ # integration-tests module via the grpc-echo Maven profile (repo-root build context).
+ grpc-echo:
+ build:
+ context: ..
+ dockerfile: integration-tests/src/main/docker/grpc-echo/Dockerfile
+ networks:
+ - api-sheriff
+ restart: unless-stopped
+ # /dev/tcp readiness probe on the gRPC port — the eclipse-temurin runtime image
+ # ships bash, so no extra probe binary is required.
+ healthcheck:
+ test: ["CMD-SHELL", "bash -c 'echo -n > /dev/tcp/127.0.0.1/9000'"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 30s
+
api-sheriff:
image: "api-sheriff:distroless"
build:
@@ -90,10 +112,19 @@ services:
# UPSTREAM topology alias resolves to the go-httpbin echo backend.
- SHERIFF_CONFIG_DIR=/app/sheriff-config
+ # Map form so grpc-echo can gate on service_healthy — the gateway must not start
+ # until the gRPC echo upstream is accepting connections, otherwise the first
+ # streaming IT can race the upstream's boot. The other upstreams retain the prior
+ # service_started semantics.
depends_on:
- - keycloak
- - go-httpbin
- - asset-origin
+ keycloak:
+ condition: service_started
+ go-httpbin:
+ condition: service_started
+ asset-origin:
+ condition: service_started
+ grpc-echo:
+ condition: service_healthy
volumes:
# Read-only certificate mount (production pattern)
@@ -105,8 +136,11 @@ services:
# `directory:` root in the asset endpoint config. A read-only mount into the
# read_only container is fine — bind mounts are independent of the root fs.
- ./src/main/docker/assets:/app/assets:ro
- # Mount target directory for log files (defaults to ./target)
- - ${LOG_TARGET_DIR:-./target}:/logs:rw
+ # Mount the dedicated log subdirectory (defaults to ./target/quarkus-logs).
+ # The start-integration-container.sh script exports LOG_TARGET_DIR to this subdir
+ # and world-writes only it (least privilege); this fallback keeps a standalone
+ # compose run mounting the same subdir rather than the whole target tree.
+ - ${LOG_TARGET_DIR:-./target/quarkus-logs}:/logs:rw
# OWASP Security hardening (production-grade)
security_opt:
diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml
index 0fd9865d..104c75b3 100644
--- a/integration-tests/pom.xml
+++ b/integration-tests/pom.xml
@@ -25,13 +25,33 @@
true
+
+
+ true
-
+
de.cuioss.sheriff.api
api-sheriff
+ provided
+
+
+
+
+ io.quarkus
+ quarkus-grpc
@@ -59,7 +79,13 @@
-
+
io.quarkus
quarkus-maven-plugin
@@ -68,6 +94,13 @@
default
none
+
+ grpc-codegen
+
+ generate-code
+ generate-code-tests
+
+
@@ -101,6 +134,30 @@
+
+
+ grpc-echo
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+
+
+ grpc-echo-build
+
+ build
+
+
+
+
+
+
+
+
integration-tests
diff --git a/integration-tests/scripts/dump-keycloak-logs.sh b/integration-tests/scripts/dump-keycloak-logs.sh
index ed908389..4b6f0ff9 100755
--- a/integration-tests/scripts/dump-keycloak-logs.sh
+++ b/integration-tests/scripts/dump-keycloak-logs.sh
@@ -4,7 +4,8 @@
# Usage: ./dump-keycloak-logs.sh
# Example: ./dump-keycloak-logs.sh target
#
-# Note: Quarkus logs are written directly to target/quarkus.log via file logging
+# Note: Quarkus logs are written by default to target/quarkus-logs/quarkus.log via file logging
+# (this script only dumps the Keycloak container logs to the argument)
set -euo pipefail
diff --git a/integration-tests/scripts/start-integration-container.sh b/integration-tests/scripts/start-integration-container.sh
index 0a7305c6..6ae1f017 100755
--- a/integration-tests/scripts/start-integration-container.sh
+++ b/integration-tests/scripts/start-integration-container.sh
@@ -72,9 +72,18 @@ else
fi
-# Set LOG_TARGET_DIR to project's target directory for Quarkus file logging
-export LOG_TARGET_DIR="${LOG_TARGET_DIR:-${PROJECT_DIR}/target}"
+# Set LOG_TARGET_DIR to a dedicated log subdirectory for Quarkus file logging.
+# The api-sheriff native/distroless container runs as uid 1001, but this host
+# directory is created by the (differently-numbered) Maven user, so the bind-mounted
+# /logs is not writable by the container and the file log sink fails with
+# "FileNotFoundException: /logs/quarkus.log (Permission denied)". Grant world write on
+# a dedicated 'quarkus-logs' subdirectory only — least privilege — so uid 1001 can write
+# quarkus.log there without making the entire build target tree world-writable (ephemeral
+# test output — the container keeps its no-new-privileges / cap_drop / read_only posture).
+LOG_TARGET_ROOT="${LOG_TARGET_DIR:-${PROJECT_DIR}/target}"
+export LOG_TARGET_DIR="${LOG_TARGET_ROOT}/quarkus-logs"
mkdir -p "${LOG_TARGET_DIR}"
+chmod 0777 "${LOG_TARGET_DIR}"
echo "📁 Quarkus logs will be written to: ${LOG_TARGET_DIR}/quarkus.log"
# Start with Docker Compose (includes Keycloak)
diff --git a/integration-tests/scripts/verify-invalid-config-fails.sh b/integration-tests/scripts/verify-invalid-config-fails.sh
index 60380dd4..f0214340 100755
--- a/integration-tests/scripts/verify-invalid-config-fails.sh
+++ b/integration-tests/scripts/verify-invalid-config-fails.sh
@@ -130,4 +130,47 @@ chmod 755 "${ANCHOR_INVALID_DIR}"
chmod 644 "${ANCHOR_INVALID_DIR}/gateway.yaml"
assert_fails_to_boot "${ANCHOR_INVALID_DIR}" "an anchor-violation configuration" "pairwise disjoint"
+# Case 3: a fail-closed WebSocket violation (ADR-0015) — a bearer 'protocol: websocket'
+# route that declares no (empty/absent) allowed_origins allowlist. The running WebSocket
+# integration stack cannot host this route (it aborts boot fail-fast), so the fail-closed
+# contract WebSocketProxyIT documents is proven here end-to-end: the bound config trips the
+# ConfigValidator WS allowlist rule and the container exits non-zero. A complete, otherwise
+# valid config is assembled (gateway.yaml + topology.properties + endpoints/websocket.yaml) so
+# the ONLY violation is the missing allowlist on the bearer WS route.
+WS_FAILCLOSED_DIR="$(mktemp -d)"
+CONFIG_DIRS+=("${WS_FAILCLOSED_DIR}")
+mkdir -p "${WS_FAILCLOSED_DIR}/endpoints"
+cat > "${WS_FAILCLOSED_DIR}/gateway.yaml" <<'YAML'
+version: 1
+metadata:
+ config_version: "ws-fail-closed"
+anchors:
+ ws:
+ path_prefix: /ws
+ type: proxy
+ access: public
+YAML
+cat > "${WS_FAILCLOSED_DIR}/topology.properties" <<'PROPS'
+WS_UPSTREAM=http://go-httpbin:8080/websocket/echo
+PROPS
+cat > "${WS_FAILCLOSED_DIR}/endpoints/websocket.yaml" <<'YAML'
+endpoint:
+ id: websocket
+ base_url: WS_UPSTREAM
+ anchor: ws
+ routes:
+ - id: ws-bearer-open
+ protocol: websocket
+ auth:
+ require: bearer
+ match:
+ path_prefix: /ws/bearer
+YAML
+chmod 755 "${WS_FAILCLOSED_DIR}" "${WS_FAILCLOSED_DIR}/endpoints"
+chmod 644 "${WS_FAILCLOSED_DIR}/gateway.yaml" "${WS_FAILCLOSED_DIR}/topology.properties" \
+ "${WS_FAILCLOSED_DIR}/endpoints/websocket.yaml"
+# Marker: the tail of the ConfigValidator fail-closed message. The route id is a config KEY
+# (safe to assert on), never a redacted scalar value.
+assert_fails_to_boot "${WS_FAILCLOSED_DIR}" "a fail-closed WebSocket configuration" "fail-closed"
+
echo "✅ All invalid configurations correctly caused fail-fast non-zero exits."
diff --git a/integration-tests/src/main/docker/apisix/apisix.yaml b/integration-tests/src/main/docker/apisix/apisix.yaml
index 905f188b..4fb6ed91 100644
--- a/integration-tests/src/main/docker/apisix/apisix.yaml
+++ b/integration-tests/src/main/docker/apisix/apisix.yaml
@@ -6,9 +6,11 @@
# each k6 aspect measures the same request shape against both gateways.
#
# Parity reference (consulted, never modified by this file):
-# sheriff-config/gateway.yaml -- anchors, body cap, bearer issuer
-# sheriff-config/endpoints/httpbin.yaml -- proxy / graphql / upload routes
-# sheriff-config/endpoints/secure.yaml -- bearer-protected /secure route
+# sheriff-config/gateway.yaml -- anchors, body cap, bearer issuer
+# sheriff-config/endpoints/httpbin.yaml -- proxy / graphql / upload routes
+# sheriff-config/endpoints/secure.yaml -- bearer-protected /secure route
+# sheriff-config/endpoints/websocket.yaml -- ws-echo relay route (ws aspect)
+# sheriff-config/endpoints/grpc.yaml -- bare-service-path Echo route (grpc aspect)
#
# NOTE: the standalone provider requires the literal `#END` marker within the last
# ten bytes of this file (apisix/core/config_yaml.lua seeks to end-10 and rejects
@@ -36,6 +38,30 @@ upstreams:
nodes:
"nginx-static:8080": 1
+ # PROTOCOL-APPROPRIATE backends for the ws and grpc aspects. These two aspects
+ # are the documented exception to the single-static-backend fairness invariant
+ # above: no static HTTP backend speaks WebSocket or gRPC, so each rides a real
+ # protocol backend on BOTH gateways. go-httpbin serves a WebSocket echo at
+ # /websocket/echo, and the in-repo grpc-echo service serves the Echo gRPC
+ # contract; API Sheriff's ws/grpc routes target the very same two backends
+ # (WS_UPSTREAM -> go-httpbin, GRPC_ECHO -> grpc-echo), so the two sides stay
+ # symmetric. The FAIRNESS CAVEAT, documented in README.adoc: the ws and grpc
+ # rows fold a real protocol backend's echo cost into the number, so unlike the
+ # six HTTP rows they are NOT pure gateway-overhead measurements — read them as
+ # protocol-relay comparisons, symmetric across gateways but not backend-free.
+ - id: ws-echo-upstream
+ type: roundrobin
+ nodes:
+ "go-httpbin:8080": 1
+
+ # grpc-echo speaks plaintext h2c on :9000 (no TLS), so the upstream scheme is
+ # grpc (not grpcs). APISIX proxies the gRPC call over HTTP/2 to it.
+ - id: grpc-echo-upstream
+ scheme: grpc
+ type: roundrobin
+ nodes:
+ "grpc-echo:9000": 1
+
routes:
# --- unauth aspect -------------------------------------------------------
# Mirrors httpbin.yaml's httpbin-proxy route under gateway.yaml's 'api' anchor
@@ -113,18 +139,28 @@ routes:
proxy-rewrite:
uri: /anything/upload-large
- # --- pre-provisioned declarations only -----------------------------------
- # Mirrors gateway.yaml's 'ws' and 'grpc' anchors, which are declarations with no
- # behaviour behind them (the API Sheriff request pipeline still boot-rejects both
- # protocols; roadmap plan 05 owns that behaviour). They exist here so the two
- # topologies stay route-for-route comparable when that behaviour lands, and are
- # driven by no k6 aspect today. An upstream is required for the route to be
- # schema-valid, so both point at the static backend.
- - id: ws-declaration
- uri: /ws/*
- upstream_id: static-upstream
+ # --- ws aspect -----------------------------------------------------------
+ # Mirrors endpoints/websocket.yaml's ws-echo route. Driven by websocket_echo.js
+ # at /ws/echo. enable_websocket lets APISIX proxy the upgrade and relay frames;
+ # proxy-rewrite maps the gateway path to go-httpbin's echo path, exactly as API
+ # Sheriff's ws-echo route relays to WS_UPSTREAM's /websocket/echo. API Sheriff
+ # additionally enforces a fail-closed Origin allowlist on this route; APISIX has
+ # no equivalent gate, which is part of the ws-row fairness caveat above.
+ - id: ws-echo
+ uri: /ws/echo
+ enable_websocket: true
+ upstream_id: ws-echo-upstream
+ plugins:
+ proxy-rewrite:
+ uri: /websocket/echo
- - id: grpc-declaration
- uri: /grpc/*
- upstream_id: static-upstream
+ # --- grpc aspect ---------------------------------------------------------
+ # Mirrors endpoints/grpc.yaml's grpc-echo route on the bare service path. Driven
+ # by grpc_unary.js against /de.cuioss.sheriff.api.integration.grpc.Echo/Unary.
+ # The grpc-scheme upstream makes APISIX proxy the call over HTTP/2 to grpc-echo,
+ # matching API Sheriff's forced-h2 relay to the same in-repo Echo service. No
+ # path rewrite: the full method path is preserved end-to-end on both gateways.
+ - id: grpc-echo
+ uri: /de.cuioss.sheriff.api.integration.grpc.Echo/*
+ upstream_id: grpc-echo-upstream
#END
diff --git a/integration-tests/src/main/docker/grpc-echo/Dockerfile b/integration-tests/src/main/docker/grpc-echo/Dockerfile
new file mode 100644
index 00000000..a6a6fe7a
--- /dev/null
+++ b/integration-tests/src/main/docker/grpc-echo/Dockerfile
@@ -0,0 +1,41 @@
+# grpc-echo — in-repo Quarkus gRPC echo upstream for the integration-test matrix.
+#
+# The gateway dials this service over the compose network (grpc-echo:9000) for the
+# unary / server-streaming / deliberate-failure gRPC IT scenarios. It is built as a
+# JVM fast-jar of the integration-tests module's echo service via the `grpc-echo`
+# Maven profile, which is the only place the module's Quarkus `build` goal is enabled
+# (the normal build only runs gRPC proto codegen). The build context is the repository
+# root so `-am` can build the reactor; the gateway (api-sheriff) is a `provided`
+# dependency, so it is NOT bundled into this echo application.
+
+# ---- Build stage: produce the echo fast-jar ------------------------------------
+FROM eclipse-temurin:25-jdk AS build
+WORKDIR /src
+COPY . .
+RUN ./mvnw -B -e -DskipTests -pl integration-tests -am -Pgrpc-echo clean package
+
+# ---- Runtime stage: run the echo fast-jar on a minimal JRE ---------------------
+FROM eclipse-temurin:25-jre
+LABEL org.opencontainers.image.title="API Sheriff gRPC Echo Upstream"
+LABEL org.opencontainers.image.description="In-repo Quarkus gRPC echo service backing the gRPC integration-test matrix"
+LABEL org.opencontainers.image.vendor="CUIoss"
+LABEL org.opencontainers.image.licenses="Apache-2.0"
+LABEL org.opencontainers.image.source="https://github.com/cuioss/API-Sheriff"
+
+WORKDIR /app
+# Quarkus fast-jar layout: quarkus-run.jar plus the lib/, app/ and quarkus/ trees.
+# Owned by the non-root runtime UID so the JVM can read the app without running as root.
+COPY --chown=10001:10001 --from=build /src/integration-tests/target/quarkus-app/ /app/
+
+# Separate gRPC server, bound on all interfaces so the gateway can reach it by service name.
+EXPOSE 9000
+
+# Drop privileges: run the echo service as a non-root numeric UID (no /etc/passwd entry needed).
+USER 10001:10001
+
+ENTRYPOINT ["java", \
+ "-Dquarkus.grpc.server.use-separate-server=true", \
+ "-Dquarkus.grpc.server.host=0.0.0.0", \
+ "-Dquarkus.grpc.server.port=9000", \
+ "-Dquarkus.http.host=0.0.0.0", \
+ "-jar", "quarkus-run.jar"]
diff --git a/integration-tests/src/main/docker/health-check.sh b/integration-tests/src/main/docker/health-check.sh
index df20c822..272b6009 100755
--- a/integration-tests/src/main/docker/health-check.sh
+++ b/integration-tests/src/main/docker/health-check.sh
@@ -1,6 +1,13 @@
#!/bin/bash
# Internal health check script for API Gateway Integration Tests
# Uses /dev/tcp for connection testing (Docker best practice)
+#
+# Scope: this probe reports the GATEWAY container's own readiness only. It deliberately
+# does NOT probe upstream availability (go-httpbin, asset-origin, grpc-echo): the gateway
+# dials upstreams per-request and stays ready independently of them, so gating gateway
+# readiness on an upstream would mask a legitimately-ready gateway. Upstream ordering is
+# enforced one layer up, in docker-compose.yml, where the gateway's `depends_on` waits on
+# `grpc-echo: condition: service_healthy` before it starts. Keep upstream readiness there.
# Check if the application port is listening using /dev/tcp
# This approach is preferred over /proc/net/tcp parsing
diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/grpc.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/grpc.yaml
new file mode 100644
index 00000000..a8b54324
--- /dev/null
+++ b/integration-tests/src/main/docker/sheriff-config/endpoints/grpc.yaml
@@ -0,0 +1,69 @@
+# yaml-language-server: $schema=../../../../../../api-sheriff/src/main/resources/schema/endpoint.schema.json
+# gRPC test endpoint backing GrpcProxyIT (deliverable 11). Both routes relay to the in-repo
+# Quarkus gRPC echo upstream (GRPC_ECHO -> grpc-echo:9000, forced-h2).
+#
+# ROUTING MODEL — bare service path (operator decision 2026-07-21). A gRPC method path is the
+# service-rooted /{package}.{Service}/{Method}. A route matches on the bare service path via
+# match.path_prefix and sets an IDENTICAL upstream.path, so the gateway's path rewrite
+# (upstream = stripTrailingSlash(upstream.path) + remainder-after-prefix) reconstructs the full
+# method path unchanged end-to-end. There is NO synthetic /grpc/ anchor prefix and NO
+# client-side path rewriting: a stock grpc-java channel (and k6's grpc client) dials the real
+# service path and works as-is.
+#
+# The public and bearer routes ride DISTINCT services — Echo and SecureEcho — precisely because
+# the gateway rejects two routes that share an identical path_prefix unless they are statically
+# disjoint (see doc/configuration.adoc route selection). A bearer route on the SAME /{package}.Echo
+# path as the public route is not disjoint (a public gRPC client sends no distinguishing header or
+# host), so the bearer route is given its own SecureEcho service path. SecureEcho is defined in the
+# proto but deliberately NOT implemented upstream: the bearer route rejects a tokenless call before
+# any upstream dial, so it is never reached.
+#
+# The deny-by-default forward stage means the gRPC framing metadata (content-type: application/grpc,
+# te, grpc-encoding, ...) and the client's custom metadata must be allow-listed for the call to
+# cross to the upstream; set_headers additionally injects a gateway-side metadata header.
+endpoint:
+ id: grpc
+ enabled: true
+ base_url: GRPC_ECHO
+ anchor: grpc
+ # The 'grpc' anchor is access: public and therefore carries no auth block (ADR-0013),
+ # so the endpoint declares the require: none posture itself — inherited by the public
+ # route (grpc-echo). The bearer route below overrides it with a route-level auth block
+ # (route auth wins over endpoint/anchor auth in the resolution chain, ADR-0007).
+ auth:
+ require: none
+ routes:
+ # Public gRPC relay: unary + server-streaming echo and grpc-status/trailer relay from the
+ # intentionally failing method. The match prefix is the bare Echo service path and upstream.path
+ # is identical, so the full /{package}.Echo/{Method} path reaches the upstream unchanged. The
+ # forward allowlist carries the gRPC framing headers the opaque relay must pass through, plus the
+ # client's x-echo-meta metadata; set_headers injects x-sheriff-injected toward the upstream.
+ - id: grpc-echo
+ protocol: grpc
+ match:
+ path_prefix: /de.cuioss.sheriff.api.integration.grpc.Echo
+ upstream:
+ path: /de.cuioss.sheriff.api.integration.grpc.Echo
+ forward:
+ headers_allow:
+ ["content-type", "te", "grpc-encoding", "grpc-accept-encoding", "grpc-timeout",
+ "user-agent", "x-echo-meta"]
+ set_headers:
+ x-sheriff-injected: gateway
+
+ # Bearer-protected gRPC route on the distinct SecureEcho service path (route-level auth
+ # strengthens the public anchor). A tokenless call is rejected UNAUTHENTICATED as a trailers-only
+ # gRPC response (a gRPC client cannot consume problem+json) at the offline bearer stage, before
+ # any upstream dial — so SecureEcho is never actually served upstream.
+ - id: grpc-bearer
+ protocol: grpc
+ auth:
+ require: bearer
+ match:
+ path_prefix: /de.cuioss.sheriff.api.integration.grpc.SecureEcho
+ upstream:
+ path: /de.cuioss.sheriff.api.integration.grpc.SecureEcho
+ forward:
+ headers_allow:
+ ["content-type", "te", "grpc-encoding", "grpc-accept-encoding", "grpc-timeout",
+ "user-agent"]
diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/websocket.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/websocket.yaml
new file mode 100644
index 00000000..d2d4970b
--- /dev/null
+++ b/integration-tests/src/main/docker/sheriff-config/endpoints/websocket.yaml
@@ -0,0 +1,55 @@
+# yaml-language-server: $schema=../../../../../../api-sheriff/src/main/resources/schema/endpoint.schema.json
+# WebSocket test endpoint backing WebSocketProxyIT (deliverable 10). Every route relays to
+# the single go-httpbin WebSocket echo upstream (WS_UPSTREAM -> /websocket/echo): each route's
+# match prefix maps to the empty upstream remainder, so the opaque relay dials
+# ws://go-httpbin:8080/websocket/echo for every scenario. The 'ws' anchor is access: public
+# (ADR-0013); the bearer route below strengthens that floor with its own route-level auth block
+# (endpoint/route auth wins over anchor auth, ADR-0007), which is why the public anchor carries
+# no auth block yet a require: bearer route is legal here.
+endpoint:
+ id: websocket
+ enabled: true
+ base_url: WS_UPSTREAM
+ anchor: ws
+ # The 'ws' anchor is access: public and therefore carries no auth block (ADR-0013),
+ # so the endpoint declares the require: none posture itself — inherited by every public
+ # route (ws-echo, ws-idle). The bearer route below overrides it with a route-level auth
+ # block (route auth wins over endpoint/anchor auth in the resolution chain, ADR-0007).
+ auth:
+ require: none
+ routes:
+ # Public WebSocket relay with a populated Origin allowlist. Drives two scenarios:
+ # * echo round-trip — a handshake carrying an allow-listed Origin upgrades and the opaque
+ # relay round-trips a text frame through go-httpbin's echo;
+ # * cross-site rejection (GW-09 / CSWSH) — a foreign or absent Origin is rejected 403 by the
+ # fail-closed OriginValidationStage *before* the upstream is dialed.
+ - id: ws-echo
+ protocol: websocket
+ match:
+ path_prefix: /ws/echo
+ websocket:
+ allowed_origins: ["https://sheriff.test"]
+
+ # Bearer-protected WebSocket route (route-level auth strengthens the public anchor). A
+ # handshake with no token is rejected 401 at the offline bearer stage *before* the Origin
+ # gate and before any upstream contact — proving auth-before-dial. A non-empty allowed_origins
+ # is mandatory on a bearer WS route (ADR-0015 fail-closed), satisfied here.
+ - id: ws-bearer
+ protocol: websocket
+ auth:
+ require: bearer
+ match:
+ path_prefix: /ws/bearer
+ websocket:
+ allowed_origins: ["https://sheriff.test"]
+
+ # Public WebSocket relay with a short idle timeout and no Origin enforcement (empty allowlist
+ # only reaches boot on a non-bearer route). Drives the idle-reclaim-vs-heartbeat pair: an idle
+ # established relay is reclaimed with WebSocket close 1001 after idle_timeout_seconds, while a
+ # relay kept warm by periodic frames survives past that window.
+ - id: ws-idle
+ protocol: websocket
+ match:
+ path_prefix: /ws/idle
+ websocket:
+ idle_timeout_seconds: 2
diff --git a/integration-tests/src/main/docker/sheriff-config/topology.properties b/integration-tests/src/main/docker/sheriff-config/topology.properties
index 957a62df..43413d99 100644
--- a/integration-tests/src/main/docker/sheriff-config/topology.properties
+++ b/integration-tests/src/main/docker/sheriff-config/topology.properties
@@ -12,3 +12,20 @@ UPSTREAM=http://go-httpbin:8080/anything
# gateway-owned response envelope. Reached on the internal compose network by service
# name; it publishes no host port.
ASSET_ORIGIN=http://asset-origin:80
+
+# WS_UPSTREAM is the go-httpbin WebSocket echo endpoint, reached by the gateway on the
+# internal compose network. go-httpbin serves a bidirectional echo at /websocket/echo, so
+# every WebSocket integration-test route relays to this one upstream path — the alias carries
+# the full /websocket/echo base path and each WS route's match prefix maps to the empty
+# remainder, so the relay dials ws://go-httpbin:8080/websocket/echo. WebSocketProxyIT exercises
+# the opaque relay (echo round-trip, Origin gate, idle reclaim) against it.
+WS_UPSTREAM=http://go-httpbin:8080/websocket/echo
+
+# GRPC_ECHO is the in-repo Quarkus gRPC echo upstream (grpc-echo:9000, plaintext h2c —
+# the gateway's forced-h2 client speaks prior-knowledge h2c to the http-scheme target). The
+# alias carries no base path: each gRPC route matches a bare service path
+# (/{package}.Echo for the public route, /{package}.SecureEcho for the bearer route) and sets an
+# identical upstream.path, so the full gRPC method path (/{package}.{Service}/{Method}) is preserved
+# end-to-end to the upstream. GrpcProxyIT relays unary / server-streaming / failing calls through
+# the gateway against it (and drives the bearer route's rejection path, which never reaches upstream).
+GRPC_ECHO=http://grpc-echo:9000
diff --git a/integration-tests/src/main/java/de/cuioss/sheriff/api/integration/grpc/GrpcEchoService.java b/integration-tests/src/main/java/de/cuioss/sheriff/api/integration/grpc/GrpcEchoService.java
new file mode 100644
index 00000000..0c9511ae
--- /dev/null
+++ b/integration-tests/src/main/java/de/cuioss/sheriff/api/integration/grpc/GrpcEchoService.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.integration.grpc;
+
+import io.grpc.Status;
+import io.quarkus.grpc.GrpcService;
+import io.smallrye.mutiny.Multi;
+import io.smallrye.mutiny.Uni;
+
+/**
+ * Mutiny-based Quarkus gRPC echo upstream backing the gRPC integration-test matrix.
+ * It implements the three methods declared in {@code echo.proto}:
+ *
+ * - {@link #unary(EchoRequest)} — returns the request message unchanged;
+ * - {@link #serverStream(EchoRequest)} — emits {@code count} echoed responses;
+ * - {@link #fail(EchoRequest)} — always completes with a non-OK {@code grpc-status}
+ * so the gateway's trailer/status relay can be asserted end-to-end.
+ *
+ *
+ * This is test scaffolding: the gateway dials it over the compose network as the
+ * {@code grpc-echo} upstream. The bean is stateless and therefore thread-safe; Quarkus
+ * gRPC invokes the reactive methods on its event loop.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@GrpcService
+public class GrpcEchoService implements Echo {
+
+ /**
+ * Echoes the request message back as a single response at index {@code 0}.
+ *
+ * @param request the caller's echo request
+ * @return a {@link Uni} emitting the echoed response
+ */
+ @Override
+ public Uni unary(EchoRequest request) {
+ return Uni.createFrom().item(response(request.getMessage(), 0));
+ }
+
+ /**
+ * Emits {@code count} responses (clamped to at least one), each echoing the request
+ * message and carrying its zero-based position.
+ *
+ * @param request the caller's echo request; {@code count} controls the stream length
+ * @return a {@link Multi} emitting the echoed responses in order
+ */
+ @Override
+ public Multi serverStream(EchoRequest request) {
+ int count = Math.max(1, request.getCount());
+ return Multi.createFrom().range(0, count)
+ .map(index -> response(request.getMessage(), index));
+ }
+
+ /**
+ * Always fails with {@link Status#FAILED_PRECONDITION} so integration tests can assert
+ * that the gateway relays a non-OK {@code grpc-status} and its trailers.
+ *
+ * @param request the caller's echo request (ignored)
+ * @return a {@link Uni} that always fails with a non-OK status
+ */
+ @Override
+ public Uni fail(EchoRequest request) {
+ return Uni.createFrom().failure(Status.FAILED_PRECONDITION
+ .withDescription("grpc-echo: intentional failure for trailer/status assertions")
+ .asRuntimeException());
+ }
+
+ private static EchoResponse response(String message, int index) {
+ return EchoResponse.newBuilder()
+ .setMessage(message)
+ .setIndex(index)
+ .build();
+ }
+}
diff --git a/integration-tests/src/main/proto/echo.proto b/integration-tests/src/main/proto/echo.proto
new file mode 100644
index 00000000..3d791a00
--- /dev/null
+++ b/integration-tests/src/main/proto/echo.proto
@@ -0,0 +1,48 @@
+syntax = "proto3";
+
+// In-repo gRPC echo contract backing the gRPC integration-test matrix. The gateway
+// dials the echo upstream on the compose network so the ITs can assert unary echo,
+// server-streaming echo, and grpc-status/trailer relay from a deliberately failing
+// method. Kept intentionally tiny — this is test scaffolding, not a product surface.
+package de.cuioss.sheriff.api.integration.grpc;
+
+option java_multiple_files = true;
+option java_package = "de.cuioss.sheriff.api.integration.grpc";
+option java_outer_classname = "EchoProto";
+
+// Echo upstream exercised end-to-end through the gateway by GrpcProxyIT.
+service Echo {
+ // Unary: returns the request message unchanged (index 0).
+ rpc Unary(EchoRequest) returns (EchoResponse);
+
+ // Server-streaming: emits `count` responses (index 0..count-1), each echoing the message.
+ rpc ServerStream(EchoRequest) returns (stream EchoResponse);
+
+ // Always fails with a non-OK grpc-status so the IT can assert trailer/status relay.
+ rpc Fail(EchoRequest) returns (EchoResponse);
+}
+
+// A second, bearer-protected echo service. The gateway routes gRPC on the bare service path, so
+// a public Echo route and a bearer route on the SAME /{package}.Echo path would not be statically
+// disjoint and would fail the boot. This distinct service gives the bearer gRPC route its own bare
+// path; GrpcProxyIT drives it with a stock generated stub (no path rewriting) and asserts that a
+// tokenless call is rejected UNAUTHENTICATED before any upstream dial — so the upstream
+// deliberately does NOT implement it.
+service SecureEcho {
+ // Unary echo behind the bearer gate; exercised only through its rejection path.
+ rpc Unary(EchoRequest) returns (EchoResponse);
+}
+
+message EchoRequest {
+ // Payload echoed back verbatim.
+ string message = 1;
+ // Number of responses the server-streaming method emits (clamped to >= 1).
+ int32 count = 2;
+}
+
+message EchoResponse {
+ // The echoed payload.
+ string message = 1;
+ // Zero-based position of this response within a (possibly single-element) stream.
+ int32 index = 2;
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/GrpcProxyIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/GrpcProxyIT.java
new file mode 100644
index 00000000..f6158fcb
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/GrpcProxyIT.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+
+import de.cuioss.sheriff.api.integration.grpc.EchoGrpc;
+import de.cuioss.sheriff.api.integration.grpc.EchoRequest;
+import de.cuioss.sheriff.api.integration.grpc.EchoResponse;
+import de.cuioss.sheriff.api.integration.grpc.SecureEchoGrpc;
+
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.NettyChannelBuilder;
+import io.grpc.stub.MetadataUtils;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises the {@code protocol: grpc} dispatch path end-to-end through the gateway against the
+ * in-repo Quarkus gRPC echo upstream, driving the opaque forced-h2 relay, the {@code grpc-status} /
+ * trailer relay, and the trailers-only rejection contract (deliverable 11) over the mounted
+ * {@code endpoints/grpc.yaml} routes.
+ *
+ * The gateway routes gRPC on the bare service path (operator decision 2026-07-21): a route
+ * matches the service-rooted {@code /{package}.Echo} prefix and sets an identical
+ * {@code upstream.path}, so the full {@code /{package}.Echo/{Method}} path reaches the upstream
+ * unchanged. There is no synthetic anchor prefix and no client-side path rewriting — a stock
+ * grpc-java channel dials the real service path and works as-is, so this suite installs
+ * no path-rewriting interceptor.
+ *
+ * Metadata injection is exercised two ways on the echo route: the client attaches an
+ * {@code x-echo-meta} request-metadata header (allow-listed in {@code grpc.yaml}), and the route's
+ * {@code forward.set_headers} injects a gateway-side {@code x-sheriff-injected} header toward the
+ * upstream. The echo scaffolding does not reflect metadata back (the gateway relay is opaque and
+ * never inspects the protobuf payload), so the assertion is that the unary/streaming echo
+ * round-trips faithfully with both metadata surfaces active — proving the metadata path does not
+ * corrupt the relay. Following the {@link BearerValidationIT} precedent, the bearer gRPC route is
+ * exercised through its rejection path (no signing key to mint a token): a tokenless call
+ * is rejected {@code UNAUTHENTICATED} as a trailers-only gRPC response before any upstream dial. The
+ * bearer route rides a distinct {@code SecureEcho} service path (a bearer route on the same
+ * {@code Echo} path would not be statically disjoint from the public route and would fail the boot),
+ * driven by the stock generated {@link SecureEchoGrpc} stub.
+ */
+class GrpcProxyIT extends BaseIntegrationTest {
+
+ private static final int CALL_TIMEOUT_SECONDS = 15;
+
+ private static ManagedChannel channel;
+
+ @BeforeAll
+ static void setUpGrpcChannel() throws Exception {
+ int testPort = Integer.parseInt(System.getProperty("test.https.port", "10443"));
+ // Trust-all TLS over ALPN h2 for the stack's self-signed localhost certificate — the gRPC
+ // analogue of BaseIntegrationTest's relaxed REST Assured validation. Scoped strictly to this
+ // black-box integration test against a throwaway local certificate, never production trust.
+ SslContext sslContext = GrpcSslContexts.forClient()
+ .trustManager(InsecureTrustManagerFactory.INSTANCE)
+ .build();
+ channel = NettyChannelBuilder.forAddress("localhost", testPort)
+ .overrideAuthority("localhost")
+ .sslContext(sslContext)
+ .build();
+ }
+
+ @AfterAll
+ static void tearDownGrpcChannel() throws Exception {
+ if (channel != null) {
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ @DisplayName("a unary echo round-trips through the gateway with client + gateway-injected metadata")
+ void unaryEchoThroughGateway() {
+ EchoResponse response = echoStub().unary(EchoRequest.newBuilder().setMessage("sheriff-grpc").build());
+
+ assertEquals("sheriff-grpc", response.getMessage(), "the opaque relay must round-trip the unary echo verbatim");
+ assertEquals(0, response.getIndex(), "the unary echo is a single response at index 0");
+ }
+
+ @Test
+ @DisplayName("a server-streaming echo relays every response frame in order through the gateway")
+ void serverStreamEchoThroughGateway() {
+ Iterator responses = echoStub()
+ .serverStream(EchoRequest.newBuilder().setMessage("stream-me").setCount(3).build());
+
+ List collected = new ArrayList<>();
+ responses.forEachRemaining(collected::add);
+
+ assertEquals(3, collected.size(), "the gateway must relay every server-streamed response frame");
+ for (int index = 0; index < collected.size(); index++) {
+ assertEquals("stream-me", collected.get(index).getMessage(), "each streamed frame echoes the message");
+ assertEquals(index, collected.get(index).getIndex(), "streamed frames arrive in order with their index");
+ }
+ }
+
+ @Test
+ @DisplayName("the gateway relays a non-OK grpc-status and its trailers from the failing method")
+ void grpcStatusRelayFromFailingMethod() {
+ StatusRuntimeException failure = assertThrows(StatusRuntimeException.class,
+ () -> echoStub().fail(EchoRequest.newBuilder().setMessage("boom").build()),
+ "the failing method must surface a non-OK gRPC status through the gateway");
+
+ assertEquals(Status.Code.FAILED_PRECONDITION, failure.getStatus().getCode(),
+ "the upstream grpc-status must be relayed to the client unchanged");
+ assertTrue(failure.getStatus().getDescription() != null
+ && failure.getStatus().getDescription().contains("intentional failure"),
+ "the upstream grpc-message trailer must be relayed to the client");
+ }
+
+ @Test
+ @DisplayName("a tokenless call on a bearer gRPC route is rejected UNAUTHENTICATED (trailers-only)")
+ void unauthenticatedBearerGrpcRejected() {
+ SecureEchoGrpc.SecureEchoBlockingStub bearerStub = SecureEchoGrpc.newBlockingStub(channel);
+
+ StatusRuntimeException failure = assertThrows(StatusRuntimeException.class,
+ () -> bearerStub.unary(EchoRequest.newBuilder().setMessage("no-token").build()),
+ "a tokenless bearer gRPC call must be rejected before any upstream dial");
+
+ assertEquals(Status.Code.UNAUTHENTICATED, failure.getStatus().getCode(),
+ "a gateway-generated gRPC rejection maps 401 to UNAUTHENTICATED as a trailers-only response");
+ }
+
+ /**
+ * A blocking stub for the public {@code grpc-echo} route: a stock channel dials the real
+ * {@code /{package}.Echo/{Method}} service path (no path rewriting), and an {@code x-echo-meta}
+ * request-metadata header is attached to exercise the client-side metadata path.
+ */
+ private static EchoGrpc.EchoBlockingStub echoStub() {
+ Metadata metadata = new Metadata();
+ metadata.put(Metadata.Key.of("x-echo-meta", Metadata.ASCII_STRING_MARSHALLER), "sheriff-client");
+ return EchoGrpc.newBlockingStub(channel)
+ .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
+ .withDeadlineAfter(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/WebSocketProxyIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/WebSocketProxyIT.java
new file mode 100644
index 00000000..8d36f421
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/WebSocketProxyIT.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.net.http.WebSocketHandshakeException;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises the {@code protocol: websocket} dispatch path end-to-end over the public HTTPS edge,
+ * driving the opaque WebSocket relay, the fail-closed {@code Origin} gate (GW-09 / CSWSH), the
+ * per-route idle-timeout reclaim, and auth-before-dial rejection against the mounted
+ * {@code endpoints/websocket.yaml} routes. Every route relays to the go-httpbin
+ * {@code /websocket/echo} upstream (the {@code WS_UPSTREAM} topology alias).
+ *
+ * The suite is a black-box client: it opens real {@code wss://} handshakes with the JDK
+ * {@link java.net.http.WebSocket} client (trust-all TLS for the stack's self-signed localhost
+ * certificate, mirroring {@link BaseIntegrationTest}'s relaxed REST Assured validation). Following
+ * the {@link BearerValidationIT} precedent — the black-box suite holds no signing key, so it cannot
+ * mint a valid bearer token — the successful echo round-trip is driven over a public WS
+ * route with a populated {@code allowed_origins}, while the bearer WS route is exercised through its
+ * rejection path: a tokenless handshake is rejected {@code 401} at the offline bearer stage
+ * before the {@code Origin} gate and before any upstream dial. Together the routes cover every WS
+ * behaviour: opaque relay, Origin enforcement, auth-before-dial, idle-reclaim-vs-heartbeat, and the
+ * unmatched-path {@code 404}. The remaining fail-closed contract — a bearer WS route booting with an
+ * empty {@code allowed_origins} — cannot coexist with a bootable stack (it aborts boot fail-fast), so
+ * it is proven by {@code verify-invalid-config-fails.sh} and the unit-level {@code ConfigValidatorTest}
+ * rather than against the running edge here.
+ */
+class WebSocketProxyIT extends BaseIntegrationTest {
+
+ /** The one Origin the {@code ws-echo} / {@code ws-bearer} routes allow-list (websocket.yaml). */
+ private static final String ALLOWED_ORIGIN = "https://sheriff.test";
+
+ /** A foreign Origin that must be rejected by the fail-closed Origin gate. */
+ private static final String FOREIGN_ORIGIN = "https://evil.example";
+
+ private static final int HANDSHAKE_TIMEOUT_SECONDS = 15;
+ private static final int WEBSOCKET_IDLE_TIMEOUT_SECONDS = 2;
+ /** Idle-reclaim close code (WebSocket 1001 Going Away) the relay emits on idle expiry. */
+ private static final int CLOSE_GOING_AWAY = 1001;
+
+ private static String wsBaseUri;
+ private static HttpClient httpClient;
+
+ @BeforeAll
+ static void setUpWebSocketClient() throws Exception {
+ String testPort = System.getProperty("test.https.port", "10443");
+ wsBaseUri = "wss://localhost:" + testPort;
+
+ // Trust-all TLS: the integration stack serves a self-signed localhost certificate, exactly
+ // the case BaseIntegrationTest handles for REST Assured via useRelaxedHTTPSValidation(). The
+ // JDK WebSocket client offers no equivalent one-liner, so a trust-all context is built here.
+ // Scoped to this black-box IT against a throwaway local certificate — never production trust.
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, new TrustManager[]{new TrustAllManager()}, new SecureRandom());
+ httpClient = HttpClient.newBuilder().sslContext(sslContext).build();
+ }
+
+ @Test
+ @DisplayName("an allow-listed Origin handshake upgrades and the relay round-trips an echo frame")
+ void echoRoundTripThroughGateway() throws Exception {
+ var listener = new RecordingListener();
+ WebSocket socket = httpClient.newWebSocketBuilder()
+ .header("Origin", ALLOWED_ORIGIN)
+ .buildAsync(URI.create(wsBaseUri + "/ws/echo"), listener)
+ .get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ try {
+ socket.sendText("sheriff-ws-echo", true).get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ String echoed = listener.firstMessage.get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertEquals("sheriff-ws-echo", echoed, "the opaque relay must round-trip the text frame verbatim");
+ } finally {
+ socket.sendClose(WebSocket.NORMAL_CLOSURE, "done");
+ }
+ }
+
+ @Test
+ @DisplayName("a tokenless handshake on a bearer WS route is rejected 401 before any upstream dial")
+ void unauthenticatedBearerHandshakeRejected() {
+ WebSocketHandshakeException failure = expectHandshakeFailure("/ws/bearer", ALLOWED_ORIGIN);
+ assertEquals(401, failure.getResponse().statusCode(),
+ "a bearer WS route must reject a missing token 401 at the auth stage, before the Origin gate and dial");
+ }
+
+ @Test
+ @DisplayName("a foreign-Origin handshake is rejected 403 by the fail-closed Origin gate before dial")
+ void foreignOriginHandshakeRejected() {
+ WebSocketHandshakeException failure = expectHandshakeFailure("/ws/echo", FOREIGN_ORIGIN);
+ assertEquals(403, failure.getResponse().statusCode(),
+ "a foreign Origin must be rejected 403 (GW-09 / CSWSH) before the upstream is dialed");
+ }
+
+ @Test
+ @DisplayName("an absent-Origin handshake is rejected 403 by the fail-closed Origin gate before dial")
+ void absentOriginHandshakeRejected() {
+ WebSocketHandshakeException failure = expectHandshakeFailure("/ws/echo", null);
+ assertEquals(403, failure.getResponse().statusCode(),
+ "an absent Origin must be rejected 403 — there is no any-origin default");
+ }
+
+ @Test
+ @DisplayName("an idle established relay is reclaimed with close 1001 after idle_timeout_seconds")
+ void idleRelayReclaimedAfterTimeout() throws Exception {
+ var listener = new RecordingListener();
+ WebSocket socket = httpClient.newWebSocketBuilder()
+ .buildAsync(URI.create(wsBaseUri + "/ws/idle"), listener)
+ .get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ try {
+ // Send nothing: the relay must reclaim the idle connection after idle_timeout_seconds (2s).
+ int closeCode = listener.closed.get(WEBSOCKET_IDLE_TIMEOUT_SECONDS + 8, TimeUnit.SECONDS);
+ assertEquals(CLOSE_GOING_AWAY, closeCode, "an idle relay must be reclaimed with WebSocket close 1001");
+ } finally {
+ socket.abort();
+ }
+ }
+
+ @Test
+ @DisplayName("a relay kept warm by periodic frames survives past the idle timeout")
+ void heartbeatedRelaySurvivesIdleTimeout() throws Exception {
+ var listener = new RecordingListener();
+ WebSocket socket = httpClient.newWebSocketBuilder()
+ .buildAsync(URI.create(wsBaseUri + "/ws/idle"), listener)
+ .get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ try {
+ // Beat well inside the 2s idle window for ~5s (2.5x the idle timeout); every frame resets
+ // the relay's idle timer, so the connection must NOT be reclaimed.
+ for (int beat = 0; beat < 6; beat++) {
+ socket.sendText("beat-" + beat, true).get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ TimeUnit.MILLISECONDS.sleep(800);
+ }
+ assertFalse(listener.closed.isDone(),
+ "a heartbeated relay must survive past the idle timeout — it was reclaimed");
+ } finally {
+ socket.sendClose(WebSocket.NORMAL_CLOSURE, "done");
+ }
+ }
+
+ @Test
+ @DisplayName("a handshake to an unmatched WS path is rejected 404 by deny-by-default route selection")
+ void unmatchedWebSocketPathRejected() {
+ WebSocketHandshakeException failure = expectHandshakeFailure("/ws/does-not-exist", ALLOWED_ORIGIN);
+ assertEquals(404, failure.getResponse().statusCode(),
+ "an unmatched WS path must be rejected 404 by deny-by-default route selection");
+ }
+
+ /**
+ * Opens a handshake expected to fail (non-101) and returns the underlying
+ * {@link WebSocketHandshakeException} so the caller can assert the HTTP status. An {@code origin}
+ * of {@code null} omits the {@code Origin} header entirely (the absent-Origin case).
+ */
+ private static WebSocketHandshakeException expectHandshakeFailure(String path, String origin) {
+ WebSocket.Builder builder = httpClient.newWebSocketBuilder();
+ if (origin != null) {
+ builder.header("Origin", origin);
+ }
+ ExecutionException thrown = assertThrows(ExecutionException.class,
+ () -> builder.buildAsync(URI.create(wsBaseUri + path), new RecordingListener())
+ .get(HANDSHAKE_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "the handshake to " + path + " was expected to fail");
+ assertInstanceOf(WebSocketHandshakeException.class, thrown.getCause(),
+ "a rejected WebSocket handshake must surface a WebSocketHandshakeException");
+ return (WebSocketHandshakeException) thrown.getCause();
+ }
+
+ /**
+ * Captures the first fully-assembled text message and the close status code, so a test can await
+ * the echo round-trip and observe the idle-reclaim close.
+ */
+ private static final class RecordingListener implements WebSocket.Listener {
+
+ private final CompletableFuture firstMessage = new CompletableFuture<>();
+ private final CompletableFuture closed = new CompletableFuture<>();
+ private final StringBuilder buffer = new StringBuilder();
+
+ @Override
+ public CompletableFuture> onText(WebSocket webSocket, CharSequence data, boolean last) {
+ buffer.append(data);
+ if (last) {
+ firstMessage.complete(buffer.toString());
+ buffer.setLength(0);
+ }
+ webSocket.request(1);
+ return null;
+ }
+
+ @Override
+ public CompletableFuture> onClose(WebSocket webSocket, int statusCode, String reason) {
+ closed.complete(statusCode);
+ return null;
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ firstMessage.completeExceptionally(error);
+ closed.completeExceptionally(error);
+ }
+ }
+
+ /**
+ * A trust-all {@link X509TrustManager} for the stack's self-signed localhost certificate. Scoped
+ * strictly to this black-box integration test — never a production trust decision.
+ */
+ private static final class TrustAllManager implements X509TrustManager {
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ // Trust-all test manager: the local stack's self-signed certificate is intentionally accepted.
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ // Trust-all test manager: the local stack's self-signed certificate is intentionally accepted.
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ }
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/grpc/GrpcEchoServiceTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/grpc/GrpcEchoServiceTest.java
new file mode 100644
index 00000000..97b440e1
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/grpc/GrpcEchoServiceTest.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.sheriff.api.integration.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.Duration;
+import java.util.List;
+
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Isolated unit tests for {@link GrpcEchoService}. The service is exercised directly (no
+ * gRPC transport) by awaiting its Mutiny {@code Uni}/{@code Multi} results, asserting the
+ * unary echo, the server-streaming fan-out, and the deliberate non-OK failure.
+ */
+@DisplayName("GrpcEchoService")
+class GrpcEchoServiceTest {
+
+ private static final Duration AWAIT = Duration.ofSeconds(5);
+
+ private final GrpcEchoService service = new GrpcEchoService();
+
+ @Nested
+ @DisplayName("unary")
+ class Unary {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"hello", "", "unicode-☃-payload", " spaced "})
+ @DisplayName("echoes the request message unchanged at index 0")
+ void echoesMessage(String message) {
+ // Arrange
+ var request = EchoRequest.newBuilder().setMessage(message).build();
+
+ // Act
+ var response = service.unary(request).await().atMost(AWAIT);
+
+ // Assert
+ assertEquals(message, response.getMessage());
+ assertEquals(0, response.getIndex());
+ }
+ }
+
+ @Nested
+ @DisplayName("serverStream")
+ class ServerStream {
+
+ @Test
+ @DisplayName("emits count responses, each echoing the message with a rising index")
+ void emitsCountResponses() {
+ // Arrange
+ var request = EchoRequest.newBuilder().setMessage("tick").setCount(3).build();
+
+ // Act
+ List responses = service.serverStream(request)
+ .collect().asList().await().atMost(AWAIT);
+
+ // Assert
+ assertEquals(3, responses.size());
+ for (int index = 0; index < responses.size(); index++) {
+ assertEquals("tick", responses.get(index).getMessage());
+ assertEquals(index, responses.get(index).getIndex());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -1, -100})
+ @DisplayName("clamps a non-positive count to a single response")
+ void clampsNonPositiveCount(int count) {
+ // Arrange
+ var request = EchoRequest.newBuilder().setMessage("one").setCount(count).build();
+
+ // Act
+ List responses = service.serverStream(request)
+ .collect().asList().await().atMost(AWAIT);
+
+ // Assert
+ assertEquals(1, responses.size());
+ assertEquals("one", responses.getFirst().getMessage());
+ assertEquals(0, responses.getFirst().getIndex());
+ }
+ }
+
+ @Nested
+ @DisplayName("fail")
+ class Fail {
+
+ @Test
+ @DisplayName("always completes with a non-OK FAILED_PRECONDITION status")
+ void failsWithFailedPrecondition() {
+ // Arrange
+ var request = EchoRequest.newBuilder().setMessage("ignored").build();
+
+ // Act
+ var thrown = assertThrows(StatusRuntimeException.class,
+ () -> service.fail(request).await().atMost(AWAIT));
+
+ // Assert
+ assertEquals(Status.Code.FAILED_PRECONDITION, thrown.getStatus().getCode());
+ }
+ }
+}