diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriffLogMessages.java index 4d6132e8..b5135ba3 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriffLogMessages.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriffLogMessages.java @@ -27,8 +27,8 @@ * {@code ApiSheriff} prefix and a stable numeric identifier, so they are greppable and * assertable. This catalogue's identifier ranges are disjoint from * {@link de.cuioss.sheriff.api.config.ConfigLogMessages}'s (the boot-time configuration - * subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1-2} / - * {@code 100} / {@code 103-104} here vs {@code 2-3} / {@code 101-102} / {@code 200-201} + * subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1} / {@code 4} / + * {@code 100} / {@code 103-106} here vs {@code 2-3} / {@code 101-102} / {@code 200-201} * there — never renumber one catalogue without checking the other for a collision. * Security-relevant {@code WARN}s record only the failure type and route id — * never the raw offending payload. {@code DEBUG} / {@code TRACE} diagnostics use the logger @@ -54,6 +54,13 @@ public static final class INFO { .identifier(1) .template("Route table compiled: %s route runtime(s) assembled") .build(); + + /** A WebSocket upgrade was accepted and the opaque bidirectional relay was established. */ + public static final LogRecord WEBSOCKET_RELAY_ESTABLISHED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(4) + .template("WebSocket relay established for route '%s'") + .build(); } /** @@ -85,5 +92,23 @@ public static final class WARN { .identifier(104) .template("Circuit breaker closed for upstream '%s'") .build(); + + /** + * A WebSocket upgrade was rejected by the Origin allowlist (GW-09 / CSWSH). Records the + * route id and the rejection disposition ({@code absent} or {@code foreign}) only — the raw + * offending {@code Origin} value is never logged. + */ + public static final LogRecord WEBSOCKET_ORIGIN_REJECTED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(105) + .template("WebSocket upgrade rejected on route '%s': %s origin") + .build(); + + /** An established WebSocket relay was reclaimed after exceeding its idle timeout. */ + public static final LogRecord WEBSOCKET_IDLE_RECLAIM = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(106) + .template("WebSocket relay on route '%s' reclaimed after idle timeout of %s seconds") + .build(); } } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java index 01ad90a3..797d1d32 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java @@ -19,9 +19,12 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.EnumSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Optional; +import java.util.Set; import de.cuioss.sheriff.api.config.model.AccessLevel; @@ -43,6 +46,7 @@ import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; 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 de.cuioss.tools.logging.CuiLogger; /** @@ -54,8 +58,10 @@ * {@code path_prefix} length (most specific first), and * materializes each route's effective auth, effective {@code allowed_methods}, * effective {@code security_filter} / {@code security_headers}, effective retry - * / not-modified toggles, and the effective deny-by-default {@code forward} - * allowlist into a {@link ResolvedRoute}. The inheritance chains + * / not-modified toggles, the effective deny-by-default {@code forward} + * allowlist, and the effective upstream base path (the route-level + * {@code upstream.path} replacing the alias-derived base path when declared) + * into a {@link ResolvedRoute}. The inheritance chains * (gateway defaults → anchor → endpoint → route, wholesale replacement at every * step — ADR-0007) are resolved here, once, so the request pipeline never * re-implements them and never consults an anchor. The effective posture of each @@ -78,6 +84,9 @@ public final class RouteTableBuilder { * display fallback for an absent anchor name / security-filter profile in {@link #logPosture}. */ private static final String NONE = "none"; + /** The default {@code websocket.idle_timeout_seconds} applied when a WebSocket route omits it. */ + private static final int DEFAULT_WEBSOCKET_IDLE_TIMEOUT_SECONDS = 300; + /** * Builds the route table from the enabled endpoints and the resolved topology. * @@ -160,9 +169,15 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou .flatMap(UpstreamConfig.NotModified::enabled) .orElse(defaults.notModifiedEnabled()); ForwardConfig effectiveForward = route.forward().orElseGet(() -> ForwardConfig.builder().build()); + Protocol protocol = route.protocol().orElse(Protocol.HTTP); + Set allowedOrigins = effectiveAllowedOrigins(route); + Optional idleTimeout = protocol == Protocol.WEBSOCKET + ? Optional.of(route.websocket().flatMap(WebSocketConfig::idleTimeoutSeconds) + .orElse(DEFAULT_WEBSOCKET_IDLE_TIMEOUT_SECONDS)) + : Optional.empty(); ResolvedRoute.ResolvedRouteBuilder builder = ResolvedRoute.builder() .id(route.id()) - .protocol(route.protocol().orElse(Protocol.HTTP)) + .protocol(protocol) .anchor(anchor.map(AnchorConfig::name)) .match(route.match()) .effectiveAuth(auth) @@ -171,7 +186,9 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou .effectiveSecurityHeaders(securityHeaders) .retryEnabled(retryEnabled) .notModifiedEnabled(notModifiedEnabled) - .effectiveForward(effectiveForward); + .effectiveForward(effectiveForward) + .effectiveAllowedOrigins(allowedOrigins) + .effectiveWebSocketIdleTimeoutSeconds(idleTimeout); // A route resolves to exactly one terminal action: an asset action (when the route // declares an asset block) is materialized here; otherwise the route proxies to its // endpoint upstream. ADR-0014: upstream XOR asset. @@ -179,13 +196,40 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou if (asset.isPresent()) { builder.asset(Optional.of(resolveAsset(route, asset.get(), anchor, auth, topology))); } else { - builder.upstream(Optional.of(upstream)); + builder.upstream(Optional.of(applyRouteUpstreamPath(upstream, route))); } ResolvedRoute resolved = builder.build(); logPosture(resolved); return resolved; } + /** + * Materializes the route-level {@code upstream.path} into the route's effective upstream base + * path. A route that declares a non-blank {@code upstream.path} replaces the + * alias-derived base path with it (the bare-service-path routing model): the forward URI is + * then reconstructed as {@code stripTrailingSlash(upstream.path) + remainder-after-prefix} by + * {@link de.cuioss.sheriff.api.edge.DispatchStage#upstreamRequestUri}, so a gRPC route's + * {@code /{package}.{Service}} segment (and a benchmark route's {@code /anything/} + * rewrite) reaches the upstream instead of being stripped. The alias host / port / scheme are + * carried through unchanged, so the client- and guard-sharing tuple + * ({@link de.cuioss.sheriff.api.edge.RouteRuntimeAssembler.UpstreamTarget}, keyed on + * scheme/host/port) is unaffected. A route without {@code upstream.path} keeps the + * alias-derived base path unchanged — the default proxy behavior. + * + * @param aliasUpstream the endpoint's alias-resolved upstream (shared across the endpoint's + * routes) + * @param route the route whose optional {@code upstream.path} overrides the base path + * @return the per-route upstream carrying the effective base path + */ + private static ResolvedUpstream applyRouteUpstreamPath(ResolvedUpstream aliasUpstream, RouteConfig route) { + return route.upstream() + .flatMap(UpstreamConfig::path) + .filter(path -> !path.isBlank()) + .map(path -> new ResolvedUpstream(aliasUpstream.scheme(), aliasUpstream.host(), + aliasUpstream.port(), path)) + .orElse(aliasUpstream); + } + /** * Materializes a route's asset terminal action (ADR-0014). A {@code directory} * source carries its configured root; an {@code upstream} source resolves its @@ -280,6 +324,21 @@ private static List effectiveAllowedMethods(GatewayConfig gateway, E return STANDARD_ALLOWED_METHODS; } + /** + * The materialized WebSocket {@code allowed_origins} allowlist, lower-cased once at + * assembly for case-insensitive host matching (scheme and port are already + * case-insensitive). Iteration order is not significant — origin acceptance is an + * exact-membership test, so the set may be defensively re-copied downstream without + * any ordering guarantee. Empty for a route that declares no {@code websocket} block. + */ + private static Set effectiveAllowedOrigins(RouteConfig route) { + Set origins = new LinkedHashSet<>(); + for (String origin : route.websocket().map(WebSocketConfig::allowedOrigins).orElseGet(List::of)) { + origins.add(origin.toLowerCase(Locale.ROOT)); + } + return origins; + } + /** * Signals a route-table assembly failure: an enabled endpoint whose alias does * not resolve, or a route with no resolvable effective auth. Both are boot diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java index 3e0c3029..caf5f228 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import lombok.Builder; @@ -64,6 +65,13 @@ * @param effectiveForward the materialized, deny-by-default {@code forward} * allowlist consumed by stage 5; an empty * {@link ForwardConfig} when the route declares none + * @param effectiveAllowedOrigins the materialized, lower-cased exact-match + * {@code Origin} allowlist for a WebSocket route, + * empty for a non-WebSocket route (meaningful only when + * {@code protocol} is {@link Protocol#WEBSOCKET}) + * @param effectiveWebSocketIdleTimeoutSeconds the materialized idle timeout for a + * WebSocket route with the {@code 300}-second default + * applied, empty for a non-WebSocket route * @author API Sheriff Team * @since 1.0 */ @@ -72,15 +80,16 @@ public record ResolvedRoute(String id, Protocol protocol, Optional ancho AuthConfig effectiveAuth, List effectiveAllowedMethods, Optional effectiveSecurityFilter, Optional effectiveSecurityHeaders, boolean retryEnabled, boolean notModifiedEnabled, Optional upstream, Optional asset, -ForwardConfig effectiveForward) { +ForwardConfig effectiveForward, Set effectiveAllowedOrigins, +Optional effectiveWebSocketIdleTimeoutSeconds) { /** * Canonical constructor requiring the mandatory components, defensively copying - * {@code effectiveAllowedMethods}, normalizing absent optionals, defaulting an - * absent {@code protocol} to {@link Protocol#HTTP}, defaulting an absent - * {@code effectiveForward} to a deny-by-default empty {@link ForwardConfig}, and - * enforcing the terminal-action invariant: exactly one of {@code upstream} - * (proxy) or {@code asset} resolves. + * {@code effectiveAllowedMethods} and {@code effectiveAllowedOrigins}, normalizing + * absent optionals, defaulting an absent {@code protocol} to {@link Protocol#HTTP}, + * defaulting an absent {@code effectiveForward} to a deny-by-default empty + * {@link ForwardConfig}, and enforcing the terminal-action invariant: exactly one + * of {@code upstream} (proxy) or {@code asset} resolves. */ public ResolvedRoute { Objects.requireNonNull(id, "id"); @@ -98,6 +107,9 @@ public record ResolvedRoute(String id, Protocol protocol, Optional ancho "route '" + id + "' must resolve exactly one terminal action (upstream XOR asset)"); } effectiveForward = effectiveForward == null ? ForwardConfig.builder().build() : effectiveForward; + effectiveAllowedOrigins = effectiveAllowedOrigins == null ? Set.of() : Set.copyOf(effectiveAllowedOrigins); + effectiveWebSocketIdleTimeoutSeconds = Objects.requireNonNullElse(effectiveWebSocketIdleTimeoutSeconds, + Optional.empty()); } /** diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java index df8844e9..18f4383f 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java @@ -44,13 +44,16 @@ * route carries at most one terminal action, so {@code asset} * and {@code upstream} are mutually exclusive (ADR-0014) * @param rateLimit the reserved rate-limit block, empty when omitted + * @param websocket the per-route WebSocket settings ({@code allowed_origins}, + * {@code idle_timeout_seconds}), empty for non-WebSocket routes * @author API Sheriff Team * @since 1.0 */ @Builder public record RouteConfig(String id, Optional protocol, Optional anchor, MatchConfig match, Optional auth, Optional securityFilter, Optional forward, -Optional upstream, Optional asset, Optional rateLimit) { +Optional upstream, Optional asset, Optional rateLimit, +Optional websocket) { /** * Canonical constructor requiring {@code id} and {@code match} and normalizing @@ -67,5 +70,6 @@ public record RouteConfig(String id, Optional protocol, Optional + * {@code allowedOrigins} is a fail-closed, deny-by-default allowlist of exact-match + * {@code Origin} strings (scheme + host + port, no wildcards); an absent or empty + * allowlist on a bearer WebSocket route rejects the upgrade at boot — there is no + * "any origin" default. Host matching is case-insensitive; the effective allowlist + * is lower-cased once, at route-table assembly. {@code idleTimeoutSeconds} bounds an + * established relay ("idle" = no frame in either direction, ping/pong counting as + * activity); it defaults to {@code 300} when absent, applied at resolution. + * + * @param allowedOrigins the exact-match, case-insensitive-host origin allowlist, + * empty when none is declared + * @param idleTimeoutSeconds the per-route idle timeout in seconds, empty when the + * default applies + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record WebSocketConfig(List allowedOrigins, Optional idleTimeoutSeconds) { + + /** + * Canonical constructor defensively copying {@code allowedOrigins} into an + * unmodifiable copy, normalizing an absent list to empty and an absent + * {@code idleTimeoutSeconds} to {@link Optional#empty()}. + */ + public WebSocketConfig { + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + idleTimeoutSeconds = Objects.requireNonNullElse(idleTimeoutSeconds, Optional.empty()); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java index 68e103a5..1117e2bc 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java @@ -43,11 +43,13 @@ import de.cuioss.sheriff.api.config.model.MatchConfig; import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher; 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; import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; import de.cuioss.sheriff.api.config.model.TlsConfig; +import de.cuioss.sheriff.api.config.model.WebSocketConfig; import de.cuioss.sheriff.api.config.validation.rule.ValidationRule; import de.cuioss.tools.logging.CuiLogger; @@ -124,7 +126,8 @@ public final class ConfigValidator { (gateway, endpoints, topology, errors) -> validateCors(gateway, errors), (gateway, endpoints, topology, errors) -> validateSessionMode(gateway, errors), (gateway, endpoints, topology, errors) -> validatePassthroughHostCollision(gateway, endpoints, errors), - (gateway, endpoints, topology, errors) -> validatePassthroughAliasResolvable(gateway, topology, errors)); + (gateway, endpoints, topology, errors) -> validatePassthroughAliasResolvable(gateway, topology, errors), + (gateway, endpoints, topology, errors) -> validateWebSocketConfig(gateway, endpoints, errors)); private final List rules; @@ -318,6 +321,18 @@ private static void validateAnchorReferencesExist(GatewayConfig gateway, List + * {@code protocol: grpc} routes are exempt from both containment rules. A gRPC + * method path is the service-rooted {@code /{package}.{Service}/{Method}} whose + * service segment is a single opaque path segment (dots, no slashes), so it is + * structurally never nested under a gateway path namespace on a segment boundary + * — no non-root anchor {@code path_prefix} can contain it, and stock gRPC clients + * cannot be told to prepend a namespace prefix. Only the path-prefix containment + * geometry is skipped for gRPC routes: a gRPC route still declares an anchor for + * its ADR-0013 type/access classification and its ADR-0007 auth floor, and every + * other anchor rule (declared-anchor existence, pairwise-disjoint prefixes, + * non-weakenable auth floor, access→auth matrix) stays enforced for it unchanged. + * {@code websocket} and {@code http} routes keep full containment enforcement. */ private static void validateAnchorNamespaceMembership(GatewayConfig gateway, List endpoints, List errors) { @@ -326,6 +341,11 @@ private static void validateAnchorNamespaceMembership(GatewayConfig gateway, Lis } for (EndpointConfig endpoint : endpoints) { for (RouteConfig route : endpoint.routes()) { + if (route.protocol().orElse(Protocol.HTTP) == Protocol.GRPC) { + // gRPC routes ride a service-rooted single-segment path that no gateway path + // namespace can contain on a segment boundary — exempt from containment (rules 3 & 4). + continue; + } Optional declaredName = declaredAnchorName(endpoint, route); String routePrefix = route.match().pathPrefix(); checkRouteInsideDeclaredAnchorNamespace(gateway, endpoint, route, declaredName, routePrefix, errors); @@ -750,6 +770,62 @@ private static void validatePassthroughAliasResolvable(GatewayConfig gateway, Re } } + /** + * Rule: the fail-closed WebSocket allowlist contract (ADR-0015). Each + * {@code protocol: websocket} route is validated by {@link #validateWebSocketRoute} + * (bearer allowlist, exact-match origins, positive idle timeout). A route that + * declares a {@code websocket} block while its protocol is not + * {@code websocket} is rejected here, since those settings would otherwise be + * silently ignored. Every violation collects into the shared list; the rule never + * fails fast. + */ + private static void validateWebSocketConfig(GatewayConfig gateway, List endpoints, + List errors) { + for (EndpointConfig endpoint : endpoints) { + for (RouteConfig route : endpoint.routes()) { + if (route.protocol().orElse(Protocol.HTTP) == Protocol.WEBSOCKET) { + validateWebSocketRoute(gateway, endpoint, route, errors); + } else if (route.websocket().isPresent()) { + errors.add(new ConfigError(endpointFile(endpoint), ENDPOINT_ROUTES_POINTER, + "route '%s' declares a websocket block but its protocol is not 'websocket'; the websocket settings would be silently ignored" + .formatted(route.id()))); + } + } + } + } + + /** + * Validates a single {@code protocol: websocket} route against the fail-closed + * WebSocket allowlist contract (ADR-0015): a bearer route needs a non-empty + * {@code allowed_origins}, no origin may contain a wildcard, and a declared + * {@code idle_timeout_seconds} must be positive. Every violation collects into the + * shared list; the rule never fails fast. + */ + private static void validateWebSocketRoute(GatewayConfig gateway, EndpointConfig endpoint, RouteConfig route, + List errors) { + Optional websocket = route.websocket(); + List origins = websocket.map(WebSocketConfig::allowedOrigins).orElseGet(List::of); + if (REQUIRE_BEARER.equals(effectiveRequire(gateway, endpoint, route)) && origins.isEmpty()) { + errors.add(new ConfigError(endpointFile(endpoint), ENDPOINT_ROUTES_POINTER, + "websocket route '%s' with effective auth 'bearer' must declare a non-empty allowed_origins allowlist (fail-closed)" + .formatted(route.id()))); + } + for (String origin : origins) { + if (origin.contains(WILDCARD_ORIGIN)) { + errors.add(new ConfigError(endpointFile(endpoint), ENDPOINT_ROUTES_POINTER, + "websocket route '%s' allowed_origins entry '%s' must be an exact origin; wildcards are not permitted" + .formatted(route.id(), origin))); + } + } + websocket.flatMap(WebSocketConfig::idleTimeoutSeconds).ifPresent(timeout -> { + if (timeout <= 0) { + errors.add(new ConfigError(endpointFile(endpoint), ENDPOINT_ROUTES_POINTER, + "websocket route '%s' idle_timeout_seconds must be a positive integer, but was %d" + .formatted(route.id(), timeout))); + } + }); + } + private static Map passthroughSni(GatewayConfig gateway) { return gateway.tls().map(TlsConfig::passthroughSni).orElseGet(Map::of); } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java index e591e4ed..85d2762f 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java @@ -47,6 +47,7 @@ import de.cuioss.sheriff.api.config.model.ForwardedConfig; import de.cuioss.sheriff.api.config.model.GatewayConfig; 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.ResolvedUpstream; import de.cuioss.sheriff.api.config.model.RouteTable; @@ -60,6 +61,7 @@ import de.cuioss.sheriff.api.pipeline.BasicChecksStage; import de.cuioss.sheriff.api.pipeline.CanonicalPathGuard; import de.cuioss.sheriff.api.pipeline.FramingGate; +import de.cuioss.sheriff.api.pipeline.OriginValidationStage; import de.cuioss.sheriff.api.pipeline.PipelineRequest; import de.cuioss.sheriff.api.pipeline.RouteSelectionStage; import de.cuioss.sheriff.api.pipeline.SecurityHeadersStage; @@ -77,9 +79,12 @@ 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.HttpClientOptions; import io.vertx.core.http.HttpClientResponse; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.http.HttpVersion; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import jakarta.enterprise.context.ApplicationScoped; @@ -161,6 +166,9 @@ public class GatewayEdgeRoute { private final AuthenticationStage authenticationStage; private final ForwardPolicyStage forwardPolicyStage; private final ResponseStage responseStage; + private final OriginValidationStage originValidationStage; + private final WebSocketRelayStage webSocketRelayStage; + private final GrpcStatusMapper grpcStatusMapper; private final Semaphore admission; private final AtomicInteger inFlight = new AtomicInteger(); @@ -213,7 +221,7 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, RouteRuntimeAssembler assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry()); this.routes = assembler.assemble(routeTable, GatewayEdgeRoute::securityConfigurationFor, - _ -> vertx.createHttpClient(), + target -> clientFor(vertx, target), this::guardFor, GatewayEdgeRoute::assetSourceFor); LOGGER.info(ApiSheriffLogMessages.INFO.ROUTE_TABLE_COMPILED, routes.size()); @@ -228,6 +236,9 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, this.authenticationStage = new AuthenticationStage(tokenValidator); this.forwardPolicyStage = new ForwardPolicyStage(resolver, peerGate, emitMode); this.responseStage = new ResponseStage(); + this.originValidationStage = new OriginValidationStage(); + this.webSocketRelayStage = new WebSocketRelayStage(upstreamFailureMapper, gatewayEventCounter); + this.grpcStatusMapper = new GrpcStatusMapper(); // Bind the boot-shared cui-http counter to Micrometer so the per-UrlSecurityFailureType // security-filter counts surface as sheriff_security_events_total, completing the fixed @@ -379,7 +390,16 @@ private void process(RoutingContext ctx) { authenticationStage.process(request); ForwardPolicyStage.Result forward = forwardPolicyStage.process(request, route.getEffectiveForward(), route.isNotModifiedEnabled()); - dispatchAndRelay(ctx, request, route, forward); + // Protocol-dispatch seam: a WebSocket route validates its handshake Origin and hands the + // upgrade to the opaque relay; a gRPC route dispatches over the forced-h2 GrpcDispatchStage + // and relays response trailers. Every other protocol takes the HTTP dispatch path. + 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); + } } catch (GatewayException rejected) { // Upstream failures are already metered inside UpstreamFailureMapper; meter the rest here. if (rejected.getEventType().category() != EventCategory.UPSTREAM) { @@ -391,7 +411,7 @@ private void process(RoutingContext ctx) { LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage()); } recordError(ctx, rejected.getEventType()); - renderProblem(ctx, request, rejected.getEventType()); + renderRejection(ctx, request, rejected.getEventType()); } catch (RuntimeException unexpected) { LOGGER.debug(unexpected, "Unexpected edge failure: %s", unexpected.getMessage()); renderProblem(ctx, request, null); @@ -432,6 +452,59 @@ private void dispatchAndRelay(RoutingContext ctx, PipelineRequest request, Route .onFailure(failure -> failRelay(ctx, failure))); } + /** + * Dispatches a {@code protocol: websocket} route: validates the handshake {@code Origin} against + * the route's effective allowlist (a foreign / absent origin throws a + * {@link EventType#WEBSOCKET_ORIGIN_REJECTED} {@link GatewayException} rendered as {@code 403} + * before any upstream contact — GW-09 / CSWSH), then hands the upgrade to the opaque + * {@link WebSocketRelayStage}. The upstream dial and the client upgrade are performed by the + * relay stage, so no HTTP {@link ResponseStage} relay runs for a WebSocket route. + */ + private void dispatchWebSocket(RoutingContext ctx, PipelineRequest request, RouteRuntime route, + ForwardPolicyStage.Result forward) { + originValidationStage.validate(request, route.getId(), route.getEffectiveAllowedOrigins()); + String prefix = stripTrailingSlash(route.getMatcher().pathPrefix()); + String canonical = requireCanonicalPath(request); + String remainder = canonical.length() >= prefix.length() ? canonical.substring(prefix.length()) : ""; + String query = renderQuery(forward.query()); + ResolvedUpstream upstreamTarget = route.getUpstream() + .orElseThrow(() -> new IllegalStateException("WebSocket dispatch requires a resolved upstream")); + String uri = DispatchStage.upstreamRequestUri(upstreamTarget, remainder, query); + webSocketRelayStage.relay(ctx, route, forward.headers(), request.responseHeaders(), uri); + } + + /** + * Dispatches a {@code protocol: grpc} route: streams the request opaquely to the forced-h2 + * upstream via {@link GrpcDispatchStage}, then relays the upstream response — including its + * {@code grpc-status} / {@code grpc-message} trailers — with {@link ResponseStage#relayWithTrailers}. + * A dispatch failure (including an h2-negotiation failure) surfaces as a {@link GatewayException} + * that {@link #renderRejection} renders as a trailers-only gRPC status rather than problem+json. + */ + private void dispatchGrpc(RoutingContext ctx, PipelineRequest request, RouteRuntime route, + ForwardPolicyStage.Result forward) { + String prefix = stripTrailingSlash(route.getMatcher().pathPrefix()); + String canonical = requireCanonicalPath(request); + String remainder = canonical.length() >= prefix.length() ? canonical.substring(prefix.length()) : ""; + String query = renderQuery(forward.query()); + ResolvedUpstream upstreamTarget = route.getUpstream() + .orElseThrow(() -> new IllegalStateException("gRPC dispatch requires a resolved upstream")); + String uri = DispatchStage.upstreamRequestUri(upstreamTarget, remainder, query); + long cap = route.getSecurityConfiguration().map(SecurityConfiguration::maxBodySize).orElse(defaultMaxBodySize); + + GrpcDispatchStage grpcDispatchStage = new GrpcDispatchStage(cap, upstreamFailureMapper); + long upstreamStartNanos = System.nanoTime(); + HttpClientResponse upstream = grpcDispatchStage.dispatch(route, request.method(), uri, forward.headers(), + ctx.request()); + sheriffMetrics.recordUpstreamDuration(route.getId(), Duration.ofNanos(System.nanoTime() - upstreamStartNanos)); + gatewayEventCounter.increment(EventType.REQUEST_FORWARDED); + // The trailer relay mutates the event-loop-bound response; hop back onto the event loop, exactly + // like the HTTP relay path. + ctx.vertx().runOnContext(v -> + responseStage.relayWithTrailers(upstream, ctx.response(), route.isNotModifiedEnabled(), + request.responseHeaders()) + .onFailure(failure -> failRelay(ctx, failure))); + } + /** * Serves an asset route's terminal action: resolves the confined asset through the route's * {@link AssetSource} (behind the shared {@code PathConfinement} and gateway-owned @@ -495,6 +568,22 @@ private void writeShortCircuit(RoutingContext ctx, PipelineRequest request) { }); } + /** + * Renders a post-route-resolution rejection: a {@code protocol: grpc} route emits a trailers-only + * gRPC status via {@link GrpcStatusMapper} (a gRPC client cannot consume problem+json), every + * other route renders the RFC 9457 problem+json response. A rejection that fires before route + * selection (no selected route) always renders problem+json. + */ + private void renderRejection(RoutingContext ctx, @Nullable PipelineRequest request, EventType eventType) { + RouteRuntime selected = request != null ? request.selectedRoute() : null; + if (selected != null && selected.getProtocol() == Protocol.GRPC) { + Map 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> 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()); + } + } +}