diff --git a/api-sheriff/pom.xml b/api-sheriff/pom.xml index 12050ca..07b5f8f 100644 --- a/api-sheriff/pom.xml +++ b/api-sheriff/pom.xml @@ -29,6 +29,13 @@ de.cuioss cui-java-tools + + + de.cuioss + cui-http + @@ -49,6 +56,12 @@ io.quarkus quarkus-arc + io.quarkus quarkus-resteasy @@ -69,6 +82,12 @@ io.quarkus quarkus-micrometer-registry-prometheus + + + io.quarkus + quarkus-smallrye-fault-tolerance + @@ -126,6 +145,71 @@ cui-test-juli-logger test + + + de.cuioss + cui-http + ${version.cui.http} + generators + test + + + + de.cuioss.sheriff.token + token-sheriff-validation + ${version.token-sheriff} + generators + test + + + + io.jsonwebtoken + jjwt-api + test + + + io.jsonwebtoken + jjwt-impl + test + + + io.jsonwebtoken + jjwt-jackson + test + + + + io.smallrye + smallrye-fault-tolerance-standalone + test + + + + com.tngtech.archunit + archunit + 1.4.2 + test + diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriff.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriff.java deleted file mode 100644 index 239f96c..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriff.java +++ /dev/null @@ -1,47 +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; - -import de.cuioss.tools.logging.CuiLogger; -import io.quarkus.runtime.annotations.RegisterForReflection; - -/** - * Main API Sheriff class. - * Placeholder for the main functionality of the API Sheriff library. - * - * @author API Sheriff Team - */ -@RegisterForReflection -public class ApiSheriff { - - private static final CuiLogger LOGGER = new CuiLogger(ApiSheriff.class); - - /** - * Creates a new ApiSheriff instance. - */ - public ApiSheriff() { - LOGGER.debug("ApiSheriff initialized"); - } - - /** - * Placeholder method for main functionality. - * - * @return a status message - */ - public String getStatus() { - return "API Sheriff is operational"; - } -} 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 new file mode 100644 index 0000000..4d6132e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/ApiSheriffLogMessages.java @@ -0,0 +1,89 @@ +/* + * 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; + +import de.cuioss.tools.logging.LogRecord; +import de.cuioss.tools.logging.LogRecordModel; + +import lombok.experimental.UtilityClass; + +/** + * DSL-style {@link LogRecord} catalogue for the API Sheriff gateway request-pipeline edge. + *

+ * Structured {@code INFO} (1-99) and {@code WARN} (100-199) messages carry the + * {@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} + * 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 + * directly and are not catalogued here. + * + * @author API Sheriff Team + * @since 1.0 + */ +@UtilityClass +public final class ApiSheriffLogMessages { + + private static final String PREFIX = "ApiSheriff"; + + /** + * Info-level messages (INFO range 1-99). + */ + @UtilityClass + public static final class INFO { + + /** The route table was compiled into the immutable per-route runtime at boot. */ + public static final LogRecord ROUTE_TABLE_COMPILED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(1) + .template("Route table compiled: %s route runtime(s) assembled") + .build(); + } + + /** + * Warn-level messages (WARN range 100-199). + */ + @UtilityClass + public static final class WARN { + + /** + * A cui-http security filter rejected a request. Records the route id and the + * failure type only — the raw offending payload is never logged. + */ + public static final LogRecord SECURITY_FILTER_VIOLATION = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(100) + .template("Security filter rejected a request on route '%s': failure type %s") + .build(); + + /** The circuit breaker opened for an upstream after consecutive failures. */ + public static final LogRecord CIRCUIT_BREAKER_OPEN = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(103) + .template("Circuit breaker opened for upstream '%s'") + .build(); + + /** The circuit breaker closed again for an upstream after recovery. */ + public static final LogRecord CIRCUIT_BREAKER_CLOSED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(104) + .template("Circuit breaker closed for upstream '%s'") + .build(); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/AuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/AuthenticationStage.java new file mode 100644 index 0000000..ec60aff --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/AuthenticationStage.java @@ -0,0 +1,150 @@ +/* + * 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.auth; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.pipeline.PipelineRequest; +import de.cuioss.sheriff.api.routing.RouteRuntime; +import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.sheriff.token.validation.domain.context.AccessTokenRequest; +import de.cuioss.sheriff.token.validation.domain.token.AccessTokenContent; +import de.cuioss.sheriff.token.validation.exception.TokenValidationException; + +import jakarta.inject.Provider; + +/** + * Stage 4 — offline bearer-token validation, run after the per-route thorough checks. + *

+ * The stage enforces the selected route's effective auth posture: + *

+ * The upstream is never contacted on any authentication or authorization rejection. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class AuthenticationStage { + + private static final String REQUIRE_NONE = "none"; + private static final String REQUIRE_BEARER = "bearer"; + private static final String BEARER_PREFIX = "Bearer "; + + private final Provider tokenValidator; + + /** + * @param tokenValidator a lazy provider of the single shared validator built from the + * {@code token_validation} config. The validator is resolved via + * {@link Provider#get()} only at the first actual bearer validation, so a + * gateway serving only {@code require: none} routes (with no + * {@code token_validation} block) never triggers the validator producer and + * therefore never fails boot for a missing validation config. + */ + public AuthenticationStage(Provider tokenValidator) { + this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator"); + } + + /** + * Enforces the selected route's auth posture. + * + * @param request the in-flight request context; its route must be selected (stage 2) + * @throws GatewayException on a missing / invalid token (401) or a missing scope (403) + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + RouteRuntime route = requireSelectedRoute(request); + AuthConfig auth = route.getEffectiveAuth(); + String require = auth.require(); + if (REQUIRE_NONE.equals(require)) { + return; + } + if (REQUIRE_BEARER.equals(require)) { + validateBearer(request, auth, route); + return; + } + throw new IllegalStateException( + "Route " + route.getId() + " reached authentication with unsupported require '" + require + "'"); + } + + private void validateBearer(PipelineRequest request, AuthConfig auth, RouteRuntime route) { + String token = extractBearerToken(request) + .orElseThrow(() -> unauthorized(request, EventType.TOKEN_MISSING, "No bearer token presented")); + + AccessTokenContent content; + try { + content = tokenValidator.get().createAccessToken(new AccessTokenRequest( + token, request.headers(), requireCanonicalPath(request), request.method().name())); + } catch (TokenValidationException validationFailure) { + throw unauthorizedFromValidation(request, validationFailure); + } + + List requiredScopes = auth.requiredScopes(); + if (!requiredScopes.isEmpty() && !content.providesScopes(requiredScopes)) { + throw new GatewayException(EventType.SCOPE_MISSING, + "Token missing a required scope for route " + route.getId()); + } + } + + private static Optional extractBearerToken(PipelineRequest request) { + return request.firstHeader("Authorization") + .filter(value -> value.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) + .map(value -> value.substring(BEARER_PREFIX.length()).strip()) + .filter(token -> !token.isEmpty()); + } + + private static GatewayException unauthorized(PipelineRequest request, EventType eventType, String detail) { + request.responseHeaders().put("WWW-Authenticate", "Bearer"); + return new GatewayException(eventType, detail); + } + + private static GatewayException unauthorizedFromValidation(PipelineRequest request, + TokenValidationException validationFailure) { + request.responseHeaders().put("WWW-Authenticate", "Bearer"); + return new GatewayException(EventType.TOKEN_INVALID, "Bearer token rejected by validation", validationFailure); + } + + private static RouteRuntime requireSelectedRoute(PipelineRequest request) { + RouteRuntime route = request.selectedRoute(); + if (route == null) { + throw new IllegalStateException("Authentication requires the route selected at stage 2"); + } + return route; + } + + private static String requireCanonicalPath(PipelineRequest request) { + String canonicalPath = request.canonicalPath(); + if (canonicalPath == null) { + throw new IllegalStateException("Authentication requires the canonical path resolved at stage 1"); + } + return canonicalPath; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/GatewayValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/GatewayValidator.java new file mode 100644 index 0000000..746787b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/GatewayValidator.java @@ -0,0 +1,49 @@ +/* + * 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.auth; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.inject.Qualifier; + +/** + * CDI qualifier distinguishing the gateway's own {@code TokenValidator} — the single shared + * validator {@link TokenValidatorProducer} builds from the {@code token_validation} block of + * {@code gateway.yaml} — from the unqualified validator the {@code token-sheriff-validation-quarkus} + * extension produces from its {@code cui.jwt.*} property surface. + *

+ * The gateway configures issuers through its own YAML model, not the extension's MicroProfile + * properties, so the two producers must coexist without an ambiguous-dependency clash. The gateway + * request pipeline injects {@code @GatewayValidator TokenValidator}; the extension's own beans keep + * their unqualified default. + * + * @author API Sheriff Team + * @since 1.0 + */ +@Qualifier +@Documented +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface GatewayValidator { +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/TokenValidatorProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/TokenValidatorProducer.java new file mode 100644 index 0000000..ae17863 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/TokenValidatorProducer.java @@ -0,0 +1,121 @@ +/* + * 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.auth; + +import java.util.ArrayList; +import java.util.List; + + +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.IssuerConfig; +import de.cuioss.sheriff.api.config.model.TokenValidationConfig; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.token.commons.transport.HttpJwksLoaderConfig; +import de.cuioss.sheriff.token.validation.TokenValidator; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; + +/** + * CDI producer of the gateway's single shared {@link TokenValidator}, built once from the + * {@code token_validation} block of {@code gateway.yaml}. + *

+ * The produced validator carries the {@link GatewayValidator} qualifier so it coexists with the + * unqualified validator the {@code token-sheriff-validation-quarkus} extension produces from its + * {@code cui.jwt.*} property surface — the gateway drives issuers from its own YAML model, not the + * extension's properties. Each gateway {@link IssuerConfig} maps to a token-sheriff + * {@link de.cuioss.sheriff.token.validation.IssuerConfig}: an {@code http} JWKS source becomes an + * {@link HttpJwksLoaderConfig}, a {@code file} source becomes a JWKS file path. Validation is fully + * offline once the key material has loaded. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class TokenValidatorProducer { + + private static final String SOURCE_HTTP = "http"; + private static final String SOURCE_FILE = "file"; + + private final GatewayConfig gatewayConfig; + + /** + * @param gatewayConfig the bound gateway document carrying the {@code token_validation} block + */ + @Inject + public TokenValidatorProducer(GatewayConfig gatewayConfig) { + this.gatewayConfig = gatewayConfig; + } + + /** + * Builds the single shared gateway validator from configured issuers. + * + * @return the gateway {@link TokenValidator} + * @throws GatewayException with {@link EventType#CONFIG_INVALID} when {@code token_validation} is + * absent or an issuer declares no usable JWKS source + */ + @Produces + @ApplicationScoped + @GatewayValidator + public TokenValidator gatewayTokenValidator() { + TokenValidationConfig config = gatewayConfig.tokenValidation() + .orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID, + "token_validation is required to build the bearer-token validator")); + List issuers = new ArrayList<>(); + for (IssuerConfig issuer : config.issuers()) { + issuers.add(toValidationIssuer(issuer)); + } + return TokenValidator.builder().issuerConfigs(issuers).build(); + } + + private static de.cuioss.sheriff.token.validation.IssuerConfig toValidationIssuer(IssuerConfig issuer) { + de.cuioss.sheriff.token.validation.IssuerConfig.IssuerConfigBuilder builder = + de.cuioss.sheriff.token.validation.IssuerConfig.builder().issuerIdentifier(issuer.issuer()); + // Audience is optional in the gateway config model (IssuerConfig#audience). token-sheriff + // requires an explicit choice at build time — either a non-empty expected audience OR an + // explicit opt-out — so an issuer that configures no audience must disable audience + // validation; otherwise IssuerConfig.build() throws and the (lazily created) validator bean + // fails on the first bearer request instead of validating the token. + issuer.audience().ifPresentOrElse( + builder::expectedAudience, + () -> builder.audienceValidationDisabled(true)); + IssuerConfig.Jwks jwks = issuer.jwks().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID, + "Issuer '" + issuer.name() + "' declares no jwks source")); + applyJwks(builder, issuer, jwks); + return builder.build(); + } + + private static void applyJwks(de.cuioss.sheriff.token.validation.IssuerConfig.IssuerConfigBuilder builder, + IssuerConfig issuer, IssuerConfig.Jwks jwks) { + if (SOURCE_HTTP.equals(jwks.source())) { + String url = jwks.url().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID, + "Issuer '" + issuer.name() + "' jwks source 'http' declares no url")); + builder.httpJwksLoaderConfig(HttpJwksLoaderConfig.builder() + .issuerIdentifier(issuer.issuer()) + .jwksUrl(url) + .build()); + } else if (SOURCE_FILE.equals(jwks.source())) { + String file = jwks.file().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID, + "Issuer '" + issuer.name() + "' jwks source 'file' declares no file path")); + builder.jwksFilePath(file); + } else { + throw new GatewayException(EventType.CONFIG_INVALID, + "Issuer '" + issuer.name() + "' declares unsupported jwks source '" + jwks.source() + "'"); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/package-info.java new file mode 100644 index 0000000..5a2c32d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/package-info.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +/** + * Stage 4 — offline bearer-token validation and its CDI wiring. + *

+ * {@link de.cuioss.sheriff.api.auth.AuthenticationStage} enforces each route's effective auth + * posture on the agnostic {@code PipelineRequest}, validating {@code Bearer} tokens offline through + * the shared {@link de.cuioss.sheriff.token.validation.TokenValidator}. + * {@link de.cuioss.sheriff.api.auth.TokenValidatorProducer} produces that validator from the + * gateway's {@code token_validation} config, tagged with the + * {@link de.cuioss.sheriff.api.auth.GatewayValidator} qualifier so it coexists with the validator + * shipped by {@code token-sheriff-validation-quarkus}. + *

+ * Framework edge. This package holds CDI wiring ({@code jakarta.enterprise} / + * {@code jakarta.inject}) and is therefore part of the framework edge, deliberately outside the + * ADR-0005 framework-agnostic arch-gate rule set (which covers {@code config.model}, + * {@code config.validation}, {@code events}, {@code forward}, and {@code pipeline} only). + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.auth; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java index 7ebf968..45df7b2 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java @@ -17,6 +17,7 @@ import de.cuioss.tools.logging.LogRecord; import de.cuioss.tools.logging.LogRecordModel; + import lombok.experimental.UtilityClass; /** 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 6fea4c9..17a5cc0 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 @@ -23,9 +23,11 @@ import java.util.Objects; import java.util.Optional; + import de.cuioss.sheriff.api.config.model.AnchorConfig; import de.cuioss.sheriff.api.config.model.AuthConfig; import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.ForwardConfig; import de.cuioss.sheriff.api.config.model.GatewayConfig; import de.cuioss.sheriff.api.config.model.HttpMethod; import de.cuioss.sheriff.api.config.model.Protocol; @@ -48,8 +50,9 @@ * endpoints contribute no rows — orders them by descending normalized * {@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}, and effective retry - * / not-modified toggles into a {@link ResolvedRoute}. The inheritance chains + * 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 * (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 @@ -150,6 +153,7 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou .flatMap(UpstreamConfig::notModified) .flatMap(UpstreamConfig.NotModified::enabled) .orElse(defaults.notModifiedEnabled()); + ForwardConfig effectiveForward = route.forward().orElseGet(() -> ForwardConfig.builder().build()); ResolvedRoute resolved = ResolvedRoute.builder() .id(route.id()) .protocol(route.protocol().orElse(Protocol.HTTP)) @@ -162,6 +166,7 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou .retryEnabled(retryEnabled) .notModifiedEnabled(notModifiedEnabled) .upstream(upstream) + .effectiveForward(effectiveForward) .build(); logPosture(resolved); return resolved; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java index 2c17e54..bd0213a 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java @@ -31,6 +31,11 @@ import java.util.regex.Pattern; import java.util.stream.Stream; + +import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; + import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.databind.DeserializationContext; @@ -50,14 +55,10 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; - import com.networknt.schema.JsonSchema; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; import com.networknt.schema.ValidationMessage; -import de.cuioss.sheriff.api.config.model.EndpointConfig; -import de.cuioss.sheriff.api.config.model.GatewayConfig; -import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; import org.jspecify.annotations.Nullable; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; 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 d303abb..1309d74 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 @@ -53,6 +53,9 @@ * @param notModifiedEnabled the materialized HTTP-304 not-modified toggle * @param upstream the resolved upstream target for the route's * endpoint (mandatory) + * @param effectiveForward the materialized, deny-by-default {@code forward} + * allowlist consumed by stage 5; an empty + * {@link ForwardConfig} when the route declares none * @author API Sheriff Team * @since 1.0 */ @@ -60,12 +63,13 @@ public record ResolvedRoute(String id, Protocol protocol, Optional anchor, MatchConfig match, AuthConfig effectiveAuth, List effectiveAllowedMethods, Optional effectiveSecurityFilter, Optional effectiveSecurityHeaders, -boolean retryEnabled, boolean notModifiedEnabled, ResolvedUpstream upstream) { +boolean retryEnabled, boolean notModifiedEnabled, ResolvedUpstream upstream, ForwardConfig effectiveForward) { /** * Canonical constructor requiring the mandatory components, defensively copying - * {@code effectiveAllowedMethods}, normalizing absent optionals, and defaulting - * an absent {@code protocol} to {@link Protocol#HTTP}. + * {@code effectiveAllowedMethods}, normalizing absent optionals, defaulting an + * absent {@code protocol} to {@link Protocol#HTTP}, and defaulting an absent + * {@code effectiveForward} to a deny-by-default empty {@link ForwardConfig}. */ public ResolvedRoute { Objects.requireNonNull(id, "id"); @@ -77,6 +81,7 @@ public record ResolvedRoute(String id, Protocol protocol, Optional ancho effectiveSecurityFilter = Objects.requireNonNullElse(effectiveSecurityFilter, Optional.empty()); effectiveSecurityHeaders = Objects.requireNonNullElse(effectiveSecurityHeaders, Optional.empty()); Objects.requireNonNull(upstream, "upstream"); + effectiveForward = effectiveForward == null ? ForwardConfig.builder().build() : effectiveForward; } /** diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java index 6f0e0bd..d2f9ae7 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java @@ -31,10 +31,12 @@ import java.util.Properties; import java.util.regex.Pattern; + import de.cuioss.sheriff.api.config.load.EnvSecretResolver; import de.cuioss.sheriff.api.config.model.EndpointConfig; import de.cuioss.sheriff.api.config.model.ResolvedTopology; import de.cuioss.sheriff.api.config.model.ResolvedUpstream; + import org.jspecify.annotations.Nullable; /** 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 fe0e7cc..019bb7e 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 @@ -28,6 +28,7 @@ import java.util.Optional; import java.util.Set; + import de.cuioss.sheriff.api.config.ConfigLogMessages; import de.cuioss.sheriff.api.config.RouteTableBuilder; import de.cuioss.sheriff.api.config.load.ConfigError; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java index f170baa..1fcde6d 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java @@ -17,6 +17,7 @@ import java.util.List; + import de.cuioss.sheriff.api.config.load.ConfigError; import de.cuioss.sheriff.api.config.model.EndpointConfig; import de.cuioss.sheriff.api.config.model.GatewayConfig; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/DispatchStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/DispatchStage.java new file mode 100644 index 0000000..4b3e5bc --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/DispatchStage.java @@ -0,0 +1,339 @@ +/* + * 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 java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.function.LongConsumer; +import java.util.function.LongSupplier; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.streams.ReadStream; +import org.jspecify.annotations.Nullable; + +/** + * Stage 6 — upstream dispatch over the route's shared Vert.x {@code HttpClient}, invoked through + * that route's SmallRye Fault-Tolerance guard. + *

+ * The request body is streamed, never buffered: it flows through a + * {@link ByteCappedBodyStream} that forwards each chunk to the upstream request as it arrives and + * enforces the {@code max_body_bytes} ceiling with a running counter. A mid-stream breach ABORTS + * the in-flight upstream call (Vert.x {@link HttpClientRequest#reset()}) and surfaces + * {@link EventType#PARAMETER_LIMIT_EXCEEDED} (400). The upstream body is never + * materialized into an {@code HttpResult} (ADR-0006/0008): the returned + * {@link HttpClientResponse} is a live {@link ReadStream} whose body {@link ResponseStage} streams + * back with backpressure. + *

+ * The dispatch is awaited synchronously on the caller's virtual thread inside the guard so the + * breaker observes each call's success / failure / timeout. Guard failures are mapped to the error + * contract by {@link UpstreamFailureMapper}; a body-cap breach propagates its own + * {@link GatewayException} unchanged. + *

+ * Stream-aware retry gating. When the route enables SmallRye retry, the guarded + * lambda may be re-invoked after a failure. A streamed request cannot be safely replayed once any + * body byte has crossed to the upstream, and a non-idempotent verb must never be re-sent — so every + * retry re-entry (never the first attempt) is vetted by a {@link StreamAwareRetryGate} + * against the request method and the running body-bytes-sent count. A disallowed re-entry is aborted + * by re-raising the first attempt's failure as a mapped {@link GatewayException}, which the guard's + * {@code abortOn(GatewayException.class)} contract turns into an immediate abort — no duplicate + * upstream request is ever issued. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class DispatchStage { + + /** Bounds the cause-chain walk so a self-referential cause cycle cannot spin forever. */ + private static final int MAX_CAUSE_DEPTH = 16; + + private final long maxBodyBytes; + private final UpstreamFailureMapper failureMapper; + + /** + * @param maxBodyBytes the streaming request-body ceiling in bytes ({@code max_body_bytes}) + * @param failureMapper the mapper turning guarded-dispatch failures into the error contract + */ + public DispatchStage(long maxBodyBytes, UpstreamFailureMapper failureMapper) { + this.maxBodyBytes = maxBodyBytes; + this.failureMapper = Objects.requireNonNull(failureMapper, "failureMapper"); + } + + /** + * Builds the upstream request URI (path + query) — the resolved upstream base path, the request + * path remainder, and the allow-listed raw query appended verbatim. + * + * @param upstream the resolved upstream target + * @param pathRemainder the request path remainder after the route prefix is stripped + * @param rawQuery the raw query string including its leading {@code ?}, or empty when none + * @return the upstream request URI + */ + public static String upstreamRequestUri(ResolvedUpstream upstream, String pathRemainder, String rawQuery) { + Objects.requireNonNull(upstream, "upstream"); + Objects.requireNonNull(pathRemainder, "pathRemainder"); + String path = stripTrailingSlash(upstream.basePath()) + pathRemainder; + return rawQuery == null || rawQuery.isEmpty() ? path : path + rawQuery; + } + + /** + * Dispatches the request to the route's upstream, streaming the (byte-capped) request body and + * returning the response whose body is not yet consumed. Every retry re-entry is vetted by a + * {@link StreamAwareRetryGate} so a non-idempotent method or a request that has already streamed + * a body byte is never re-sent (see the class javadoc). + * + * @param method the request method — used both to build the upstream request and to + * gate retry re-entries for idempotency + * @param route the resolved route runtime holding the shared client and guard + * @param requestUri the upstream request URI (see {@link #upstreamRequestUri}) + * @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 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) { + Objects.requireNonNull(route, "route"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(requestUri, "requestUri"); + Objects.requireNonNull(forwardHeaders, "forwardHeaders"); + Objects.requireNonNull(requestBody, "requestBody"); + AtomicLong bytesSent = new AtomicLong(); + AtomicBoolean bodyStreamSubscribed = new AtomicBoolean(); + StreamAwareRetryGate retryGate = new StreamAwareRetryGate(route.isRetryEnabled()); + io.vertx.core.http.HttpMethod upstreamMethod = io.vertx.core.http.HttpMethod.valueOf(method.name()); + return guardedDispatch(route.getResilienceGuard(), retryGate, method, bytesSent::get, + bodyStreamSubscribed::get, + () -> awaitDispatch(route, upstreamMethod, requestUri, forwardHeaders, requestBody, bytesSent, + bodyStreamSubscribed)); + } + + /** + * Runs {@code attempt} through the route's resilience {@code guard}, vetting every retry + * re-entry: the first attempt always proceeds, but each subsequent re-entry is aborted — by + * re-raising the prior failure as a mapped {@link GatewayException} (which the guard's + * {@code abortOn(GatewayException.class)} contract honours) — whenever either the + * {@code retryGate} refuses a retry for {@code method} at the current {@code bytesSent} count, + * or {@code bodyStreamConsumed} reports that the one-shot request-body stream was + * already subscribed on a prior attempt. The inbound body {@link ReadStream} is single-use: + * re-attaching an already-subscribed stream to a fresh upstream request would silently stall + * waiting for events that already fired on the first attempt, so such a re-entry must fail + * explicitly rather than hang — even in the {@code bytesSent == 0} case, since the stream is + * subscribed the instant the first attempt reaches {@code request.send(...)}, before any byte + * crosses. Package-private so the retry-gating decision can be exercised without a live upstream. + */ + HttpClientResponse guardedDispatch(Guard guard, StreamAwareRetryGate retryGate, HttpMethod method, + LongSupplier bytesSent, BooleanSupplier bodyStreamConsumed, Callable attempt) { + AtomicInteger attemptIndex = new AtomicInteger(); + AtomicReference priorFailure = new AtomicReference<>(); + try { + return guard.call(() -> { + if (attemptIndex.getAndIncrement() > 0 + && (bodyStreamConsumed.getAsBoolean() + || !retryGate.allowsRetry(method, bytesSent.getAsLong()))) { + throw failureMapper.toGatewayException(priorFailure.get()); + } + try { + return attempt.call(); + } catch (ExecutionException executionFailure) { + // awaitDispatch blocks on CompletableFuture#get, which wraps the real cause (e.g. a + // client-side body-cap GatewayException from ByteCappedBodyStream) in an + // ExecutionException. Unwrap it so the guard's skipOn(GatewayException.class) and + // abortOn(GatewayException.class) rules see the actual GatewayException rather than + // the wrapper — otherwise a client-caused rejection is miscounted as an upstream + // failure and can trip the circuit breaker for reasons unrelated to upstream health. + Throwable cause = executionFailure.getCause(); + Exception unwrapped = cause instanceof Exception exception ? exception : executionFailure; + priorFailure.set(unwrapped); + throw unwrapped; + } + }, HttpClientResponse.class); + } catch (GatewayException direct) { + throw direct; + } /*~~(TODO: Catch specific not Exception. Suppress: // cui-rewrite:disable InvalidExceptionUsageRecipe)~~>*/ catch (Exception guarded) { + GatewayException breach = extractGatewayException(guarded); + if (breach != null) { + throw breach; + } + throw failureMapper.toGatewayException(guarded); + } + } + + private HttpClientResponse awaitDispatch(RouteRuntime route, io.vertx.core.http.HttpMethod method, + String requestUri, Map forwardHeaders, ReadStream requestBody, + AtomicLong bytesSent, AtomicBoolean bodyStreamSubscribed) throws Exception { + ResolvedUpstream upstream = route.getUpstream(); + RequestOptions options = new RequestOptions() + .setMethod(method) + .setHost(upstream.host()) + .setPort(upstream.port()) + .setSsl("https".equalsIgnoreCase(upstream.scheme())) + .setURI(requestUri); + Future response = route.getHttpClient().request(options) + .compose(request -> { + forwardHeaders.forEach(request::putHeader); + // The one-shot inbound body stream is subscribed the instant it is handed to + // request.send(...); mark it so a retry re-entry never re-attaches the consumed + // stream (which would stall) — see guardedDispatch's bodyStreamConsumed gate. + bodyStreamSubscribed.set(true); + return request.send(new ByteCappedBodyStream(requestBody, maxBodyBytes, request::reset, + bytesSent::addAndGet)); + }) + .map(received -> { + // Pause the upstream body the instant its head arrives, on the client's own + // event-loop context and before any body chunk is dispatched. ResponseStage#relay + // is deferred onto the server request's event loop (GatewayEdgeRoute), so without + // this pause a small upstream response can fully arrive and end before the deferred + // pipeTo subscribes — Vert.x then fails the pipe with "Response already ended". The + // pipe re-enables the stream when it subscribes. + received.pause(); + return received; + }); + return response.toCompletionStage().toCompletableFuture().get(); + } + + private static @Nullable GatewayException extractGatewayException(Throwable failure) { + Throwable current = failure; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof GatewayException gatewayException) { + return gatewayException; + } + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; + } + return null; + } + + private static String stripTrailingSlash(String value) { + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } + + /** + * A {@link ReadStream} decorator that forwards each request-body chunk to the upstream as it + * arrives — never accumulating the body — while counting bytes against a ceiling. On breach it + * aborts the in-flight upstream request and fails the stream with a + * {@link EventType#PARAMETER_LIMIT_EXCEEDED} {@link GatewayException}. + */ + static final class ByteCappedBodyStream implements ReadStream { + + private final ReadStream delegate; + private final long maxBytes; + private final Runnable abortAction; + private final LongConsumer bytesForwarded; + private long bytesSeen; + private @Nullable Handler dataHandler; + private @Nullable Handler failureHandler; + private boolean aborted; + + ByteCappedBodyStream(ReadStream delegate, long maxBytes, Runnable abortAction) { + this(delegate, maxBytes, abortAction, length -> { + }); + } + + ByteCappedBodyStream(ReadStream delegate, long maxBytes, Runnable abortAction, + LongConsumer bytesForwarded) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.maxBytes = maxBytes; + this.abortAction = Objects.requireNonNull(abortAction, "abortAction"); + this.bytesForwarded = Objects.requireNonNull(bytesForwarded, "bytesForwarded"); + delegate.exceptionHandler(this::propagateFailure); + delegate.handler(this::onChunk); + } + + private void onChunk(Buffer chunk) { + if (aborted) { + return; + } + bytesSeen += chunk.length(); + if (bytesSeen > maxBytes) { + aborted = true; + delegate.pause(); + abortAction.run(); + propagateFailure(new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED, + "Request body exceeded max_body_bytes=" + maxBytes)); + return; + } + // The chunk cleared the cap and is about to cross to the upstream — record it so the + // stream-aware retry gate can see that a body byte has been sent on this attempt. + bytesForwarded.accept(chunk.length()); + if (dataHandler != null) { + dataHandler.handle(chunk); + } + } + + private void propagateFailure(Throwable failure) { + if (failureHandler != null) { + failureHandler.handle(failure); + } + } + + @Override + public ReadStream handler(@Nullable Handler handler) { + this.dataHandler = handler; + return this; + } + + @Override + public ReadStream exceptionHandler(@Nullable Handler handler) { + this.failureHandler = handler; + return this; + } + + @Override + public ReadStream pause() { + delegate.pause(); + return this; + } + + @Override + public ReadStream resume() { + delegate.resume(); + return this; + } + + @Override + public ReadStream fetch(long amount) { + delegate.fetch(amount); + return this; + } + + @Override + public ReadStream endHandler(@Nullable Handler endHandler) { + delegate.endHandler(endHandler); + return this; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/EdgeHardeningOptions.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/EdgeHardeningOptions.java new file mode 100644 index 0000000..f827176 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/EdgeHardeningOptions.java @@ -0,0 +1,99 @@ +/* + * 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.concurrent.TimeUnit; + +import io.quarkus.vertx.http.HttpServerOptionsCustomizer; +import io.vertx.core.http.HttpServerOptions; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Edge hardening for the public data-plane HTTP server. It is BOTH the carrier of the + * gateway's inbound-transport bounds AND the {@link HttpServerOptionsCustomizer} SPI bean that + * applies them to every listener Quarkus opens. + *

+ * The transport bounds fail fast on abusive framing before a request is ever admitted to the + * pipeline: an over-long request line, an over-large header block, or an over-large chunk is + * rejected by the Vert.x codec, and an idle connection is reaped so a slow-loris / h2 abuse load + * cannot pin connection slots. Two further limits are consumed by {@link GatewayEdgeRoute} rather + * than the transport: the {@linkplain #admissionCap() admission cap} bounds the number of requests + * that may be in flight at once (rejected with {@code 503} before a virtual thread is + * dispatched, so a flood cannot spawn unbounded virtual threads), and the + * {@linkplain #drainTimeoutMillis() drain timeout} bounds the graceful-shutdown wait for in-flight + * requests to complete on {@code SIGTERM}. + *

+ * The values are deliberate secure defaults chosen to keep the abuse surface bounded while + * comfortably serving ordinary API traffic; the drain timeout is kept below the Quarkus default + * shutdown timeout so the drain completes within the shutdown window rather than being cut short. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class EdgeHardeningOptions implements HttpServerOptionsCustomizer { + + /** Maximum size of the whole request header block in bytes (default 16 KiB). */ + private static final int MAX_HEADER_SIZE_BYTES = 16 * 1024; + + /** Maximum length of the HTTP/1.x request line ({@code METHOD SP request-target SP HTTP/x}). */ + private static final int MAX_INITIAL_LINE_LENGTH_BYTES = 8 * 1024; + + /** Maximum size of a single chunked-transfer chunk in bytes. */ + private static final int MAX_CHUNK_SIZE_BYTES = 16 * 1024; + + /** Idle-connection reap threshold in seconds — an idle connection is closed after this. */ + private static final int IDLE_TIMEOUT_SECONDS = 60; + + /** Maximum number of requests permitted in flight at once before a virtual thread is dispatched. */ + private static final int ADMISSION_CAP = 2048; + + /** Bounded graceful-drain wait for in-flight requests on shutdown (below the Quarkus default). */ + private static final long DRAIN_TIMEOUT_MILLIS = 25_000L; + + @Override + public void customizeHttpServer(HttpServerOptions options) { + apply(options); + } + + @Override + public void customizeHttpsServer(HttpServerOptions options) { + apply(options); + } + + private static void apply(HttpServerOptions options) { + options.setMaxHeaderSize(MAX_HEADER_SIZE_BYTES) + .setMaxInitialLineLength(MAX_INITIAL_LINE_LENGTH_BYTES) + .setMaxChunkSize(MAX_CHUNK_SIZE_BYTES) + .setIdleTimeout(IDLE_TIMEOUT_SECONDS) + .setIdleTimeoutUnit(TimeUnit.SECONDS); + } + + /** + * @return the maximum number of concurrently in-flight requests the edge admits before a + * virtual thread is dispatched; a request beyond the cap is rejected {@code 503} + */ + public int admissionCap() { + return ADMISSION_CAP; + } + + /** + * @return the bounded graceful-drain wait in milliseconds the edge honours on shutdown + */ + public long drainTimeoutMillis() { + return DRAIN_TIMEOUT_MILLIS; + } +} 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 new file mode 100644 index 0000000..53d6a77 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java @@ -0,0 +1,615 @@ +/* + * 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.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + + +import de.cuioss.http.forwarded.ForwardedHeaderResolver; +import de.cuioss.http.forwarded.ForwardedResolverConfig; +import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.http.security.config.SecurityConfigurationBuilder; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.sheriff.api.ApiSheriffLogMessages; +import de.cuioss.sheriff.api.auth.AuthenticationStage; +import de.cuioss.sheriff.api.auth.GatewayValidator; +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.RouteTable; +import de.cuioss.sheriff.api.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.api.events.EventCategory; +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.forward.ForwardPolicyStage; +import de.cuioss.sheriff.api.forward.TcpPeerGate; +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.PipelineRequest; +import de.cuioss.sheriff.api.pipeline.RouteSelectionStage; +import de.cuioss.sheriff.api.pipeline.SecurityHeadersStage; +import de.cuioss.sheriff.api.pipeline.ThoroughChecksStage; +import de.cuioss.sheriff.api.pipeline.VerbGateStage; +import de.cuioss.sheriff.api.quarkus.SheriffMetrics; +import de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry; +import de.cuioss.sheriff.api.routing.RouteRuntime; +import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.tools.logging.CuiLogger; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.virtual.threads.VirtualThreads; +import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import org.jspecify.annotations.Nullable; + +/** + * The public data-plane edge: a single catch-all Vert.x route that runs every inbound request + * through the fixed pipeline (stages 0-7) on a virtual thread, then relays the streamed upstream + * response back to the client. + *

+ * Boot-time assembly. The constructor compiles the frozen {@link RouteTable} into + * immutable {@link RouteRuntime} instances via the {@link RouteRuntimeAssembler} (deduplicating the + * shared Vert.x clients and SmallRye guards), builds every stage once with the shared + * {@link SecurityEventCounter}, the default {@link SecurityConfiguration}, the boot-wired + * {@link ForwardedHeaderResolver} + {@link TcpPeerGate} (from the global {@code forwarded} block), + * and the shared {@link GatewayEventCounter}. That same shared {@link SecurityEventCounter} is bound + * to {@link SheriffMetrics} here so its per-{@code UrlSecurityFailureType} counts surface as the + * {@code sheriff_security_events_total} meter. An unsupported protocol or {@code session} auth fails + * boot here (fail fast), so no request is ever served on an invalid route set. + *

+ * Per-request flow. The catch-all is registered {@linkplain io.vertx.ext.web.Route#last() + * last} so management / health routes keep working. Each request is admitted under a bounded + * {@linkplain EdgeHardeningOptions#admissionCap() admission cap} before a virtual thread is + * dispatched (a flood is rejected {@code 503} rather than spawning unbounded virtual threads), then + * the request stream is paused and the whole pipeline runs on a virtual thread: + *

    + *
  1. stage 0 — response-header preparation + CORS preflight (short-circuits a preflight here);
  2. + *
  3. stage 1 — baseline security filter (records the single canonical path), the canonical-path + * guard, and the framing gate;
  4. + *
  5. stage 2 / 2b — deny-by-default route selection then the per-route verb gate;
  6. + *
  7. stage 3 — per-route thorough checks ({@code allowed_paths}, body cap, divergent pipeline);
  8. + *
  9. stage 4 — offline bearer-token validation;
  10. + *
  11. stage 5 — the zero-trust forward policy, consuming the route's resolved + * {@link RouteRuntime#getEffectiveForward() effectiveForward} and the global forwarded block;
  12. + *
  13. stage 6 / 7 — streamed upstream dispatch (byte-capped) and the streamed response relay.
  14. + *
+ * A {@link GatewayException} at any stage is rendered as an RFC 9457 {@code application/problem+json} + * response carrying the failing event's status and problem type, never leaking internal detail. On + * {@code SIGTERM} the edge stops admitting new requests and drains in-flight ones within a bounded + * window. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class GatewayEdgeRoute { + + private static final CuiLogger LOGGER = new CuiLogger(GatewayEdgeRoute.class); + + private static final String PROBLEM_JSON = "application/problem+json"; + private static final String DEFAULT_EMIT_MODE = "x-forwarded"; + private static final int SERVICE_UNAVAILABLE = 503; + private static final int INTERNAL_ERROR = 500; + private static final int BAD_GATEWAY = 502; + private static final long DRAIN_POLL_INTERVAL_MILLIS = 50L; + + /** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */ + private static final String ROUTE_KEY = "sheriff.route"; + + private final List routes; + private final ExecutorService virtualThreadExecutor; + private final EdgeHardeningOptions hardening; + + private final long defaultMaxBodySize; + private final GatewayEventCounter gatewayEventCounter; + private final UpstreamFailureMapper upstreamFailureMapper; + private final SheriffMetrics sheriffMetrics; + + private final SecurityHeadersStage securityHeadersStage; + private final BasicChecksStage basicChecksStage; + private final CanonicalPathGuard canonicalPathGuard; + private final FramingGate framingGate; + private final RouteSelectionStage routeSelectionStage; + private final VerbGateStage verbGateStage; + private final ThoroughChecksStage thoroughChecksStage; + private final AuthenticationStage authenticationStage; + private final ForwardPolicyStage forwardPolicyStage; + private final ResponseStage responseStage; + + private final Semaphore admission; + private final AtomicInteger inFlight = new AtomicInteger(); + private volatile boolean draining; + + /** + * Assembles the routes and every stage once, at boot. + * + * @param routeTable the frozen, longest-prefix-ordered route table + * @param gatewayConfig the bound global gateway document (source of the {@code forwarded} + * block and {@code security_headers}) + * @param tokenValidator a lazy CDI handle to the gateway's shared offline bearer-token + * validator; resolved via {@link Instance#get()} only when a + * {@code require: bearer} route actually validates a token, so a + * config with only {@code require: none} routes never touches the + * validator producer (and never fails boot on a missing + * {@code token_validation} block) + * @param vertx the Vert.x instance the per-tuple upstream clients are created on + * @param virtualThreadExecutor the Quarkus-managed virtual-thread executor + * @param hardening the edge transport / admission bounds + * @param sheriffMetrics the Micrometer adapter the request/error/upstream signals are + * recorded through + */ + @Inject + public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, + @GatewayValidator Instance tokenValidator, Vertx vertx, + @VirtualThreads ExecutorService virtualThreadExecutor, EdgeHardeningOptions hardening, + SheriffMetrics sheriffMetrics) { + this.virtualThreadExecutor = virtualThreadExecutor; + this.hardening = hardening; + this.sheriffMetrics = sheriffMetrics; + this.admission = new Semaphore(hardening.admissionCap()); + + SecurityEventCounter securityEventCounter = new SecurityEventCounter(); + SecurityConfiguration defaultConfiguration = SecurityConfiguration.defaults(); + this.defaultMaxBodySize = defaultConfiguration.maxBodySize(); + this.gatewayEventCounter = new GatewayEventCounter(); + this.upstreamFailureMapper = new UpstreamFailureMapper(gatewayEventCounter); + + List trustedProxies = gatewayConfig.forwarded() + .map(ForwardedConfig::trustedProxies).orElse(List.of()); + String emitMode = gatewayConfig.forwarded().flatMap(ForwardedConfig::emit).orElse(DEFAULT_EMIT_MODE); + ForwardedResolverConfig resolverConfig = ForwardedResolverConfig.builder() + .trustedProxies(Set.copyOf(trustedProxies)) + .securityConfig(defaultConfiguration) + .build(); + ForwardedHeaderResolver resolver = new ForwardedHeaderResolver(resolverConfig, securityEventCounter); + TcpPeerGate peerGate = new TcpPeerGate(trustedProxies); + + RouteRuntimeAssembler assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry()); + this.routes = assembler.assemble(routeTable, + GatewayEdgeRoute::securityConfigurationFor, + target -> vertx.createHttpClient(), + this::guardFor); + LOGGER.info(ApiSheriffLogMessages.INFO.ROUTE_TABLE_COMPILED, routes.size()); + + this.securityHeadersStage = new SecurityHeadersStage(gatewayConfig.securityHeaders()); + this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter); + this.canonicalPathGuard = new CanonicalPathGuard(); + this.framingGate = new FramingGate(); + this.routeSelectionStage = new RouteSelectionStage(routes); + this.verbGateStage = new VerbGateStage(); + this.thoroughChecksStage = new ThoroughChecksStage(defaultConfiguration, securityEventCounter); + this.authenticationStage = new AuthenticationStage(tokenValidator); + this.forwardPolicyStage = new ForwardPolicyStage(resolver, peerGate, emitMode); + this.responseStage = new ResponseStage(); + + // 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 + // five-meter contract alongside the request/duration/error/upstream meters recorded above. + sheriffMetrics.bindSecurityEventCounter(securityEventCounter); + } + + /** + * Registers the catch-all data-plane route, last, so management / health routes keep priority. + * + * @param router the Vert.x web router, observed during Quarkus startup + */ + public void registerRoutes(@Observes Router router) { + router.route().last().handler(this::handle); + LOGGER.debug("Registered catch-all gateway edge over %s route(s)", routes.size()); + } + + /** + * Stops admitting new requests on {@code SIGTERM} and drains in-flight ones within a bounded + * window so the shutdown completes cleanly within the Quarkus shutdown timeout. + * + * @param event the Quarkus shutdown event + */ + void onShutdown(@Observes ShutdownEvent event) { + draining = true; + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(hardening.drainTimeoutMillis()); + while (inFlight.get() > 0 && System.nanoTime() < deadline) { + try { + Thread.sleep(DRAIN_POLL_INTERVAL_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + LOGGER.debug("Edge drain finished; in-flight=%s", inFlight.get()); + } + + private void handle(RoutingContext ctx) { + if (draining || !admission.tryAcquire()) { + reject(ctx, SERVICE_UNAVAILABLE); + return; + } + long startNanos = System.nanoTime(); + inFlight.incrementAndGet(); + // Guard the admission accounting so it is rolled back exactly once. The end handler releases + // it on the normal path; the executor-rejection path below also rolls it back, and both must + // never double-release (reject() ends the response, which itself fires this end handler). + AtomicBoolean admissionReleased = new AtomicBoolean(); + ctx.addEndHandler(result -> { + releaseAdmission(admissionReleased); + recordRequestMetrics(ctx, startNanos); + }); + ctx.request().pause(); + try { + virtualThreadExecutor.execute(() -> process(ctx)); + } /*~~(TODO: Catch specific not RuntimeException. Suppress: // cui-rewrite:disable InvalidExceptionUsageRecipe)~~>*/ catch (RuntimeException rejected) { + // The virtual-thread executor refused the dispatch (e.g. RejectedExecutionException during + // a shutdown race), so process(ctx) will never run and the response would otherwise never + // end — leaking the admission permit and the in-flight count. Roll the admission back now + // (idempotently) and fail the request 503 directly. + LOGGER.debug(rejected, "Virtual-thread dispatch rejected: %s", rejected.getMessage()); + releaseAdmission(admissionReleased); + reject(ctx, SERVICE_UNAVAILABLE); + } + } + + /** + * Releases one admission permit and decrements the in-flight counter exactly once, guarded by + * {@code released} so the normal end-handler path and the executor-rejection rollback path can + * both call it without double-counting. + */ + private void releaseAdmission(AtomicBoolean released) { + if (released.compareAndSet(false, true)) { + admission.release(); + inFlight.decrementAndGet(); + } + } + + /** + * Records the terminal {@link SheriffMetrics#REQUESTS_TOTAL request count} and + * {@link SheriffMetrics#REQUEST_DURATION_SECONDS request-duration timer} for one served request + * from the single end-of-response hook, so every terminal path (streamed success, short-circuit, + * and rendered failure) is metered exactly once. The bounded {@code route} label is the id + * stashed at route selection, or {@link SheriffMetrics#NO_ROUTE} for an unmatched or + * short-circuited request. + */ + private void recordRequestMetrics(RoutingContext ctx, long startNanos) { + Duration elapsed = Duration.ofNanos(System.nanoTime() - startNanos); + String route = routeLabel(ctx); + String method = ctx.request().method().name(); + sheriffMetrics.recordRequest(route, method, SheriffMetrics.statusFamily(ctx.response().getStatusCode())); + sheriffMetrics.recordRequestDuration(route, elapsed); + } + + /** + * Counts one categorized failure against {@link SheriffMetrics#ERRORS_TOTAL}, keyed by the + * request's stashed route (or {@link SheriffMetrics#NO_ROUTE}) and the failure + * {@link EventCategory}. An uncategorized failure (e.g. an unexpected internal error) carries no + * category and surfaces only through the {@code 5xx} bucket of {@link SheriffMetrics#REQUESTS_TOTAL}. + */ + private void recordError(RoutingContext ctx, EventType eventType) { + EventCategory category = eventType.category(); + if (category != null) { + sheriffMetrics.recordError(routeLabel(ctx), category); + } + } + + private static String routeLabel(RoutingContext ctx) { + String route = ctx.get(ROUTE_KEY); + return route != null ? route : SheriffMetrics.NO_ROUTE; + } + + private void process(RoutingContext ctx) { + PipelineRequest request = null; + try { + HttpServerRequest raw = ctx.request(); + Optional parsedMethod = parseMethod(raw.method().name()); + if (parsedMethod.isEmpty()) { + renderProblem(ctx, null, EventType.METHOD_NOT_ALLOWED); + return; + } + HttpMethod method = parsedMethod.get(); + request = buildPipelineRequest(raw, method); + + securityHeadersStage.process(request); + basicChecksStage.process(request); + canonicalPathGuard.process(request); + framingGate.process(request); + if (request.shortCircuitStatus().isPresent()) { + writeShortCircuit(ctx, request); + return; + } + routeSelectionStage.process(request); + verbGateStage.process(request); + RouteRuntime route = requireSelectedRoute(request); + ctx.put(ROUTE_KEY, route.getId()); + thoroughChecksStage.process(request, route.getEffectiveAllowedPaths()); + authenticationStage.process(request); + ForwardPolicyStage.Result forward = forwardPolicyStage.process(request, + route.getEffectiveForward(), route.isNotModifiedEnabled()); + 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) { + gatewayEventCounter.increment(rejected.getEventType()); + } + if (rejected.getEventType() == EventType.SECURITY_FILTER_VIOLATION) { + // Security-relevant WARN (D4): the failure-type detail only, never the raw payload — + // rejected.getMessage() already carries a sanitized description (see GatewayException). + LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage()); + } + recordError(ctx, rejected.getEventType()); + renderProblem(ctx, request, rejected.getEventType()); + } /*~~(TODO: Catch specific not RuntimeException. Suppress: // cui-rewrite:disable InvalidExceptionUsageRecipe)~~>*/ catch (RuntimeException unexpected) { + LOGGER.debug(unexpected, "Unexpected edge failure: %s", unexpected.getMessage()); + renderProblem(ctx, request, null); + } + } + + private void dispatchAndRelay(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()); + String uri = DispatchStage.upstreamRequestUri(route.getUpstream(), remainder, query); + long cap = route.getSecurityConfiguration().map(SecurityConfiguration::maxBodySize).orElse(defaultMaxBodySize); + + DispatchStage dispatchStage = new DispatchStage(cap, upstreamFailureMapper); + long upstreamStartNanos = System.nanoTime(); + HttpClientResponse upstream = dispatchStage.dispatch(route, request.method(), uri, forward.headers(), + ctx.request()); + sheriffMetrics.recordUpstreamDuration(route.getId(), Duration.ofNanos(System.nanoTime() - upstreamStartNanos)); + gatewayEventCounter.increment(EventType.REQUEST_FORWARDED); + // The relay mutates the Vert.x HttpServerResponse (status, headers) and subscribes the + // upstream pipeTo — all of which are event-loop-bound and NOT safe to touch from this + // virtual thread. Hop back onto the event loop exactly like every other terminal path + // (renderProblem / writeShortCircuit / failRelay); doing the relay off-loop races the + // response object and corrupts / truncates the streamed body. + ctx.vertx().runOnContext(v -> + responseStage.relay(upstream, ctx.response(), route.isNotModifiedEnabled(), request.responseHeaders()) + .onFailure(failure -> failRelay(ctx, failure))); + } + + private void failRelay(RoutingContext ctx, Throwable failure) { + LOGGER.debug(failure, "Response relay failed: %s", failure.getMessage()); + ctx.vertx().runOnContext(v -> { + HttpServerResponse response = ctx.response(); + if (response.ended()) { + return; + } + // If the relay failed mid-stream after the response head was already written, the status + // line is gone — Vert.x throws IllegalStateException on setStatusCode once headWritten() + // is true. Only set the 502 when the head has not yet been written; either way end() the + // (possibly truncated) response so the client connection is closed cleanly. + if (!response.headWritten()) { + response.setStatusCode(BAD_GATEWAY); + } + response.end(); + }); + } + + private void writeShortCircuit(RoutingContext ctx, PipelineRequest request) { + int status = request.shortCircuitStatus().orElse(204); + Map responseHeaders = Map.copyOf(request.responseHeaders()); + ctx.vertx().runOnContext(v -> { + HttpServerResponse response = ctx.response(); + if (response.ended()) { + return; + } + response.setStatusCode(status); + responseHeaders.forEach(response::putHeader); + response.end(); + }); + } + + private void renderProblem(RoutingContext ctx, @Nullable PipelineRequest request, @Nullable EventType eventType) { + int status; + String type; + String title; + if (eventType == null) { + status = INTERNAL_ERROR; + type = "about:blank"; + title = "Internal Server Error"; + } else { + status = eventType.hasHttpMapping() ? eventType.httpStatus() : INTERNAL_ERROR; + EventCategory category = eventType.category(); + type = category != null ? category.problemType() : "about:blank"; + title = category != null ? category.title() : "Internal Server Error"; + } + String body = "{\"type\":\"" + type + "\",\"title\":\"" + title + "\",\"status\":" + status + "}"; + Map responseHeaders = request != null ? Map.copyOf(request.responseHeaders()) : Map.of(); + ctx.vertx().runOnContext(v -> { + HttpServerResponse response = ctx.response(); + if (response.ended()) { + return; + } + response.setStatusCode(status); + responseHeaders.forEach(response::putHeader); + response.putHeader("Content-Type", PROBLEM_JSON); + response.end(body); + }); + } + + private static void reject(RoutingContext ctx, int status) { + HttpServerResponse response = ctx.response(); + if (!response.ended()) { + response.setStatusCode(status).end(); + } + } + + private static PipelineRequest buildPipelineRequest(HttpServerRequest raw, HttpMethod method) { + String rawUri = raw.uri(); + int queryStart = rawUri.indexOf('?'); + String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart); + long contentLength = parseContentLength(raw); + boolean bodyPresent = contentLength > 0 || raw.headers().contains("Transfer-Encoding"); + return PipelineRequest.builder() + .method(method) + .requestPath(rawPath) + .queryParameters(toListMap(raw.params())) + .headers(toListMap(raw.headers())) + .host(raw.authority() != null ? raw.authority().host() : raw.host()) + .peerAddress(raw.remoteAddress() != null ? raw.remoteAddress().hostAddress() : null) + .declaredContentLength(contentLength) + .bodyPresent(bodyPresent) + .build(); + } + + private static Map> toListMap(MultiMap multiMap) { + Map> map = new LinkedHashMap<>(); + for (String name : multiMap.names()) { + map.put(name, List.copyOf(multiMap.getAll(name))); + } + return map; + } + + private static long parseContentLength(HttpServerRequest raw) { + String value = raw.getHeader("Content-Length"); + if (value == null) { + return -1L; + } + try { + return Long.parseLong(value.strip()); + } catch (NumberFormatException notANumber) { + return -1L; + } + } + + private static String renderQuery(Map> query) { + if (query.isEmpty()) { + return ""; + } + StringBuilder rendered = new StringBuilder("?"); + boolean first = true; + for (Map.Entry> entry : query.entrySet()) { + for (String value : entry.getValue()) { + if (!first) { + rendered.append('&'); + } + rendered.append(encode(entry.getKey())).append('=').append(encode(value)); + first = false; + } + } + return rendered.toString(); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static Optional parseMethod(String name) { + try { + return Optional.of(HttpMethod.valueOf(name)); + } catch (IllegalArgumentException unsupported) { + return Optional.empty(); + } + } + + private static RouteRuntime requireSelectedRoute(PipelineRequest request) { + RouteRuntime route = request.selectedRoute(); + if (route == null) { + throw new IllegalStateException("Route dispatch requires the route selected at stage 2"); + } + return route; + } + + private static String requireCanonicalPath(PipelineRequest request) { + String canonical = request.canonicalPath(); + if (canonical == null) { + throw new IllegalStateException("Route dispatch requires the canonical path resolved at stage 1"); + } + return canonical; + } + + private static String stripTrailingSlash(String value) { + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } + + /** + * 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 + * undeclared dimension never falls below the gateway baseline. + */ + private static SecurityConfiguration securityConfigurationFor(SecurityFilterConfig filter) { + SecurityConfigurationBuilder builder = SecurityConfiguration.builder(); + filter.maxBodyBytes().ifPresent(value -> builder.maxBodySize(value.longValue())); + filter.maxQueryParams().ifPresent(builder::maxParameterCount); + filter.maxHeaderCount().ifPresent(builder::maxHeaderCount); + filter.maxParamValueLength().ifPresent(builder::maxParameterValueLength); + filter.maxHeaderValueLength().ifPresent(builder::maxHeaderValueLength); + if (!filter.allowedHeaderNames().isEmpty()) { + builder.allowedHeaderNames(Set.copyOf(filter.allowedHeaderNames())); + } + if (!filter.blockedHeaderNames().isEmpty()) { + builder.blockedHeaderNames(Set.copyOf(filter.blockedHeaderNames())); + } + if (!filter.allowedContentTypes().isEmpty()) { + builder.allowedContentTypes(Set.copyOf(filter.allowedContentTypes())); + } + return builder.build(); + } + + /** + * Builds the per-shape SmallRye Fault-Tolerance guard: a circuit breaker plus an upstream + * timeout, with retry added only for a route that enables it. Gateway rejections + * ({@link GatewayException}, e.g. a body-cap breach) are skipped so they never trip the breaker + * or trigger a retry. + */ + private Guard guardFor(RouteRuntimeAssembler.ResilienceShape shape) { + // Include retryEnabled in the breaker name: RouteRuntimeAssembler's guardCache is keyed by the + // full ResilienceShape (target + retryEnabled), so two routes to the same host:port that differ + // only in retryEnabled resolve to two distinct guards. Deriving the name from host:port alone + // would hand both the SAME programmatic circuit-breaker name, which SmallRye rejects as a + // duplicate. The retry-qualified name is used for both .name(...) and the transition callback. + String name = shape.target().host() + ":" + shape.target().port() + ":retry=" + shape.retryEnabled(); + Guard.Builder builder = Guard.create().withDescription("upstream-" + name); + builder.withCircuitBreaker() + .name(name) + .requestVolumeThreshold(20) + .failureRatio(0.5) + .delay(5, ChronoUnit.SECONDS) + .successThreshold(2) + .skipOn(GatewayException.class) + .onStateChange(state -> upstreamFailureMapper.recordBreakerTransition(name, state)) + .done(); + builder.withTimeout().duration(30, ChronoUnit.SECONDS).done(); + if (shape.retryEnabled()) { + builder.withRetry().maxRetries(2).delay(100, ChronoUnit.MILLIS) + .abortOn(GatewayException.class).done(); + } + return builder.build(); + } +} 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 new file mode 100644 index 0000000..086bc63 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java @@ -0,0 +1,125 @@ +/* + * 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.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import io.vertx.core.Future; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpServerResponse; + +/** + * Stage 7 — the streamed response relay. + *

+ * The upstream status and (hop-by-hop-stripped) headers are copied to the client response, then the + * upstream body is streamed back with backpressure via Vert.x + * {@link HttpClientResponse#pipeTo(io.vertx.core.streams.WriteStream) pipeTo} — the body is never + * materialized. Header rules: + *

    + *
  • Hop-by-hop headers (RFC 7230 §6.1) plus length/framing headers Vert.x + * recomputes are stripped in the response direction.
  • + *
  • Conditional-response headers ({@code ETag} / {@code Last-Modified}) pass + * through only when the route enables {@code not_modified}; on a disabled route they are + * stripped so no validator ever reaches the client. A {@code 304} on an enabled route relays + * untouched.
  • + *
  • Stage-0 security headers accumulated on the response are applied last, so + * gateway-controlled headers win over any upstream value.
  • + *
+ * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ResponseStage { + + /** Hop-by-hop (RFC 7230 §6.1) plus framing headers Vert.x recomputes. All lower case. */ + private static final Set HOP_BY_HOP_HEADERS = Set.of( + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", "content-length"); + + /** Conditional-response validators stripped unless the route enables {@code not_modified}. */ + private static final Set CONDITIONAL_RESPONSE_HEADERS = Set.of("etag", "last-modified"); + + /** + * @param name the upstream response-header name + * @param notModifiedEnabled whether the route honours conditional requests / responses + * @return {@code true} when the header crosses back to the client + */ + public static boolean isForwardableResponseHeader(String name, boolean notModifiedEnabled) { + String lower = Objects.requireNonNull(name, "name").toLowerCase(Locale.ROOT); + if (HOP_BY_HOP_HEADERS.contains(lower)) { + return false; + } + return notModifiedEnabled || !CONDITIONAL_RESPONSE_HEADERS.contains(lower); + } + + /** + * Relays the upstream response to the client: status, filtered headers, stage-0 security + * headers, then the streamed body. + * + * @param upstream the upstream response (body 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 has been fully streamed + */ + public Future relay(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)) { + // add (not set) so multi-valued headers such as Set-Cookie are all preserved. + client.headers().add(header.getKey(), header.getValue()); + } + } + stageZeroSecurityHeaders.forEach((name, value) -> client.headers().set(name, value)); + applyResponseFraming(upstream, client); + return upstream.pipeTo(client); + } + + /** + * Re-establishes the client response body framing after {@link #isForwardableResponseHeader + * hop-by-hop stripping} removed the upstream framing headers. Only {@code Transfer-Encoding} is + * hop-by-hop and must be recomputed by the client protocol; {@code Content-Length} is end-to-end + * and — because the body is relayed byte-for-byte — remains accurate. Preserve a declared + * upstream {@code Content-Length} so an HTTP/1.1 client receives a well-framed fixed-length body; + * when the upstream framed the body as chunked (no {@code Content-Length}), stream the client + * response chunked. Without this an HTTP/1.1 response defaults to {@code Content-Length: 0} and + * the streamed body is silently dropped (HTTP/2 ignores both signals and is unaffected). + */ + private static void applyResponseFraming(HttpClientResponse upstream, HttpServerResponse client) { + String contentLength = upstream.getHeader("Content-Length"); + if (contentLength != null) { + client.putHeader("Content-Length", contentLength); + } else if (mayCarryBody(upstream.statusCode())) { + client.setChunked(true); + } + } + + /** + * @param status the relayed response status + * @return {@code true} unless the status forbids a message body (1xx, 204, 304) + */ + static boolean mayCarryBody(int status) { + return status != 204 && status != 304 && (status < 100 || status >= 200); + } +} 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 new file mode 100644 index 0000000..22de50f --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java @@ -0,0 +1,214 @@ +/* + * 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.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + + +import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.sheriff.api.config.model.HttpMethod; +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.SecurityFilterConfig; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.routing.ProtocolProcessor; +import de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry; +import de.cuioss.sheriff.api.routing.RouteMatcher; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.http.HttpClient; + +/** + * Boot-time assembler compiling the frozen {@link RouteTable} into immutable + * {@link RouteRuntime} instances, deduplicating the heavy collaborators so shared shapes + * reuse one object rather than copying it: + *
    + *
  • one cui-http {@link SecurityConfiguration} per distinct {@link SecurityFilterConfig} shape;
  • + *
  • one Vert.x {@link HttpClient} per distinct {@linkplain UpstreamTarget upstream-target tuple} + * (scheme, host, port) — routes sharing a tuple hold the same client reference;
  • + *
  • one SmallRye Fault-Tolerance {@link Guard} per distinct {@linkplain ResilienceShape + * resilience shape}.
  • + *
+ * The heavy objects are produced by the injected factories (so tests supply fakes and the + * production wiring supplies the real Vert.x / SmallRye instances). A route requesting + * {@code session} auth, or a {@code GRPC} / {@code WEBSOCKET} protocol, fails boot with a + * {@link GatewayException} carrying {@link EventType#CONFIG_INVALID}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class RouteRuntimeAssembler { + + private static final String SESSION_REQUIRE = "session"; + + private final ProtocolProcessorRegistry protocolRegistry; + + /** + * @param protocolRegistry the registry selecting a processor per route and rejecting + * unsupported protocols at boot + */ + public RouteRuntimeAssembler(ProtocolProcessorRegistry protocolRegistry) { + this.protocolRegistry = Objects.requireNonNull(protocolRegistry, "protocolRegistry"); + } + + /** + * Compiles every route in {@code table} into a {@link RouteRuntime}, sharing the deduplicated + * heavy collaborators. + * + * @param table the frozen route table + * @param securityConfigFactory builds one {@link SecurityConfiguration} per security-filter shape + * @param clientFactory builds one {@link HttpClient} per upstream target tuple + * @param guardFactory builds one {@link Guard} per resilience shape + * @return the assembled runtimes, in the table's longest-prefix-first order + * @throws GatewayException when a route requests {@code session} auth or an unsupported protocol + */ + public List assemble(RouteTable table, SecurityConfigurationFactory securityConfigFactory, + UpstreamClientFactory clientFactory, ResilienceGuardFactory guardFactory) { + Objects.requireNonNull(table, "table"); + Objects.requireNonNull(securityConfigFactory, "securityConfigFactory"); + Objects.requireNonNull(clientFactory, "clientFactory"); + Objects.requireNonNull(guardFactory, "guardFactory"); + + Map securityCache = new HashMap<>(); + Map clientCache = new HashMap<>(); + Map guardCache = new HashMap<>(); + List runtimes = new ArrayList<>(); + + for (ResolvedRoute route : table.routes()) { + rejectUnsupportedAuth(route); + ProtocolProcessor processor = protocolRegistry.require(route.protocol(), route.id()); + + Optional securityConfiguration = route.effectiveSecurityFilter() + .map(filter -> securityCache.computeIfAbsent(filter, securityConfigFactory::create)); + + UpstreamTarget target = UpstreamTarget.of(route.upstream()); + HttpClient client = clientCache.computeIfAbsent(target, clientFactory::create); + + ResilienceShape shape = new ResilienceShape(target, route.retryEnabled()); + Guard guard = guardCache.computeIfAbsent(shape, guardFactory::create); + + runtimes.add(RouteRuntime.builder() + .id(route.id()) + .protocol(route.protocol()) + .matcher(RouteMatcher.from(route.match())) + .protocolProcessor(processor) + .effectiveAllowedMethods(toMethodSet(route.effectiveAllowedMethods())) + .effectiveAuth(route.effectiveAuth()) + .requiredScopes(route.effectiveAuth().requiredScopes()) + .securityConfiguration(securityConfiguration) + .securityHeaders(route.effectiveSecurityHeaders()) + .effectiveForward(route.effectiveForward()) + .effectiveAllowedPaths(route.effectiveSecurityFilter() + .map(SecurityFilterConfig::allowedPaths).orElse(List.of())) + .retryEnabled(route.retryEnabled()) + .notModifiedEnabled(route.notModifiedEnabled()) + .upstream(route.upstream()) + .httpClient(client) + .resilienceGuard(guard) + .build()); + } + return List.copyOf(runtimes); + } + + private static void rejectUnsupportedAuth(ResolvedRoute route) { + if (SESSION_REQUIRE.equals(route.effectiveAuth().require())) { + throw new GatewayException(EventType.CONFIG_INVALID, + "Route '" + route.id() + "' requires session authentication which is not yet implemented"); + } + } + + private static Set toMethodSet(List methods) { + return methods.isEmpty() ? EnumSet.noneOf(HttpMethod.class) : EnumSet.copyOf(methods); + } + + /** + * The upstream-target tuple keying Vert.x client dedup: routes sharing (scheme, host, port) + * share one client instance. + * + * @param scheme the upstream scheme + * @param host the upstream host + * @param port the upstream port + */ + public record UpstreamTarget(String scheme, String host, int port) { + + /** + * @param upstream the resolved upstream + * @return the target tuple for {@code upstream} + */ + public static UpstreamTarget of(ResolvedUpstream upstream) { + return new UpstreamTarget(upstream.scheme(), upstream.host(), upstream.port()); + } + } + + /** + * The resilience shape keying Fault-Tolerance guard dedup: routes sharing an upstream target + * and retry posture share one guard instance. + * + * @param target the upstream target + * @param retryEnabled the materialized retry toggle + */ + public record ResilienceShape(UpstreamTarget target, boolean retryEnabled) { + } + + /** + * Factory building one cui-http {@link SecurityConfiguration} for a security-filter shape. + */ + @FunctionalInterface + public interface SecurityConfigurationFactory { + + /** + * @param filter the security-filter shape + * @return the built security configuration + */ + SecurityConfiguration create(SecurityFilterConfig filter); + } + + /** + * Factory building one Vert.x {@link HttpClient} for an upstream target tuple. + */ + @FunctionalInterface + public interface UpstreamClientFactory { + + /** + * @param target the upstream target tuple + * @return the built (or shared) client + */ + HttpClient create(UpstreamTarget target); + } + + /** + * Factory building one SmallRye {@link Guard} for a resilience shape. + */ + @FunctionalInterface + public interface ResilienceGuardFactory { + + /** + * @param shape the resilience shape + * @return the built guard + */ + Guard create(ResilienceShape shape); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGate.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGate.java new file mode 100644 index 0000000..c67a8a1 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGate.java @@ -0,0 +1,75 @@ +/* + * 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.EnumSet; +import java.util.Objects; +import java.util.Set; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; + +/** + * The stream-aware retry gate for stage 6 upstream dispatch. + *

+ * A streamed request cannot be safely retried once any request body byte has crossed to the + * upstream — the source stream is consumed and cannot be replayed, and re-sending a partially + * consumed non-idempotent request risks duplicate side effects. This gate therefore permits a + * retry only when ALL of the following hold: + *

    + *
  • the route enables upstream retry ({@link de.cuioss.sheriff.api.routing.RouteRuntime#isRetryEnabled()});
  • + *
  • the request method is idempotent (RFC 7231 §4.2.2: {@code GET}, {@code HEAD}, + * {@code PUT}, {@code DELETE}, {@code OPTIONS} — never {@code POST} / {@code PATCH});
  • + *
  • zero request body bytes have been sent to the upstream yet.
  • + *
+ * The gate holds no mutable state; the per-request body-byte count is supplied by the caller. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class StreamAwareRetryGate { + + /** RFC 7231 §4.2.2 idempotent methods — the only verbs a partially-attempted request may retry. */ + private static final Set IDEMPOTENT_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.OPTIONS); + + private final boolean routeRetryEnabled; + + /** + * @param routeRetryEnabled the route's materialized upstream-retry toggle + */ + public StreamAwareRetryGate(boolean routeRetryEnabled) { + this.routeRetryEnabled = routeRetryEnabled; + } + + /** + * @param method the inbound request method + * @param bodyBytesSent the number of request body bytes already streamed to the upstream + * @return {@code true} only when retry is enabled, the method is idempotent, and no body byte + * has yet been sent + */ + public boolean allowsRetry(HttpMethod method, long bodyBytesSent) { + Objects.requireNonNull(method, "method"); + return routeRetryEnabled && isIdempotent(method) && bodyBytesSent == 0L; + } + + /** + * @param method the request method + * @return {@code true} when {@code method} is an idempotent verb (RFC 7231 §4.2.2) + */ + public static boolean isIdempotent(HttpMethod method) { + return IDEMPOTENT_METHODS.contains(Objects.requireNonNull(method, "method")); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapper.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapper.java new file mode 100644 index 0000000..dc18226 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapper.java @@ -0,0 +1,131 @@ +/* + * 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.Objects; + + +import de.cuioss.sheriff.api.ApiSheriffLogMessages; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayEventCounter; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.tools.logging.CuiLogger; + +import io.smallrye.faulttolerance.api.CircuitBreakerState; +import org.eclipse.microprofile.faulttolerance.exceptions.CircuitBreakerOpenException; +import org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException; + +/** + * Maps an upstream dispatch failure to the gateway error contract (stage 6/7). + *

+ * The stage-6 dispatch runs through the route's SmallRye Fault-Tolerance guard, so a failure + * surfaces as one of three shapes, each with a distinct HTTP status per the error contract in + * {@code architecture.adoc}: + *

    + *
  • {@link CircuitBreakerOpenException} — the breaker is open, the upstream was never called → + * {@link EventType#UPSTREAM_CIRCUIT_OPEN} (503, {@code Retry-After} added by the edge);
  • + *
  • a timeout ({@link TimeoutException}, {@link java.util.concurrent.TimeoutException}, or a + * Vert.x timeout) — the upstream exceeded its configured deadline → + * {@link EventType#UPSTREAM_TIMEOUT} (504);
  • + *
  • any other connection / protocol failure → {@link EventType#UPSTREAM_ERROR} (502).
  • + *
+ * Every mapped failure is metered on the shared {@link GatewayEventCounter}. Breaker state + * transitions are metered and logged through {@link #recordBreakerTransition(String, CircuitBreakerState)}, + * which the boot-time edge registers with SmallRye's {@code CircuitBreakerMaintenance.onStateChange}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class UpstreamFailureMapper { + + private static final CuiLogger LOGGER = new CuiLogger(UpstreamFailureMapper.class); + + /** Bounds the cause-chain walk so a self-referential cause cycle cannot spin forever. */ + private static final int MAX_CAUSE_DEPTH = 16; + + private final GatewayEventCounter eventCounter; + + /** + * @param eventCounter the shared in-process event counter feeding the metrics edge + */ + public UpstreamFailureMapper(GatewayEventCounter eventCounter) { + this.eventCounter = Objects.requireNonNull(eventCounter, "eventCounter"); + } + + /** + * Classifies an upstream failure into its error-contract {@link EventType} without metering. + * + * @param failure the failure thrown by the guarded upstream dispatch + * @return the mapped upstream event type (502 / 503 / 504) + */ + public EventType classify(Throwable failure) { + Objects.requireNonNull(failure, "failure"); + Throwable current = failure; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof CircuitBreakerOpenException) { + return EventType.UPSTREAM_CIRCUIT_OPEN; + } + if (isTimeout(current)) { + return EventType.UPSTREAM_TIMEOUT; + } + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; + } + return EventType.UPSTREAM_ERROR; + } + + /** + * Classifies and meters an upstream failure, producing the typed {@link GatewayException} the + * edge renders (never leaking the internal cause to the client body). + * + * @param failure the failure thrown by the guarded upstream dispatch + * @return the gateway exception carrying the mapped {@link EventType} + */ + public GatewayException toGatewayException(Throwable failure) { + EventType type = classify(failure); + eventCounter.increment(type); + return new GatewayException(type, "Upstream dispatch failed: " + type.name(), failure); + } + + /** + * Meters and logs a circuit-breaker state transition. Registered by the edge with SmallRye's + * {@code CircuitBreakerMaintenance.onStateChange(routeId, mapper::recordBreakerTransition)}. + * + * @param routeId the route id owning the breaker + * @param state the new breaker state + */ + public void recordBreakerTransition(String routeId, CircuitBreakerState state) { + Objects.requireNonNull(routeId, "routeId"); + Objects.requireNonNull(state, "state"); + switch (state) { + case OPEN -> { + eventCounter.increment(EventType.UPSTREAM_CIRCUIT_OPEN); + LOGGER.warn(ApiSheriffLogMessages.WARN.CIRCUIT_BREAKER_OPEN, routeId); + } + case CLOSED -> LOGGER.warn(ApiSheriffLogMessages.WARN.CIRCUIT_BREAKER_CLOSED, routeId); + default -> LOGGER.debug("Circuit breaker for route '%s' transitioned to %s", routeId, state); + } + } + + private static boolean isTimeout(Throwable candidate) { + return candidate instanceof TimeoutException + || candidate instanceof java.util.concurrent.TimeoutException + || candidate.getClass().getSimpleName().contains("Timeout"); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/package-info.java similarity index 51% rename from api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java rename to api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/package-info.java index a9fb77b..d444013 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/package-info.java @@ -14,16 +14,19 @@ * limitations under the License. */ /** - * Interim minimal reverse-proxy edge for the API Sheriff gateway. + * The framework edge: the Vert.x-bound catch-all route, edge hardening, streamed upstream + * dispatch / response, and the boot-time + * {@link de.cuioss.sheriff.api.edge.RouteRuntimeAssembler} that compiles the frozen route + * table into immutable {@link de.cuioss.sheriff.api.routing.RouteRuntime} instances with + * deduplicated heavy collaborators. *

- * One Vert.x route per {@code path_prefix} - * ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyRoute}) forwards matched - * requests to the upstream resolved for that route by the configuration - * subsystem's {@link de.cuioss.sheriff.api.config.model.RouteTable}, executing the - * blocking forward on a virtual thread. This is the outermost shell only: it is - * kept as the edge and has its internals replaced by the real request pipeline in - * Plan 03. + * This package is framework-coupled (Vert.x, SmallRye Fault-Tolerance) and is therefore + * outside the ADR-0005 framework-agnostic arch-gate rule set. * + * @author API Sheriff Team * @since 1.0 */ -package de.cuioss.sheriff.api.gateway.proxy; +@NullMarked +package de.cuioss.sheriff.api.edge; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventCategory.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventCategory.java new file mode 100644 index 0000000..a560e26 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventCategory.java @@ -0,0 +1,77 @@ +/* + * 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.events; + +/** + * Impact grouping for gateway failure events. The category names the + * RFC 9457 problem type (see {@link #problemType()}) and keys the error metrics; + * the concrete HTTP status is carried per {@link EventType} (two events in one + * category may map to different statuses). + *

+ * {@link #CONFIGURATION} events occur at boot only — the gateway refuses to start — + * and never surface as an HTTP response. Success / informational events carry no + * category at all (see {@link EventType}). + * + * @author API Sheriff Team + * @since 1.0 + */ +public enum EventCategory { + + /** Path / parameter / header pipeline, collection limits, or body-size violations. */ + INPUT_VALIDATION("input-validation", "Input Validation"), + + /** Missing or invalid bearer token, or invalid / expired session. */ + AUTHENTICATION("authentication", "Authentication"), + + /** Valid credentials lacking a required scope, or a failed CSRF origin check. */ + AUTHORIZATION("authorization", "Authorization"), + + /** Upstream connection failure, timeout, or open circuit breaker. */ + UPSTREAM("upstream", "Upstream"), + + /** Boot-time configuration failure — never rendered as an HTTP response. */ + CONFIGURATION("configuration", "Configuration"); + + private final String slug; + private final String title; + + EventCategory(String slug, String title) { + this.slug = slug; + this.title = title; + } + + /** + * @return the stable, lowercase, hyphenated slug for this category + */ + public String slug() { + return slug; + } + + /** + * @return the short, human-readable title used as the RFC 9457 {@code title} member + */ + public String title() { + return title; + } + + /** + * @return the RFC 9457 {@code type} URI naming this problem category, e.g. + * {@code urn:api-sheriff:problem:input-validation} + */ + public String problemType() { + return "urn:api-sheriff:problem:" + slug; + } +} 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 new file mode 100644 index 0000000..79758fa --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java @@ -0,0 +1,125 @@ +/* + * 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.events; + +import org.jspecify.annotations.Nullable; + +/** + * The catalogue of gateway events, each carrying its {@link EventCategory} and, for + * request-time failures, the HTTP status the edge renders. Modelled on + * {@code token-sheriff}'s {@code SecurityEventCounter.EventType}. + *

+ * Success / informational events carry a {@code null} category (per CUI + * convention) and have no HTTP mapping ({@code httpStatus == 0}) — they are metric + * counters only. Configuration events carry {@link EventCategory#CONFIGURATION} + * but also have no HTTP mapping: they occur at boot and abort startup rather than + * producing a response. Request-time failures carry a category and a + * positive HTTP status, matching the error contract in {@code architecture.adoc}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public enum EventType { + + // --- Success / informational (no category, no HTTP mapping) --- + + /** A request was successfully forwarded to its upstream. */ + REQUEST_FORWARDED(null, 0), + /** A bearer token was refreshed. */ + TOKEN_REFRESHED(null, 0), + /** Gateway configuration was loaded and validated at boot. */ + CONFIG_LOADED(null, 0), + + // --- Configuration failures (boot only, never an HTTP response) --- + + /** Configuration failed validation; the gateway refuses to start. */ + CONFIG_INVALID(EventCategory.CONFIGURATION, 0), + /** A route weakens an authentication default via an explicit override. */ + AUTH_WEAKENED(EventCategory.CONFIGURATION, 0), + + // --- Input validation (400 / 404 / 405) --- + + /** A cui-http security filter rejected the request. */ + SECURITY_FILTER_VIOLATION(EventCategory.INPUT_VALIDATION, 400), + /** The normalized path is outside the route's {@code allowed_paths} whitelist. */ + PATH_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 400), + /** A collection / parameter limit was exceeded. */ + PARAMETER_LIMIT_EXCEEDED(EventCategory.INPUT_VALIDATION, 400), + /** No route matched the canonical path (deny-by-default routing). */ + NO_ROUTE_MATCHED(EventCategory.INPUT_VALIDATION, 404), + /** The request method is outside the route's effective {@code allowed_methods}. */ + METHOD_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 405), + + // --- Authentication (401) --- + + /** No bearer token was presented on a route requiring one. */ + TOKEN_MISSING(EventCategory.AUTHENTICATION, 401), + /** The presented bearer token was invalid or expired. */ + TOKEN_INVALID(EventCategory.AUTHENTICATION, 401), + + // --- Authorization (403) --- + + /** Valid credentials lacking a required scope. */ + SCOPE_MISSING(EventCategory.AUTHORIZATION, 403), + /** A CSRF origin check failed. */ + CSRF_REJECTED(EventCategory.AUTHORIZATION, 403), + + // --- Upstream (502 / 503 / 504) --- + + /** Upstream connection failure or an invalid upstream response. */ + UPSTREAM_ERROR(EventCategory.UPSTREAM, 502), + /** 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); + + private final @Nullable EventCategory category; + private final int httpStatus; + + EventType(@Nullable EventCategory category, int httpStatus) { + this.category = category; + this.httpStatus = httpStatus; + } + + /** + * @return this event's category, or {@code null} for a success / informational event + */ + public @Nullable EventCategory category() { + return category; + } + + /** + * @return the HTTP status the edge renders for this event, or {@code 0} when the event + * has no HTTP mapping (success / informational or boot-only configuration events) + */ + public int httpStatus() { + return httpStatus; + } + + /** + * @return {@code true} when this event represents a failure (carries a category) + */ + public boolean isFailure() { + return category != null; + } + + /** + * @return {@code true} when this event maps to an HTTP status (a request-time failure) + */ + public boolean hasHttpMapping() { + return httpStatus > 0; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayEventCounter.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayEventCounter.java new file mode 100644 index 0000000..de36129 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayEventCounter.java @@ -0,0 +1,73 @@ +/* + * 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.events; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Thread-safe, lock-free in-process event counter, modelled on {@code token-sheriff}'s + * {@code SecurityEventCounter}. It feeds the metrics and error-mapping edges without + * pulling in a broker, an observer bus, or a Micrometer dependency — keeping the + * {@code events} package framework-agnostic (ADR-0005). + *

+ * Increments use {@link ConcurrentHashMap#computeIfAbsent} plus + * {@link AtomicLong#incrementAndGet}, so concurrent callers never block one another. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class GatewayEventCounter { + + private final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + + /** + * Atomically increments the counter for the given event type. + * + * @param eventType the event to count + */ + public void increment(EventType eventType) { + counters.computeIfAbsent(eventType, key -> new AtomicLong()).incrementAndGet(); + } + + /** + * @param eventType the event to query + * @return the current count for {@code eventType}, or {@code 0} if never incremented + */ + public long getCount(EventType eventType) { + AtomicLong current = counters.get(eventType); + return current == null ? 0L : current.get(); + } + + /** + * @return an unmodifiable point-in-time snapshot of all non-zero counters + */ + public Map getCounters() { + Map snapshot = new EnumMap<>(EventType.class); + counters.forEach((eventType, value) -> snapshot.put(eventType, value.get())); + return Collections.unmodifiableMap(snapshot); + } + + /** + * Resets every counter to zero. Intended for tests and controlled restarts. + */ + public void reset() { + counters.clear(); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayException.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayException.java new file mode 100644 index 0000000..17e9353 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayException.java @@ -0,0 +1,70 @@ +/* + * 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.events; + +import java.io.Serial; + +import org.jspecify.annotations.Nullable; + +/** + * Typed gateway failure carrying the {@link EventType} that produced it. The HTTP edge + * reads {@link #getEventType()} to render the correct status and RFC 9457 problem type + * without leaking internal detail. The exception message is for logging only and + * is never placed in the response body. + * + * @author API Sheriff Team + * @since 1.0 + */ +public class GatewayException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final EventType eventType; + + /** + * @param eventType the failure event; its {@code name()} becomes the log message + */ + public GatewayException(EventType eventType) { + this(eventType, eventType.name()); + } + + /** + * @param eventType the failure event + * @param message the internal log message (never rendered to the client) + */ + public GatewayException(EventType eventType, String message) { + super(message); + this.eventType = eventType; + } + + /** + * @param eventType the failure event + * @param message the internal log message (never rendered to the client) + * @param cause the underlying cause, if any + */ + public GatewayException(EventType eventType, String message, @Nullable Throwable cause) { + super(message, cause); + this.eventType = eventType; + } + + /** + * @return the event type that produced this failure + */ + public EventType getEventType() { + return eventType; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/package-info.java new file mode 100644 index 0000000..34c1adc --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/package-info.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +/** + * Framework-agnostic event and error model for the API Sheriff gateway. + *

+ * {@link de.cuioss.sheriff.api.events.EventCategory} groups failures by impact and names + * the RFC 9457 problem type; {@link de.cuioss.sheriff.api.events.EventType} enumerates every + * event with its category and (for request-time failures) HTTP status; + * {@link de.cuioss.sheriff.api.events.GatewayEventCounter} is the lock-free in-process + * counter feeding metrics and error mapping; and + * {@link de.cuioss.sheriff.api.events.GatewayException} is the typed failure the HTTP edge + * maps to a status and problem type. + *

+ * Framework-agnostic seam (ADR-0005). This package carries no CDI, Quarkus, + * Vert.x, MicroProfile, or Micrometer imports — enforced by + * {@code FrameworkAgnosticArchTest}. The framework-bound rendering lives in the edge and + * {@code quarkus} packages. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.events; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/ForwardPolicyStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/ForwardPolicyStage.java new file mode 100644 index 0000000..03266b4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/ForwardPolicyStage.java @@ -0,0 +1,165 @@ +/* + * 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.forward; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + + +import de.cuioss.http.forwarded.ForwardedHeaderResolver; +import de.cuioss.http.forwarded.ResolvedForwarding; +import de.cuioss.sheriff.api.config.model.ForwardConfig; +import de.cuioss.sheriff.api.pipeline.PipelineRequest; + +/** + * Stage 5 — the zero-trust forward policy, run after authentication and before upstream dispatch. + *

+ * The stage computes exactly what crosses to the upstream, deny-by-default: + *

    + *
  • Allowlists. Only headers named in {@code headers_allow} and query + * parameters named in {@code query_allow} are forwarded; everything else is dropped. Inbound + * {@code Authorization} crosses only when explicitly allow-listed.
  • + *
  • Regenerated forwarding headers. Inbound {@code X-Forwarded-*} / + * {@code Forwarded} headers are NEVER propagated — they are regenerated through the shared + * {@link ForwardedHeaderResolver}, emitting {@code X-Forwarded-*} always and RFC 7239 + * {@code Forwarded} additionally when {@code emit: both}. When the immediate TCP peer is not a + * {@linkplain TcpPeerGate#isTrustedPeer(String) trusted proxy}, inbound forwarding headers are + * ignored (a spoofed chain from an untrusted peer never influences the regenerated set).
  • + *
  • Static set headers. {@code set_headers} are appended verbatim.
  • + *
  • Conditional requests. {@code If-None-Match} / {@code If-Modified-Since} + * cross only when the route enables {@code not_modified}; otherwise they are dropped here.
  • + *
+ * The stage is framework-agnostic: the immediate peer address is supplied on the + * {@link PipelineRequest} by the edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ForwardPolicyStage { + + static final String EMIT_BOTH = "both"; + private static final String FORWARDED_HEADER = "Forwarded"; + + private static final Set FORWARDING_HEADERS = Set.of( + "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-port", + "x-forwarded-prefix", "forwarded"); + private static final List CONDITIONAL_HEADERS = List.of("If-None-Match", "If-Modified-Since"); + + private final ForwardedHeaderResolver resolver; + private final TcpPeerGate peerGate; + private final boolean emitForwarded; + + /** + * @param resolver the shared, boot-wired forwarded-header resolver + * @param peerGate the immediate-TCP-peer trust gate (ADR-0003) + * @param emitMode the {@code forwarded.emit} mode ({@code x-forwarded} or {@code both}) + */ + public ForwardPolicyStage(ForwardedHeaderResolver resolver, TcpPeerGate peerGate, String emitMode) { + this.resolver = Objects.requireNonNull(resolver, "resolver"); + this.peerGate = Objects.requireNonNull(peerGate, "peerGate"); + this.emitForwarded = EMIT_BOTH.equals(Objects.requireNonNull(emitMode, "emitMode")); + } + + /** + * Computes the deny-by-default upstream header and query sets. + * + * @param request the in-flight request context + * @param forwardConfig the selected route's {@code forward} block + * @param notModifiedEnabled whether the route honours conditional requests + * @return the forwarded headers and query parameters that cross to the upstream + */ + public Result process(PipelineRequest request, ForwardConfig forwardConfig, boolean notModifiedEnabled) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(forwardConfig, "forwardConfig"); + + Map headers = new LinkedHashMap<>(); + copyAllowedHeaders(request, forwardConfig, headers); + headers.putAll(forwardConfig.setHeaders()); + applyConditionalHeaders(request, notModifiedEnabled, headers); + applyRegeneratedForwarding(request, headers); + + return new Result(Map.copyOf(headers), copyAllowedQuery(request, forwardConfig)); + } + + private static void copyAllowedHeaders(PipelineRequest request, ForwardConfig forwardConfig, + Map headers) { + for (String name : forwardConfig.headersAllow()) { + if (FORWARDING_HEADERS.contains(name.toLowerCase(Locale.ROOT))) { + continue; + } + request.firstHeader(name).ifPresent(value -> headers.put(name, value)); + } + } + + private static void applyConditionalHeaders(PipelineRequest request, boolean notModifiedEnabled, + Map headers) { + if (!notModifiedEnabled) { + return; + } + for (String name : CONDITIONAL_HEADERS) { + request.firstHeader(name).ifPresent(value -> headers.put(name, value)); + } + } + + private void applyRegeneratedForwarding(PipelineRequest request, Map headers) { + boolean peerTrusted = peerGate.isTrustedPeer(request.peerAddress()); + Function lookup = name -> { + if (!peerTrusted && FORWARDING_HEADERS.contains(name.toLowerCase(Locale.ROOT))) { + return null; + } + return request.firstHeader(name).orElse(null); + }; + ResolvedForwarding resolved = resolver.resolve(lookup); + headers.putAll(resolved.toXForwardedHeaders()); + if (emitForwarded) { + resolved.toForwardedHeader().ifPresent(value -> headers.put(FORWARDED_HEADER, value)); + } + } + + private static Map> copyAllowedQuery(PipelineRequest request, ForwardConfig forwardConfig) { + Map> query = new LinkedHashMap<>(); + for (String name : forwardConfig.queryAllow()) { + List values = request.queryParameters().get(name); + if (values != null && !values.isEmpty()) { + query.put(name, List.copyOf(values)); + } + } + return Map.copyOf(query); + } + + /** + * The computed upstream request projection: the regenerated, allow-listed headers and the + * allow-listed query parameters that cross to the upstream. + * + * @param headers the outbound header set (allow-listed + set_headers + regenerated forwarding) + * @param query the outbound query parameters (allow-listed only) + */ + public record Result(Map headers, Map> query) { + + /** + * Canonical constructor defensively copying the collections. + */ + public Result { + headers = Map.copyOf(headers); + query = Map.copyOf(query); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/TcpPeerGate.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/TcpPeerGate.java new file mode 100644 index 0000000..1f2df9b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/TcpPeerGate.java @@ -0,0 +1,132 @@ +/* + * 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.forward; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +/** + * The gateway-side immediate-TCP-peer trust gate (ADR-0003) — API Sheriff code, deliberately not + * delegated to the forwarded-header resolver. + *

+ * The {@code forwarded.trusted_proxies} CIDR set is parsed once, at boot, into a matcher list; at + * request time only the single immediate peer address is parsed (no per-request CIDR string + * parsing). {@link #isTrustedPeer(String)} answers whether the immediate TCP peer sits inside the + * trusted set, which the {@link ForwardPolicyStage} consults before it honours any inbound + * forwarding header — a spoofed {@code X-Forwarded-For} from an untrusted peer is therefore ignored. + * The gate fails closed: an empty trusted set trusts no peer, and an unparseable peer address is + * never trusted. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class TcpPeerGate { + + private final List trustedRanges; + + /** + * @param trustedProxyCidrs the boot-parsed {@code forwarded.trusted_proxies} CIDR entries + */ + public TcpPeerGate(List trustedProxyCidrs) { + Objects.requireNonNull(trustedProxyCidrs, "trustedProxyCidrs"); + List ranges = new ArrayList<>(); + for (String cidr : trustedProxyCidrs) { + ranges.add(Cidr.parse(cidr)); + } + this.trustedRanges = List.copyOf(ranges); + } + + /** + * @param peerAddress the immediate TCP peer address, {@code null} when the edge supplied none + * @return {@code true} when the peer falls inside a trusted CIDR range + */ + public boolean isTrustedPeer(@Nullable String peerAddress) { + if (peerAddress == null || trustedRanges.isEmpty()) { + return false; + } + InetAddress peer; + try { + peer = InetAddress.ofLiteral(peerAddress.strip()); + } catch (IllegalArgumentException notALiteral) { + return false; + } + for (Cidr range : trustedRanges) { + if (range.contains(peer)) { + return true; + } + } + return false; + } + + /** + * A boot-parsed CIDR range: the network prefix bytes and the significant-bit count. Matching is + * address-family aware — an IPv4 candidate never matches an IPv6 range and vice versa. + *

+ * The network address is held as an immutable {@link List} of bytes rather than a raw + * {@code byte[]} so the record's generated {@code equals} / {@code hashCode} / {@code toString} + * are content-aware (an array component would compare by identity, which is misleading). + * + * @param network the network address bytes (4 for IPv4, 16 for IPv6) + * @param prefixLength the number of significant leading bits + */ + private record Cidr(List network, int prefixLength) { + + static Cidr parse(String cidr) { + Objects.requireNonNull(cidr, "cidr"); + int slash = cidr.indexOf('/'); + if (slash < 0) { + throw new IllegalArgumentException("Not a CIDR range: " + cidr); + } + byte[] networkBytes = InetAddress.ofLiteral(cidr.substring(0, slash).strip()).getAddress(); + int prefixLength = Integer.parseInt(cidr.substring(slash + 1).strip()); + if (prefixLength < 0 || prefixLength > networkBytes.length * 8) { + throw new IllegalArgumentException("CIDR prefix out of range: " + cidr); + } + return new Cidr(toImmutableByteList(networkBytes), prefixLength); + } + + boolean contains(InetAddress candidate) { + byte[] address = candidate.getAddress(); + if (address.length != network.size()) { + return false; + } + int fullBytes = prefixLength / 8; + for (int i = 0; i < fullBytes; i++) { + if (address[i] != network.get(i)) { + return false; + } + } + int remainingBits = prefixLength % 8; + if (remainingBits == 0) { + return true; + } + int mask = (0xFF << (8 - remainingBits)) & 0xFF; + return (address[fullBytes] & mask) == (network.get(fullBytes) & mask); + } + + private static List toImmutableByteList(byte[] bytes) { + List list = new ArrayList<>(bytes.length); + for (byte value : bytes) { + list.add(value); + } + return List.copyOf(list); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/package-info.java new file mode 100644 index 0000000..c2800d5 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/package-info.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +/** + * Stage 5 — the zero-trust forward policy. + *

+ * {@link de.cuioss.sheriff.api.forward.ForwardPolicyStage} computes the deny-by-default upstream + * projection — allow-listed headers and query parameters, static {@code set_headers}, and + * regenerated forwarding headers (inbound {@code X-Forwarded-*} / {@code Forwarded} are + * never propagated) — through the shared cui-http forwarded-header resolver. + * {@link de.cuioss.sheriff.api.forward.TcpPeerGate} is the gateway-side immediate-TCP-peer trust + * gate (ADR-0003) over the boot-parsed {@code trusted_proxies} CIDR set. + *

+ * Framework-agnostic. The package operates on the agnostic + * {@link de.cuioss.sheriff.api.pipeline.PipelineRequest} (the immediate peer address is supplied by + * the edge) and carries no {@code io.vertx..} / {@code io.quarkus..} / {@code jakarta..} / + * {@code org.eclipse.microprofile..} / {@code io.micrometer..} imports, so it stays inside the + * ADR-0005 framework-agnostic arch-gate rule set. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.forward; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/GatewayResource.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/GatewayResource.java deleted file mode 100644 index 303139a..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/GatewayResource.java +++ /dev/null @@ -1,79 +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.gateway; - -import de.cuioss.sheriff.api.ApiSheriff; -import io.quarkus.runtime.annotations.RegisterForReflection; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -/** - * REST resource for the API Sheriff gateway providing health and info endpoints. - * - * @author API Sheriff Team - * @since 1.0 - */ -@Path("/api") -@ApplicationScoped -@RegisterForReflection -@Produces(MediaType.APPLICATION_JSON) -public class GatewayResource { - - private final ApiSheriff apiSheriff; - - /** - * Creates a new GatewayResource with the given ApiSheriff instance. - * - * @param apiSheriff the ApiSheriff instance to use - */ - @Inject - public GatewayResource(ApiSheriff apiSheriff) { - this.apiSheriff = apiSheriff; - } - - /** - * Health check endpoint to verify that API Sheriff is properly configured. - * - * @return Response indicating the health status of API Sheriff - */ - @GET - @Path("/health") - @Produces(MediaType.APPLICATION_JSON) - public Response health() { - String status = apiSheriff.getStatus(); - - return Response.ok("{\"status\":\"UP\",\"apiSheriff\":\"" + status + "\"}") - .build(); - } - - /** - * Info endpoint for basic API Sheriff status. - * - * @return Response with application information - */ - @GET - @Path("/info") - @Produces(MediaType.APPLICATION_JSON) - public Response info() { - return Response.ok("{\"message\":\"API Sheriff Gateway\",\"version\":\"1.0.0-SNAPSHOT\"}") - .build(); - } -} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/package-info.java deleted file mode 100644 index d971fd9..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/package-info.java +++ /dev/null @@ -1,24 +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. - */ -/** - * Gateway REST endpoints for the API Sheriff application. - *

- * This package contains the JAX-RS resources that serve as the external API - * for the API Sheriff gateway, including health and info endpoints. - * - * @since 1.0 - */ -package de.cuioss.sheriff.api.gateway; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyLogMessages.java deleted file mode 100644 index 263a1ea..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyLogMessages.java +++ /dev/null @@ -1,65 +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.gateway.proxy; - -import de.cuioss.tools.logging.LogRecord; -import de.cuioss.tools.logging.LogRecordModel; -import lombok.experimental.UtilityClass; - -/** - * DSL-style {@link LogRecord} catalogue for the API Sheriff proxy edge. - *

- * Structured {@code INFO} / {@code WARN} / {@code ERROR} messages carry the - * {@code ApiSheriff} prefix and a stable numeric identifier, so they are - * greppable and assertable. {@code DEBUG} / {@code TRACE} diagnostics use the - * logger directly and are not catalogued here. - * - * @author API Sheriff Team - * @since 1.0 - */ -@UtilityClass -public final class ProxyLogMessages { - - private static final String PREFIX = "ApiSheriff"; - - /** - * Info-level messages (INFO range 1-99). - */ - @UtilityClass - public static final class INFO { - - /** The catch-all proxy route was registered on the data plane. */ - public static final LogRecord ROUTE_REGISTERED = LogRecordModel.builder() - .prefix(PREFIX) - .identifier(1) - .template("Proxy route registered: path-prefix='%s' upstream='%s'") - .build(); - } - - /** - * Warn-level messages (WARN range 100-199). - */ - @UtilityClass - public static final class WARN { - - /** Forwarding a request to the configured upstream failed. */ - public static final LogRecord UPSTREAM_FAILURE = LogRecordModel.builder() - .prefix(PREFIX) - .identifier(100) - .template("Proxy request to upstream '%s' failed: %s") - .build(); - } -} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java deleted file mode 100644 index f96ddf4..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java +++ /dev/null @@ -1,266 +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.gateway.proxy; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ExecutorService; - -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.gateway.proxy.ProxyLogMessages.INFO; -import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.WARN; -import de.cuioss.tools.logging.CuiLogger; -import io.quarkus.virtual.threads.VirtualThreads; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.http.HttpServerRequest; -import io.vertx.core.http.HttpServerResponse; -import io.vertx.ext.web.Router; -import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.handler.BodyHandler; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.event.Observes; -import jakarta.inject.Inject; - -/** - * Interim minimal reverse proxy: a Vert.x route per configured - * {@code path_prefix} that forwards matched requests to the upstream resolved for - * that route. - *

- * The routing table is the {@link RouteTable} assembled by the configuration - * subsystem (Deliverable 7); this edge sources each route's upstream from the - * table's {@link ResolvedUpstream} rather than from a single static configuration - * bean. Behaviour (deliberately interim — Plan 03 keeps this edge and replaces the - * internals with the real request pipeline): - *

    - *
  • One route is registered per {@code path_prefix} in the table; the prefix - * is stripped and the remaining path plus the query string are appended to - * the route's upstream base URL.
  • - *
  • Method, path remainder, query and body pass through; hop-by-hop headers - * are stripped in both directions.
  • - *
  • Forwarding uses a blocking JDK {@link HttpClient} {@code send()} executed - * on a virtual thread, so the Vert.x event loop is never blocked.
  • - *
  • Any path with no matching route falls through to {@code 404} - * (deny-by-default); upstream failures surface as {@code 502}.
  • - *
- * - * @author API Sheriff Team - * @since 1.0 - */ -@ApplicationScoped -public class ProxyRoute { - - private static final CuiLogger LOGGER = new CuiLogger(ProxyRoute.class); - - /** - * Hop-by-hop headers (RFC 7230 §6.1) plus JDK {@link HttpClient} restricted - * request headers, which must not be forwarded to the upstream. All lower case. - */ - private static final Set REQUEST_SKIP_HEADERS = Set.of( - "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", - "te", "trailer", "transfer-encoding", "upgrade", - "host", "content-length", "expect"); - - /** - * Hop-by-hop headers plus length/framing headers that Vert.x recomputes when - * the response body is written. All lower case. - */ - private static final Set RESPONSE_SKIP_HEADERS = Set.of( - "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", - "te", "trailer", "transfer-encoding", "upgrade", "content-length"); - - /** - * Disposable stopgap mitigation: cap the buffered request body the interim - * proxy accepts, so an unbounded upload cannot exhaust heap before Plan 04 - * replaces this edge with the real streaming pipeline. An over-limit body is - * rejected by the Vert.x {@link BodyHandler} with {@code 413 Payload Too Large}. - */ - private static final long MAX_REQUEST_BODY_BYTES = 1024L * 1024L; - - /** - * Disposable stopgap mitigation: bound each upstream request so a slow or hung - * upstream cannot pin a virtual thread indefinitely. On expiry the JDK - * {@link HttpClient} throws {@link java.net.http.HttpTimeoutException} (an - * {@link IOException}), which surfaces to the client as {@code 502}. Superseded - * by Plan 04's timeouts-from-config. - */ - private static final Duration UPSTREAM_REQUEST_TIMEOUT = Duration.ofSeconds(30); - - /** - * Test-only seam: JVM system property that overrides {@link #UPSTREAM_REQUEST_TIMEOUT} - * (value in milliseconds) so the timeout-trips behaviour can be exercised without a - * multi-second wait. This is a throwaway JVM property, NOT gateway configuration — - * config-driven upstream timeouts are Plan 04's edge and are deliberately not added here. - */ - private static final String UPSTREAM_REQUEST_TIMEOUT_MS_PROPERTY = - "apisheriff.proxy.upstream-request-timeout-ms"; - - private final RouteTable routeTable; - private final ExecutorService virtualThreadExecutor; - private final HttpClient httpClient; - - /** - * Creates the proxy route. - * - * @param routeTable the assembled route table sourcing each route's - * upstream - * @param virtualThreadExecutor the Quarkus-managed virtual-thread executor - * (thread names use {@code quarkus.virtual-threads.name-prefix}) - */ - @Inject - public ProxyRoute(RouteTable routeTable, @VirtualThreads ExecutorService virtualThreadExecutor) { - this.routeTable = routeTable; - this.virtualThreadExecutor = virtualThreadExecutor; - this.httpClient = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(10)) - .followRedirects(HttpClient.Redirect.NEVER) - .build(); - } - - /** - * Registers one catch-all proxy route per {@code path_prefix} in the route - * table on the data-plane router at startup. - * - * @param router the Vert.x web router, observed during Quarkus startup - */ - public void registerRoutes(@Observes Router router) { - for (ResolvedRoute route : routeTable.routes()) { - String routePath = stripTrailingSlash(route.pathPrefix()) + "/*"; - router.route(routePath) - .handler(BodyHandler.create().setBodyLimit(MAX_REQUEST_BODY_BYTES)) - .handler(ctx -> virtualThreadExecutor.execute(() -> forward(ctx))); - LOGGER.info(INFO.ROUTE_REGISTERED, route.pathPrefix(), upstreamBaseUrl(route.upstream())); - } - } - - /** - * Forwards a single request to the upstream resolved for its matching route. - * Runs on a virtual thread; the response is written back on the Vert.x context. - * - * @param ctx the routing context of the matched request - */ - private void forward(RoutingContext ctx) { - String rawUri = ctx.request().uri(); - int queryStart = rawUri.indexOf('?'); - String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart); - String rawQuery = queryStart < 0 ? "" : rawUri.substring(queryStart); - - Optional match = routeTable.lookup(rawPath); - if (match.isEmpty()) { - ctx.vertx().runOnContext(v -> { - if (!ctx.response().ended()) { - ctx.response().setStatusCode(404).end(); - } - }); - return; - } - - String prefix = stripTrailingSlash(match.get().pathPrefix()); - String upstreamBaseUrl = upstreamBaseUrl(match.get().upstream()); - // Everything that can throw (substring, URI parsing, send) stays inside the - // try so any failure yields a 502 rather than an uncaught error that would - // leave the client hanging on the virtual thread. - String target = upstreamBaseUrl; - try { - String remainder = rawPath.substring(prefix.length()); - target = upstreamBaseUrl + remainder + rawQuery; - - HttpRequest upstreamRequest = buildUpstreamRequest(ctx, target); - HttpResponse upstreamResponse = httpClient.send(upstreamRequest, HttpResponse.BodyHandlers.ofByteArray()); - ctx.vertx().runOnContext(v -> writeUpstreamResponse(ctx, upstreamResponse)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - failBadGateway(ctx, target, e); - } catch (IOException | IllegalArgumentException | IndexOutOfBoundsException e) { - failBadGateway(ctx, target, e); - } - } - - private HttpRequest buildUpstreamRequest(RoutingContext ctx, String target) { - HttpServerRequest request = ctx.request(); - Buffer body = ctx.body() == null ? null : ctx.body().buffer(); - HttpRequest.BodyPublisher bodyPublisher = body == null || body.length() == 0 - ? HttpRequest.BodyPublishers.noBody() - : HttpRequest.BodyPublishers.ofByteArray(body.getBytes()); - - HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target)) - .timeout(resolveUpstreamRequestTimeout()); - request.headers().forEach(header -> { - if (!REQUEST_SKIP_HEADERS.contains(header.getKey().toLowerCase())) { - builder.header(header.getKey(), header.getValue()); - } - }); - return builder.method(request.method().name(), bodyPublisher).build(); - } - - private void writeUpstreamResponse(RoutingContext ctx, HttpResponse upstreamResponse) { - HttpServerResponse response = ctx.response(); - response.setStatusCode(upstreamResponse.statusCode()); - upstreamResponse.headers().map().forEach((name, values) -> { - if (!RESPONSE_SKIP_HEADERS.contains(name.toLowerCase())) { - // headers().add (not putHeader) so multi-valued headers such as - // Set-Cookie are all preserved rather than collapsed to the last. - values.forEach(value -> response.headers().add(name, value)); - } - }); - response.end(Buffer.buffer(upstreamResponse.body())); - } - - private void failBadGateway(RoutingContext ctx, String target, Exception cause) { - LOGGER.warn(cause, WARN.UPSTREAM_FAILURE, target, cause.getMessage()); - ctx.vertx().runOnContext(v -> { - if (!ctx.response().ended()) { - ctx.response().setStatusCode(502).end(); - } - }); - } - - private static String upstreamBaseUrl(ResolvedUpstream upstream) { - return stripTrailingSlash("%s://%s:%d%s".formatted(upstream.scheme(), upstream.host(), upstream.port(), - upstream.basePath())); - } - - private static String stripTrailingSlash(String value) { - return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; - } - - /** - * Resolves the upstream request timeout, honouring the {@value #UPSTREAM_REQUEST_TIMEOUT_MS_PROPERTY} - * test-only override when set and falling back to {@link #UPSTREAM_REQUEST_TIMEOUT} otherwise. - * {@link Long#getLong(String, long)} returns the default when the property is absent or unparseable. - * A non-positive configured value (0 or negative) is rejected in favour of the default, because - * {@link HttpRequest.Builder#timeout(Duration)} throws {@link IllegalArgumentException} for a - * non-positive duration — which would otherwise surface as a {@code 502} on every proxied request - * rather than as a clear misconfiguration fallback. - * Read per request so a test can set the override immediately before a call without depending on - * class-load or Quarkus-boot ordering. - */ - private static Duration resolveUpstreamRequestTimeout() { - long configuredMillis = Long.getLong(UPSTREAM_REQUEST_TIMEOUT_MS_PROPERTY, UPSTREAM_REQUEST_TIMEOUT.toMillis()); - if (configuredMillis <= 0) { - return UPSTREAM_REQUEST_TIMEOUT; - } - return Duration.ofMillis(configuredMillis); - } -} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/BasicChecksStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/BasicChecksStage.java new file mode 100644 index 0000000..5356133 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/BasicChecksStage.java @@ -0,0 +1,134 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Objects; + + +import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.http.security.exceptions.UrlSecurityException; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.http.security.pipeline.PipelineFactory; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; + +/** + * Stage 1 — the baseline cui-http security filter plus collection-limit fast-reject, run for + * every request before route selection. + *

+ * The stage validates the raw path, every query-parameter value, and every header name / value + * through the shared {@link PipelineFactory.PipelineSet} built from the gateway's default + * {@link SecurityConfiguration}. The path pipeline yields the single canonical path + * ({@link PipelineRequest#canonicalPath(String)}) that GW-01 requires every later stage to consume. + * A pipeline violation ({@link UrlSecurityException}) becomes a + * {@link EventType#SECURITY_FILTER_VIOLATION} (400); a parameter- or header-count overflow beyond + * the configured caps becomes a {@link EventType#PARAMETER_LIMIT_EXCEEDED} (400) — both without ever + * echoing the offending value. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class BasicChecksStage { + + private final SecurityConfiguration configuration; + private final PipelineFactory.PipelineSet pipelines; + + /** + * @param configuration the gateway's default inbound validation policy + * @param eventCounter the shared cui-http security event counter (never a local instance) + */ + public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter) { + this.configuration = Objects.requireNonNull(configuration, "configuration"); + this.pipelines = PipelineFactory.createCommonPipelines(configuration, + Objects.requireNonNull(eventCounter, "eventCounter")); + } + + /** + * Runs the baseline filter, records the canonical path, and enforces collection caps. + * + * @param request the in-flight request context + * @throws GatewayException with {@link EventType#SECURITY_FILTER_VIOLATION} on a pipeline + * violation, or {@link EventType#PARAMETER_LIMIT_EXCEEDED} on a + * parameter- or header-count overflow + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + enforceCollectionLimits(request); + String canonical = validatePath(request.requestPath()); + validateParameters(request.queryParameters()); + validateHeaders(request.headers()); + request.canonicalPath(canonical); + } + + private void enforceCollectionLimits(PipelineRequest request) { + long paramCount = request.queryParameters().values().stream().mapToLong(List::size).sum(); + if (paramCount > configuration.maxParameterCount()) { + throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED, + "Query-parameter count %d exceeds cap %d".formatted(paramCount, configuration.maxParameterCount())); + } + long headerCount = request.headers().values().stream().mapToLong(List::size).sum(); + if (headerCount > configuration.maxHeaderCount()) { + throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED, + "Header count %d exceeds cap %d".formatted(headerCount, configuration.maxHeaderCount())); + } + } + + private String validatePath(String rawPath) { + try { + return pipelines.urlPathPipeline().validate(rawPath).orElse(rawPath); + } catch (UrlSecurityException violation) { + throw rejected(violation); + } + } + + private void validateParameters(Map> parameters) { + try { + for (Map.Entry> parameter : parameters.entrySet()) { + // Validate the parameter NAME as well as each value. cui-http exposes no dedicated + // URL-parameter-name pipeline to this project, so the url-parameter pipeline is reused + // against the key — closing the name-validation gap with the same rigor applied to + // values, mirroring validateHeaders which validates both header name and value. + pipelines.urlParameterPipeline().validate(parameter.getKey()); + for (String value : parameter.getValue()) { + pipelines.urlParameterPipeline().validate(value); + } + } + } catch (UrlSecurityException violation) { + throw rejected(violation); + } + } + + private void validateHeaders(Map> headers) { + try { + for (Map.Entry> header : headers.entrySet()) { + pipelines.headerNamePipeline().validate(header.getKey()); + for (String value : header.getValue()) { + pipelines.headerValuePipeline().validate(value); + } + } + } catch (UrlSecurityException violation) { + throw rejected(violation); + } + } + + private static GatewayException rejected(UrlSecurityException violation) { + return new GatewayException(EventType.SECURITY_FILTER_VIOLATION, + "Security filter rejected %s at %s".formatted(violation.getFailureType(), violation.getValidationType()), + violation); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuard.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuard.java new file mode 100644 index 0000000..2761740 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuard.java @@ -0,0 +1,75 @@ +/* + * 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 de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; + +/** + * D3b GW-01 single-canonical-path guard, run at stage 1. + *

+ * The GW-01 threat is a decoding split: the gateway authorizes one interpretation of a path while + * the upstream dispatches a different one, letting a request slip past route/verb-gate/auth/whitelist + * checks. The guard closes it by rejecting the two constructs that make a path multi-valued — + * an encoded path separator ({@code %2f} / {@code %2F}, and the encoded backslash + * {@code %5c} / {@code %5C}) and a matrix parameter ({@code ;}) — with a 400 + * {@link EventType#SECURITY_FILTER_VIOLATION}. The guard inspects the raw inbound path (before + * normalization can hide the ambiguity) and additionally asserts that stage 1 recorded exactly one + * {@link PipelineRequest#canonicalPath() canonical path}, which every later stage then consumes. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class CanonicalPathGuard { + + private static final String ENCODED_SLASH = "%2f"; + private static final String ENCODED_BACKSLASH = "%5c"; + private static final char MATRIX_PARAM = ';'; + + /** + * Rejects ambiguous path encodings and asserts the single-canonical-path invariant. + * + * @param request the in-flight request context; its canonical path must be set (stage 1) + * @throws GatewayException with {@link EventType#SECURITY_FILTER_VIOLATION} on an encoded + * separator or a matrix parameter + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + String rawPath = request.requestPath(); + String lowered = rawPath.toLowerCase(Locale.ROOT); + if (lowered.contains(ENCODED_SLASH)) { + throw violation("encoded path separator %2f"); + } + if (lowered.contains(ENCODED_BACKSLASH)) { + throw violation("encoded backslash %5c"); + } + if (rawPath.indexOf(MATRIX_PARAM) >= 0) { + throw violation("matrix parameter ';'"); + } + if (request.canonicalPath() == null) { + throw new IllegalStateException("Canonical path guard requires the canonical path resolved at stage 1"); + } + } + + private static GatewayException violation(String detail) { + return new GatewayException(EventType.SECURITY_FILTER_VIOLATION, + "Non-canonical path rejected: " + detail); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/FramingGate.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/FramingGate.java new file mode 100644 index 0000000..ea16a7f --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/FramingGate.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 java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +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; + +/** + * D3b GW-02 anti-request-smuggling / framing gate, run at stage 1 and re-runnable after any + * header mutation. + *

+ * The gate rejects the three framing-desync vectors with a 400 + * {@link EventType#SECURITY_FILTER_VIOLATION} before a request can reach the upstream: + *

    + *
  • CL+TE: {@code Content-Length} and {@code Transfer-Encoding} both present, + * the classic front-end/back-end desync primer;
  • + *
  • body on a bodyless method: a declared body (or {@code Transfer-Encoding}) + * on {@code GET} or {@code HEAD};
  • + *
  • framing/trust-header strip via {@code Connection}: a {@code Connection} + * token naming a framing header ({@code Content-Length} / {@code Transfer-Encoding} / + * {@code Host}) or a trust header ({@code Authorization} / {@code Forwarded} / + * {@code X-Forwarded-*}), which would drop that header hop-by-hop and reopen the desync.
  • + *
+ * Because the gate is stateless it is safe to re-invoke after stage 5 regenerates forwarding + * headers, re-asserting framing integrity on the mutated header set. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class FramingGate { + + private static final Set BODYLESS_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD); + + private static final Set PROTECTED_HEADERS = Set.of( + "content-length", "transfer-encoding", "host", + "authorization", "forwarded", + "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-port"); + + /** + * Re-asserts framing integrity on the current header set. + * + * @param request the in-flight request context + * @throws GatewayException with {@link EventType#SECURITY_FILTER_VIOLATION} on any framing vector + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + rejectConflictingFraming(request); + rejectBodyOnBodylessMethod(request); + rejectFramingHeaderStrip(request); + } + + private static void rejectConflictingFraming(PipelineRequest request) { + // RFC 7230 §3.3.2: a message carrying more than one Content-Length field — whether sent as + // multiple Content-Length headers or as a single field with a comma-separated value list — + // is ambiguous and MUST be rejected, since it is a classic HTTP request-smuggling vector. + // This is checked before the CL+TE coexistence rule below. + List contentLengths = request.headerValues("Content-Length"); + if (contentLengths.size() > 1) { + throw violation("Multiple Content-Length headers present"); + } + if (!contentLengths.isEmpty() && contentLengths.getFirst().indexOf(',') >= 0) { + throw violation("Content-Length header carries a comma-separated value list"); + } + if (request.hasHeader("Content-Length") && request.hasHeader("Transfer-Encoding")) { + throw violation("Content-Length and Transfer-Encoding both present"); + } + } + + private static void rejectBodyOnBodylessMethod(PipelineRequest request) { + if (BODYLESS_METHODS.contains(request.method()) + && (request.bodyPresent() || request.declaredContentLength() > 0 + || request.hasHeader("Transfer-Encoding"))) { + throw violation("Body present on bodyless method " + request.method()); + } + } + + private static void rejectFramingHeaderStrip(PipelineRequest request) { + for (String connectionValue : request.headerValues("Connection")) { + for (String token : connectionValue.split(",")) { + if (PROTECTED_HEADERS.contains(token.strip().toLowerCase(Locale.ROOT))) { + throw violation("Connection header attempts to strip protected header " + token.strip()); + } + } + } + } + + private static GatewayException violation(String detail) { + return new GatewayException(EventType.SECURITY_FILTER_VIOLATION, "Framing rejected: " + detail); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/PipelineRequest.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/PipelineRequest.java new file mode 100644 index 0000000..6f3840c --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/PipelineRequest.java @@ -0,0 +1,351 @@ +/* + * 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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +import org.jspecify.annotations.Nullable; + +/** + * The framework-agnostic request carrier threaded through the fixed pipeline. The edge builds + * one instance per inbound request from its framework-specific {@code RoutingContext} (no + * Vert.x / Quarkus type leaks in here), then hands it to stages 0-7 in order. + *

+ * Inbound fields are immutable (the raw method, path, query, headers, host, + * peer address, and body framing, all supplied by the edge). Derived fields are + * populated by the stages as the request flows: the {@linkplain #canonicalPath() + * single canonical path} (stage 1), the {@linkplain #selectedRoute() selected route} (stage 2), + * the accumulating {@linkplain #responseHeaders() response-header map} (stage 0 onward, applied + * to every response including rejections), and an optional {@linkplain #shortCircuitStatus() + * short-circuit status} (e.g. a CORS preflight answered at stage 0 before auth). + *

+ * Header lookups are case-insensitive: header names are normalized to lower case when the + * instance is built, matching RFC 7230 field-name semantics. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class PipelineRequest { + + private final HttpMethod method; + private final String requestPath; + private final Map> queryParameters; + private final Map> headers; + private final @Nullable String host; + private final @Nullable String peerAddress; + private final long declaredContentLength; + private final boolean bodyPresent; + + private final Map responseHeaders = new LinkedHashMap<>(); + private @Nullable String canonicalPath; + private @Nullable RouteRuntime selectedRoute; + private @Nullable Integer shortCircuitStatus; + + private PipelineRequest(Builder builder) { + this.method = Objects.requireNonNull(builder.method, "method"); + this.requestPath = Objects.requireNonNull(builder.requestPath, "requestPath"); + this.queryParameters = Map.copyOf(builder.queryParameters); + this.headers = normalizeHeaders(builder.headers); + this.host = builder.host; + this.peerAddress = builder.peerAddress; + this.declaredContentLength = builder.declaredContentLength; + this.bodyPresent = builder.bodyPresent; + } + + private static Map> normalizeHeaders(Map> raw) { + Map> normalized = new LinkedHashMap<>(); + raw.forEach((name, values) -> normalized.merge( + name.toLowerCase(Locale.ROOT), List.copyOf(values), + (existing, added) -> { + List merged = new ArrayList<>(existing); + merged.addAll(added); + return List.copyOf(merged); + })); + return Map.copyOf(normalized); + } + + /** + * @return a new builder for a {@link PipelineRequest} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * @return the inbound request method + */ + public HttpMethod method() { + return method; + } + + /** + * @return the raw inbound request path, exactly as received (before canonicalization) + */ + public String requestPath() { + return requestPath; + } + + /** + * @return the inbound query parameters keyed by name (values in inbound order), empty when none + */ + public Map> queryParameters() { + return queryParameters; + } + + /** + * @return the inbound headers, lower-case-keyed, values in inbound order, empty when none + */ + public Map> headers() { + return headers; + } + + /** + * @param name the header name (case-insensitive) + * @return the first value for {@code name}, or empty when the header is absent + */ + public Optional firstHeader(String name) { + List values = headers.get(name.toLowerCase(Locale.ROOT)); + return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.getFirst()); + } + + /** + * @param name the header name (case-insensitive) + * @return every value for {@code name}, empty when the header is absent + */ + public List headerValues(String name) { + return headers.getOrDefault(name.toLowerCase(Locale.ROOT), List.of()); + } + + /** + * @param name the header name (case-insensitive) + * @return {@code true} when the header is present with at least one value + */ + public boolean hasHeader(String name) { + List values = headers.get(name.toLowerCase(Locale.ROOT)); + return values != null && !values.isEmpty(); + } + + /** + * A single-valued view of the headers (first value per name) for the compiled + * {@link RouteRuntime#matcher() route matcher}, which matches on scalar header values. + * + * @return the first value per header name, lower-case-keyed + */ + public Map singleValueHeaders() { + Map single = new LinkedHashMap<>(); + headers.forEach((name, values) -> { + if (!values.isEmpty()) { + single.put(name, values.getFirst()); + } + }); + return Map.copyOf(single); + } + + /** + * @return the request host authority, or {@code null} when absent + */ + public @Nullable String host() { + return host; + } + + /** + * @return the immediate TCP peer address, or {@code null} when the edge did not supply one + */ + public @Nullable String peerAddress() { + return peerAddress; + } + + /** + * @return the declared {@code Content-Length} in bytes, or {@code -1} when none was declared + */ + public long declaredContentLength() { + return declaredContentLength; + } + + /** + * @return {@code true} when the inbound request carries (or announces) a body + */ + public boolean bodyPresent() { + return bodyPresent; + } + + /** + * @return the single canonical path resolved by stage 1, or {@code null} before stage 1 runs + */ + public @Nullable String canonicalPath() { + return canonicalPath; + } + + /** + * Records the single canonical path every later stage consumes (GW-01 single-path invariant). + * + * @param canonicalPath the validated, normalized path + */ + public void canonicalPath(String canonicalPath) { + this.canonicalPath = Objects.requireNonNull(canonicalPath, "canonicalPath"); + } + + /** + * @return the route selected by stage 2, or {@code null} before selection + */ + public @Nullable RouteRuntime selectedRoute() { + return selectedRoute; + } + + /** + * Records the route selected by stage 2. + * + * @param selectedRoute the matched route runtime + */ + public void selectedRoute(RouteRuntime selectedRoute) { + this.selectedRoute = Objects.requireNonNull(selectedRoute, "selectedRoute"); + } + + /** + * The mutable response-header map stage 0 seeds and later stages append to; the edge applies + * it to every response, including rejections. + * + * @return the accumulating response headers + */ + public Map responseHeaders() { + return responseHeaders; + } + + /** + * @return the short-circuit status the edge must return immediately (e.g. a CORS preflight + * answered at stage 0), or empty when the request should flow through the full pipeline + */ + public Optional shortCircuitStatus() { + return Optional.ofNullable(shortCircuitStatus); + } + + /** + * Marks the request as answered before reaching the upstream — the edge returns {@code status} + * with the accumulated {@link #responseHeaders()} and no upstream call. + * + * @param status the HTTP status to return + */ + public void shortCircuit(int status) { + this.shortCircuitStatus = status; + } + + /** + * Builder collecting the immutable inbound components of a {@link PipelineRequest}. + */ + public static final class Builder { + + private @Nullable HttpMethod method; + private @Nullable String requestPath; + private Map> queryParameters = Map.of(); + private Map> headers = Map.of(); + private @Nullable String host; + private @Nullable String peerAddress; + private long declaredContentLength = -1L; + private boolean bodyPresent; + + private Builder() { + } + + /** + * @param method the inbound method + * @return this builder + */ + public Builder method(HttpMethod method) { + this.method = method; + return this; + } + + /** + * @param requestPath the raw inbound path + * @return this builder + */ + public Builder requestPath(String requestPath) { + this.requestPath = requestPath; + return this; + } + + /** + * @param queryParameters the inbound query parameters, keyed by name + * @return this builder + */ + public Builder queryParameters(Map> queryParameters) { + this.queryParameters = queryParameters; + return this; + } + + /** + * @param headers the inbound headers, keyed by name (normalized to lower case at build) + * @return this builder + */ + public Builder headers(Map> headers) { + this.headers = headers; + return this; + } + + /** + * @param host the request host authority + * @return this builder + */ + public Builder host(@Nullable String host) { + this.host = host; + return this; + } + + /** + * @param peerAddress the immediate TCP peer address + * @return this builder + */ + public Builder peerAddress(@Nullable String peerAddress) { + this.peerAddress = peerAddress; + return this; + } + + /** + * @param declaredContentLength the declared {@code Content-Length}, or {@code -1} when absent + * @return this builder + */ + public Builder declaredContentLength(long declaredContentLength) { + this.declaredContentLength = declaredContentLength; + return this; + } + + /** + * @param bodyPresent whether the request carries or announces a body + * @return this builder + */ + public Builder bodyPresent(boolean bodyPresent) { + this.bodyPresent = bodyPresent; + return this; + } + + /** + * @return the assembled immutable-inbound {@link PipelineRequest} + */ + public PipelineRequest build() { + return new PipelineRequest(this); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStage.java new file mode 100644 index 0000000..fa4ec3e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStage.java @@ -0,0 +1,81 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Objects; + + +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +/** + * Stage 2 — deny-by-default route selection on the single canonical path. + *

+ * The stage walks the boot-assembled {@link RouteRuntime} list (already ordered longest-prefix-first + * by the {@code RouteRuntimeAssembler}) and selects the first route whose compiled + * {@link RouteRuntime#matcher() matcher} accepts the request — prefix and match-methods + * and host and every header matcher. Because the list is longest-prefix-first, the + * first match is the most specific route. No candidate is a hard 404 + * ({@link EventType#NO_ROUTE_MATCHED}); the gateway never forwards an unmatched request. + *

+ * Selection consumes the {@link PipelineRequest#canonicalPath() canonical path} set at stage 1 + * (GW-01 single-path invariant), never the raw inbound path. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class RouteSelectionStage { + + private final List routes; + + /** + * @param routes the boot-assembled routes, ordered longest {@code path_prefix} first + */ + public RouteSelectionStage(List routes) { + this.routes = List.copyOf(Objects.requireNonNull(routes, "routes")); + } + + /** + * Selects the most specific matching route and records it on the request. + * + * @param request the in-flight request context; its canonical path must be set (stage 1) + * @throws GatewayException with {@link EventType#NO_ROUTE_MATCHED} when no route matches + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + String canonicalPath = requireCanonicalPath(request); + Map headers = request.singleValueHeaders(); + for (RouteRuntime route : routes) { + if (route.getMatcher().matches(canonicalPath, request.method(), request.host(), headers)) { + request.selectedRoute(route); + return; + } + } + throw new GatewayException(EventType.NO_ROUTE_MATCHED, + "No route matched canonical path (method " + request.method() + ")"); + } + + private static String requireCanonicalPath(PipelineRequest request) { + String canonicalPath = request.canonicalPath(); + if (canonicalPath == null) { + throw new IllegalStateException("Route selection requires the canonical path resolved at stage 1"); + } + return canonicalPath; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStage.java new file mode 100644 index 0000000..ecbd076 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStage.java @@ -0,0 +1,120 @@ +/* + * 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.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig.Cors; + +/** + * Stage 0 — response-header preparation and CORS preflight, run before authentication. + *

+ * The stage seeds the {@link PipelineRequest#responseHeaders() response-header map} the edge + * applies to every response (success and rejection alike): {@code Strict-Transport-Security}, + * {@code X-Content-Type-Options: nosniff}, and {@code X-Frame-Options: DENY}, each emitted only when + * the global {@code security_headers} block enables it. When CORS is enabled and the inbound request + * is a preflight ({@code OPTIONS} carrying {@code Origin} and {@code Access-Control-Request-Method} + * from an allow-listed origin), the stage answers it here — {@linkplain PipelineRequest#shortCircuit(int) + * short-circuiting} with {@code 204} and the CORS response headers — so a browser preflight never + * reaches authentication or the upstream. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class SecurityHeadersStage { + + private static final int NO_CONTENT = 204; + private static final String WILDCARD_ORIGIN = "*"; + + private final Optional config; + + /** + * @param config the global {@code security_headers} posture, empty when none is configured + */ + public SecurityHeadersStage(Optional config) { + this.config = Objects.requireNonNull(config, "config"); + } + + /** + * Seeds response headers and, for an allow-listed CORS preflight, short-circuits the request. + * + * @param request the in-flight request context + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + config.ifPresent(headers -> applyResponseHeaders(request, headers)); + config.flatMap(SecurityHeadersConfig::cors) + .filter(cors -> cors.enabled().orElse(Boolean.FALSE)) + .ifPresent(cors -> applyCors(request, cors)); + } + + private static void applyResponseHeaders(PipelineRequest request, SecurityHeadersConfig headers) { + headers.hsts().ifPresent(hsts -> { + StringBuilder value = new StringBuilder("max-age=").append(hsts.maxAge().orElse(0)); + if (hsts.includeSubdomains().orElse(Boolean.FALSE)) { + value.append("; includeSubDomains"); + } + request.responseHeaders().put("Strict-Transport-Security", value.toString()); + }); + if (headers.contentTypeNosniff().orElse(Boolean.FALSE)) { + request.responseHeaders().put("X-Content-Type-Options", "nosniff"); + } + if (headers.frameDeny().orElse(Boolean.FALSE)) { + request.responseHeaders().put("X-Frame-Options", "DENY"); + } + } + + private static void applyCors(PipelineRequest request, Cors cors) { + Optional origin = request.firstHeader("Origin"); + if (origin.isEmpty() || !isOriginAllowed(cors, origin.get())) { + return; + } + request.responseHeaders().put("Access-Control-Allow-Origin", origin.get()); + if (cors.allowCredentials().orElse(Boolean.FALSE)) { + request.responseHeaders().put("Access-Control-Allow-Credentials", "true"); + } + if (isPreflight(request)) { + if (!cors.allowedMethods().isEmpty()) { + request.responseHeaders().put("Access-Control-Allow-Methods", String.join(", ", cors.allowedMethods())); + } + if (!cors.allowedHeaders().isEmpty()) { + request.responseHeaders().put("Access-Control-Allow-Headers", String.join(", ", cors.allowedHeaders())); + } + request.shortCircuit(NO_CONTENT); + } + } + + /** + * Decides whether {@code origin} is CORS-allowed. A configured {@code "*"} is a real wildcard + * (ConfigValidator permits it only when {@code allowCredentials} is false): a request {@code Origin} + * header is never literally {@code "*"}, so without this wildcard branch a configured wildcard would + * silently never match and CORS headers would never be emitted for any origin. When the wildcard is + * present the presented origin is accepted and reflected by the caller; otherwise the origin must be + * listed explicitly. + */ + private static boolean isOriginAllowed(Cors cors, String origin) { + return cors.allowedOrigins().contains(WILDCARD_ORIGIN) || cors.allowedOrigins().contains(origin); + } + + private static boolean isPreflight(PipelineRequest request) { + return request.method() == HttpMethod.OPTIONS + && request.hasHeader("Access-Control-Request-Method"); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStage.java new file mode 100644 index 0000000..27a5dc7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStage.java @@ -0,0 +1,175 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + + +import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.http.security.exceptions.UrlSecurityException; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.http.security.pipeline.PipelineFactory; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +/** + * Stage 3 — per-route thorough checks, run after the verb gate on the selected route. + *

+ * Three per-route enforcements, all on the single canonical path: + *

    + *
  • Re-run only on divergence. When the route carries its own + * {@link SecurityConfiguration} that differs from the stage-1 default, its pipelines are + * re-run on the canonical path, every parameter value, and every header value; a route whose + * config equals the default is skipped (stage 1 already covered it). Per-config pipeline sets + * are cached so shared route shapes reuse one set.
  • + *
  • {@code allowed_paths} whitelist. When the caller supplies a non-empty + * whitelist, the canonical path must match one pattern, where a {@code {name}} segment matches + * exactly one path segment; a miss is a 400 {@link EventType#PATH_NOT_ALLOWED}.
  • + *
  • {@code max_body_bytes} fast-reject. A declared {@code Content-Length} + * already exceeding the route config's {@code maxBodySize} is rejected 400 + * ({@link EventType#PARAMETER_LIMIT_EXCEEDED}) before the body is read.
  • + *
+ * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ThoroughChecksStage { + + private static final String SEGMENT_WILDCARD_PREFIX = "{"; + private static final String SEGMENT_WILDCARD_SUFFIX = "}"; + + private final SecurityConfiguration defaultConfiguration; + private final SecurityEventCounter eventCounter; + private final Map pipelineCache = new ConcurrentHashMap<>(); + + /** + * @param defaultConfiguration the stage-1 default policy, used to skip a route whose config matches + * @param eventCounter the shared cui-http security event counter (never a local instance) + */ + public ThoroughChecksStage(SecurityConfiguration defaultConfiguration, SecurityEventCounter eventCounter) { + this.defaultConfiguration = Objects.requireNonNull(defaultConfiguration, "defaultConfiguration"); + this.eventCounter = Objects.requireNonNull(eventCounter, "eventCounter"); + } + + /** + * Runs the per-route thorough checks. + * + * @param request the in-flight request context; its route must be selected (stage 2) + * @param allowedPaths the selected route's {@code allowed_paths} whitelist, empty when unrestricted + * @throws GatewayException on a divergent-pipeline violation ({@link EventType#SECURITY_FILTER_VIOLATION}), + * a whitelist miss ({@link EventType#PATH_NOT_ALLOWED}), or a body-cap breach + * ({@link EventType#PARAMETER_LIMIT_EXCEEDED}) + */ + public void process(PipelineRequest request, List allowedPaths) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(allowedPaths, "allowedPaths"); + RouteRuntime route = requireSelectedRoute(request); + String canonicalPath = requireCanonicalPath(request); + + route.getSecurityConfiguration().ifPresent(routeConfig -> { + if (!routeConfig.equals(defaultConfiguration)) { + reRunPipelines(request, routeConfig, canonicalPath); + } + enforceBodyCap(request, routeConfig); + }); + enforceAllowedPaths(canonicalPath, allowedPaths); + } + + private void reRunPipelines(PipelineRequest request, SecurityConfiguration routeConfig, String canonicalPath) { + PipelineFactory.PipelineSet pipelines = pipelineCache.computeIfAbsent(routeConfig, + config -> PipelineFactory.createCommonPipelines(config, eventCounter)); + try { + pipelines.urlPathPipeline().validate(canonicalPath); + for (List values : request.queryParameters().values()) { + for (String value : values) { + pipelines.urlParameterPipeline().validate(value); + } + } + for (List values : request.headers().values()) { + for (String value : values) { + pipelines.headerValuePipeline().validate(value); + } + } + } catch (UrlSecurityException violation) { + throw new GatewayException(EventType.SECURITY_FILTER_VIOLATION, + "Per-route filter rejected %s at %s".formatted(violation.getFailureType(), + violation.getValidationType()), + violation); + } + } + + private static void enforceBodyCap(PipelineRequest request, SecurityConfiguration routeConfig) { + long cap = routeConfig.maxBodySize(); + if (request.declaredContentLength() > cap) { + throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED, + "Declared body %d exceeds route cap %d".formatted(request.declaredContentLength(), cap)); + } + } + + private static void enforceAllowedPaths(String canonicalPath, List allowedPaths) { + if (allowedPaths.isEmpty()) { + return; + } + for (String pattern : allowedPaths) { + if (matchesPattern(canonicalPath, pattern)) { + return; + } + } + throw new GatewayException(EventType.PATH_NOT_ALLOWED, "Canonical path outside route allowed_paths"); + } + + private static boolean matchesPattern(String path, String pattern) { + String[] pathSegments = path.split("/", -1); + String[] patternSegments = pattern.split("/", -1); + if (pathSegments.length != patternSegments.length) { + return false; + } + for (int i = 0; i < patternSegments.length; i++) { + String patternSegment = patternSegments[i]; + if (isWildcard(patternSegment)) { + if (pathSegments[i].isEmpty()) { + return false; + } + } else if (!patternSegment.equals(pathSegments[i])) { + return false; + } + } + return true; + } + + private static boolean isWildcard(String segment) { + return segment.startsWith(SEGMENT_WILDCARD_PREFIX) && segment.endsWith(SEGMENT_WILDCARD_SUFFIX); + } + + private static RouteRuntime requireSelectedRoute(PipelineRequest request) { + RouteRuntime route = request.selectedRoute(); + if (route == null) { + throw new IllegalStateException("Thorough checks require the route selected at stage 2"); + } + return route; + } + + private static String requireCanonicalPath(PipelineRequest request) { + Optional canonicalPath = Optional.ofNullable(request.canonicalPath()); + return canonicalPath.orElseThrow( + () -> new IllegalStateException("Thorough checks require the canonical path resolved at stage 1")); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/VerbGateStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/VerbGateStage.java new file mode 100644 index 0000000..680faaa --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/VerbGateStage.java @@ -0,0 +1,70 @@ +/* + * 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.Objects; +import java.util.Set; +import java.util.stream.Collectors; + + +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; + +/** + * Stage 2b — the per-route verb gate, run after route selection. + *

+ * A request whose method is outside the selected route's effective {@code allowed_methods} is + * rejected 405 ({@link EventType#METHOD_NOT_ALLOWED}) with the {@code Allow} response header naming + * the permitted verbs, per RFC 7231 §6.5.5. The {@code Allow} header is seeded onto the request's + * response-header map before the rejection is thrown so the edge renders it on the 405. The upstream + * is never contacted for a rejected verb. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class VerbGateStage { + + /** + * Enforces the route's effective verb allowlist. + * + * @param request the in-flight request context; its route must be selected (stage 2) + * @throws GatewayException with {@link EventType#METHOD_NOT_ALLOWED} when the verb is not allowed + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + RouteRuntime route = requireSelectedRoute(request); + Set allowed = route.getEffectiveAllowedMethods(); + if (!allowed.contains(request.method())) { + request.responseHeaders().put("Allow", renderAllow(allowed)); + throw new GatewayException(EventType.METHOD_NOT_ALLOWED, + "Method " + request.method() + " not allowed for route " + route.getId()); + } + } + + private static String renderAllow(Set allowed) { + return allowed.stream().sorted().map(Enum::name).collect(Collectors.joining(", ")); + } + + private static RouteRuntime requireSelectedRoute(PipelineRequest request) { + RouteRuntime route = request.selectedRoute(); + if (route == null) { + throw new IllegalStateException("Verb gate requires the route selected at stage 2"); + } + return route; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/package-info.java new file mode 100644 index 0000000..562deee --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/package-info.java @@ -0,0 +1,48 @@ +/* + * 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. + */ +/** + * The framework-agnostic request pipeline: stages 0-3 of the fixed inbound flow plus the D3b + * GW-01 / GW-02 hardening gates. + *

+ * Every stage operates on the agnostic {@link de.cuioss.sheriff.api.pipeline.PipelineRequest} + * carrier the edge builds from its framework-specific request, so this package carries no + * {@code io.vertx..} / {@code io.quarkus..} / {@code jakarta..} / {@code org.eclipse.microprofile..} + * imports and stays inside the ADR-0005 framework-agnostic arch-gate rule set. Stages reject by + * throwing a typed {@link de.cuioss.sheriff.api.events.GatewayException}; the framework edge maps it + * to the RFC 9457 response and never lets a rejected request reach the upstream. + *

    + *
  • {@link de.cuioss.sheriff.api.pipeline.SecurityHeadersStage} — stage 0: response-header + * preparation and CORS preflight, before auth;
  • + *
  • {@link de.cuioss.sheriff.api.pipeline.BasicChecksStage} — stage 1: the baseline cui-http + * filter yielding the single canonical path, plus collection-limit fast-reject;
  • + *
  • {@link de.cuioss.sheriff.api.pipeline.FramingGate} — D3b GW-02 anti-smuggling framing gate;
  • + *
  • {@link de.cuioss.sheriff.api.pipeline.CanonicalPathGuard} — D3b GW-01 single-canonical-path + * guard (encoded separator / matrix parameter rejection);
  • + *
  • {@link de.cuioss.sheriff.api.pipeline.RouteSelectionStage} — stage 2: deny-by-default + * longest-prefix route selection;
  • + *
  • {@link de.cuioss.sheriff.api.pipeline.VerbGateStage} — stage 2b: the 405 verb gate with + * {@code Allow};
  • + *
  • {@link de.cuioss.sheriff.api.pipeline.ThoroughChecksStage} — stage 3: per-route divergent + * filters, {@code allowed_paths} whitelist, and {@code max_body_bytes} fast-reject.
  • + *
+ * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.pipeline; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ApiSheriffProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ApiSheriffProducer.java deleted file mode 100644 index 741c3d6..0000000 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ApiSheriffProducer.java +++ /dev/null @@ -1,47 +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.ApiSheriff; -import de.cuioss.tools.logging.CuiLogger; -import io.quarkus.runtime.annotations.RegisterForReflection; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.inject.Produces; - -/** - * CDI producer for API Sheriff components. - * This class provides CDI integration for the API Sheriff library in Quarkus applications. - * - * @author API Sheriff Team - */ -@ApplicationScoped -@RegisterForReflection -public class ApiSheriffProducer { - - private static final CuiLogger LOGGER = new CuiLogger(ApiSheriffProducer.class); - - /** - * Produces an application-scoped ApiSheriff instance. - * - * @return configured ApiSheriff instance - */ - @Produces - @ApplicationScoped - public ApiSheriff produceApiSheriff() { - LOGGER.debug("Creating ApiSheriff instance"); - return new ApiSheriff(); - } -} 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 60d7b14..c4c5009 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 @@ -36,6 +36,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 io.quarkus.runtime.annotations.RegisterForReflection; /** diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java index 0349b9d..30d0b41 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Map; + import de.cuioss.sheriff.api.config.ConfigLogMessages; import de.cuioss.sheriff.api.config.RouteTableBuilder; import de.cuioss.sheriff.api.config.load.ConfigError; @@ -34,6 +35,7 @@ import de.cuioss.sheriff.api.config.topology.TopologyResolver; import de.cuioss.sheriff.api.config.validation.ConfigValidator; import de.cuioss.tools.logging.CuiLogger; + import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; 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 new file mode 100644 index 0000000..c7cca89 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java @@ -0,0 +1,85 @@ +/* + * 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/quarkus/GatewayReadinessCheck.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayReadinessCheck.java new file mode 100644 index 0000000..f46ac29 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayReadinessCheck.java @@ -0,0 +1,114 @@ +/* + * 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 java.util.Optional; + + +import de.cuioss.sheriff.api.auth.GatewayValidator; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.Metadata; +import de.cuioss.sheriff.api.config.model.TokenValidationConfig; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.token.validation.TokenValidator; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.CreationException; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Readiness; + +/** + * SmallRye {@code @Readiness} probe served on the management port ({@code /q/health/ready}), + * reporting whether the gateway is ready to serve traffic. + *

+ * Readiness reflects two facts, per {@code architecture.adoc} § Metrics (Health): + *

    + *
  • Configuration — the {@link GatewayConfig} bean is present, which proves + * the boot-time load-and-validate pipeline in {@link ConfigProducer} succeeded (an invalid + * configuration aborts startup, so the application would never reach readiness with an + * unbound config);
  • + *
  • JWKS — when a {@code token_validation} block is configured, the gateway's + * own {@link GatewayValidator}-qualified {@link TokenValidator} resolves successfully. Building + * that validator requires every configured issuer to declare a usable JWKS source, so a + * resolution failure ({@link GatewayException}) marks the probe {@code DOWN} with the cause. + * A gateway with no {@code token_validation} block needs no bearer validation, so JWKS is + * reported {@code not-applicable} and does not gate readiness.
  • + *
+ * The validator is resolved lazily through an {@link Instance} so a misconfigured JWKS source + * yields a clean {@code DOWN} response rather than failing this probe's own construction. + * + * @author API Sheriff Team + * @since 1.0 + */ +@Readiness +@ApplicationScoped +public class GatewayReadinessCheck implements HealthCheck { + + private static final String CHECK_NAME = "gateway-readiness"; + private static final String DATA_CONFIG = "config"; + private static final String DATA_CONFIG_VERSION = "config_version"; + private static final String DATA_JWKS = "jwks"; + private static final String DATA_ISSUERS = "issuers"; + private static final String DATA_ERROR = "error"; + + private final GatewayConfig gatewayConfig; + private final Instance gatewayValidator; + + /** + * @param gatewayConfig the bound, boot-validated gateway document + * @param gatewayValidator the lazily-resolved gateway bearer-token validator + */ + @Inject + public GatewayReadinessCheck(GatewayConfig gatewayConfig, + @GatewayValidator Instance gatewayValidator) { + this.gatewayConfig = gatewayConfig; + this.gatewayValidator = gatewayValidator; + } + + /** + * {@inheritDoc} + * + * @return {@code UP} when the configuration is bound and — if bearer validation is configured — + * the JWKS-backed validator resolves; {@code DOWN} carrying the failure cause otherwise + */ + @Override + public HealthCheckResponse call() { + HealthCheckResponseBuilder builder = HealthCheckResponse.named(CHECK_NAME) + .withData(DATA_CONFIG, "loaded"); + gatewayConfig.metadata().flatMap(Metadata::configVersion) + .ifPresent(version -> builder.withData(DATA_CONFIG_VERSION, version)); + + Optional tokenValidation = gatewayConfig.tokenValidation(); + if (tokenValidation.isEmpty()) { + return builder.withData(DATA_JWKS, "not-applicable").up().build(); + } + + int issuerCount = tokenValidation.get().issuers().size(); + builder.withData(DATA_ISSUERS, issuerCount); + try { + gatewayValidator.get(); + return builder.withData(DATA_JWKS, "ready").up().build(); + } catch (GatewayException | CreationException failure) { + return builder.withData(DATA_JWKS, "unavailable") + .withData(DATA_ERROR, String.valueOf(failure.getMessage())) + .down().build(); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/SheriffMetrics.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/SheriffMetrics.java new file mode 100644 index 0000000..ba2850f --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/SheriffMetrics.java @@ -0,0 +1,169 @@ +/* + * 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 java.time.Duration; +import java.util.Objects; + + +import de.cuioss.http.security.core.UrlSecurityFailureType; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.sheriff.api.events.EventCategory; + +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * The framework-bound metrics adapter (ADR-0005 seam) that surfaces the gateway's request, + * error, security, and upstream signals as Micrometer meters on the management port, exposed + * as Prometheus data via {@code quarkus-micrometer-registry-prometheus}. + *

+ * The meter names are the fixed contract named in {@code architecture.adoc} § Metrics: + *

    + *
  • {@value #REQUESTS_TOTAL}{@code {route,method,status_family}} — the "paths" view;
  • + *
  • {@value #REQUEST_DURATION_SECONDS}{@code {route}} — per-route latency distribution;
  • + *
  • {@value #ERRORS_TOTAL}{@code {route,category}} — the "errors" view, keyed by + * {@link EventCategory};
  • + *
  • {@value #SECURITY_EVENTS_TOTAL}{@code {failure_type}} — the {@code cui-http} + * security-filter counts;
  • + *
  • {@value #UPSTREAM_DURATION_SECONDS}{@code {route}} — downstream-call time, separated + * from gateway overhead.
  • + *
+ * Route cardinality is bounded (route id is a config-fixed label; unmatched requests share the + * fixed {@value #NO_ROUTE} value), so every meter is safe to keep always on. Each record call + * resolves its meter through the {@link MeterRegistry}, which caches meters by name and tag set, + * so the recorder holds no per-meter state and is thread-safe by delegation. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class SheriffMetrics { + + /** Counter of requests per route, method, and status family — the "paths" view. */ + public static final String REQUESTS_TOTAL = "sheriff_requests_total"; + /** Per-route request latency distribution (timer). */ + public static final String REQUEST_DURATION_SECONDS = "sheriff_request_duration_seconds"; + /** Counter of rejected / failed requests keyed by {@link EventCategory}. */ + public static final String ERRORS_TOTAL = "sheriff_errors_total"; + /** The {@code cui-http} security-filter counts, per failure type. */ + public static final String SECURITY_EVENTS_TOTAL = "sheriff_security_events_total"; + /** Per-route downstream-call time (timer). */ + public static final String UPSTREAM_DURATION_SECONDS = "sheriff_upstream_duration_seconds"; + + /** The bounded label value shared by requests that matched no route. */ + public static final String NO_ROUTE = ""; + + private static final String TAG_ROUTE = "route"; + private static final String TAG_METHOD = "method"; + private static final String TAG_STATUS_FAMILY = "status_family"; + private static final String TAG_CATEGORY = "category"; + private static final String TAG_FAILURE_TYPE = "failure_type"; + + private final MeterRegistry registry; + + /** + * @param registry the Micrometer registry the meters are registered against + */ + @Inject + public SheriffMetrics(MeterRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + /** + * Counts one completed request against {@link #REQUESTS_TOTAL}. + * + * @param route the config-fixed route id, or {@link #NO_ROUTE} when unmatched + * @param method the request method (e.g. {@code GET}) + * @param statusFamily the response status family ({@code 2xx} / {@code 3xx} / {@code 4xx} / + * {@code 5xx}) + */ + public void recordRequest(String route, String method, String statusFamily) { + registry.counter(REQUESTS_TOTAL, + TAG_ROUTE, route, TAG_METHOD, method, TAG_STATUS_FAMILY, statusFamily).increment(); + } + + /** + * Records the total time spent handling a request against {@link #REQUEST_DURATION_SECONDS}. + * + * @param route the config-fixed route id, or {@link #NO_ROUTE} when unmatched + * @param duration the elapsed request time + */ + public void recordRequestDuration(String route, Duration duration) { + registry.timer(REQUEST_DURATION_SECONDS, TAG_ROUTE, route).record(duration); + } + + /** + * Counts one rejected / failed request against {@link #ERRORS_TOTAL}, keyed by category slug. + * + * @param route the config-fixed route id, or {@link #NO_ROUTE} when unmatched + * @param category the failure category + */ + public void recordError(String route, EventCategory category) { + registry.counter(ERRORS_TOTAL, TAG_ROUTE, route, TAG_CATEGORY, category.slug()).increment(); + } + + /** + * Binds the boot-shared {@code cui-http} {@link SecurityEventCounter} to the registry, exposing its + * per-{@link UrlSecurityFailureType} violation counts as the {@link #SECURITY_EVENTS_TOTAL} meter. + * One {@link FunctionCounter} is registered per failure type, so the {@code failure_type} label + * cardinality is fixed at the {@link UrlSecurityFailureType} enum (never operator-controlled input), + * keeping the meter safe to keep always on. Each function counter reads the live + * {@link SecurityEventCounter#getCount(UrlSecurityFailureType) count} the security-filter stages + * accumulate on the hot path, so a security-relevant rejection moves the meter on the next scrape + * without any per-request recording call. + *

+ * Called once at boot with the single {@link SecurityEventCounter} instance shared across the + * baseline / thorough security-filter stages and the forwarded-header resolver, so the meter + * reflects every {@code cui-http} violation the gateway counts, not only those observed at the edge. + * + * @param securityEventCounter the boot-shared cui-http security event counter (never a local instance) + */ + public void bindSecurityEventCounter(SecurityEventCounter securityEventCounter) { + Objects.requireNonNull(securityEventCounter, "securityEventCounter"); + for (UrlSecurityFailureType failureType : UrlSecurityFailureType.values()) { + FunctionCounter.builder(SECURITY_EVENTS_TOTAL, securityEventCounter, + counter -> counter.getCount(failureType)) + .tag(TAG_FAILURE_TYPE, failureType.name()) + .register(registry); + } + } + + /** + * Records the time spent in the downstream call against {@link #UPSTREAM_DURATION_SECONDS}. + * + * @param route the config-fixed route id + * @param duration the elapsed upstream-call time + */ + public void recordUpstreamDuration(String route, Duration duration) { + registry.timer(UPSTREAM_DURATION_SECONDS, TAG_ROUTE, route).record(duration); + } + + /** + * Classifies an HTTP status code into its bounded {@code status_family} label + * ({@code 1xx} / {@code 2xx} / {@code 3xx} / {@code 4xx} / {@code 5xx}) for the + * {@link #REQUESTS_TOTAL} counter, keeping the {@code status_family} label cardinality fixed + * regardless of the concrete status. + * + * @param statusCode the response HTTP status code (e.g. {@code 200}, {@code 404}) + * @return the leading-digit status family (e.g. {@code "2xx"}, {@code "4xx"}) + */ + public static String statusFamily(int statusCode) { + return (statusCode / 100) + "xx"; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/HttpProtocolProcessor.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/HttpProtocolProcessor.java new file mode 100644 index 0000000..a53b79f --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/HttpProtocolProcessor.java @@ -0,0 +1,50 @@ +/* + * 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 standard-verb HTTP processor. Serves every proxyable verb in {@link HttpMethod} and + * is reused for GraphQL-over-HTTP routes (the {@link ProtocolProcessorRegistry} maps both + * {@code HTTP} and {@code GRAPHQL} to a single shared instance). + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class HttpProtocolProcessor implements ProtocolProcessor { + + private static final Set STANDARD_METHODS = Set.copyOf(EnumSet.allOf(HttpMethod.class)); + + @Override + public String id() { + return "http"; + } + + @Override + public Set standardMethods() { + return STANDARD_METHODS; + } + + @Override + public boolean supports(HttpMethod method) { + return STANDARD_METHODS.contains(method); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessor.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessor.java new file mode 100644 index 0000000..5e41990 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessor.java @@ -0,0 +1,48 @@ +/* + * 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.Set; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; + +/** + * Strategy carrying the verb semantics for a route's application protocol. The + * {@link ProtocolProcessorRegistry} selects one processor per route at boot; unsupported + * protocols are rejected at boot rather than represented here. + * + * @author API Sheriff Team + * @since 1.0 + */ +public interface ProtocolProcessor { + + /** + * @return the stable identifier of this processor (e.g. {@code "http"}) + */ + String id(); + + /** + * @return the standard verb set this processor serves + */ + Set standardMethods(); + + /** + * @param method the request method + * @return {@code true} when this processor serves {@code method} + */ + boolean supports(HttpMethod 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 new file mode 100644 index 0000000..f358c35 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java @@ -0,0 +1,78 @@ +/* + * 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.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + + +import de.cuioss.sheriff.api.config.model.Protocol; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; + +/** + * 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. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ProtocolProcessorRegistry { + + private final Map processors; + + /** + * Builds the default registry: a shared HTTP processor for {@code HTTP} and {@code GRAPHQL}. + */ + public ProtocolProcessorRegistry() { + HttpProtocolProcessor http = new HttpProtocolProcessor(); + Map map = new EnumMap<>(Protocol.class); + map.put(Protocol.HTTP, http); + map.put(Protocol.GRAPHQL, http); + 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}). + * + * @param protocol the route's effective protocol + * @param routeId the route id, for the failure message + * @return the processor serving {@code protocol} + * @throws GatewayException carrying {@link EventType#CONFIG_INVALID} when the protocol is unsupported + */ + public ProtocolProcessor require(Protocol protocol, String routeId) { + Objects.requireNonNull(protocol, "protocol"); + ProtocolProcessor processor = processors.get(protocol); + if (processor == null) { + throw new GatewayException(EventType.CONFIG_INVALID, + "Route '" + routeId + "' requests protocol " + protocol + " which is not yet implemented"); + } + return processor; + } + + /** + * @param protocol the protocol to test + * @return {@code true} when a processor is registered for {@code protocol} + */ + public boolean supports(Protocol protocol) { + return processors.containsKey(protocol); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteMatcher.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteMatcher.java new file mode 100644 index 0000000..c9ecc16 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteMatcher.java @@ -0,0 +1,134 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher; + +import org.jspecify.annotations.Nullable; + +/** + * The compiled form of a route's {@code match} block, assembled once at boot. Matchers + * compose with AND semantics: a request matches only when the path is at or below the + * prefix on a segment boundary AND (when constrained) the method, host, and every header + * matcher hold. + *

+ * This is the match test only; the effective {@code allowed_methods} verb gate (405) is + * carried separately on {@link RouteRuntime}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class RouteMatcher { + + private final String pathPrefix; + private final Set matchMethods; + private final Optional host; + private final List headers; + + private RouteMatcher(String pathPrefix, Set matchMethods, Optional host, + List headers) { + this.pathPrefix = pathPrefix; + this.matchMethods = matchMethods; + this.host = host; + this.headers = headers; + } + + /** + * Compiles a matcher from the resolved {@code match} block. + * + * @param match the resolved match configuration + * @return the compiled matcher + */ + public static RouteMatcher from(MatchConfig match) { + Objects.requireNonNull(match, "match"); + Set methods = match.methods().isEmpty() + ? EnumSet.noneOf(HttpMethod.class) + : EnumSet.copyOf(match.methods()); + return new RouteMatcher(match.pathPrefix(), methods, match.host(), List.copyOf(match.headers())); + } + + /** + * @return the route's literal {@code path_prefix} + */ + public String pathPrefix() { + return pathPrefix; + } + + /** + * Tests whether {@code path} is covered by this route's prefix on a segment boundary. + * + * @param path the request path + * @return {@code true} when the path is at or below the prefix + */ + public boolean matchesPrefix(String path) { + Objects.requireNonNull(path, "path"); + if (path.equals(pathPrefix)) { + return true; + } + return pathPrefix.endsWith("/") ? path.startsWith(pathPrefix) : path.startsWith(pathPrefix + "/"); + } + + /** + * Applies the full matcher set (prefix AND method AND host AND headers). + * + * @param path the request path + * @param method the request method + * @param requestHost the request host, {@code null} when absent + * @param requestHeaders the request headers, keyed by name + * @return {@code true} when every declared matcher holds + */ + public boolean matches(String path, HttpMethod method, @Nullable String requestHost, + Map requestHeaders) { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(requestHeaders, "requestHeaders"); + if (!matchesPrefix(path)) { + return false; + } + if (!matchMethods.isEmpty() && !matchMethods.contains(method)) { + return false; + } + if (host.isPresent() && !host.get().equals(requestHost)) { + return false; + } + return headersMatch(requestHeaders); + } + + private boolean headersMatch(Map requestHeaders) { + for (HeaderMatcher header : headers) { + String actual = requestHeaders.get(header.name()); + Optional expectedValue = header.value(); + if (expectedValue.isPresent()) { + if (!expectedValue.get().equals(actual)) { + return false; + } + } else if (header.present().orElse(Boolean.FALSE) && actual == null) { + return false; + } + } + return true; + } +} 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 new file mode 100644 index 0000000..290d5ea --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java @@ -0,0 +1,113 @@ +/* + * 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.List; +import java.util.Optional; +import java.util.Set; + + +import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.sheriff.api.config.model.AuthConfig; +import de.cuioss.sheriff.api.config.model.ForwardConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.Protocol; +import de.cuioss.sheriff.api.config.model.ResolvedUpstream; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; + +import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.http.HttpClient; +import lombok.Builder; +import lombok.Getter; + +/** + * The immutable, boot-time-compiled runtime for one route. The request pipeline consumes + * these already-resolved values on the hot path and never re-derives inheritance. + *

+ * Framework-coupled by design (operator resolution 2026-07-19). Unlike the + * agnostic core, this type holds the shared data-plane Vert.x {@link HttpClient} reference and + * the per-route SmallRye Fault-Tolerance {@link Guard} directly — there is no separate + * {@code edge.RouteBinding}. Consequently the {@code routing} package is excluded from the + * ADR-0005 framework-agnostic arch-gate. The client and guard are shared instances handed in + * by the {@code RouteRuntimeAssembler}; this type holds references, it does not construct them. + * + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +@Getter +public final class RouteRuntime { + + /** The route id (also the bounded metrics {@code route} label). */ + private final String id; + + /** The effective protocol. */ + private final Protocol protocol; + + /** The compiled matcher set (prefix, methods, host, headers). */ + private final RouteMatcher matcher; + + /** The protocol processor serving this route (shared across same-protocol routes). */ + private final ProtocolProcessor protocolProcessor; + + /** The effective {@code allowed_methods} verb allowlist for the stage-2b verb gate (405). */ + private final Set effectiveAllowedMethods; + + /** The materialized auth posture. */ + private final AuthConfig effectiveAuth; + + /** The required scopes enforced for this route (empty when none). */ + private final List requiredScopes; + + /** The deduplicated cui-http security configuration, empty when the route declares none. */ + private final Optional securityConfiguration; + + /** The effective response-header posture, empty when none resolves. */ + private final Optional securityHeaders; + + /** + * The effective, deny-by-default {@code forward} allowlist consumed by stage 5 — the + * per-route {@code headers_allow} / {@code query_allow} / {@code set_headers} sets resolved + * once at boot. An empty {@link ForwardConfig} when the route declares no {@code forward} block. + */ + @Builder.Default + private final ForwardConfig effectiveForward = ForwardConfig.builder().build(); + + /** + * The effective {@code allowed_paths} whitelist enforced by stage 3 — the per-route path + * allowlist resolved once at boot from the route's {@code security_filter} block. Empty when + * the route declares no whitelist (unrestricted). Carried separately from + * {@link #securityConfiguration} because {@code allowed_paths} is a gateway concept the cui-http + * {@link SecurityConfiguration} does not model. + */ + @Builder.Default + private final List effectiveAllowedPaths = List.of(); + + /** The materialized upstream-retry toggle. */ + private final boolean retryEnabled; + + /** The materialized HTTP-304 not-modified toggle. */ + private final boolean notModifiedEnabled; + + /** The resolved upstream target. */ + private final ResolvedUpstream upstream; + + /** The shared Vert.x client for this route's upstream tuple (one instance per tuple). */ + private final HttpClient httpClient; + + /** The shared SmallRye Fault-Tolerance guard for this route's resilience shape. */ + private final Guard resilienceGuard; +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/package-info.java new file mode 100644 index 0000000..6e265c8 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/package-info.java @@ -0,0 +1,39 @@ +/* + * 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. + */ +/** + * The compiled per-route runtime model. + *

+ * {@link de.cuioss.sheriff.api.routing.RouteRuntime} is the immutable, boot-assembled runtime + * for one route; {@link de.cuioss.sheriff.api.routing.RouteMatcher} is its compiled matcher; + * and the {@link de.cuioss.sheriff.api.routing.ProtocolProcessor} / + * {@link de.cuioss.sheriff.api.routing.HttpProtocolProcessor} / + * {@link de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry} triad selects and shares + * the protocol strategy, rejecting unsupported protocols at boot. + *

+ * Deliberately framework-coupled (operator resolution 2026-07-19). Because + * {@code RouteRuntime} holds the shared Vert.x {@code HttpClient} reference and the per-route + * SmallRye Fault-Tolerance guard directly, this package is excluded from the ADR-0005 + * framework-agnostic arch-gate rule set (which covers {@code config.model}, + * {@code config.validation}, {@code events}, {@code forward}, and {@code pipeline} only). The + * deviation is recorded in {@code architecture.adoc}. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.api.routing; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/ApiSheriffTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/ApiSheriffTest.java deleted file mode 100644 index 3d172ab..0000000 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/ApiSheriffTest.java +++ /dev/null @@ -1,33 +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; - -import static org.junit.jupiter.api.Assertions.assertEquals; - - -import org.junit.jupiter.api.Test; - -/** - * Test class for {@link ApiSheriff}. - */ -class ApiSheriffTest { - - @Test - void shouldReturnStatus() { - ApiSheriff sheriff = new ApiSheriff(); - assertEquals("API Sheriff is operational", sheriff.getStatus()); - } -} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/arch/FrameworkAgnosticArchTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/arch/FrameworkAgnosticArchTest.java new file mode 100644 index 0000000..87b5f6e --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/arch/FrameworkAgnosticArchTest.java @@ -0,0 +1,100 @@ +/* + * 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.arch; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * ADR-0005 framework-agnostic package-boundary gate. Asserts that the agnostic + * core packages ({@code config.model}, {@code config.validation}, {@code events}, + * {@code forward}, {@code pipeline}) carry no framework imports, so the request + * pipeline stays portable across the framework edge. + *

+ * The {@code routing} package is deliberately excluded from the agnostic set + * (operator resolution 2026-07-19): {@code routing.RouteRuntime} holds the shared Vert.x + * {@code HttpClient} reference and the per-route SmallRye Fault-Tolerance guard by design, + * so it is framework-coupled and must NOT be asserted framework-agnostic here. + *

+ * This is a plain JUnit 5 test (no ArchUnit {@code @AnalyzeClasses} runner) so it runs in + * both {@code test} and {@code verify -Ppre-commit}, wiring the boundary into the quality gate. + * + * @since 1.0 + */ +class FrameworkAgnosticArchTest { + + private static final String BASE_PACKAGE = "de.cuioss.sheriff.api"; + + /** + * The agnostic core packages the ADR-0005 gate protects. {@code routing} is intentionally + * absent — see the class Javadoc. + */ + private static final String[] AGNOSTIC_PACKAGES = { + "de.cuioss.sheriff.api.config.model..", + "de.cuioss.sheriff.api.config.validation..", + "de.cuioss.sheriff.api.events..", + "de.cuioss.sheriff.api.forward..", + "de.cuioss.sheriff.api.pipeline.." + }; + + /** Framework packages that an agnostic-core class must never depend on. */ + private static final String[] FRAMEWORK_PACKAGES = { + "io.quarkus..", + "io.vertx..", + "jakarta.enterprise..", + "jakarta.inject..", + "org.eclipse.microprofile..", + "io.micrometer.." + }; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(BASE_PACKAGE); + + @Test + @DisplayName("ADR-0005: agnostic core packages must not depend on framework packages") + void agnosticPackagesMustNotDependOnFrameworks() { + ArchRule rule = noClasses() + .that().resideInAnyPackage(AGNOSTIC_PACKAGES) + .should().dependOnClassesThat().resideInAnyPackage(FRAMEWORK_PACKAGES) + .because("ADR-0005 requires config.model, config.validation, events, forward, and " + + "pipeline to remain framework-agnostic (routing is excluded by design)") + .allowEmptyShould(true); + + rule.check(PRODUCTION_CLASSES); + } + + @Test + @DisplayName("ADR-0005 gate detects a deliberate framework dependency (negative control)") + void gateFailsOnFrameworkDependency() { + ArchRule ruleAgainstFrameworkCoupledPackage = noClasses() + .that().resideInAPackage("de.cuioss.sheriff.api.quarkus..") + .should().dependOnClassesThat().resideInAnyPackage(FRAMEWORK_PACKAGES) + .allowEmptyShould(true); + + assertThrows(AssertionError.class, + () -> ruleAgainstFrameworkCoupledPackage.check(PRODUCTION_CLASSES), + "The gate must fail when a covered package depends on a framework package — " + + "the framework-coupled quarkus package is the standing negative control"); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/AuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/AuthenticationStageTest.java new file mode 100644 index 0000000..34ddcb3 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/AuthenticationStageTest.java @@ -0,0 +1,152 @@ +/* + * 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.auth; + +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 de.cuioss.sheriff.api.config.model.AuthConfig; +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.pipeline.PipelineRequest; +import de.cuioss.sheriff.api.routing.RouteRuntime; +import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.sheriff.token.validation.test.TestTokenHolder; +import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators; +import de.cuioss.test.generator.junit.EnableGeneratorController; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@EnableGeneratorController +@DisplayName("AuthenticationStage — stage 4 offline bearer-token validation") +class AuthenticationStageTest { + + private static final String ABSENT_SCOPE = "gateway:definitely-absent-scope-xyz"; + + @Test + @DisplayName("passes a require:none route without inspecting any token") + void passesRequireNone() { + // Arrange + AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next()); + PipelineRequest request = request(authConfig("none", List.of()), Map.of()); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request)); + } + + @Test + @DisplayName("passes a require:none route without ever resolving the lazy validator") + void passesRequireNoneWithoutResolvingValidator() { + // Arrange — a provider that fails when resolved proves that a require:none route never + // triggers the (potentially config-absent) validator producer via Provider#get(). + AuthenticationStage stage = new AuthenticationStage(() -> { + throw new AssertionError("require:none must not resolve the token validator"); + }); + PipelineRequest request = request(authConfig("none", List.of()), Map.of()); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request)); + } + + @Test + @DisplayName("accepts a valid bearer token on a require:bearer route") + void acceptsValidBearerToken() { + // Arrange + TestTokenHolder holder = TestTokenGenerators.accessTokens().next(); + AuthenticationStage stage = stageFor(holder); + PipelineRequest request = bearerRequest(holder.getRawToken(), authConfig("bearer", List.of())); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request)); + } + + @Test + @DisplayName("rejects a missing bearer token 401 with WWW-Authenticate") + void rejectsMissingBearerToken() { + // Arrange + AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next()); + PipelineRequest request = request(authConfig("bearer", List.of()), Map.of()); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + // Assert + assertEquals(EventType.TOKEN_MISSING, thrown.getEventType()); + assertEquals("Bearer", request.responseHeaders().get("WWW-Authenticate")); + } + + @Test + @DisplayName("rejects a malformed bearer token 401 with WWW-Authenticate") + void rejectsInvalidBearerToken() { + // Arrange + AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next()); + PipelineRequest request = bearerRequest("not.a.valid.jwt", authConfig("bearer", List.of())); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + // Assert + assertEquals(EventType.TOKEN_INVALID, thrown.getEventType()); + assertEquals("Bearer", request.responseHeaders().get("WWW-Authenticate")); + } + + @Test + @DisplayName("rejects a valid token lacking a required scope 403") + void rejectsMissingScope() { + // Arrange + TestTokenHolder holder = TestTokenGenerators.accessTokens().next(); + AuthenticationStage stage = stageFor(holder); + PipelineRequest request = bearerRequest(holder.getRawToken(), authConfig("bearer", List.of(ABSENT_SCOPE))); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + // Assert + assertEquals(EventType.SCOPE_MISSING, thrown.getEventType()); + } + + private static AuthenticationStage stageFor(TestTokenHolder holder) { + TokenValidator validator = TokenValidator.builder().issuerConfig(holder.getIssuerConfig()).build(); + return new AuthenticationStage(() -> validator); + } + + private static AuthConfig authConfig(String require, List requiredScopes) { + return AuthConfig.builder().require(require).requiredScopes(requiredScopes).build(); + } + + private static PipelineRequest bearerRequest(String token, AuthConfig auth) { + return request(auth, Map.of("authorization", List.of("Bearer " + token))); + } + + private static PipelineRequest request(AuthConfig auth, Map> headers) { + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/orders") + .queryParameters(Map.of()) + .headers(headers) + .build(); + request.canonicalPath("/api/orders"); + request.selectedRoute(RouteRuntime.builder().id("orders").effectiveAuth(auth).build()); + return request; + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/TokenValidatorProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/TokenValidatorProducerTest.java new file mode 100644 index 0000000..2626f5f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/TokenValidatorProducerTest.java @@ -0,0 +1,164 @@ +/* + * 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.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Optional; + + +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.IssuerConfig; +import de.cuioss.sheriff.api.config.model.TokenValidationConfig; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.token.validation.TokenValidator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("TokenValidatorProducer — builds the shared gateway validator from token_validation") +class TokenValidatorProducerTest { + + private static final String ISSUER = "https://issuer.example"; + private static final String JWKS_URL = "https://issuer.example/jwks"; + + @Test + @DisplayName("fails config-invalid when no token_validation block is present") + void failsWhenTokenValidationAbsent() { + // Arrange + TokenValidatorProducer producer = new TokenValidatorProducer(GatewayConfig.builder().version(1).build()); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType()); + } + + @Test + @DisplayName("builds a validator from an http-JWKS issuer with an explicit expected audience") + void buildsFromHttpIssuerWithAudience() { + // Arrange + TokenValidatorProducer producer = producerFor(IssuerConfig.builder() + .name("primary") + .issuer(ISSUER) + .audience(Optional.of("api-sheriff")) + .jwks(Optional.of(IssuerConfig.Jwks.builder().source("http").url(Optional.of(JWKS_URL)).build())) + .build()); + + // Act + TokenValidator validator = producer.gatewayTokenValidator(); + + // Assert + assertNotNull(validator, "an http issuer with a jwks url yields a built validator"); + } + + @Test + @DisplayName("builds a validator from an http-JWKS issuer that configures no audience (validation disabled)") + void buildsFromHttpIssuerWithoutAudience() { + // Arrange — no audience means audience validation is explicitly disabled at build time + TokenValidatorProducer producer = producerFor(IssuerConfig.builder() + .name("primary") + .issuer(ISSUER) + .jwks(Optional.of(IssuerConfig.Jwks.builder().source("http").url(Optional.of(JWKS_URL)).build())) + .build()); + + // Act + TokenValidator validator = producer.gatewayTokenValidator(); + + // Assert + assertNotNull(validator, "an audience-less issuer still yields a built validator"); + } + + @Test + @DisplayName("fails config-invalid when an issuer declares no jwks source") + void failsWhenIssuerHasNoJwks() { + // Arrange + TokenValidatorProducer producer = producerFor(IssuerConfig.builder() + .name("primary") + .issuer(ISSUER) + .build()); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType()); + } + + @Test + @DisplayName("fails config-invalid when an http jwks source declares no url") + void failsWhenHttpJwksHasNoUrl() { + // Arrange + TokenValidatorProducer producer = producerFor(IssuerConfig.builder() + .name("primary") + .issuer(ISSUER) + .jwks(Optional.of(IssuerConfig.Jwks.builder().source("http").build())) + .build()); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType()); + } + + @Test + @DisplayName("fails config-invalid when a file jwks source declares no file path") + void failsWhenFileJwksHasNoFile() { + // Arrange + TokenValidatorProducer producer = producerFor(IssuerConfig.builder() + .name("primary") + .issuer(ISSUER) + .jwks(Optional.of(IssuerConfig.Jwks.builder().source("file").build())) + .build()); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType()); + } + + @Test + @DisplayName("fails config-invalid for an unsupported jwks source") + void failsForUnsupportedJwksSource() { + // Arrange + TokenValidatorProducer producer = producerFor(IssuerConfig.builder() + .name("primary") + .issuer(ISSUER) + .jwks(Optional.of(IssuerConfig.Jwks.builder().source("ldap").build())) + .build()); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType()); + } + + private static TokenValidatorProducer producerFor(IssuerConfig issuer) { + GatewayConfig config = GatewayConfig.builder() + .version(1) + .tokenValidation(Optional.of(new TokenValidationConfig(List.of(issuer)))) + .build(); + return new TokenValidatorProducer(config); + } +} 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 dca29ac..b99aa7c 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 @@ -29,14 +29,10 @@ import java.util.Optional; -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 de.cuioss.sheriff.api.config.model.AnchorConfig; import de.cuioss.sheriff.api.config.model.AuthConfig; import de.cuioss.sheriff.api.config.model.EndpointConfig; +import de.cuioss.sheriff.api.config.model.ForwardConfig; import de.cuioss.sheriff.api.config.model.GatewayConfig; import de.cuioss.sheriff.api.config.model.HttpMethod; import de.cuioss.sheriff.api.config.model.MatchConfig; @@ -57,6 +53,11 @@ import de.cuioss.test.juli.TestLogLevel; import de.cuioss.test.juli.junit5.EnableTestLogger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; + /** * Tests for {@link RouteTableBuilder}: enabled-only merge, longest-prefix * ordering over normalized prefixes (same-prefix disjointness now lives in @@ -648,6 +649,41 @@ void shouldInheritWhenPerRouteToggleAbsent() { } } + @Nested + @DisplayName("Effective forward-policy resolution") + class EffectiveForward { + + @Test + @DisplayName("Should carry the route-level forward block onto the resolved route") + void shouldCarryRouteForward() { + ForwardConfig forward = new ForwardConfig(List.of("Accept"), List.of("page"), + Map.of("X-Gateway", "api-sheriff")); + RouteConfig route = RouteConfig.builder().id("r").match(match("/r", HttpMethod.GET)) + .forward(Optional.of(forward)).build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(route)).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + ResolvedRoute resolved = find(table, "r"); + assertEquals(forward, resolved.effectiveForward(), "the route forward block flows through unchanged"); + } + + @Test + @DisplayName("Should default an absent forward block to a deny-by-default empty allowlist") + void shouldDefaultAbsentForwardToEmpty() { + EndpointConfig endpoint = endpoint("orders", "ORDERS") + .routes(List.of(route("r", HttpMethod.GET))).build(); + + RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")); + + ResolvedRoute resolved = find(table, "r"); + assertTrue(resolved.effectiveForward().headersAllow().isEmpty(), + "an unforwarded route resolves to an empty, deny-by-default allowlist"); + assertTrue(resolved.effectiveForward().queryAllow().isEmpty()); + assertTrue(resolved.effectiveForward().setHeaders().isEmpty()); + } + } + @Nested @DisplayName("Invariants over generated prefix / host / method permutations") class PropertyBasedInvariants { diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java index 4925826..816c862 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java @@ -31,9 +31,6 @@ import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - import de.cuioss.sheriff.api.config.model.AnchorConfig; import de.cuioss.sheriff.api.config.model.EndpointConfig; import de.cuioss.sheriff.api.config.model.GatewayConfig; @@ -42,6 +39,9 @@ import de.cuioss.sheriff.api.config.model.RouteConfig; import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + /** * Tests for {@link ConfigLoader}: binding a valid {@code gateway.yaml} (including * secret resolution and the flattened {@code upstream_defaults} block), endpoint diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java index 03e5d08..37bf5bc 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java @@ -23,14 +23,14 @@ import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - import de.cuioss.sheriff.api.config.load.EnvSecretResolver.MalformedPlaceholderException; import de.cuioss.sheriff.api.config.load.EnvSecretResolver.MissingVariableException; import de.cuioss.test.generator.Generators; import de.cuioss.test.generator.junit.EnableGeneratorController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + /** * Tests for {@link EnvSecretResolver}, the single D4 substitution engine: reference * detection, bare-reference classification for the secrets rule, required 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 f51160f..ad81f0a 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 @@ -29,7 +29,6 @@ import java.util.Optional; import java.util.stream.Stream; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Nested; @@ -190,6 +189,7 @@ private static ResolvedRoute resolvedRoute() { .retryEnabled(true) .notModifiedEnabled(true) .upstream(resolvedUpstream()) + .effectiveForward(new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "api-sheriff"))) .build(); } @@ -444,12 +444,16 @@ void anchorConfigNormalizesAbsentComponents() { @Test void resolvedRouteNormalizesAbsentOptionals() { ResolvedRoute cfg = new ResolvedRoute("id", null, null, matchConfig(), auth(), null, null, null, true, - true, resolvedUpstream()); + true, resolvedUpstream(), null); assertTrue(cfg.anchor().isEmpty()); assertTrue(cfg.effectiveSecurityFilter().isEmpty()); assertTrue(cfg.effectiveSecurityHeaders().isEmpty()); assertTrue(cfg.effectiveAllowedMethods().isEmpty()); assertEquals(Protocol.HTTP, cfg.protocol()); + assertTrue(cfg.effectiveForward().headersAllow().isEmpty(), + "an absent forward block normalizes to a deny-by-default empty allowlist"); + assertTrue(cfg.effectiveForward().queryAllow().isEmpty()); + assertTrue(cfg.effectiveForward().setHeaders().isEmpty()); } @Test @@ -591,13 +595,14 @@ void routeConfigRequiresIdAndMatch() { void resolvedRouteRequiresIdMatchAuthAndUpstream() { assertThrows(NullPointerException.class, () -> new ResolvedRoute(null, Protocol.HTTP, Optional.empty(), matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true, - resolvedUpstream())); + resolvedUpstream(), null)); assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(), null, - auth(), List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream())); + auth(), List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream(), null)); assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(), - matchConfig(), null, List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream())); + matchConfig(), null, List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream(), + null)); assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(), - matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true, null)); + matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true, null, null)); } } @@ -656,6 +661,8 @@ void resolvedRouteCarriesTheMaterializedEffectivePosture() { assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), cfg.effectiveAllowedMethods()); assertTrue(cfg.effectiveSecurityFilter().isPresent()); assertTrue(cfg.effectiveSecurityHeaders().isPresent()); + assertEquals(List.of("Accept"), cfg.effectiveForward().headersAllow(), + "the materialized forward allowlist is carried on the resolved route"); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java index ad34904..6f665fa 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java @@ -31,13 +31,6 @@ import java.util.stream.Stream; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - import de.cuioss.sheriff.api.config.load.EnvSecretResolver; import de.cuioss.sheriff.api.config.model.AuthConfig; import de.cuioss.sheriff.api.config.model.EndpointConfig; @@ -45,6 +38,13 @@ import de.cuioss.sheriff.api.config.model.ResolvedUpstream; import de.cuioss.sheriff.api.config.topology.TopologyResolver.TopologyResolutionException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + /** * Tests for {@link TopologyResolver}: file resolution, URL decomposition with * scheme-default ports, the D4 {@code ${VAR}} / {@code ${VAR:-default}} substitution 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 50ff597..06c8c3c 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 @@ -26,11 +26,6 @@ import java.util.Optional; -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 de.cuioss.sheriff.api.config.RouteTableBuilder; import de.cuioss.sheriff.api.config.load.ConfigError; import de.cuioss.sheriff.api.config.model.AnchorConfig; @@ -57,6 +52,11 @@ import de.cuioss.test.juli.TestLoggerFactory; import de.cuioss.test.juli.junit5.EnableTestLogger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; + /** * Tests for {@link ConfigValidator}: one negative case per enforced cross-cutting * rule (including the seven ADR-0007 anchor / effective-auth rules), the D5 diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/DispatchStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/DispatchStageTest.java new file mode 100644 index 0000000..143f5be --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/DispatchStageTest.java @@ -0,0 +1,322 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +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.events.GatewayException; + +import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.streams.ReadStream; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("DispatchStage — stage 6 streamed upstream dispatch") +class DispatchStageTest { + + @Nested + @DisplayName("upstream request URI assembly") + class UpstreamUri { + + private static final ResolvedUpstream UPSTREAM = new ResolvedUpstream("http", "orders-svc", 8080, "/base"); + + @Test + @DisplayName("appends path remainder and raw query to the upstream base path") + void appendsRemainderAndQuery() { + assertEquals("/base/orders?page=2", + DispatchStage.upstreamRequestUri(UPSTREAM, "/orders", "?page=2")); + } + + @Test + @DisplayName("omits the query when none is present") + void omitsEmptyQuery() { + assertEquals("/base/orders", DispatchStage.upstreamRequestUri(UPSTREAM, "/orders", "")); + } + + @Test + @DisplayName("strips a trailing slash on the upstream base path before appending") + void stripsTrailingBasePathSlash() { + ResolvedUpstream slashed = new ResolvedUpstream("http", "orders-svc", 8080, "/base/"); + assertEquals("/base/orders", DispatchStage.upstreamRequestUri(slashed, "/orders", "")); + } + } + + @Nested + @DisplayName("streamed request body byte cap") + class ByteCap { + + @Test + @DisplayName("forwards each chunk immediately while the running count stays within the cap") + void streamsChunksUnderCap() { + // Arrange + TestReadStream source = new TestReadStream(); + List forwarded = new ArrayList<>(); + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean aborted = new AtomicBoolean(); + DispatchStage.ByteCappedBodyStream capped = + new DispatchStage.ByteCappedBodyStream(source, 10L, () -> aborted.set(true)); + capped.handler(forwarded::add); + capped.exceptionHandler(failure::set); + + // Act — 5 + 4 = 9 bytes, both under the 10-byte cap + source.emit(Buffer.buffer("12345")); + assertEquals(1, forwarded.size(), "first chunk must be forwarded immediately, not buffered"); + source.emit(Buffer.buffer("6789")); + + // Assert + assertEquals(2, forwarded.size(), "each in-cap chunk streams through as it arrives"); + assertNull(failure.get(), "no failure while under the cap"); + assertFalse(aborted.get(), "the upstream call is not aborted while under the cap"); + } + + @Test + @DisplayName("aborts the in-flight upstream call and fails the stream when the cap is breached") + void abortsOnBreach() { + // Arrange + TestReadStream source = new TestReadStream(); + List forwarded = new ArrayList<>(); + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean aborted = new AtomicBoolean(); + DispatchStage.ByteCappedBodyStream capped = + new DispatchStage.ByteCappedBodyStream(source, 10L, () -> aborted.set(true)); + capped.handler(forwarded::add); + capped.exceptionHandler(failure::set); + + // Act — 6 bytes (ok), then 6 more crossing the 10-byte cap + source.emit(Buffer.buffer("123456")); + source.emit(Buffer.buffer("ABCDEF")); + + // Assert — the breaching chunk is not forwarded, the call is aborted, and a 400 is raised + assertEquals(1, forwarded.size(), "the breaching chunk must never cross to the upstream"); + assertTrue(aborted.get(), "a mid-stream breach must abort the in-flight upstream call"); + Throwable raised = failure.get(); + GatewayException gatewayException = assertInstanceOf(GatewayException.class, raised); + assertEquals(EventType.PARAMETER_LIMIT_EXCEEDED, gatewayException.getEventType()); + } + + @Test + @DisplayName("ignores further chunks once the stream has aborted") + void ignoresChunksAfterAbort() { + // Arrange + TestReadStream source = new TestReadStream(); + List forwarded = new ArrayList<>(); + DispatchStage.ByteCappedBodyStream capped = + new DispatchStage.ByteCappedBodyStream(source, 4L, () -> { + }); + capped.handler(forwarded::add); + capped.exceptionHandler(t -> { + }); + + // Act + source.emit(Buffer.buffer("12345")); // 5 bytes → immediate breach + source.emit(Buffer.buffer("late")); // must be ignored + + // Assert + assertTrue(forwarded.isEmpty(), "no chunk crosses once the very first breaches the cap"); + } + } + + @Nested + @DisplayName("stream-aware retry gating on the guarded dispatch path") + class RetryGating { + + private DispatchStage newStage() { + return new DispatchStage(1024L, new UpstreamFailureMapper(new GatewayEventCounter())); + } + + /** A retry-enabled guard (1 + 2 retries), aborting on {@link GatewayException} like production. */ + private Guard retryGuard() { + return Guard.create() + .withRetry().maxRetries(2).delay(0, ChronoUnit.MILLIS) + .abortOn(GatewayException.class).done() + .build(); + } + + @Test + @DisplayName("retries an idempotent GET while no request body byte has been sent") + void retriesIdempotentWithoutBody() { + // Arrange — a failing upstream that never streams a body byte (fails before send) + DispatchStage stage = newStage(); + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + AtomicInteger attempts = new AtomicInteger(); + AtomicLong bytesSent = new AtomicLong(); + Callable failing = () -> { + attempts.incrementAndGet(); + // awaitDispatch surfaces upstream failures as ExecutionException (from Future#get); + // guardedDispatch must unwrap it before mapping/retrying. + throw new ExecutionException("upstream down", new IllegalStateException("upstream down")); + }; + + // Act — the stream is never subscribed (failure before send), so retry is allowed + GatewayException raised = assertThrows(GatewayException.class, + () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.GET, bytesSent::get, () -> false, + failing)); + + // Assert — the safe idempotent+bodyless request was retried the full budget + assertEquals(3, attempts.get(), + "an idempotent GET with zero body bytes must be retried (1 attempt + 2 retries)"); + assertEquals(EventType.UPSTREAM_ERROR, raised.getEventType()); + } + + @Test + @DisplayName("never retries a non-idempotent POST — the retry re-entry is aborted") + void neverRetriesPost() { + // Arrange + DispatchStage stage = newStage(); + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + AtomicInteger attempts = new AtomicInteger(); + AtomicLong bytesSent = new AtomicLong(); + Callable failing = () -> { + attempts.incrementAndGet(); + throw new ExecutionException("upstream down", new IllegalStateException("upstream down")); + }; + + // Act + assertThrows(GatewayException.class, + () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.POST, bytesSent::get, () -> false, + failing)); + + // Assert — a POST must never be re-sent, so the upstream is called exactly once + assertEquals(1, attempts.get(), "a POST must never be re-sent to the upstream"); + } + + @Test + @DisplayName("never retries an idempotent request once a body byte has crossed to the upstream") + void neverRetriesAfterBodyByte() { + // Arrange — the first attempt streams body bytes before it fails mid-body + DispatchStage stage = newStage(); + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + AtomicInteger attempts = new AtomicInteger(); + AtomicLong bytesSent = new AtomicLong(); + Callable failing = () -> { + attempts.incrementAndGet(); + bytesSent.addAndGet(5L); + throw new ExecutionException("upstream down mid-body", + new IllegalStateException("upstream down mid-body")); + }; + + // Act + assertThrows(GatewayException.class, + () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.PUT, bytesSent::get, () -> false, + failing)); + + // Assert — a streamed request cannot be replayed once a body byte has been sent + assertEquals(1, attempts.get(), + "an idempotent request whose body has streamed a byte must never be retried"); + } + + @Test + @DisplayName("never retries when the one-shot body stream was already subscribed, even with zero bytes sent") + void neverRetriesWhenBodyStreamAlreadySubscribed() { + // Arrange — the first attempt reached request.send(...) and subscribed the single-use + // request-body stream before failing, even though no body byte was counted yet. A retry + // would re-attach the already-consumed stream and stall, so it must fail explicitly. + DispatchStage stage = newStage(); + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + AtomicInteger attempts = new AtomicInteger(); + AtomicLong bytesSent = new AtomicLong(); + Callable failing = () -> { + attempts.incrementAndGet(); + throw new ExecutionException("upstream reset after subscribe", + new IllegalStateException("upstream down")); + }; + + // Act — idempotent GET, zero bytes sent, but the body stream is already subscribed + assertThrows(GatewayException.class, + () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.GET, bytesSent::get, () -> true, + failing)); + + // Assert — reusing an already-subscribed one-shot body stream is refused, no re-send + assertEquals(1, attempts.get(), + "a retry that would reuse an already-subscribed one-shot body stream must be refused"); + } + } + + /** + * A minimal {@link ReadStream} fake: captures the handler the decorator installs and lets a test + * push buffers synchronously through it. + */ + private static final class TestReadStream implements ReadStream { + + private @Nullable Handler handler; + private @Nullable Handler exceptionHandler; + private @Nullable Handler endHandler; + + void emit(Buffer buffer) { + if (handler != null) { + handler.handle(buffer); + } + } + + @Override + public ReadStream handler(@Nullable Handler handler) { + this.handler = handler; + return this; + } + + @Override + public ReadStream exceptionHandler(@Nullable Handler exceptionHandler) { + this.exceptionHandler = exceptionHandler; + 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(@Nullable Handler endHandler) { + this.endHandler = endHandler; + return this; + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/EdgeHardeningTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/EdgeHardeningTest.java new file mode 100644 index 0000000..6c80903 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/EdgeHardeningTest.java @@ -0,0 +1,127 @@ +/* + * 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.assertTrue; + +import java.util.concurrent.TimeUnit; + +import io.vertx.core.http.HttpServerOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("EdgeHardeningOptions — inbound transport bounds and admission/drain limits") +class EdgeHardeningTest { + + private static final int MAX_HEADER_SIZE_BYTES = 16 * 1024; + private static final int MAX_INITIAL_LINE_LENGTH_BYTES = 8 * 1024; + private static final int MAX_CHUNK_SIZE_BYTES = 16 * 1024; + private static final int IDLE_TIMEOUT_SECONDS = 60; + private static final int ADMISSION_CAP = 2048; + private static final long DRAIN_TIMEOUT_MILLIS = 25_000L; + + @Test + @DisplayName("clamps the plaintext listener framing to bounded header, line, and chunk sizes") + void clampsPlaintextListenerFraming() { + // Arrange + EdgeHardeningOptions hardening = new EdgeHardeningOptions(); + HttpServerOptions options = new HttpServerOptions(); + + // Act + hardening.customizeHttpServer(options); + + // Assert — an over-long line, over-large header block, or over-large chunk is rejected by + // the Vert.x codec before a request is ever admitted to the pipeline. + assertEquals(MAX_HEADER_SIZE_BYTES, options.getMaxHeaderSize(), + "The whole request header block is bounded to 16 KiB"); + assertEquals(MAX_INITIAL_LINE_LENGTH_BYTES, options.getMaxInitialLineLength(), + "The HTTP/1.x request line is bounded to 8 KiB"); + assertEquals(MAX_CHUNK_SIZE_BYTES, options.getMaxChunkSize(), + "A single chunked-transfer chunk is bounded to 16 KiB"); + } + + @Test + @DisplayName("reaps an idle connection after a bounded idle window (slow-loris / h2 abuse guard)") + void reapsIdleConnection() { + // Arrange + EdgeHardeningOptions hardening = new EdgeHardeningOptions(); + HttpServerOptions options = new HttpServerOptions(); + + // Act + hardening.customizeHttpServer(options); + + // Assert — an idle connection is reaped so a slow-loris / h2 abuse load cannot pin slots. + assertEquals(IDLE_TIMEOUT_SECONDS, options.getIdleTimeout(), + "An idle connection is reaped after 60 seconds"); + assertEquals(TimeUnit.SECONDS, options.getIdleTimeoutUnit(), + "The idle-timeout unit is seconds"); + } + + @Test + @DisplayName("applies the same transport bounds to the TLS listener as to the plaintext listener") + void appliesSameBoundsToHttpsListener() { + // Arrange + EdgeHardeningOptions hardening = new EdgeHardeningOptions(); + HttpServerOptions options = new HttpServerOptions(); + + // Act + hardening.customizeHttpsServer(options); + + // Assert — the customizer applies to every listener Quarkus opens, TLS included. + assertEquals(MAX_HEADER_SIZE_BYTES, options.getMaxHeaderSize(), + "The TLS listener bounds the header block identically"); + assertEquals(MAX_INITIAL_LINE_LENGTH_BYTES, options.getMaxInitialLineLength(), + "The TLS listener bounds the request line identically"); + assertEquals(MAX_CHUNK_SIZE_BYTES, options.getMaxChunkSize(), + "The TLS listener bounds the chunk size identically"); + assertEquals(IDLE_TIMEOUT_SECONDS, options.getIdleTimeout(), + "The TLS listener reaps idle connections identically"); + assertEquals(TimeUnit.SECONDS, options.getIdleTimeoutUnit(), + "The TLS listener idle-timeout unit is seconds"); + } + + @Test + @DisplayName("bounds in-flight requests with a positive admission cap") + void exposesPositiveAdmissionCap() { + // Arrange + EdgeHardeningOptions hardening = new EdgeHardeningOptions(); + + // Act + int cap = hardening.admissionCap(); + + // Assert — a request beyond the cap is rejected 503 before a virtual thread is dispatched, + // so a flood cannot spawn unbounded virtual threads. + assertEquals(ADMISSION_CAP, cap, "The admission cap is the documented secure default"); + assertTrue(cap > 0, "The admission cap must be a positive bound"); + } + + @Test + @DisplayName("bounds the graceful-drain wait below the Quarkus shutdown window") + void exposesBoundedDrainTimeout() { + // Arrange + EdgeHardeningOptions hardening = new EdgeHardeningOptions(); + + // Act + long drainMillis = hardening.drainTimeoutMillis(); + + // Assert — the drain timeout is a positive, bounded wait kept below the Quarkus default + // 30-second shutdown timeout so the drain completes within the shutdown window. + assertEquals(DRAIN_TIMEOUT_MILLIS, drainMillis, "The drain timeout is the documented secure default"); + assertTrue(drainMillis > 0L, "The drain timeout must be a positive bound"); + assertTrue(drainMillis < 30_000L, "The drain timeout must stay below the 30s Quarkus shutdown window"); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgePipelineTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgePipelineTest.java new file mode 100644 index 0000000..3e65a18 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgePipelineTest.java @@ -0,0 +1,347 @@ +/* + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Annotation; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + + +import de.cuioss.http.security.config.SecurityConfiguration; +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.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.RequestOptions; +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 serving contract of the public data-plane edge, driven over a live Vert.x HTTP server + * against a local stub upstream — no Docker, no Quarkus. A real request crosses the whole fixed + * pipeline (stages 0-7): response-header prep + CORS preflight, the baseline security filter, route + * selection and the verb gate, the per-route thorough checks, bearer authentication, streamed + * upstream dispatch, and the streamed response relay. These complement the boot-time and lifecycle + * assertions in {@link GatewayEdgeRouteTest}. + */ +@EnableGeneratorController +@DisplayName("GatewayEdgeRoute — end-to-end request pipeline over a live Vert.x server") +class GatewayEdgePipelineTest { + + private static final String ORIGIN = "https://app.example"; + + private Vertx vertx; + private ExecutorService virtualThreadExecutor; + private HttpServer upstreamServer; + private HttpServer frontServer; + private HttpClient client; + private int frontPort; + + @BeforeEach + void setUp() throws Exception { + vertx = Vertx.vertx(); + virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + + // Stub upstream: echoes the received method, URI, and body so the forward path is observable. + upstreamServer = vertx.createHttpServer().requestHandler(request -> + request.body().onComplete(body -> { + String payload = body.succeeded() && body.result() != null ? body.result().toString() : ""; + request.response() + .putHeader("X-Upstream-Echo", "hit") + .end(request.method().name() + " " + request.uri() + " body=" + payload); + })) + .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS); + int upstreamPort = upstreamServer.actualPort(); + + TokenValidator tokenValidator = TokenValidator.builder() + .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build(); + + RouteTable routeTable = new RouteTable(List.of( + route("secure", "/secure", "bearer", upstreamPort, HttpMethod.GET), + route("echo", "/echo", "none", upstreamPort, HttpMethod.GET, HttpMethod.POST))); + + GatewayConfig gatewayConfig = GatewayConfig.builder() + .version(1) + .securityHeaders(Optional.of(corsHeaders())) + .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(); + + client = vertx.createHttpClient(); + } + + @AfterEach + void tearDown() throws Exception { + client.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("forwards a matched GET to the upstream and relays the streamed response body") + void forwardsHappyPathGet() throws Exception { + // Act + Response response = send(io.vertx.core.http.HttpMethod.GET, "/echo/orders", Map.of(), null); + + // Assert — the prefix is stripped and the remainder is forwarded; the relay preserves the body + assertEquals(200, response.status()); + assertEquals("hit", response.headers().get("X-Upstream-Echo"), + "the upstream response headers relay back to the client"); + assertTrue(response.body().contains("GET /orders"), + "the forwarded request reaches the upstream with the prefix-stripped path: " + response.body()); + } + + @Test + @DisplayName("streams a POST request body through to the upstream") + void forwardsPostBody() throws Exception { + // Act + Response response = send(io.vertx.core.http.HttpMethod.POST, "/echo/submit", Map.of(), "hello-upstream"); + + // Assert + assertEquals(200, response.status()); + assertTrue(response.body().contains("POST /submit"), response.body()); + assertTrue(response.body().contains("body=hello-upstream"), + "the streamed request body reaches the upstream: " + response.body()); + } + + @Test + @DisplayName("rejects an unmatched path 404 (deny by default)") + void rejectsUnmatchedPath() throws Exception { + // Act + Response response = send(io.vertx.core.http.HttpMethod.GET, "/nowhere", Map.of(), null); + + // Assert + assertEquals(404, response.status()); + } + + @Test + @DisplayName("rejects a verb outside the route's allowed_methods 405 with an Allow header") + void rejectsDisallowedVerb() throws Exception { + // Act — the echo route allows GET and POST, never DELETE + Response response = send(io.vertx.core.http.HttpMethod.DELETE, "/echo/orders", Map.of(), null); + + // Assert + assertEquals(405, response.status()); + assertNotNull(response.headers().get("Allow"), "a 405 names the permitted verbs in the Allow header"); + } + + @Test + @DisplayName("rejects a request breaching the baseline parameter-count cap 400") + void rejectsExcessiveParameters() throws Exception { + // Arrange — one parameter beyond the default cap trips the stage-1 baseline filter + int cap = SecurityConfiguration.defaults().maxParameterCount(); + StringJoiner query = new StringJoiner("&", "/echo/orders?", ""); + for (int i = 0; i <= cap; i++) { + query.add("p" + i + "=1"); + } + + // Act + Response response = send(io.vertx.core.http.HttpMethod.GET, query.toString(), Map.of(), null); + + // Assert + assertEquals(400, response.status()); + } + + @Test + @DisplayName("rejects a bearer route without a token 401 carrying WWW-Authenticate") + void rejectsMissingBearerToken() throws Exception { + // Act + Response response = send(io.vertx.core.http.HttpMethod.GET, "/secure/data", Map.of(), null); + + // Assert + assertEquals(401, response.status()); + assertEquals("Bearer", response.headers().get("WWW-Authenticate"), + "a missing bearer token challenges with WWW-Authenticate: Bearer"); + } + + @Test + @DisplayName("rejects a bearer route with an invalid token 401") + void rejectsInvalidBearerToken() throws Exception { + // Act — a syntactically-bogus token is rejected offline by the validator + Response response = send(io.vertx.core.http.HttpMethod.GET, "/secure/data", + Map.of("Authorization", "Bearer not-a-real-token"), null); + + // Assert + assertEquals(401, response.status()); + } + + @Test + @DisplayName("answers an allow-listed CORS preflight 204 at stage 0 without reaching the upstream") + void answersCorsPreflight() throws Exception { + // Act — an OPTIONS preflight from an allow-listed origin short-circuits before route selection + Response response = send(io.vertx.core.http.HttpMethod.OPTIONS, "/echo/orders", + Map.of("Origin", ORIGIN, "Access-Control-Request-Method", "GET"), null); + + // Assert + assertEquals(204, response.status()); + assertEquals(ORIGIN, response.headers().get("Access-Control-Allow-Origin"), + "the preflight reflects the allow-listed origin"); + assertNotNull(response.headers().get("Access-Control-Allow-Methods"), + "the preflight advertises the allowed methods"); + } + + private Response send(io.vertx.core.http.HttpMethod method, String uri, Map requestHeaders, + String body) throws Exception { + RequestOptions options = new RequestOptions() + .setHost("localhost").setPort(frontPort).setMethod(method).setURI(uri); + CompletableFuture future = client.request(options) + .compose(request -> { + requestHeaders.forEach(request::putHeader); + return body == null ? request.send() : request.send(Buffer.buffer(body)); + }) + .compose(response -> { + int status = response.statusCode(); + Map headers = new HashMap<>(); + response.headers().forEach(entry -> headers.putIfAbsent(entry.getKey(), entry.getValue())); + return response.body().map(buffer -> new Response(status, headers, + buffer == null ? "" : buffer.toString())); + }) + .toCompletionStage().toCompletableFuture(); + return future.get(15, TimeUnit.SECONDS); + } + + private static SecurityHeadersConfig corsHeaders() { + SecurityHeadersConfig.Cors cors = SecurityHeadersConfig.Cors.builder() + .enabled(Optional.of(Boolean.TRUE)) + .allowedOrigins(List.of(ORIGIN)) + .allowedMethods(List.of("GET", "POST")) + .allowedHeaders(List.of("authorization")) + .build(); + return SecurityHeadersConfig.builder().cors(Optional.of(cors)).build(); + } + + private static ResolvedRoute route(String id, String pathPrefix, String require, int upstreamPort, + HttpMethod... methods) { + return ResolvedRoute.builder() + .id(id) + .protocol(Protocol.HTTP) + .match(MatchConfig.builder().pathPrefix(pathPrefix).build()) + .effectiveAuth(AuthConfig.builder().require(require).build()) + .effectiveAllowedMethods(List.of(methods)) + .upstream(new ResolvedUpstream("http", "localhost", upstreamPort, "")) + .build(); + } + + /** A record capturing the terminal response for assertions after the streamed body completes. */ + private record Response(int status, Map headers, String body) { + } + + /** + * Minimal {@link Instance} test double resolving to a single supplied validator; only + * {@link #get()} and {@link #iterator()} are exercised by the pipeline, the remaining CDI + * accessors are unused and 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/edge/GatewayEdgeRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java new file mode 100644 index 0000000..4ce5d13 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java @@ -0,0 +1,255 @@ +/* + * 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.lang.annotation.Annotation; +import java.time.Duration; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +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.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +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.quarkus.runtime.ShutdownEvent; +import io.vertx.core.Vertx; +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; + +/** + * Boot-time and lifecycle contract of the public data-plane edge. The per-request serving behaviour + * (pipeline stages over a live Vert.x server, h2 abuse bounds, streamed relay on the public port) is + * exercised end-to-end by the {@code integration-tests} module; these module tests cover the + * deterministic, server-free guarantees: clean boot assembly, fail-fast on an invalid route set, + * the catch-all registered last, and a bounded graceful drain. + */ +@EnableGeneratorController +@DisplayName("GatewayEdgeRoute — boot-time assembly, catch-all registration, and graceful drain") +class GatewayEdgeRouteTest { + + private Vertx vertx; + private ExecutorService virtualThreadExecutor; + private GatewayConfig gatewayConfig; + private TokenValidator tokenValidator; + private EdgeHardeningOptions hardening; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + gatewayConfig = GatewayConfig.builder().version(1).build(); + tokenValidator = TokenValidator.builder() + .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build(); + hardening = new EdgeHardeningOptions(); + } + + @AfterEach + void tearDown() { + virtualThreadExecutor.close(); + vertx.close(); + } + + @Test + @DisplayName("boots cleanly over an empty route table") + void bootsCleanlyOverEmptyRouteTable() { + // Arrange + RouteTable emptyTable = new RouteTable(List.of()); + + // Act + Assert — assembling every stage once, at boot, must not throw for a valid config. + assertDoesNotThrow(() -> newEdge(emptyTable), + "A valid route set assembles every stage without error"); + } + + @Test + @DisplayName("registers the catch-all data-plane route so management routes keep priority") + void registersCatchAllRoute() { + // Arrange + GatewayEdgeRoute edge = newEdge(new RouteTable(List.of())); + Router router = Router.router(vertx); + + // Act + edge.registerRoutes(router); + + // Assert — exactly one catch-all route is registered; it is added last so management / + // health routes registered earlier keep priority. + assertEquals(1, router.getRoutes().size(), "The edge registers a single catch-all route"); + } + + @Test + @DisplayName("fails boot fast when a route requires session authentication") + void failsBootForSessionAuth() { + // Arrange + RouteTable sessionTable = new RouteTable(List.of( + route("s", Protocol.HTTP, "session"))); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(sessionTable), + "Session auth is not implemented and must fail boot"); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(), + "A session-auth route is rejected as an invalid configuration"); + } + + @Test + @DisplayName("fails boot fast for a gRPC route (unsupported protocol)") + void failsBootForGrpcProtocol() { + // 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"); + } + + @Test + @DisplayName("fails boot fast for a WebSocket route (unsupported protocol)") + void failsBootForWebSocketProtocol() { + // Arrange + RouteTable webSocketTable = new RouteTable(List.of( + route("w", Protocol.WEBSOCKET, "none"))); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(webSocketTable), + "WebSocket is unsupported and must fail boot"); + + // Assert + assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(), + "A WebSocket route is rejected as an invalid configuration"); + } + + @Test + @DisplayName("drains within the bounded window on shutdown when nothing is in flight") + void drainsPromptlyWhenIdle() { + // Arrange + GatewayEdgeRoute edge = newEdge(new RouteTable(List.of())); + + // Act + Assert — with zero in-flight requests the drain loop returns immediately, well + // within its bounded window, so the shutdown completes cleanly and never hangs. + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> edge.onShutdown(new ShutdownEvent()), + "Graceful drain returns promptly when no request is in flight"); + } + + private GatewayEdgeRoute newEdge(RouteTable table) { + return new GatewayEdgeRoute(table, gatewayConfig, new SingletonInstance<>(tokenValidator), vertx, + virtualThreadExecutor, hardening, new SheriffMetrics(new SimpleMeterRegistry())); + } + + /** + * Minimal {@link Instance} test double resolving to a single supplied bean. These boot / drain + * tests exercise only {@link #get()} (and none of them reaches a {@code require: bearer} route, so + * even that is not resolved); the remaining CDI accessors are unused and 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(); + } + } + + private static ResolvedRoute route(String id, Protocol protocol, String require) { + return ResolvedRoute.builder() + .id(id) + .protocol(protocol) + .match(MatchConfig.builder().pathPrefix("/" + id).build()) + .effectiveAuth(AuthConfig.builder().require(require).build()) + .effectiveAllowedMethods(List.of(HttpMethod.GET)) + .upstream(new ResolvedUpstream("https", id + ".example", 443, "")) + .build(); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/ResponseStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/ResponseStageTest.java new file mode 100644 index 0000000..ea775d4 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/ResponseStageTest.java @@ -0,0 +1,108 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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; + +@DisplayName("ResponseStage — stage 7 streamed response header policy") +class ResponseStageTest { + + @Nested + @DisplayName("hop-by-hop stripping") + class HopByHop { + + @ParameterizedTest + @ValueSource(strings = { + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "TE", "Trailer", "Transfer-Encoding", "Upgrade", "Content-Length"}) + @DisplayName("strips hop-by-hop and framing headers regardless of not_modified") + void stripsHopByHop(String header) { + assertFalse(ResponseStage.isForwardableResponseHeader(header, true), + header + " is hop-by-hop and must never relay (not_modified enabled)"); + assertFalse(ResponseStage.isForwardableResponseHeader(header, false), + header + " is hop-by-hop and must never relay (not_modified disabled)"); + } + + @Test + @DisplayName("matching is case-insensitive") + void caseInsensitive() { + assertFalse(ResponseStage.isForwardableResponseHeader("transfer-encoding", true)); + assertFalse(ResponseStage.isForwardableResponseHeader("CONTENT-LENGTH", true)); + } + } + + @Nested + @DisplayName("conditional-response headers gated by not_modified") + class ConditionalHeaders { + + @ParameterizedTest + @ValueSource(strings = {"ETag", "Last-Modified"}) + @DisplayName("relays validators untouched when the route enables not_modified") + void relaysValidatorsWhenEnabled(String header) { + assertTrue(ResponseStage.isForwardableResponseHeader(header, true), + header + " must relay on a not_modified-enabled route (304 pass-through)"); + } + + @ParameterizedTest + @ValueSource(strings = {"ETag", "Last-Modified"}) + @DisplayName("strips validators when the route disables not_modified") + void stripsValidatorsWhenDisabled(String header) { + assertFalse(ResponseStage.isForwardableResponseHeader(header, false), + header + " must be stripped on a not_modified-disabled route"); + } + } + + @Nested + @DisplayName("ordinary headers") + class OrdinaryHeaders { + + @ParameterizedTest + @ValueSource(strings = {"Content-Type", "Cache-Control", "Set-Cookie", "Location"}) + @DisplayName("relays ordinary response headers regardless of not_modified") + void relaysOrdinaryHeaders(String header) { + assertTrue(ResponseStage.isForwardableResponseHeader(header, true)); + assertTrue(ResponseStage.isForwardableResponseHeader(header, false)); + } + } + + @Nested + @DisplayName("body-framing eligibility (Content-Length preservation vs chunked streaming)") + class BodyFraming { + + @ParameterizedTest + @ValueSource(ints = {200, 201, 301, 400, 404, 500, 502}) + @DisplayName("a body-bearing status streams a relayed body (chunked when length is unknown)") + void statusMayCarryBody(int status) { + assertTrue(ResponseStage.mayCarryBody(status), + status + " permits a message body and must frame the streamed relay"); + } + + @ParameterizedTest + @ValueSource(ints = {100, 101, 199, 204, 304}) + @DisplayName("a bodyless status never frames a streamed body") + void statusForbidsBody(int status) { + assertFalse(ResponseStage.mayCarryBody(status), + status + " forbids a message body (1xx / 204 / 304) and must not be chunked"); + } + } +} 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 new file mode 100644 index 0000000..0f22496 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java @@ -0,0 +1,249 @@ +/* + * 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.assertNotSame; +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; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; +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.ForwardConfig; +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.SecurityFilterConfig; +import de.cuioss.sheriff.api.events.EventType; +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.Vertx; +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; + +@DisplayName("RouteRuntimeAssembler — boot-time assembly and heavy-object dedup") +class RouteRuntimeAssemblerTest { + + private Vertx vertx; + private RouteRuntimeAssembler assembler; + private RouteRuntimeAssembler.SecurityConfigurationFactory securityConfigFactory; + private RouteRuntimeAssembler.UpstreamClientFactory clientFactory; + private RouteRuntimeAssembler.ResilienceGuardFactory guardFactory; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry()); + securityConfigFactory = filter -> SecurityConfiguration.builder().build(); + clientFactory = target -> vertx.createHttpClient(); + guardFactory = shape -> new StoredOnlyGuard(); + } + + @AfterEach + void tearDown() { + vertx.close(); + } + + @Test + @DisplayName("Should reuse one SecurityConfiguration for routes sharing a security-filter shape") + void shouldReuseSecurityConfigurationForSharedShape() { + SecurityFilterConfig sharedFilter = SecurityFilterConfig.builder().allowedPaths(List.of("/shared")).build(); + var invocations = new AtomicInteger(); + securityConfigFactory = filter -> { + invocations.incrementAndGet(); + return SecurityConfiguration.builder().build(); + }; + RouteTable table = new RouteTable(List.of( + route("r1", Protocol.HTTP, "none", Optional.of(sharedFilter), upstream("a.example")), + route("r2", Protocol.HTTP, "none", Optional.of(sharedFilter), upstream("a.example")))); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertEquals(1, invocations.get(), "The factory runs once for the shared filter shape"); + assertSame(runtimes.getFirst().getSecurityConfiguration().orElseThrow(), + runtimes.get(1).getSecurityConfiguration().orElseThrow(), + "Both routes hold the same SecurityConfiguration reference"); + } + + @Test + @DisplayName("Should build distinct SecurityConfigurations for different security-filter shapes") + void shouldBuildDistinctSecurityConfigurationsForDifferentShapes() { + RouteTable table = new RouteTable(List.of( + route("r1", Protocol.HTTP, "none", + Optional.of(SecurityFilterConfig.builder().allowedPaths(List.of("/a")).build()), upstream("a.example")), + route("r2", Protocol.HTTP, "none", + Optional.of(SecurityFilterConfig.builder().allowedPaths(List.of("/b")).build()), upstream("a.example")))); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertNotSame(runtimes.getFirst().getSecurityConfiguration().orElseThrow(), + runtimes.get(1).getSecurityConfiguration().orElseThrow(), + "Distinct filter shapes must not share a SecurityConfiguration"); + } + + @Test + @DisplayName("Should reuse one client for routes sharing an upstream tuple and split by tuple") + void shouldReuseClientForSharedUpstreamTuple() { + RouteTable table = new RouteTable(List.of( + route("r1", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")), + route("r2", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")), + route("r3", Protocol.HTTP, "none", Optional.empty(), upstream("b.example")))); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertSame(runtimes.getFirst().getHttpClient(), runtimes.get(1).getHttpClient(), + "Routes sharing an upstream tuple reuse one client"); + assertNotSame(runtimes.getFirst().getHttpClient(), runtimes.get(2).getHttpClient(), + "A different upstream tuple gets a distinct client"); + } + + @Test + @DisplayName("Should preserve the route-table order") + void shouldPreserveRouteTableOrder() { + RouteTable table = new RouteTable(List.of( + route("first", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")), + route("second", Protocol.GRAPHQL, "bearer", Optional.empty(), upstream("b.example")))); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertEquals(List.of("first", "second"), runtimes.stream().map(RouteRuntime::getId).toList(), + "Assembly preserves the longest-prefix-first order"); + } + + @Test + @DisplayName("Should fail boot for session auth and unsupported protocols") + void shouldFailBootForSessionAndUnsupportedProtocols() { + var session = assertThrows(GatewayException.class, () -> assembler.assemble( + new RouteTable(List.of(route("s", Protocol.HTTP, "session", Optional.empty(), upstream("a.example")))), + securityConfigFactory, clientFactory, guardFactory), "session auth must fail boot"); + var grpc = assertThrows(GatewayException.class, () -> assembler.assemble( + new RouteTable(List.of(route("g", Protocol.GRPC, "none", Optional.empty(), upstream("a.example")))), + securityConfigFactory, clientFactory, guardFactory), "gRPC must fail boot"); + var websocket = assertThrows(GatewayException.class, () -> assembler.assemble( + new RouteTable(List.of(route("w", Protocol.WEBSOCKET, "none", Optional.empty(), upstream("a.example")))), + securityConfigFactory, clientFactory, guardFactory), "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"); + } + + @Test + @DisplayName("Should carry the required scopes from the effective auth") + void shouldCarryRequiredScopes() { + AuthConfig auth = AuthConfig.builder().require("bearer").requiredScopes(List.of("read", "write")).build(); + RouteTable table = new RouteTable(List.of(ResolvedRoute.builder() + .id("scoped").protocol(Protocol.HTTP).match(MatchConfig.builder().pathPrefix("/s").build()) + .effectiveAuth(auth).effectiveAllowedMethods(List.of(HttpMethod.GET)) + .upstream(upstream("a.example")).build())); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertTrue(runtimes.getFirst().getRequiredScopes().containsAll(List.of("read", "write")), + "Required scopes flow from the effective auth to the runtime"); + } + + @Test + @DisplayName("Should carry the effective forward allowlist from the resolved route") + void shouldCarryEffectiveForward() { + ForwardConfig forward = new ForwardConfig(List.of("Accept"), List.of("page"), + Map.of("X-Gateway", "api-sheriff")); + RouteTable table = new RouteTable(List.of(ResolvedRoute.builder() + .id("fwd").protocol(Protocol.HTTP).match(MatchConfig.builder().pathPrefix("/f").build()) + .effectiveAuth(AuthConfig.builder().require("none").build()) + .effectiveAllowedMethods(List.of(HttpMethod.GET)) + .upstream(upstream("a.example")).effectiveForward(forward).build())); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertEquals(forward, runtimes.getFirst().getEffectiveForward(), + "The materialized forward allowlist flows to the runtime unchanged"); + } + + @Test + @DisplayName("Should default an absent forward block to a deny-by-default empty allowlist") + void shouldDefaultAbsentForwardToEmpty() { + RouteTable table = new RouteTable(List.of( + route("r1", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")))); + + List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory); + + assertTrue(runtimes.getFirst().getEffectiveForward().headersAllow().isEmpty(), + "An unforwarded route carries an empty, deny-by-default allowlist"); + } + + private static ResolvedRoute route(String id, Protocol protocol, String require, + Optional filter, ResolvedUpstream upstream) { + return ResolvedRoute.builder() + .id(id) + .protocol(protocol) + .match(MatchConfig.builder().pathPrefix("/" + id).build()) + .effectiveAuth(AuthConfig.builder().require(require).build()) + .effectiveAllowedMethods(List.of(HttpMethod.GET)) + .effectiveSecurityFilter(filter) + .upstream(upstream) + .build(); + } + + private static ResolvedUpstream upstream(String host) { + return new ResolvedUpstream("https", host, 443, ""); + } + + /** + * 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/StreamAwareRetryGateTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGateTest.java new file mode 100644 index 0000000..b5d7a96 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGateTest.java @@ -0,0 +1,85 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; + +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.EnumSource; + +@DisplayName("StreamAwareRetryGate — stage 6 stream-aware retry gate") +class StreamAwareRetryGateTest { + + @Nested + @DisplayName("idempotency classification (RFC 7231 §4.2.2)") + class Idempotency { + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}) + @DisplayName("classifies safe/idempotent verbs as idempotent") + void idempotentVerbs(HttpMethod method) { + assertTrue(StreamAwareRetryGate.isIdempotent(method), method + " must be idempotent"); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"POST", "PATCH"}) + @DisplayName("classifies non-idempotent verbs as non-idempotent") + void nonIdempotentVerbs(HttpMethod method) { + assertFalse(StreamAwareRetryGate.isIdempotent(method), method + " must not be idempotent"); + } + } + + @Nested + @DisplayName("retry admission") + class RetryAdmission { + + @Test + @DisplayName("permits retry for an idempotent method with zero body bytes sent on a retry-enabled route") + void permitsRetryWhenSafe() { + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + assertTrue(gate.allowsRetry(HttpMethod.GET, 0L)); + } + + @Test + @DisplayName("never retries once any request body byte has been sent") + void neverRetriesAfterFirstBodyByte() { + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + assertFalse(gate.allowsRetry(HttpMethod.PUT, 1L), + "a streamed request cannot be replayed once a body byte has crossed to the upstream"); + } + + @Test + @DisplayName("never retries a non-idempotent method even with zero body bytes") + void neverRetriesNonIdempotent() { + StreamAwareRetryGate gate = new StreamAwareRetryGate(true); + assertFalse(gate.allowsRetry(HttpMethod.POST, 0L)); + } + + @Test + @DisplayName("never retries when the route disables retry") + void neverRetriesWhenRouteDisablesRetry() { + StreamAwareRetryGate gate = new StreamAwareRetryGate(false); + assertFalse(gate.allowsRetry(HttpMethod.GET, 0L)); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapperTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapperTest.java new file mode 100644 index 0000000..5ac7a6a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapperTest.java @@ -0,0 +1,143 @@ +/* + * 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.assertSame; + +import java.io.IOException; +import java.io.Serial; + + +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayEventCounter; +import de.cuioss.sheriff.api.events.GatewayException; + +import io.smallrye.faulttolerance.api.CircuitBreakerState; +import org.eclipse.microprofile.faulttolerance.exceptions.CircuitBreakerOpenException; +import org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("UpstreamFailureMapper — stage 6/7 failure mapping to the error contract") +class UpstreamFailureMapperTest { + + private final GatewayEventCounter eventCounter = new GatewayEventCounter(); + private final UpstreamFailureMapper mapper = new UpstreamFailureMapper(eventCounter); + + @Nested + @DisplayName("failure classification") + class Classification { + + @Test + @DisplayName("maps a breaker-open failure to 503 UPSTREAM_CIRCUIT_OPEN") + void breakerOpen() { + assertEquals(EventType.UPSTREAM_CIRCUIT_OPEN, + mapper.classify(new CircuitBreakerOpenException("open"))); + } + + @Test + @DisplayName("maps a MicroProfile timeout to 504 UPSTREAM_TIMEOUT") + void microProfileTimeout() { + assertEquals(EventType.UPSTREAM_TIMEOUT, + mapper.classify(new TimeoutException("t"))); + } + + @Test + @DisplayName("maps a java.util.concurrent timeout to 504 UPSTREAM_TIMEOUT") + void concurrentTimeout() { + assertEquals(EventType.UPSTREAM_TIMEOUT, + mapper.classify(new java.util.concurrent.TimeoutException("t"))); + } + + @Test + @DisplayName("maps any Timeout-named failure to 504 UPSTREAM_TIMEOUT") + void timeoutNamedFailure() { + assertEquals(EventType.UPSTREAM_TIMEOUT, mapper.classify(new ConnectionTimeoutError())); + } + + @Test + @DisplayName("unwraps a wrapped breaker-open cause") + void wrappedBreakerOpen() { + Throwable wrapped = new IllegalStateException(new CircuitBreakerOpenException("open")); + assertEquals(EventType.UPSTREAM_CIRCUIT_OPEN, mapper.classify(wrapped)); + } + + @Test + @DisplayName("maps a generic connection failure to 502 UPSTREAM_ERROR") + void genericFailure() { + assertEquals(EventType.UPSTREAM_ERROR, mapper.classify(new IOException("connection refused"))); + } + + @Test + @DisplayName("terminates on a self-referential cause cycle") + void selfReferentialCause() { + SelfCausingException cyclic = new SelfCausingException(); + assertEquals(EventType.UPSTREAM_ERROR, mapper.classify(cyclic)); + } + } + + @Nested + @DisplayName("metering and exception production") + class MeteringAndException { + + @Test + @DisplayName("meters and wraps a failure into the typed GatewayException") + void toGatewayExceptionMetersAndWraps() { + IOException cause = new IOException("boom"); + + GatewayException produced = mapper.toGatewayException(cause); + + assertEquals(EventType.UPSTREAM_ERROR, produced.getEventType()); + assertSame(cause, produced.getCause(), "the internal cause must be preserved for logging"); + assertEquals(1L, eventCounter.getCount(EventType.UPSTREAM_ERROR), + "every mapped failure is metered"); + } + + @Test + @DisplayName("meters a breaker OPEN transition") + void recordsBreakerOpen() { + mapper.recordBreakerTransition("orders", CircuitBreakerState.OPEN); + assertEquals(1L, eventCounter.getCount(EventType.UPSTREAM_CIRCUIT_OPEN)); + } + + @Test + @DisplayName("does not meter a breaker CLOSED / HALF_OPEN transition") + void ignoresNonOpenTransitions() { + mapper.recordBreakerTransition("orders", CircuitBreakerState.CLOSED); + mapper.recordBreakerTransition("orders", CircuitBreakerState.HALF_OPEN); + assertEquals(0L, eventCounter.getCount(EventType.UPSTREAM_CIRCUIT_OPEN)); + } + } + + /** A failure whose simple class name contains {@code Timeout}, matching the name-based fallback. */ + private static final class ConnectionTimeoutError extends RuntimeException { + @Serial + private static final long serialVersionUID = 1L; + } + + /** A failure that returns itself as its own cause, exercising the cause-cycle guard. */ + private static final class SelfCausingException extends RuntimeException { + @Serial + private static final long serialVersionUID = 1L; + + @Override + public synchronized Throwable getCause() { + return this; + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/EventTypeTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/EventTypeTest.java new file mode 100644 index 0000000..eca92fb --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/EventTypeTest.java @@ -0,0 +1,106 @@ +/* + * 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.events; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +@DisplayName("EventType — category and HTTP-status mapping") +class EventTypeTest { + + @Nested + @DisplayName("Success / informational events") + class SuccessEvents { + + @ParameterizedTest + @EnumSource(names = {"REQUEST_FORWARDED", "TOKEN_REFRESHED", "CONFIG_LOADED"}) + @DisplayName("Should carry no category and no HTTP mapping") + void shouldCarryNoCategoryAndNoHttpMapping(EventType eventType) { + assertAll("success event " + eventType, + () -> assertNull(eventType.category(), "Success events carry a null category"), + () -> assertFalse(eventType.isFailure(), "Success events are not failures"), + () -> assertEquals(0, eventType.httpStatus(), "Success events have no HTTP status"), + () -> assertFalse(eventType.hasHttpMapping(), "Success events have no HTTP mapping")); + } + } + + @Nested + @DisplayName("Boot-only configuration events") + class ConfigurationEvents { + + @ParameterizedTest + @EnumSource(names = {"CONFIG_INVALID", "AUTH_WEAKENED"}) + @DisplayName("Should be failures in the CONFIGURATION category with no HTTP mapping") + void shouldBeConfigurationFailuresWithoutHttpMapping(EventType eventType) { + assertAll("configuration event " + eventType, + () -> assertEquals(EventCategory.CONFIGURATION, eventType.category(), + "Configuration events carry the CONFIGURATION category"), + () -> assertTrue(eventType.isFailure(), "Configuration events are failures"), + () -> assertEquals(0, eventType.httpStatus(), "Configuration events never surface as HTTP"), + () -> assertFalse(eventType.hasHttpMapping(), "Configuration events have no HTTP mapping")); + } + } + + @Nested + @DisplayName("Request-time failure events (error contract)") + class FailureEvents { + + @ParameterizedTest(name = "{0} -> {1} ({2})") + @CsvSource({ + "SECURITY_FILTER_VIOLATION, 400, INPUT_VALIDATION", + "PATH_NOT_ALLOWED, 400, INPUT_VALIDATION", + "PARAMETER_LIMIT_EXCEEDED, 400, INPUT_VALIDATION", + "NO_ROUTE_MATCHED, 404, INPUT_VALIDATION", + "METHOD_NOT_ALLOWED, 405, INPUT_VALIDATION", + "TOKEN_MISSING, 401, AUTHENTICATION", + "TOKEN_INVALID, 401, AUTHENTICATION", + "SCOPE_MISSING, 403, AUTHORIZATION", + "CSRF_REJECTED, 403, AUTHORIZATION", + "UPSTREAM_ERROR, 502, UPSTREAM", + "UPSTREAM_CIRCUIT_OPEN, 503, UPSTREAM", + "UPSTREAM_TIMEOUT, 504, UPSTREAM" + }) + @DisplayName("Should map each error-contract row to its status and category") + void shouldMapEachErrorContractRow(EventType eventType, int expectedStatus, EventCategory expectedCategory) { + assertAll("failure event " + eventType, + () -> assertEquals(expectedCategory, eventType.category(), "Category must match the contract"), + () -> assertEquals(expectedStatus, eventType.httpStatus(), "Status must match the contract"), + () -> assertTrue(eventType.isFailure(), "Request-time rejections are failures"), + () -> assertTrue(eventType.hasHttpMapping(), "Request-time rejections map to a status")); + } + } + + @Test + @DisplayName("Should derive the RFC 9457 problem type from the category slug") + void shouldDeriveProblemTypeFromCategorySlug() { + assertAll("problem type", + () -> assertEquals("urn:api-sheriff:problem:input-validation", + EventCategory.INPUT_VALIDATION.problemType()), + () -> assertEquals("urn:api-sheriff:problem:upstream", + EventCategory.UPSTREAM.problemType()), + () -> assertEquals("Input Validation", EventCategory.INPUT_VALIDATION.title())); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/GatewayEventCounterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/GatewayEventCounterTest.java new file mode 100644 index 0000000..1714b9f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/GatewayEventCounterTest.java @@ -0,0 +1,93 @@ +/* + * 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.events; + +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.Map; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("GatewayEventCounter — lock-free in-process counter") +class GatewayEventCounterTest { + + @Test + @DisplayName("Should report zero for an event that was never incremented") + void shouldReportZeroForUncountedEvent() { + var counter = new GatewayEventCounter(); + + long count = counter.getCount(EventType.REQUEST_FORWARDED); + + assertEquals(0L, count, "An uncounted event must report zero"); + } + + @Test + @DisplayName("Should increment a single event type") + void shouldIncrementSingleEventType() { + var counter = new GatewayEventCounter(); + + counter.increment(EventType.TOKEN_INVALID); + counter.increment(EventType.TOKEN_INVALID); + counter.increment(EventType.TOKEN_INVALID); + + assertEquals(3L, counter.getCount(EventType.TOKEN_INVALID), "Three increments must yield three"); + } + + @Test + @DisplayName("Should count concurrent increments without loss (lock-free)") + void shouldCountConcurrentIncrementsWithoutLoss() { + var counter = new GatewayEventCounter(); + int increments = 10_000; + + IntStream.range(0, increments).parallel() + .forEach(i -> counter.increment(EventType.SECURITY_FILTER_VIOLATION)); + + assertEquals(increments, counter.getCount(EventType.SECURITY_FILTER_VIOLATION), + "Every concurrent increment must be counted"); + } + + @Test + @DisplayName("Should expose an unmodifiable snapshot of all non-zero counters") + void shouldExposeUnmodifiableSnapshot() { + var counter = new GatewayEventCounter(); + counter.increment(EventType.UPSTREAM_ERROR); + counter.increment(EventType.SCOPE_MISSING); + + Map snapshot = counter.getCounters(); + + assertEquals(2, snapshot.size(), "Only incremented events appear in the snapshot"); + assertEquals(1L, snapshot.get(EventType.UPSTREAM_ERROR), "Snapshot value must match the count"); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.put(EventType.CONFIG_LOADED, 5L), + "The snapshot must be unmodifiable"); + } + + @Test + @DisplayName("Should clear every counter on reset") + void shouldClearEveryCounterOnReset() { + var counter = new GatewayEventCounter(); + counter.increment(EventType.NO_ROUTE_MATCHED); + + counter.reset(); + + assertEquals(0L, counter.getCount(EventType.NO_ROUTE_MATCHED), "Reset must zero the counter"); + assertTrue(counter.getCounters().isEmpty(), "Reset must empty the snapshot"); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/ForwardPolicyStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/ForwardPolicyStageTest.java new file mode 100644 index 0000000..bc6ead0 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/ForwardPolicyStageTest.java @@ -0,0 +1,288 @@ +/* + * 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.forward; + +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.List; +import java.util.Map; +import java.util.Set; + + +import de.cuioss.http.forwarded.ForwardedHeaderResolver; +import de.cuioss.http.forwarded.ForwardedResolverConfig; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.sheriff.api.config.model.ForwardConfig; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.pipeline.PipelineRequest; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("ForwardPolicyStage — stage 5 zero-trust forward policy") +class ForwardPolicyStageTest { + + private static final String EMIT_XFORWARDED = "x-forwarded"; + private static final String EMIT_BOTH = "both"; + + /** CIDR range trusted both as the immediate TCP peer and as an intermediate proxy. */ + private static final String TRUSTED_CIDR = "127.0.0.0/8"; + /** An immediate TCP peer inside {@link #TRUSTED_CIDR}. */ + private static final String TRUSTED_PEER = "127.0.0.1"; + /** An immediate TCP peer outside every trusted range. */ + private static final String UNTRUSTED_PEER = "203.0.113.9"; + /** The real client the forwarding chain resolves to (never a trusted proxy). */ + private static final String CLIENT_IP = "6.6.6.6"; + private static final String XFF = "X-Forwarded-For"; + private static final String FORWARDED = "Forwarded"; + + @Nested + @DisplayName("drop-and-regenerate forwarding headers") + class DropAndRegenerate { + + @Test + @DisplayName("regenerates X-Forwarded-For from a trusted peer chain, stripping the proxy hop") + void regeneratesClientIpStrippingProxy() { + // Arrange — a two-hop chain: real client 6.6.6.6 behind trusted proxy 127.0.0.1 + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR)); + PipelineRequest request = request(TRUSTED_PEER, + Map.of(XFF, List.of(CLIENT_IP + ", " + TRUSTED_PEER))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false); + + // Assert — the regenerated value is the client only, never the verbatim inbound chain + assertTrue(result.headers().containsValue(CLIENT_IP), + "regenerated X-Forwarded-For must carry the resolved client IP"); + assertFalse(result.headers().containsValue(CLIENT_IP + ", " + TRUSTED_PEER), + "inbound forwarding chain must never be propagated verbatim"); + } + + @Test + @DisplayName("emit:both additionally regenerates the RFC 7239 Forwarded header") + void emitBothAddsForwardedHeader() { + // Arrange + ForwardPolicyStage stage = stage(EMIT_BOTH, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR)); + PipelineRequest request = request(TRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of()), false); + + // Assert — both X-Forwarded-* and Forwarded are emitted + assertTrue(result.headers().containsValue(CLIENT_IP), + "emit:both must still regenerate the X-Forwarded-* set"); + assertTrue(result.headers().containsKey(FORWARDED), + "emit:both must add the RFC 7239 Forwarded header"); + assertTrue(result.headers().get(FORWARDED).contains(CLIENT_IP), + "the regenerated Forwarded header must reference the resolved client"); + } + + @Test + @DisplayName("emit:x-forwarded omits the Forwarded header but keeps X-Forwarded-*") + void emitXForwardedOmitsForwardedHeader() { + // Arrange + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR)); + PipelineRequest request = request(TRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of()), false); + + // Assert + assertTrue(result.headers().containsValue(CLIENT_IP), + "emit:x-forwarded must regenerate the X-Forwarded-* set"); + assertFalse(result.headers().containsKey(FORWARDED), + "emit:x-forwarded must never add the RFC 7239 Forwarded header"); + } + } + + @Nested + @DisplayName("spoofed headers from untrusted peers are ignored") + class UntrustedPeer { + + @Test + @DisplayName("an untrusted immediate peer's spoofed X-Forwarded-For never influences the regenerated set") + void untrustedPeerSpoofIgnored() { + // Arrange — untrusted peer sends a spoofed forwarding header, even allow-listed + ForwardPolicyStage stage = stage(EMIT_BOTH, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR)); + PipelineRequest request = request(UNTRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false); + + // Assert — the spoofed value crosses neither as X-Forwarded-For nor as Forwarded + assertFalse(result.headers().containsValue(CLIENT_IP), + "a spoofed forwarding header from an untrusted peer must be ignored"); + assertFalse(result.headers().containsKey(FORWARDED), + "no Forwarded header may be regenerated from ignored spoofed input"); + } + + @Test + @DisplayName("a null immediate peer is never trusted, so inbound forwarding headers are ignored") + void nullPeerNeverTrusted() { + // Arrange — the edge supplied no peer address + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR)); + PipelineRequest request = request(null, Map.of(XFF, List.of(CLIENT_IP))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false); + + // Assert + assertFalse(result.headers().containsValue(CLIENT_IP), + "an absent peer address is never trusted"); + } + } + + @Nested + @DisplayName("deny-by-default allowlists") + class Allowlists { + + @Test + @DisplayName("forwards only allow-listed request headers") + void forwardsOnlyAllowListedHeaders() { + // Arrange + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(), Set.of()); + PipelineRequest request = request(UNTRUSTED_PEER, Map.of( + "X-Api-Version", List.of("v2"), + "X-Secret", List.of("leak"))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of("X-Api-Version")), false); + + // Assert + assertEquals("v2", result.headers().get("X-Api-Version"), "allow-listed header must cross"); + assertFalse(result.headers().containsKey("X-Secret"), "non-allow-listed header must be dropped"); + } + + @Test + @DisplayName("Authorization crosses only when explicitly allow-listed") + void authorizationCrossesOnlyWhenAllowListed() { + // Arrange + Map> headers = Map.of("Authorization", List.of("Bearer token-xyz")); + + // Act — dropped when not listed, crosses when listed + ForwardPolicyStage.Result dropped = stage(EMIT_XFORWARDED, List.of(), Set.of()) + .process(request(UNTRUSTED_PEER, headers), allow(List.of()), false); + ForwardPolicyStage.Result crossed = stage(EMIT_XFORWARDED, List.of(), Set.of()) + .process(request(UNTRUSTED_PEER, headers), allow(List.of("Authorization")), false); + + // Assert + assertFalse(dropped.headers().containsKey("Authorization"), + "inbound Authorization is dropped by default"); + assertEquals("Bearer token-xyz", crossed.headers().get("Authorization"), + "inbound Authorization crosses only when allow-listed"); + } + + @Test + @DisplayName("an inbound forwarding header can never be smuggled through the header allowlist") + void forwardingHeaderNeverAllowListed() { + // Arrange — X-Forwarded-For is allow-listed AND the peer is untrusted + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR)); + PipelineRequest request = request(UNTRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP))); + + // Act + ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false); + + // Assert — the verbatim inbound value is never copied through the allowlist path + assertFalse(result.headers().containsValue(CLIENT_IP), + "a forwarding header is excluded from the allowlist copy and never propagated verbatim"); + } + + @Test + @DisplayName("forwards only allow-listed query parameters") + void forwardsOnlyAllowListedQuery() { + // Arrange + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(), Set.of()); + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/orders") + .peerAddress(UNTRUSTED_PEER) + .queryParameters(Map.of("page", List.of("2"), "secret", List.of("x"))) + .build(); + + // Act + ForwardPolicyStage.Result result = stage.process(request, + ForwardConfig.builder().queryAllow(List.of("page")).build(), false); + + // Assert + assertEquals(List.of("2"), result.query().get("page"), "allow-listed query parameter must cross"); + assertFalse(result.query().containsKey("secret"), "non-allow-listed query parameter must be dropped"); + } + } + + @Nested + @DisplayName("static and conditional headers") + class StaticAndConditional { + + @Test + @DisplayName("appends static set_headers verbatim") + void appendsStaticSetHeaders() { + // Arrange + ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(), Set.of()); + PipelineRequest request = request(UNTRUSTED_PEER, Map.of()); + + // Act + ForwardPolicyStage.Result result = stage.process(request, + ForwardConfig.builder().setHeaders(Map.of("X-Gateway", "sheriff")).build(), false); + + // Assert + assertEquals("sheriff", result.headers().get("X-Gateway"), "static set_headers must be appended verbatim"); + } + + @Test + @DisplayName("conditional-request headers cross only when the route enables not_modified") + void conditionalHeadersGatedByNotModified() { + // Arrange + Map> headers = Map.of("If-None-Match", List.of("\"etag-1\"")); + + // Act + ForwardPolicyStage.Result dropped = stage(EMIT_XFORWARDED, List.of(), Set.of()) + .process(request(UNTRUSTED_PEER, headers), allow(List.of()), false); + ForwardPolicyStage.Result crossed = stage(EMIT_XFORWARDED, List.of(), Set.of()) + .process(request(UNTRUSTED_PEER, headers), allow(List.of()), true); + + // Assert + assertFalse(dropped.headers().containsKey("If-None-Match"), + "conditional headers are dropped when not_modified is disabled"); + assertEquals("\"etag-1\"", crossed.headers().get("If-None-Match"), + "conditional headers cross when not_modified is enabled"); + } + } + + private static ForwardPolicyStage stage(String emitMode, List tcpTrusted, Set resolverTrusted) { + ForwardedResolverConfig config = ForwardedResolverConfig.builder() + .trustedProxies(resolverTrusted) + .build(); + ForwardedHeaderResolver resolver = new ForwardedHeaderResolver(config, new SecurityEventCounter()); + return new ForwardPolicyStage(resolver, new TcpPeerGate(tcpTrusted), emitMode); + } + + private static ForwardConfig allow(List headersAllow) { + return ForwardConfig.builder().headersAllow(headersAllow).build(); + } + + private static PipelineRequest request(@Nullable String peer, Map> headers) { + return PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/orders") + .peerAddress(peer) + .headers(headers) + .build(); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/TcpPeerGateTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/TcpPeerGateTest.java new file mode 100644 index 0000000..3908f40 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/TcpPeerGateTest.java @@ -0,0 +1,179 @@ +/* + * 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.forward; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +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.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("TcpPeerGate — immediate-TCP-peer trust gate (ADR-0003)") +class TcpPeerGateTest { + + @Nested + @DisplayName("IPv4 CIDR membership") + class IPv4Membership { + + @ParameterizedTest + @CsvSource({ + "127.0.0.0/8,127.0.0.1", + "127.0.0.0/8,127.255.255.254", + "10.0.0.0/8,10.9.9.9", + "192.168.1.0/24,192.168.1.0", + "192.168.1.0/24,192.168.1.255", + "203.0.113.5/32,203.0.113.5" + }) + @DisplayName("trusts a peer inside the configured range") + void trustsPeerInsideRange(String cidr, String peer) { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of(cidr)); + + // Act + Assert + assertTrue(gate.isTrustedPeer(peer), peer + " must be trusted inside " + cidr); + } + + @ParameterizedTest + @CsvSource({ + "127.0.0.0/8,128.0.0.1", + "10.0.0.0/8,11.0.0.1", + "192.168.1.0/24,192.168.2.1", + "203.0.113.5/32,203.0.113.6" + }) + @DisplayName("rejects a peer outside the configured range") + void rejectsPeerOutsideRange(String cidr, String peer) { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of(cidr)); + + // Act + Assert + assertFalse(gate.isTrustedPeer(peer), peer + " must be rejected outside " + cidr); + } + + @Test + @DisplayName("trusts a peer matching any of several configured ranges") + void trustsAcrossMultipleRanges() { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of("10.0.0.0/8", "192.168.0.0/16")); + + // Act + Assert + assertTrue(gate.isTrustedPeer("10.1.2.3")); + assertTrue(gate.isTrustedPeer("192.168.50.1")); + assertFalse(gate.isTrustedPeer("172.16.0.1")); + } + } + + @Nested + @DisplayName("IPv6 CIDR membership is address-family aware") + class IPv6Membership { + + @Test + @DisplayName("trusts an IPv6 peer inside an IPv6 range") + void trustsIpv6PeerInsideRange() { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of("2001:db8::/32")); + + // Act + Assert + assertTrue(gate.isTrustedPeer("2001:db8::1")); + assertFalse(gate.isTrustedPeer("2001:dead::1")); + } + + @Test + @DisplayName("an IPv4 peer never matches an IPv6 range and vice versa") + void crossFamilyNeverMatches() { + // Arrange + TcpPeerGate ipv6Gate = new TcpPeerGate(List.of("2001:db8::/32")); + TcpPeerGate ipv4Gate = new TcpPeerGate(List.of("10.0.0.0/8")); + + // Act + Assert + assertFalse(ipv6Gate.isTrustedPeer("10.0.0.1"), "IPv4 candidate must not match an IPv6 range"); + assertFalse(ipv4Gate.isTrustedPeer("2001:db8::1"), "IPv6 candidate must not match an IPv4 range"); + } + } + + @Nested + @DisplayName("fail-closed behaviour") + class FailClosed { + + @Test + @DisplayName("an empty trusted set trusts no peer") + void emptySetTrustsNoPeer() { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of()); + + // Act + Assert + assertFalse(gate.isTrustedPeer("127.0.0.1"), "an empty trusted set must trust no peer"); + } + + @Test + @DisplayName("a null peer address is never trusted") + void nullPeerNeverTrusted() { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of("0.0.0.0/0")); + + // Act + Assert + assertFalse(gate.isTrustedPeer(null), "a null peer must never be trusted, even under 0.0.0.0/0"); + } + + @ParameterizedTest + @ValueSource(strings = {"not-an-ip", "999.0.0.1", "127.0.0.1/8", ""}) + @DisplayName("an unparseable peer address is never trusted") + void unparseablePeerNeverTrusted(String peer) { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of("127.0.0.0/8")); + + // Act + Assert + assertFalse(gate.isTrustedPeer(peer), "an unparseable peer literal must never be trusted"); + } + + @Test + @DisplayName("surrounding whitespace on the peer address is stripped before matching") + void stripsSurroundingWhitespace() { + // Arrange + TcpPeerGate gate = new TcpPeerGate(List.of("127.0.0.0/8")); + + // Act + Assert + assertTrue(gate.isTrustedPeer(" 127.0.0.1 "), "a padded but valid peer literal must be trusted"); + } + } + + @Nested + @DisplayName("boot-time CIDR parsing rejects malformed input") + class CidrParsing { + + @ParameterizedTest + @ValueSource(strings = {"127.0.0.1", "10.0.0.0/99", "10.0.0.0/-1", "999.0.0.0/8", "not-a-cidr"}) + @DisplayName("rejects a malformed trusted-proxy CIDR at construction") + void rejectsMalformedCidr(String badCidr) { + // Act + Assert + assertThrows(IllegalArgumentException.class, () -> new TcpPeerGate(List.of(badCidr)), + "malformed CIDR must fail loud at boot"); + } + + @Test + @DisplayName("rejects a null CIDR collection") + void rejectsNullCollection() { + // Act + Assert + assertThrows(NullPointerException.class, () -> new TcpPeerGate(null)); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/GatewayResourceTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/GatewayResourceTest.java deleted file mode 100644 index e330c79..0000000 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/GatewayResourceTest.java +++ /dev/null @@ -1,63 +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.gateway; - -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -/** - * Tests for {@link GatewayResource}. - */ -@QuarkusTest -class GatewayResourceTest { - - @BeforeAll - static void setup() { - RestAssured.useRelaxedHTTPSValidation(); - } - - @Test - void shouldReturnHealthUp() { - var response = given() - .when().get("/api/health") - .then() - .statusCode(200) - .extract(); - - assertEquals("UP", response.path("status")); - assertEquals("API Sheriff is operational", response.path("apiSheriff")); - } - - @Test - void shouldReturnInfo() { - var response = given() - .when().get("/api/info") - .then() - .statusCode(200) - .extract(); - - assertEquals("API Sheriff Gateway", response.path("message")); - assertNotNull(response.path("version")); - } -} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java deleted file mode 100644 index a033288..0000000 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java +++ /dev/null @@ -1,269 +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.gateway.proxy; - -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; - - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import de.cuioss.test.mockwebserver.EnableMockWebServer; -import de.cuioss.test.mockwebserver.dispatcher.HttpMethodMapper; -import de.cuioss.test.mockwebserver.dispatcher.ModuleDispatcher; -import de.cuioss.test.mockwebserver.dispatcher.ModuleDispatcherElement; -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; -import lombok.NonNull; -import mockwebserver3.MockResponse; -import mockwebserver3.MockWebServer; -import mockwebserver3.RecordedRequest; -import okio.ByteString; - -/** - * Tests for {@link ProxyRoute}: forwarding of method, path remainder, query and - * body to the upstream, hop-by-hop header stripping, {@code 404} - * deny-by-default for unmatched paths, and the two disposable stopgap mitigations - * (a {@code BodyHandler} request-body size limit and an {@code HttpRequest} - * upstream timeout). - *

- * The proxy edge sources its upstream from the {@code RouteTable} the - * configuration subsystem assembles at boot: the test config - * ({@code src/test/resources/config/testboot}) declares a single {@code /proxy} - * route ({@code endpoints/proxy.yaml}) whose {@code UPSTREAM} topology alias - * ({@code topology.properties}) resolves to the {@link MockWebServer} started on - * the fixed port below. There is no static {@code ProxyConfiguration} bean any - * more. The fixed port is required because {@code @QuarkusTest} freezes config at - * boot, so a dynamically-assigned port cannot be injected. The mock echoes each - * received request back as JSON, so the assertions verify exactly what the gateway - * forwarded from the RouteTable-sourced upstream. - */ -@QuarkusTest -@EnableMockWebServer(manualStart = true) -@ModuleDispatcher -class ProxyRouteTest { - - /** Must match the {@code UPSTREAM} alias port in {@code config/testboot/topology.properties}. */ - private static final int UPSTREAM_PORT = 19191; - - /** - * Mirrors {@code ProxyRoute}'s test-only upstream-timeout override property. Kept as a literal - * (not shared code) so the test pins the exact contract the production seam exposes. - */ - private static final String UPSTREAM_TIMEOUT_MS_PROPERTY = "apisheriff.proxy.upstream-request-timeout-ms"; - - /** Path remainder the {@link EchoDispatcher} answers slowly, to trip the upstream request timeout. */ - private static final String SLOW_MARKER = "slow-upstream"; - - @BeforeAll - static void setup() { - RestAssured.useRelaxedHTTPSValidation(); - } - - @BeforeEach - void startUpstream(MockWebServer upstream) throws IOException { - upstream.start(UPSTREAM_PORT); - } - - public ModuleDispatcherElement getModuleDispatcher() { - return new EchoDispatcher(); - } - - @Test - void shouldForwardMethodPathAndQuery() { - var response = given() - .when().get("/proxy/orders/42?page=2&size=10") - .then() - .statusCode(200) - .extract(); - - assertEquals("GET", response.path("method")); - assertEquals("/orders/42?page=2&size=10", response.path("target")); - } - - @Test - void shouldForwardBodyOnPost() { - var response = given() - .contentType("text/plain") - .body("hello-upstream") - .when().post("/proxy/submit") - .then() - .statusCode(200) - .extract(); - - assertEquals("POST", response.path("method")); - assertEquals("/submit", response.path("target")); - assertEquals("hello-upstream", response.path("body")); - } - - @Test - void shouldForwardCustomHeaderButStripHopByHop() { - var response = given() - .header("X-Forward-Test", "custom-value") - .header("Connection", "keep-alive") - .when().get("/proxy/echo") - .then() - .statusCode(200) - .extract(); - - assertEquals("custom-value", response.path("customHeader")); - assertEquals("", response.path("connectionHeader")); - } - - @Test - void shouldReturn404ForUnmatchedPath() { - given() - .when().get("/not-proxied/resource") - .then() - .statusCode(404); - } - - @Test - void shouldReturn502WhenUpstreamUnavailable(MockWebServer upstream) throws Exception { - // Take the upstream down so the forward fails with a connection error - upstream.close(); - given() - .when().get("/proxy/unavailable") - .then() - .statusCode(502); - } - - @Test - void shouldRejectRequestBodyExceedingSizeLimit() { - // BodyHandler.setBodyLimit(1 MiB): a body one byte over the limit is rejected - // by Vert.x with 413 before the proxy ever forwards it upstream. - String overLimitBody = "a".repeat(1024 * 1024 + 1); - given() - .contentType("text/plain") - .body(overLimitBody) - .when().post("/proxy/submit") - .then() - .statusCode(413); - } - - @Test - void shouldReturn502WhenUpstreamExceedsRequestTimeout() { - // Override the upstream request timeout to a short value so the slow upstream - // (EchoDispatcher delays the SLOW_MARKER path well beyond it) trips the - // HttpRequest timeout, which surfaces as a 502 — without a multi-second wait. - System.setProperty(UPSTREAM_TIMEOUT_MS_PROPERTY, "500"); - try { - given() - .when().get("/proxy/" + SLOW_MARKER) - .then() - .statusCode(502); - } finally { - System.clearProperty(UPSTREAM_TIMEOUT_MS_PROPERTY); - } - } - - @Test - void shouldFallBackToDefaultTimeoutWhenOverrideNonPositive() { - // A misconfigured non-positive override (0 or negative) must be rejected in favour - // of the default timeout rather than producing a non-positive Duration, which - // HttpRequest.Builder.timeout() would reject with IllegalArgumentException — turning - // every proxied request into a 502. With the guard, a normal fast request still - // forwards and returns 200. - System.setProperty(UPSTREAM_TIMEOUT_MS_PROPERTY, "0"); - try { - given() - .when().get("/proxy/orders/42") - .then() - .statusCode(200); - } finally { - System.clearProperty(UPSTREAM_TIMEOUT_MS_PROPERTY); - } - } - - /** - * Echoes each received request back as JSON so the test can assert exactly - * what the gateway forwarded. Matches every forwarded path ({@code "/"} base URL). - * A request whose target contains {@link #SLOW_MARKER} is answered with a long - * header delay so the proxy's upstream request timeout trips first. - */ - private static final class EchoDispatcher implements ModuleDispatcherElement { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - @Override - public String getBaseUrl() { - return "/"; - } - - @Override - public Optional handleGet(@NonNull RecordedRequest request) { - return echo(request); - } - - @Override - public Optional handlePost(@NonNull RecordedRequest request) { - return echo(request); - } - - @Override - public Optional handlePut(@NonNull RecordedRequest request) { - return echo(request); - } - - @Override - public Optional handleDelete(@NonNull RecordedRequest request) { - return echo(request); - } - - @Override - public @NonNull Set supportedMethods() { - return Set.of(HttpMethodMapper.values()); - } - - private Optional echo(RecordedRequest request) { - ByteString requestBody = request.getBody(); - // Absent headers are echoed as "" (not null): the stripped hop-by-hop - // Connection header therefore surfaces as an empty string in the assertion. - Map payload = Map.of( - "method", request.getMethod(), - "target", request.getTarget(), - "body", requestBody == null ? "" : requestBody.utf8(), - "customHeader", Optional.ofNullable(request.getHeaders().get("X-Forward-Test")).orElse(""), - "connectionHeader", Optional.ofNullable(request.getHeaders().get("Connection")).orElse("")); - try { - MockResponse.Builder builder = new MockResponse.Builder() - .code(200) - .addHeader("Content-Type", "application/json") - .body(MAPPER.writeValueAsString(payload)); - String target = request.getTarget(); - if (target != null && target.contains(SLOW_MARKER)) { - // Far longer than the overridden request timeout, so the proxy - // times out and returns 502 before this response is delivered. - builder.headersDelay(5, TimeUnit.SECONDS); - } - return Optional.of(builder.build()); - } catch (JsonProcessingException e) { - throw new IllegalStateException("Failed to serialise echo payload", e); - } - } - } -} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/BasicChecksStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/BasicChecksStageTest.java new file mode 100644 index 0000000..4a8f6bd Binary files /dev/null and b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/BasicChecksStageTest.java differ diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuardTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuardTest.java new file mode 100644 index 0000000..6849fa3 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuardTest.java @@ -0,0 +1,93 @@ +/* + * 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.Map; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +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.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("CanonicalPathGuard — D3b GW-01 single-canonical-path guard") +class CanonicalPathGuardTest { + + private final CanonicalPathGuard guard = new CanonicalPathGuard(); + + @ParameterizedTest(name = "rejects non-canonical path [{0}]") + @ValueSource(strings = { + "/orders/1%2f2", + "/orders/1%2F2", + "/orders/1%5cadmin", + "/orders/1%5Cadmin", + "/orders;jsessionid=abc", + "/orders/../admin;x=1" + }) + @DisplayName("rejects an encoded separator or a matrix parameter as a security-filter violation") + void rejectsNonCanonicalPaths(String rawPath) { + // Arrange + PipelineRequest request = requestWith(rawPath, "/orders/canonical"); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> guard.process(request)); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("accepts a clean path once the canonical path has been recorded") + void acceptsCleanPath() { + // Arrange + PipelineRequest request = requestWith("/orders/123", "/orders/123"); + + // Act + Assert + assertDoesNotThrow(() -> guard.process(request)); + } + + @Test + @DisplayName("fails loud when the canonical path was not resolved at stage 1") + void requiresCanonicalPath() { + // Arrange — no canonical path recorded + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/orders/123") + .build(); + + // Act + Assert + assertThrows(IllegalStateException.class, () -> guard.process(request)); + } + + private static PipelineRequest requestWith(String rawPath, String canonicalPath) { + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath(rawPath) + .queryParameters(Map.of()) + .headers(Map.of()) + .build(); + request.canonicalPath(canonicalPath); + return request; + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/FramingGateTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/FramingGateTest.java new file mode 100644 index 0000000..dcdc46b --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/FramingGateTest.java @@ -0,0 +1,151 @@ +/* + * 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 de.cuioss.sheriff.api.config.model.HttpMethod; +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.Test; + +@DisplayName("FramingGate — D3b GW-02 anti-smuggling framing gate") +class FramingGateTest { + + private final FramingGate gate = new FramingGate(); + + @Test + @DisplayName("rejects Content-Length and Transfer-Encoding both present") + void rejectsConflictingFraming() { + // Arrange + PipelineRequest request = request(HttpMethod.POST, Map.of( + "content-length", List.of("10"), + "transfer-encoding", List.of("chunked")), 10L, true); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> gate.process(request)); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("rejects a request carrying multiple Content-Length headers (RFC 7230 §3.3.2)") + void rejectsMultipleContentLength() { + // Arrange — two Content-Length values is an ambiguous framing / request-smuggling vector + PipelineRequest request = request(HttpMethod.POST, Map.of( + "content-length", List.of("10", "20")), 10L, true); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> gate.process(request)); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("rejects a single Content-Length header carrying a comma-separated value list") + void rejectsCommaSeparatedContentLength() { + // Arrange — "5, 6" in one Content-Length field is equally a smuggling vector per RFC 7230 + PipelineRequest request = request(HttpMethod.POST, Map.of( + "content-length", List.of("5, 6")), 5L, true); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> gate.process(request)); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("rejects a body on a bodyless GET") + void rejectsBodyOnGet() { + // Arrange + PipelineRequest request = request(HttpMethod.GET, Map.of("content-length", List.of("5")), 5L, true); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> gate.process(request)); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("rejects a HEAD request carrying a declared body") + void rejectsBodyOnHead() { + // Arrange + PipelineRequest request = request(HttpMethod.HEAD, Map.of("content-length", List.of("3")), 3L, true); + + // Act + Assert + assertThrows(GatewayException.class, () -> gate.process(request)); + } + + @Test + @DisplayName("rejects a Connection token that would strip a framing header") + void rejectsFramingHeaderStrip() { + // Arrange + PipelineRequest request = request(HttpMethod.POST, Map.of( + "content-length", List.of("4"), + "connection", List.of("keep-alive, content-length")), 4L, true); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> gate.process(request)); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("rejects a Connection token that would strip the trust header Authorization") + void rejectsTrustHeaderStrip() { + // Arrange + PipelineRequest request = request(HttpMethod.GET, Map.of( + "connection", List.of("authorization")), -1L, false); + + // Act + Assert + assertThrows(GatewayException.class, () -> gate.process(request)); + } + + @Test + @DisplayName("accepts a well-framed POST carrying only Content-Length") + void acceptsWellFramedPost() { + // Arrange + PipelineRequest request = request(HttpMethod.POST, Map.of("content-length", List.of("12")), 12L, true); + + // Act + Assert + assertDoesNotThrow(() -> gate.process(request)); + } + + private static PipelineRequest request(HttpMethod method, Map> headers, + long contentLength, boolean bodyPresent) { + return PipelineRequest.builder() + .method(method) + .requestPath("/orders") + .queryParameters(Map.of()) + .headers(headers) + .declaredContentLength(contentLength) + .bodyPresent(bodyPresent) + .build(); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStageTest.java new file mode 100644 index 0000000..02accd6 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStageTest.java @@ -0,0 +1,114 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.routing.RouteMatcher; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("RouteSelectionStage — stage 2 deny-by-default longest-prefix selection") +class RouteSelectionStageTest { + + private final RouteSelectionStage stage = new RouteSelectionStage(List.of( + route("admin", "/orders/admin"), + route("orders", "/orders"))); + + @Test + @DisplayName("selects the most specific route when both prefixes match") + void selectsLongestPrefix() { + // Arrange + PipelineRequest request = requestFor("/orders/admin/reports"); + + // Act + stage.process(request); + + // Assert + assertNotNull(request.selectedRoute()); + assertEquals("admin", request.selectedRoute().getId()); + } + + @Test + @DisplayName("selects the general route when only its prefix matches") + void selectsGeneralRoute() { + // Arrange + PipelineRequest request = requestFor("/orders/123"); + + // Act + stage.process(request); + + // Assert + assertEquals("orders", request.selectedRoute().getId()); + } + + @Test + @DisplayName("rejects an unmatched path 404 (deny by default)") + void rejectsUnmatchedPath() { + // Arrange + PipelineRequest request = requestFor("/catalog"); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + // Assert + assertEquals(EventType.NO_ROUTE_MATCHED, thrown.getEventType()); + } + + @Test + @DisplayName("fails loud when the canonical path was not resolved at stage 1") + void requiresCanonicalPath() { + // Arrange — no canonical path recorded + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/orders/123") + .build(); + + // Act + Assert + assertThrows(IllegalStateException.class, () -> stage.process(request)); + } + + private static RouteRuntime route(String id, String pathPrefix) { + MatchConfig match = MatchConfig.builder().pathPrefix(pathPrefix).build(); + return RouteRuntime.builder() + .id(id) + .matcher(RouteMatcher.from(match)) + .build(); + } + + private static PipelineRequest requestFor(String canonicalPath) { + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath(canonicalPath) + .queryParameters(Map.of()) + .headers(Map.of()) + .build(); + request.canonicalPath(canonicalPath); + return request; + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStageTest.java new file mode 100644 index 0000000..b79ba61 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStageTest.java @@ -0,0 +1,120 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig.Cors; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("SecurityHeadersStage — stage 0 response headers and CORS origin handling") +class SecurityHeadersStageTest { + + private static final String ACAO = "Access-Control-Allow-Origin"; + + @Test + @DisplayName("reflects any presented origin when a wildcard origin is configured") + void wildcardReflectsAnyOrigin() { + // Arrange — a configured "*" is a real wildcard (credentials disabled); the request Origin is + // never literally "*", so the stage must accept and reflect an arbitrary presented origin. + SecurityHeadersStage stage = corsStage(List.of("*"), false); + PipelineRequest request = corsRequest(HttpMethod.GET, "https://any.example", false); + + // Act + stage.process(request); + + // Assert + assertEquals("https://any.example", request.responseHeaders().get(ACAO)); + } + + @Test + @DisplayName("emits CORS headers for an explicitly allow-listed origin") + void exactOriginMatch() { + // Arrange + SecurityHeadersStage stage = corsStage(List.of("https://ok.example"), false); + PipelineRequest request = corsRequest(HttpMethod.GET, "https://ok.example", false); + + // Act + stage.process(request); + + // Assert + assertEquals("https://ok.example", request.responseHeaders().get(ACAO)); + } + + @Test + @DisplayName("emits no CORS header for an origin that is neither listed nor wildcarded") + void disallowedOriginEmitsNoCorsHeader() { + // Arrange + SecurityHeadersStage stage = corsStage(List.of("https://ok.example"), false); + PipelineRequest request = corsRequest(HttpMethod.GET, "https://evil.example", false); + + // Act + stage.process(request); + + // Assert + assertNull(request.responseHeaders().get(ACAO)); + } + + @Test + @DisplayName("short-circuits a wildcard-origin preflight with 204 and the reflected origin") + void wildcardPreflightShortCircuits() { + // Arrange + SecurityHeadersStage stage = corsStage(List.of("*"), false); + PipelineRequest request = corsRequest(HttpMethod.OPTIONS, "https://any.example", true); + + // Act + stage.process(request); + + // Assert + assertEquals("https://any.example", request.responseHeaders().get(ACAO)); + assertTrue(request.shortCircuitStatus().isPresent(), "a CORS preflight must short-circuit"); + assertEquals(204, request.shortCircuitStatus().orElseThrow().intValue()); + } + + private static SecurityHeadersStage corsStage(List allowedOrigins, boolean allowCredentials) { + Cors cors = Cors.builder() + .enabled(Optional.of(Boolean.TRUE)) + .allowedOrigins(allowedOrigins) + .allowedMethods(List.of("GET", "POST")) + .allowCredentials(Optional.of(allowCredentials)) + .build(); + return new SecurityHeadersStage(Optional.of( + SecurityHeadersConfig.builder().cors(Optional.of(cors)).build())); + } + + private static PipelineRequest corsRequest(HttpMethod method, String origin, boolean preflight) { + Map> headers = preflight + ? Map.of("origin", List.of(origin), "access-control-request-method", List.of("GET")) + : Map.of("origin", List.of(origin)); + return PipelineRequest.builder() + .method(method) + .requestPath("/api") + .queryParameters(Map.of()) + .headers(headers) + .build(); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStageTest.java new file mode 100644 index 0000000..d525431 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStageTest.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.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.Optional; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + + +import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.http.security.database.AttackTestCase; +import de.cuioss.http.security.database.OWASPTop10AttackDatabase; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.sheriff.api.config.model.HttpMethod; +import de.cuioss.sheriff.api.config.model.MatchConfig; +import de.cuioss.sheriff.api.events.EventType; +import de.cuioss.sheriff.api.events.GatewayException; +import de.cuioss.sheriff.api.routing.RouteMatcher; +import de.cuioss.sheriff.api.routing.RouteRuntime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("ThoroughChecksStage — stage 3 per-route divergent filters, allowed_paths, and body cap") +class ThoroughChecksStageTest { + + private final SecurityConfiguration defaultConfiguration = SecurityConfiguration.defaults(); + private final SecurityEventCounter counter = new SecurityEventCounter(); + private final ThoroughChecksStage stage = new ThoroughChecksStage(defaultConfiguration, counter); + + static Stream owaspTop10() { + return StreamSupport.stream(new OWASPTop10AttackDatabase().getAttackTestCases().spliterator(), false); + } + + @ParameterizedTest(name = "rejects {0}") + @MethodSource("owaspTop10") + @DisplayName("re-runs the divergent route filter and rejects an attack path as a security violation") + void rejectsDivergentFilterViolation(AttackTestCase attack) { + // Arrange — a route whose strict config diverges from the stage-1 default forces a re-run + PipelineRequest request = requestFor(attack.attackString(), + routeWithConfig(Optional.of(SecurityConfiguration.strict()))); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, + () -> stage.process(request, List.of())); + + // Assert + assertEquals(EventType.SECURITY_FILTER_VIOLATION, thrown.getEventType()); + } + + @Test + @DisplayName("accepts a legitimate request under a divergent strict route filter") + void acceptsLegitimateRequestUnderDivergentFilter() { + // Arrange + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/orders") + .queryParameters(Map.of("page", List.of("2"))) + .headers(Map.of("x-trace", List.of("abc123"))) + .build(); + request.canonicalPath("/api/orders"); + request.selectedRoute(routeWithConfig(Optional.of(SecurityConfiguration.strict()))); + + // Act + Assert — every pipeline (path, params, headers) passes for a benign request + assertDoesNotThrow(() -> stage.process(request, List.of())); + } + + @Test + @DisplayName("skips the pipeline re-run when the route config equals the stage-1 default") + void skipsReRunWhenRouteConfigEqualsDefault() { + // Arrange — a route whose config equals the default was already covered by stage 1 + PipelineRequest request = requestFor("/api/orders", + routeWithConfig(Optional.of(defaultConfiguration))); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request, List.of())); + } + + @Test + @DisplayName("skips per-route enforcement entirely when the route declares no security config") + void skipsWhenRouteDeclaresNoConfig() { + // Arrange + PipelineRequest request = requestFor("/api/orders", routeWithConfig(Optional.empty())); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request, List.of())); + } + + @Test + @DisplayName("fast-rejects a declared body already exceeding the route cap before the body is read") + void rejectsBodyExceedingRouteCap() { + // Arrange + long cap = defaultConfiguration.maxBodySize(); + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.POST) + .requestPath("/api/orders") + .declaredContentLength(cap + 1) + .build(); + request.canonicalPath("/api/orders"); + request.selectedRoute(routeWithConfig(Optional.of(defaultConfiguration))); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, + () -> stage.process(request, List.of())); + + // Assert + assertEquals(EventType.PARAMETER_LIMIT_EXCEEDED, thrown.getEventType()); + } + + @Test + @DisplayName("rejects a canonical path outside the route's allowed_paths whitelist") + void rejectsPathOutsideAllowedPaths() { + // Arrange + PipelineRequest request = requestFor("/api/other", routeWithConfig(Optional.empty())); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, + () -> stage.process(request, List.of("/api/orders"))); + + // Assert + assertEquals(EventType.PATH_NOT_ALLOWED, thrown.getEventType()); + } + + @Test + @DisplayName("admits a path matching an allowed_paths pattern with a single-segment wildcard") + void admitsWildcardWhitelistMatch() { + // Arrange — {id} matches exactly one non-empty path segment + PipelineRequest request = requestFor("/api/42/detail", routeWithConfig(Optional.empty())); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request, List.of("/api/{id}/detail"))); + } + + @Test + @DisplayName("rejects a whitelist pattern whose segment count differs from the path") + void rejectsWildcardSegmentCountMismatch() { + // Arrange — the path has one segment too few to match the pattern + PipelineRequest request = requestFor("/api/42", routeWithConfig(Optional.empty())); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, + () -> stage.process(request, List.of("/api/{id}/detail"))); + + // Assert + assertEquals(EventType.PATH_NOT_ALLOWED, thrown.getEventType()); + } + + @Test + @DisplayName("fails loud when the route was not selected at stage 2") + void failsLoudWhenRouteNotSelected() { + // Arrange — no selected route recorded + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/orders") + .build(); + + // Act + Assert + assertThrows(IllegalStateException.class, () -> stage.process(request, List.of())); + } + + @Test + @DisplayName("fails loud when the canonical path was not resolved at stage 1") + void failsLoudWhenCanonicalPathMissing() { + // Arrange — a selected route but no canonical path recorded + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/orders") + .build(); + request.selectedRoute(routeWithConfig(Optional.empty())); + + // Act + Assert + assertThrows(IllegalStateException.class, () -> stage.process(request, List.of())); + } + + private static PipelineRequest requestFor(String canonicalPath, RouteRuntime route) { + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath(canonicalPath) + .queryParameters(Map.of()) + .headers(Map.of()) + .build(); + request.canonicalPath(canonicalPath); + request.selectedRoute(route); + return request; + } + + private static RouteRuntime routeWithConfig(Optional config) { + return RouteRuntime.builder() + .id("r") + .matcher(RouteMatcher.from(MatchConfig.builder().pathPrefix("/api").build())) + .securityConfiguration(config) + .build(); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/VerbGateStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/VerbGateStageTest.java new file mode 100644 index 0000000..acecf58 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/pipeline/VerbGateStageTest.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.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.EnumSet; +import java.util.Map; + + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("VerbGateStage — stage 2b verb gate (405 + Allow)") +class VerbGateStageTest { + + private final VerbGateStage stage = new VerbGateStage(); + + @Test + @DisplayName("rejects a disallowed verb with 405 and a sorted Allow header") + void rejectsDisallowedVerb() { + // Arrange + PipelineRequest request = requestFor(HttpMethod.DELETE); + request.selectedRoute(routeAllowing(EnumSet.of(HttpMethod.GET, HttpMethod.POST))); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + // Assert + assertEquals(EventType.METHOD_NOT_ALLOWED, thrown.getEventType()); + assertEquals("GET, POST", request.responseHeaders().get("Allow")); + } + + @Test + @DisplayName("passes an allowed verb through untouched") + void passesAllowedVerb() { + // Arrange + PipelineRequest request = requestFor(HttpMethod.GET); + request.selectedRoute(routeAllowing(EnumSet.of(HttpMethod.GET, HttpMethod.POST))); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request)); + } + + private static RouteRuntime routeAllowing(EnumSet methods) { + return RouteRuntime.builder() + .id("orders") + .effectiveAllowedMethods(methods) + .build(); + } + + private static PipelineRequest requestFor(HttpMethod method) { + return PipelineRequest.builder() + .method(method) + .requestPath("/orders") + .queryParameters(Map.of()) + .headers(Map.of()) + .build(); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java index 885060b..5d7eb5e 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java @@ -23,12 +23,12 @@ import java.nio.file.Path; -import org.junit.jupiter.api.Test; - import de.cuioss.test.juli.LogAsserts; import de.cuioss.test.juli.TestLogLevel; import de.cuioss.test.juli.junit5.EnableTestLogger; +import org.junit.jupiter.api.Test; + /** * Tests that {@link ConfigProducer} fails fast on an invalid configuration: the * bean producer and the startup observer both throw, and every collected violation diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java index db4e33d..463d652 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java @@ -27,15 +27,15 @@ import java.nio.file.Path; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - import de.cuioss.sheriff.api.config.model.GatewayConfig; import de.cuioss.sheriff.api.config.model.RouteTable; import de.cuioss.test.juli.LogAsserts; import de.cuioss.test.juli.TestLogLevel; import de.cuioss.test.juli.junit5.EnableTestLogger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + /** * Tests for {@link ConfigProducer}: CDI bean assembly from a valid configuration * directory, the memoized single build, eager startup assembly, and the 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 new file mode 100644 index 0000000..acb86ef --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapperTest.java @@ -0,0 +1,116 @@ +/* + * 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/quarkus/SheriffMetricsTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/SheriffMetricsTest.java new file mode 100644 index 0000000..945c79b --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/SheriffMetricsTest.java @@ -0,0 +1,304 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Annotation; +import java.time.Duration; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +import de.cuioss.http.security.core.UrlSecurityFailureType; +import de.cuioss.http.security.monitoring.SecurityEventCounter; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.Metadata; +import de.cuioss.sheriff.api.config.model.TokenValidationConfig; +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.validation.TokenValidator; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Verifies the D4/D5 metrics-and-readiness surface: {@link SheriffMetrics} registers the meter + * names named in {@code architecture.adoc} § Metrics, and {@link GatewayReadinessCheck} reflects + * configuration and JWKS status. + */ +class SheriffMetricsTest { + + @Nested + @DisplayName("SheriffMetrics registers the architecture.adoc meter names") + class MeterNames { + + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final SheriffMetrics metrics = new SheriffMetrics(registry); + + @Test + @DisplayName("meter-name constants match architecture.adoc verbatim") + void meterNameConstantsMatchArchitecture() { + assertEquals("sheriff_requests_total", SheriffMetrics.REQUESTS_TOTAL); + assertEquals("sheriff_request_duration_seconds", SheriffMetrics.REQUEST_DURATION_SECONDS); + assertEquals("sheriff_errors_total", SheriffMetrics.ERRORS_TOTAL); + assertEquals("sheriff_security_events_total", SheriffMetrics.SECURITY_EVENTS_TOTAL); + assertEquals("sheriff_upstream_duration_seconds", SheriffMetrics.UPSTREAM_DURATION_SECONDS); + } + + @Test + @DisplayName("recordRequest counts under sheriff_requests_total{route,method,status_family}") + void recordRequestCountsPathsView() { + metrics.recordRequest("api", "GET", "2xx"); + metrics.recordRequest("api", "GET", "2xx"); + + var counter = registry.find("sheriff_requests_total") + .tags("route", "api", "method", "GET", "status_family", "2xx").counter(); + assertNotNull(counter, "sheriff_requests_total must be registered with the labelled tags"); + assertEquals(2.0, counter.count()); + } + + @Test + @DisplayName("recordRequestDuration records under sheriff_request_duration_seconds{route}") + void recordRequestDurationRecordsTimer() { + metrics.recordRequestDuration("api", Duration.ofMillis(5)); + + var timer = registry.find("sheriff_request_duration_seconds").tags("route", "api").timer(); + assertNotNull(timer, "sheriff_request_duration_seconds must be registered per route"); + assertEquals(1L, timer.count()); + } + + @Test + @DisplayName("recordError counts under sheriff_errors_total{route,category} keyed by category slug") + void recordErrorCountsErrorsView() { + metrics.recordError("api", EventCategory.UPSTREAM); + + var counter = registry.find("sheriff_errors_total") + .tags("route", "api", "category", "upstream").counter(); + assertNotNull(counter, "sheriff_errors_total must be keyed by the category slug"); + assertEquals(1.0, counter.count()); + } + + @Test + @DisplayName("bindSecurityEventCounter exposes the shared counter under sheriff_security_events_total{failure_type} and moves with it") + void bindSecurityEventCounterExposesAndMovesWithCounter() { + SecurityEventCounter securityEventCounter = new SecurityEventCounter(); + metrics.bindSecurityEventCounter(securityEventCounter); + + // The meter is registered up front for the fixed enum (bounded cardinality), before any event. + var functionCounter = registry.find("sheriff_security_events_total") + .tags("failure_type", "PATH_TRAVERSAL_DETECTED").functionCounter(); + assertNotNull(functionCounter, "sheriff_security_events_total must be bound per UrlSecurityFailureType"); + assertEquals(0.0, functionCounter.count(), "the meter starts at zero before any violation"); + + // A security-relevant rejection increments the shared counter; the bound meter tracks it live. + securityEventCounter.increment(UrlSecurityFailureType.PATH_TRAVERSAL_DETECTED); + securityEventCounter.increment(UrlSecurityFailureType.PATH_TRAVERSAL_DETECTED); + + assertEquals(2.0, functionCounter.count(), + "the function counter must reflect the live SecurityEventCounter count"); + } + + @Test + @DisplayName("bindSecurityEventCounter fixes failure_type cardinality at the UrlSecurityFailureType enum") + void bindSecurityEventCounterBoundsCardinalityToEnum() { + SecurityEventCounter securityEventCounter = new SecurityEventCounter(); + metrics.bindSecurityEventCounter(securityEventCounter); + + int boundSeries = registry.find("sheriff_security_events_total").functionCounters().size(); + assertEquals(UrlSecurityFailureType.values().length, boundSeries, + "one bounded series per UrlSecurityFailureType, never operator-controlled input"); + } + + @Test + @DisplayName("recordUpstreamDuration records under sheriff_upstream_duration_seconds{route}") + void recordUpstreamDurationRecordsTimer() { + metrics.recordUpstreamDuration("api", Duration.ofMillis(10)); + + var timer = registry.find("sheriff_upstream_duration_seconds").tags("route", "api").timer(); + assertNotNull(timer, "sheriff_upstream_duration_seconds must be registered per route"); + assertEquals(1L, timer.count()); + } + + @Test + @DisplayName("statusFamily buckets each status into its bounded leading-digit family") + void statusFamilyBucketsByLeadingDigit() { + // The edge feeds status_family through this classifier so the label cardinality stays + // fixed at the five families regardless of the concrete status code. + assertEquals("1xx", SheriffMetrics.statusFamily(100)); + assertEquals("2xx", SheriffMetrics.statusFamily(200)); + assertEquals("2xx", SheriffMetrics.statusFamily(204)); + assertEquals("3xx", SheriffMetrics.statusFamily(304)); + assertEquals("4xx", SheriffMetrics.statusFamily(404)); + assertEquals("5xx", SheriffMetrics.statusFamily(500)); + assertEquals("5xx", SheriffMetrics.statusFamily(503)); + } + } + + @Nested + @DisplayName("GatewayReadinessCheck reflects config and JWKS status") + class Readiness { + + @Test + @DisplayName("UP with jwks=not-applicable when no token_validation is configured") + void upWhenNoTokenValidation() { + GatewayConfig config = configWith(Optional.empty(), Optional.empty()); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); + + HealthCheckResponse response = check.call(); + + assertEquals(HealthCheckResponse.Status.UP, response.getStatus()); + Map data = response.getData().orElseThrow(); + assertEquals("loaded", data.get("config")); + assertEquals("not-applicable", data.get("jwks")); + } + + @Test + @DisplayName("UP with jwks=ready when the gateway validator resolves") + void upWhenValidatorResolves() { + GatewayConfig config = configWith(Optional.empty(), + Optional.of(new TokenValidationConfig(List.of()))); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); + + HealthCheckResponse response = check.call(); + + assertEquals(HealthCheckResponse.Status.UP, response.getStatus()); + Map data = response.getData().orElseThrow(); + assertEquals("ready", data.get("jwks")); + assertEquals(0L, data.get("issuers")); + } + + @Test + @DisplayName("DOWN with jwks=unavailable when the validator fails to resolve") + void downWhenValidatorFails() { + GatewayConfig config = configWith(Optional.empty(), + Optional.of(new TokenValidationConfig(List.of()))); + GatewayException failure = new GatewayException(EventType.CONFIG_INVALID, "no usable jwks source"); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.failing(failure)); + + HealthCheckResponse response = check.call(); + + assertEquals(HealthCheckResponse.Status.DOWN, response.getStatus()); + Map data = response.getData().orElseThrow(); + assertEquals("unavailable", data.get("jwks")); + assertTrue(String.valueOf(data.get("error")).contains("no usable jwks source")); + } + + @Test + @DisplayName("config_version is surfaced when metadata carries one") + void configVersionSurfaced() { + GatewayConfig config = configWith(Optional.of(new Metadata(Optional.of("2026-07-19"))), + Optional.empty()); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); + + HealthCheckResponse response = check.call(); + + assertEquals("2026-07-19", response.getData().orElseThrow().get("config_version")); + } + + private GatewayConfig configWith(Optional metadata, Optional tokenValidation) { + return new GatewayConfig(1, metadata, Optional.empty(), Optional.empty(), Optional.empty(), + null, null, Optional.empty(), Optional.empty(), tokenValidation, Optional.empty()); + } + } + + /** + * Minimal {@link Instance} test double: {@link #get()} either returns a resolved (unused) + * validator or throws the supplied failure, exercising the readiness UP / DOWN branches without + * a CDI container. Only {@code get()} is consumed by {@link GatewayReadinessCheck}; the remaining + * contract methods are unsupported. + */ + private static final class FakeValidatorInstance implements Instance { + + private final RuntimeException failure; + + private FakeValidatorInstance(RuntimeException failure) { + this.failure = failure; + } + + static FakeValidatorInstance resolving() { + return new FakeValidatorInstance(null); + } + + static FakeValidatorInstance failing(RuntimeException failure) { + return new FakeValidatorInstance(failure); + } + + @Override + public TokenValidator get() { + if (failure != null) { + throw failure; + } + // The readiness check only asserts that resolution does not throw; the value is unused. + return null; + } + + @Override + public Iterator iterator() { + throw new UnsupportedOperationException(); + } + + @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(TokenValidator instance) { + // no-op + } + + @Override + public Handle getHandle() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable> handles() { + throw new UnsupportedOperationException(); + } + } +} 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 new file mode 100644 index 0000000..f8620eb --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/routing/RouteRuntimeTest.java @@ -0,0 +1,127 @@ +/* + * 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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +import de.cuioss.sheriff.api.config.model.HttpMethod; +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; +import org.junit.jupiter.api.Test; + +@DisplayName("Routing — matcher and protocol selection") +class RouteRuntimeTest { + + @Nested + @DisplayName("RouteMatcher") + class RouteMatcherTest { + + @Test + @DisplayName("Should match a prefix only on segment boundaries") + void shouldMatchPrefixOnlyOnSegmentBoundaries() { + var matcher = RouteMatcher.from(MatchConfig.builder().pathPrefix("/api").build()); + + assertTrue(matcher.matchesPrefix("/api"), "Exact prefix matches"); + assertTrue(matcher.matchesPrefix("/api/users"), "Child path matches"); + assertFalse(matcher.matchesPrefix("/apiary"), "Non-boundary continuation does not match"); + } + + @Test + @DisplayName("Should apply method, host, and header matchers with AND semantics") + void shouldApplyAllMatchersWithAndSemantics() { + var matcher = RouteMatcher.from(MatchConfig.builder() + .pathPrefix("/api") + .methods(List.of(HttpMethod.GET)) + .host(Optional.of("gw.example")) + .headers(List.of(HeaderMatcher.builder().name("X-Tenant").present(Optional.of(true)).build())) + .build()); + Map headers = Map.of("X-Tenant", "acme"); + + assertTrue(matcher.matches("/api/x", HttpMethod.GET, "gw.example", headers), "All matchers hold"); + assertFalse(matcher.matches("/api/x", HttpMethod.POST, "gw.example", headers), "Wrong method fails"); + assertFalse(matcher.matches("/api/x", HttpMethod.GET, "other.example", headers), "Wrong host fails"); + assertFalse(matcher.matches("/api/x", HttpMethod.GET, "gw.example", Map.of()), "Missing header fails"); + } + + @Test + @DisplayName("Should require an exact header value when configured") + void shouldRequireExactHeaderValue() { + var matcher = RouteMatcher.from(MatchConfig.builder() + .pathPrefix("/api") + .headers(List.of(HeaderMatcher.builder().name("X-Env").value(Optional.of("prod")).build())) + .build()); + + assertTrue(matcher.matches("/api", HttpMethod.GET, null, Map.of("X-Env", "prod")), "Exact value matches"); + assertFalse(matcher.matches("/api", HttpMethod.GET, null, Map.of("X-Env", "dev")), "Wrong value fails"); + } + } + + @Nested + @DisplayName("ProtocolProcessorRegistry") + class ProtocolProcessorRegistryTest { + + private final ProtocolProcessorRegistry registry = new ProtocolProcessorRegistry(); + + @Test + @DisplayName("Should reuse one HTTP processor for HTTP and GraphQL routes") + void shouldReuseHttpProcessorForGraphql() { + ProtocolProcessor http = registry.require(Protocol.HTTP, "route-http"); + ProtocolProcessor graphql = registry.require(Protocol.GRAPHQL, "route-graphql"); + + assertSame(http, graphql, "GraphQL must reuse the shared HTTP processor instance"); + assertEquals("http", http.id(), "The shared processor is the HTTP processor"); + } + + @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"); + } + } + + @Test + @DisplayName("HttpProtocolProcessor should serve every standard proxyable verb") + void httpProcessorShouldServeStandardVerbs() { + var processor = new HttpProtocolProcessor(); + + assertTrue(processor.supports(HttpMethod.GET), "GET is a standard verb"); + assertTrue(processor.supports(HttpMethod.DELETE), "DELETE is a standard verb"); + assertEquals(HttpMethod.values().length, processor.standardMethods().size(), + "Every proxyable verb is standard"); + } +} diff --git a/benchmarks/src/main/resources/wrk-scripts/bearer_proxied_benchmark.sh b/benchmarks/src/main/resources/wrk-scripts/bearer_proxied_benchmark.sh new file mode 100755 index 0000000..d8d57a8 --- /dev/null +++ b/benchmarks/src/main/resources/wrk-scripts/bearer_proxied_benchmark.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Benchmark runner for the bearer-validated proxied route (wrk -> gateway -> upstream). +# Measures the added cost of offline bearer-token validation on the hot path: every +# request carries a valid access token the gateway validates offline before forwarding +# to the upstream. A token is minted once from the benchmark Keycloak realm and reused +# for the whole run (wrk replays it on every request), so the run measures the gateway's +# per-request validation + proxy overhead, not Keycloak's token-issuance cost. +# +# The companion bearer_proxied_check.lua gates the run on the failure rate: a run whose +# tokens are rejected (401) or whose upstream is unreachable fails non-zero (set -e here), +# so a broken bearer path never benchmarks as a PASS. +set -e + +WRK_THREADS="${WRK_THREADS:-5}" +WRK_CONNECTIONS="${WRK_CONNECTIONS:-50}" +WRK_DURATION="${WRK_DURATION:-60s}" +WRK_TIMEOUT="${WRK_TIMEOUT:-2s}" +COMPOSE_DIR="${COMPOSE_DIR:?COMPOSE_DIR must be set}" + +BENCHMARK_NAME="bearerProxied" +TARGET_URL="${TARGET_URL:-https://api-sheriff:8443/secure/get}" + +# Keycloak token acquisition (benchmark realm). Reached over the host-published HTTPS +# port; overridable per environment. Credentials mirror benchmark-realm.json. +KEYCLOAK_TOKEN_URL="${KEYCLOAK_TOKEN_URL:-https://localhost:1443/realms/benchmark/protocol/openid-connect/token}" +KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-benchmark-client}" +KEYCLOAK_CLIENT_SECRET="${KEYCLOAK_CLIENT_SECRET:-benchmark-secret}" +KEYCLOAK_USERNAME="${KEYCLOAK_USERNAME:-benchmark-user}" +KEYCLOAK_PASSWORD="${KEYCLOAK_PASSWORD:-benchmark-password}" + +echo "=== BENCHMARK METADATA ===" +echo "benchmark_name: ${BENCHMARK_NAME}" +echo "target_url: ${TARGET_URL}" +echo "threads: ${WRK_THREADS}" +echo "connections: ${WRK_CONNECTIONS}" +echo "duration: ${WRK_DURATION}" +echo "start_time: $(date +%s)" + +echo "=== ACQUIRING BEARER TOKEN ===" +TOKEN_RESPONSE="$(curl -sk -X POST "${KEYCLOAK_TOKEN_URL}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=${KEYCLOAK_CLIENT_ID}" \ + -d "client_secret=${KEYCLOAK_CLIENT_SECRET}" \ + -d "username=${KEYCLOAK_USERNAME}" \ + -d "password=${KEYCLOAK_PASSWORD}")" + +BEARER_TOKEN="$(printf '%s' "${TOKEN_RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" +if [ -z "${BEARER_TOKEN}" ]; then + echo "FATAL: could not obtain an access token from ${KEYCLOAK_TOKEN_URL}" >&2 + echo "response: ${TOKEN_RESPONSE}" >&2 + exit 1 +fi +echo "token acquired (length ${#BEARER_TOKEN})" + +echo "=== WRK OUTPUT ===" +cd "${COMPOSE_DIR}" +docker compose run --rm \ + -e "BENCHMARK_BEARER_TOKEN=${BEARER_TOKEN}" \ + -e "WRK_MAX_ERROR_RATE=${WRK_MAX_ERROR_RATE:-0.01}" \ + wrk \ + -t"${WRK_THREADS}" \ + -c"${WRK_CONNECTIONS}" \ + -d"${WRK_DURATION}" \ + --timeout "${WRK_TIMEOUT}" \ + --latency \ + -s /scripts/bearer_proxied_check.lua \ + "${TARGET_URL}" + +echo "end_time: $(date +%s)" +echo "=== BENCHMARK COMPLETE ===" diff --git a/benchmarks/src/main/resources/wrk-scripts/bearer_proxied_check.lua b/benchmarks/src/main/resources/wrk-scripts/bearer_proxied_check.lua new file mode 100644 index 0000000..bbb8ce5 --- /dev/null +++ b/benchmarks/src/main/resources/wrk-scripts/bearer_proxied_check.lua @@ -0,0 +1,108 @@ +-- bearer_proxied_check.lua +-- WRK Lua script for benchmarking the bearer-validated proxied route +-- (gateway offline token validation -> upstream). Sends GET requests carrying the +-- access token minted by the wrapper .sh and validates 200 responses. + +-- Fail the benchmark run when the failure rate exceeds this threshold. The gate folds +-- non-2xx HTTP responses AND socket-level errors (see done()), so a rejected token +-- (401) or an unreachable upstream still fails the run. A gateway that starts rejecting +-- every request must NOT benchmark as an improvement, so done() exits non-zero above the +-- gate (set -e in the wrapper .sh fails the run). Override at runtime via the +-- WRK_MAX_ERROR_RATE env var (fraction, e.g. 0.05). The override is validated to a finite +-- fraction in [0, 1]: a negative value would invert the gate, a value above 1 would +-- disable it, and a malformed value would silently mask the intended threshold, so any +-- set-but-invalid value is rejected (the default 0.01 applies only when the env var is unset). +local function resolveMaxErrorRate() + local raw = os.getenv("WRK_MAX_ERROR_RATE") + if raw == nil then + return 0.01 + end + local value = tonumber(raw) + -- value ~= value is true only for NaN; math.huge guards +/-inf. + if value == nil or value ~= value or value == math.huge or value == -math.huge then + io.write(string.format( + "FATAL: WRK_MAX_ERROR_RATE must be a finite number in [0, 1], got %q\n", raw)) + os.exit(1) + end + if value < 0 or value > 1 then + io.write(string.format( + "FATAL: WRK_MAX_ERROR_RATE must be within [0, 1], got %s\n", tostring(value))) + os.exit(1) + end + return value +end +local MAX_ERROR_RATE = resolveMaxErrorRate() + +-- The access token is minted once by the wrapper .sh and passed in via the environment. +-- Without it every request would be rejected 401, so a missing token is a hard failure. +local BEARER_TOKEN = os.getenv("BENCHMARK_BEARER_TOKEN") +if BEARER_TOKEN == nil or BEARER_TOKEN == "" then + io.write("FATAL: BENCHMARK_BEARER_TOKEN must be set (minted by the wrapper script)\n") + os.exit(1) +end + +-- Each wrk worker thread runs in its own Lua state, so per-thread counters are not +-- visible from done() (which runs in the main state). Collect every thread in setup() +-- and read each thread's final counter values back in done() via thread:get(name), +-- then sum them for a real aggregate total. +local threads = {} + +wrk.method = "GET" +wrk.headers["Accept"] = "application/json" +wrk.headers["Authorization"] = "Bearer " .. BEARER_TOKEN + +function setup(thread) + table.insert(threads, thread) +end + +function init(args) + -- Declared as globals (no 'local') so wrk can read them back per thread + -- via thread:get() in done(). + success_count = 0 + non_200_count = 0 +end + +function response(status, headers, body) + if status == 200 then + success_count = success_count + 1 + else + non_200_count = non_200_count + 1 + end +end + +function done(summary, latency, requests) + local total_success = 0 + local total_non_200 = 0 + for _, thread in ipairs(threads) do + total_success = total_success + (thread:get("success_count") or 0) + total_non_200 = total_non_200 + (thread:get("non_200_count") or 0) + end + -- Socket-level failures never reach response(), so they are absent from the HTTP + -- counters. Fold them into both the denominator and the failure count so an + -- unreachable upstream (zero HTTP responses, all connect/read/write/timeout errors) + -- fails the gate instead of scoring a false PASS at error_rate == 0. + local socket_errors = summary.errors.connect + summary.errors.read + + summary.errors.write + summary.errors.timeout + local total = total_success + total_non_200 + socket_errors + local failures = total_non_200 + socket_errors + local error_rate = 0 + if total > 0 then + error_rate = failures / total + end + + io.write("--- Lua Script Summary ---\n") + io.write(string.format("Successful requests (200): %d\n", total_success)) + io.write(string.format("Non-200 responses: %d\n", total_non_200)) + io.write(string.format("Socket errors: connect=%d, read=%d, write=%d, timeout=%d\n", + summary.errors.connect, summary.errors.read, + summary.errors.write, summary.errors.timeout)) + io.write(string.format("Failure rate: %.4f (non-2xx + socket errors; fail threshold %.4f)\n", + error_rate, MAX_ERROR_RATE)) + + if error_rate > MAX_ERROR_RATE then + io.write(string.format( + "FAIL: failure rate %.4f (non-2xx + socket errors) exceeds threshold %.4f\n", + error_rate, MAX_ERROR_RATE)) + os.exit(1) + end +end diff --git a/benchmarks/src/test/java/de/cuioss/sheriff/api/wrk/benchmark/BearerProxiedRouteBenchmarkTest.java b/benchmarks/src/test/java/de/cuioss/sheriff/api/wrk/benchmark/BearerProxiedRouteBenchmarkTest.java new file mode 100644 index 0000000..3c2c843 --- /dev/null +++ b/benchmarks/src/test/java/de/cuioss/sheriff/api/wrk/benchmark/BearerProxiedRouteBenchmarkTest.java @@ -0,0 +1,135 @@ +/* + * 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.wrk.benchmark; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the bearer-validated proxied-route benchmark's two contract halves without running + * containers: that its WRK output runs and reports through the parsing pipeline, and that + * the shipped Lua check enforces the failure-rate gate (error counters fail the run above threshold). + *

+ * The live run is the {@code -Pbenchmark} profile's job (native container + Keycloak + wrk); these + * unit tests assert the reporting pipeline against synthetic WRK output and the structural presence + * of the bearer wiring and the error gate in the shipped script resources. + * + * @author API Sheriff Team + * @since 1.0 + */ +class BearerProxiedRouteBenchmarkTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("the bearer-validated benchmark output runs and reports through the parser") + void bearerBenchmarkRunsAndReports() throws Exception { + // Arrange — synthetic WRK output for the bearerProxied benchmark + String wrkOutput = """ + === BENCHMARK METADATA === + benchmark_name: bearerProxied + start_time: 1700000000 + start_time_iso: 2023-11-14T22:13:20Z + === WRK OUTPUT === + + Running 30s test @ https://api-sheriff:8443/secure/get + 5 threads and 50 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 900.00us 400.00us 11.00ms 90.00% + Req/Sec 5.00k 600.00 9.00k 73.00% + Latency Distribution + 50% 850.00us + 75% 1.10ms + 90% 1.60ms + 99% 3.20ms + 300000 requests in 30.00s, 90.00MB read + Requests/sec: 10000.00 + Transfer/sec: 3.00MB + --- Lua Script Summary --- + Successful requests (200): 300000 + Non-200 responses: 0 + Failure rate: 0.0000 (non-2xx + socket errors; fail threshold 0.0100) + + === BENCHMARK COMPLETE === + end_time: 1700000030 + end_time_iso: 2023-11-14T22:13:50Z + duration_seconds: 30 + """; + + Path wrkDir = tempDir.resolve("wrk"); + Files.createDirectories(wrkDir); + Files.writeString(wrkDir.resolve("wrk-bearer-proxied-results.txt"), wrkOutput); + + // Act + Path outputDir = tempDir.resolve("output"); + new WrkResultPostProcessor().process(tempDir, outputDir); + + // Assert — the benchmark reported with an ordered percentile distribution + Path jsonFile = outputDir.resolve("gh-pages-ready/data/benchmark-data.json"); + assertTrue(Files.exists(jsonFile), "Benchmark data JSON should be created"); + + JsonObject json = JsonParser.parseString(Files.readString(jsonFile)).getAsJsonObject(); + JsonObject benchmark = json.getAsJsonArray("benchmarks").get(0).getAsJsonObject(); + + assertEquals("bearerProxied", benchmark.get("name").getAsString()); + assertTrue(benchmark.has("score"), "Benchmark should have a score"); + JsonObject percentiles = benchmark.getAsJsonObject("percentiles"); + assertTrue(percentiles.get("50.0").getAsDouble() <= percentiles.get("99.0").getAsDouble(), + "P50 should be <= P99"); + } + + @Test + @DisplayName("the shipped Lua check enforces the failure-rate gate above threshold") + void errorCountersFailTheRunAboveThreshold() throws Exception { + String lua = readResource("/wrk-scripts/bearer_proxied_check.lua"); + + // The gate folds non-2xx AND socket errors, then exits non-zero above the threshold — so + // a rejected-token or unreachable-upstream run fails rather than scoring a false PASS. + assertTrue(lua.contains("WRK_MAX_ERROR_RATE"), "gate must honour the WRK_MAX_ERROR_RATE override"); + assertTrue(lua.contains("summary.errors.connect"), "gate must fold socket-level errors"); + assertTrue(lua.contains("error_rate > MAX_ERROR_RATE"), "gate must compare against the threshold"); + assertTrue(lua.contains("os.exit(1)"), "gate must fail the run non-zero above the threshold"); + } + + @Test + @DisplayName("the benchmark runner acquires a bearer token and drives the bearer route") + void benchmarkRunnerDrivesBearerRoute() throws Exception { + String script = readResource("/wrk-scripts/bearer_proxied_benchmark.sh"); + + assertTrue(script.contains("grant_type=password"), "runner must mint an access token"); + assertTrue(script.contains("BENCHMARK_BEARER_TOKEN"), "runner must pass the token to wrk"); + assertTrue(script.contains("bearer_proxied_check.lua"), "runner must drive the bearer check script"); + assertTrue(script.contains("/secure/"), "runner must target the bearer-protected route"); + } + + private String readResource(String path) throws Exception { + try (InputStream in = getClass().getResourceAsStream(path)) { + assertNotNull(in, "classpath resource must be present: " + path); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index 4cb6cce..ed32d5b 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -29,7 +29,7 @@ diagnostics use the logger directly and are not catalogued. [cols="1,1,2,2", options="header"] |=== |ID |Component |Message |Description -|ApiSheriff-1 |PROXY |Proxy route registered: path-prefix='%s' upstream='%s' |Logged once at startup when the catch-all proxy route is registered on the data plane +|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) |=== @@ -39,9 +39,11 @@ diagnostics use the logger directly and are not catalogued. [cols="1,1,2,2", options="header"] |=== |ID |Component |Message |Description -|ApiSheriff-100 |PROXY |Proxy request to upstream '%s' failed: %s |Logged when forwarding a request to the configured upstream fails; the gateway responds with 502 +|ApiSheriff-100 |EDGE |Security filter rejected a request on route '%s': failure type %s |Logged for every request-time `GatewayException` carrying `SECURITY_FILTER_VIOLATION` (cui-http basic-checks, the canonical-path guard, the framing/anti-smuggling gate, or a per-route thorough-checks divergence); records the route id and the sanitized failure-type detail only — never the raw offending payload |ApiSheriff-101 |CONFIG |Route '%s' overrides anchor '%s' %s policy (wholesale replacement) |Logged during route-table assembly when a route replaces an anchor-provided non-auth policy block (security_filter or allowed_methods) wholesale, so a weakening override is an explicit, audited choice (ADR-0007) |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 |=== == ERROR Level (200-299) diff --git a/doc/architecture.adoc b/doc/architecture.adoc index 15fdffa..ff3a48d 100644 --- a/doc/architecture.adoc +++ b/doc/architecture.adoc @@ -453,3 +453,23 @@ variants only; a pure base-gateway deployment uses just the validation path. All are present in the build regardless -- the split is a runtime-usage distinction, not a dependency-graph one. See the Authentication Layer note above for the engine/BFF responsibility split. + +[[_implementation_deviations]] +== Implementation Deviations + +Points where the current implementation differs from, or has not yet fully realized, the model +described above. Each is a deliberate, recorded gap rather than a silent divergence. + +* *Data-plane structured logging is not yet emitted.* The data-plane `LogRecord` catalogue + (`ApiSheriffLogMessages`: security-filter, auth-weakened, circuit-breaker, and route-load records) + is defined but no edge stage emits it yet. The only catalogue currently emitted is the + configuration subsystem's `ConfigLogMessages` (boot-time load / route-posture / validation + records) — those, and only those, are catalogued in `LogMessages.adoc`, which is kept matched to + the *emitted* records. + +* *Integration bearer validation covers rejection, not live issuance.* The default integration suite + exercises routing, health, the RFC 9457 error contract, and bearer *rejection* (missing / malformed + token → `401`, upstream never reached) against a static file-JWKS issuer, so it needs no live + Keycloak. Positive end-to-end token validation against the Keycloak realm is exercised only under + the `-Pintegration-tests` / `-Pbenchmark` profiles (native container + compose stack), not the + default build. diff --git a/doc/configuration.adoc b/doc/configuration.adoc index aa1b366..4392384 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -471,6 +471,47 @@ Deny-by-default extends to omitted configuration: | No rate limiting (the feature is out of scope regardless; the field is reserved). |=== +[[_edge_hardening_defaults]] +=== Edge hardening (transport) defaults + +The public data-plane HTTP listener is bounded by a set of fixed secure defaults applied in code +(`EdgeHardeningOptions`), not by `gateway.yaml`. They fail fast on abusive framing before a request +is admitted to the pipeline and bound the in-flight and shutdown-drain windows. These values are +recorded here doc-first — they are the contract the edge-hardening code implements, and any change +to a constant must be reflected in this table. + +[cols="2,1,3"] +|=== +| Bound | Default | Effect + +| Maximum request header block +| 16 KiB +| An over-large header block is rejected by the Vert.x codec before the request reaches the pipeline. + +| Maximum HTTP/1.x request-line length +| 8 KiB +| An over-long `METHOD SP request-target SP HTTP/x` line is rejected at the transport. + +| Maximum chunked-transfer chunk size +| 16 KiB +| An over-large single chunk is rejected at the transport. + +| Idle-connection reap +| 60 s +| An idle connection is closed after this window, so a slow-loris / h2 abuse load cannot pin + connection slots. + +| Admission cap (in-flight requests) +| 2048 +| Requests beyond the cap are rejected `503` *before* a virtual thread is dispatched, so a flood + cannot spawn unbounded virtual threads. + +| Graceful-drain timeout +| 25 s +| The bounded wait for in-flight requests to complete on `SIGTERM`; kept below the Quarkus default + shutdown timeout so the drain completes within the shutdown window rather than being cut short. +|=== + [[_topology]] == Topology (`topology.properties`) diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml index b577719..147ee90 100644 --- a/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml +++ b/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml @@ -16,3 +16,12 @@ endpoint: - id: httpbin-proxy match: path_prefix: /proxy + # The new pipeline edge (stage 5) forwards deny-by-default: only allow-listed + # request headers and query parameters cross to the upstream. Allow the header + # and query names the proxy suite exercises so go-httpbin echoes them back — + # Content-Type so a text body is echoed as-is (not a base64 octet-stream + # data-URI), and the probe/page/size/req query params the GET/metrics/shutdown + # suites assert on. + forward: + headers_allow: ["Content-Type"] + query_allow: ["probe", "page", "size", "req"] diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/secure.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/secure.yaml new file mode 100644 index 0000000..10810af --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/endpoints/secure.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=../../../../../../api-sheriff/src/main/resources/schema/endpoint.schema.json +# Bearer-protected endpoint anchored to the 'secure' namespace (/secure). It routes +# to the same go-httpbin echo backend the 'api' proxy uses (the UPSTREAM topology +# alias), but under the anchor's require: bearer posture. BearerValidationIT drives +# only rejection scenarios against it (missing / malformed token -> 401), so the +# upstream is never reached — the echo backend is present purely so a successful +# forward would be observable, making a rejection's absence-of-echo the upstream=0 proof. +endpoint: + id: secure + enabled: true + base_url: UPSTREAM + anchor: secure + routes: + - id: secure-proxy + match: + path_prefix: /secure diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml index 8aa662c..58b12b8 100644 --- a/integration-tests/src/main/docker/sheriff-config/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -19,3 +19,19 @@ anchors: path_prefix: /bff auth: require: none + # Bearer-protected namespace exercised by BearerValidationIT. The gateway's own + # token_validation issuer loads its key set from the mounted static JWKS file + # (no Keycloak dependency), so the validator is ready offline at boot. The IT + # only drives rejection scenarios (missing / malformed token -> 401), which never + # reach the upstream — proving upstream count = 0 on every bearer rejection. + secure: + path_prefix: /secure + auth: + require: bearer +token_validation: + issuers: + - name: it-static + issuer: https://api-sheriff.test/it + jwks: + source: file + file: /app/certificates/test-jwks.json diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ApiSheriffIntegrationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ApiSheriffIntegrationIT.java index 5c1de27..a2a375b 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ApiSheriffIntegrationIT.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ApiSheriffIntegrationIT.java @@ -15,56 +15,22 @@ */ package de.cuioss.sheriff.api.integration; -import org.junit.jupiter.api.Test; - import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; /** - * Integration tests for API Sheriff Quarkus extension. - * These tests verify that the API Sheriff components are properly - * integrated and functional in a Quarkus application context. + * Integration tests for the API Sheriff management interface in a native Quarkus application context. + *

+ * The pre-1.0 clean-break removed the placeholder {@code /api/health} + {@code /api/info} data-plane + * endpoints; the gateway now exposes only the deny-by-default data-plane edge (covered by + * {@link PipelineVerbIT} / {@link BearerValidationIT}) plus the Quarkus management port. These tests + * assert the surviving management-port surface. * * @author API Sheriff Team */ class ApiSheriffIntegrationIT extends BaseIntegrationTest { - /** - * Test that the health endpoint returns a successful response, - * indicating that API Sheriff is properly configured. - */ - @Test - void apiSheriffHealthEndpoint() { - var response = given() - .when() - .get("/api/health") - .then() - .statusCode(200) - .contentType("application/json") - .extract(); - - assertEquals("UP", response.path("status")); - assertTrue(response.path("apiSheriff").toString().contains("API Sheriff is operational")); - } - - /** - * Test that the info endpoint returns expected information. - */ - @Test - void infoEndpoint() { - var response = given() - .when() - .get("/api/info") - .then() - .statusCode(200) - .contentType("application/json") - .extract(); - - assertTrue(response.path("message").toString().contains("API Sheriff Gateway")); - assertTrue(response.path("version").toString().contains("1.0.0-SNAPSHOT")); - } - /** * Test that the Quarkus health check endpoint is available on the management interface. */ diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BaseIntegrationTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BaseIntegrationTest.java index a6dba3a..177ad54 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BaseIntegrationTest.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BaseIntegrationTest.java @@ -16,6 +16,7 @@ package de.cuioss.sheriff.api.integration; import io.restassured.RestAssured; + import org.junit.jupiter.api.BeforeAll; /** diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BearerValidationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BearerValidationIT.java new file mode 100644 index 0000000..c45dfcf --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/BearerValidationIT.java @@ -0,0 +1,84 @@ +/* + * 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 io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises stage 4 (offline bearer-token validation) over the public HTTPS edge against the + * bearer-protected {@code /secure} route. + *

+ * The mounted {@code secure} anchor declares {@code require: bearer}, and the gateway's own + * {@code token_validation} issuer loads its key set from the static JWKS file mounted at + * {@code /app/certificates/test-jwks.json} — so the validator is ready offline with no Keycloak + * dependency. Only rejection scenarios are driven here (a valid signed token would need + * the private key, out of scope for the black-box suite): a missing token and a malformed token + * must both be rejected {@code 401} at the gateway and the upstream must never be reached. A + * forwarded request would carry the {@code go-httpbin} echo (a non-null {@code method}); its + * absence is the observable proof the upstream count stayed {@code 0} on every bearer rejection. + */ +class BearerValidationIT extends BaseIntegrationTest { + + @Test + @DisplayName("a request with no bearer token is rejected 401 and never forwarded") + void missingTokenRejected() { + var response = given() + .when() + .get("/secure/get") + .then() + .statusCode(401) + .header("WWW-Authenticate", "Bearer") + .extract(); + + assertTrue(response.contentType().contains("application/problem+json")); + assertNull(response.path("method"), "a rejected request must not reach the go-httpbin upstream"); + } + + @Test + @DisplayName("a request with a malformed bearer token is rejected 401 and never forwarded") + void malformedTokenRejected() { + var response = given() + .header("Authorization", "Bearer not-a-real-jwt") + .when() + .get("/secure/get") + .then() + .statusCode(401) + .header("WWW-Authenticate", "Bearer") + .extract(); + + assertTrue(response.contentType().contains("application/problem+json")); + assertNull(response.path("method"), "a rejected request must not reach the go-httpbin upstream"); + } + + @Test + @DisplayName("the public require:none proxy route does not demand a bearer token") + void publicRouteDoesNotRequireBearer() { + var response = given() + .when() + .get("/proxy/get") + .then() + .statusCode(200) + .extract(); + + assertEquals("GET", response.path("method")); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java index 38dcdf6..6b26bc8 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java @@ -15,12 +15,11 @@ */ package de.cuioss.sheriff.api.integration; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; /** * End-to-end proof that the gateway booted from the file-based configuration @@ -45,22 +44,6 @@ */ class ConfigLoadedIntegrationIT extends BaseIntegrationTest { - @Test - @DisplayName("mounted-config /proxy route forwards to the resolved upstream") - void mountedConfigRouteForwardsToUpstream() { - var response = given() - .when() - .get("/proxy/get?probe=config") - .then() - .statusCode(200) - .extract(); - - assertTrue(response.contentType().contains("application/json")); - assertEquals("GET", response.path("method")); - assertTrue(response.path("url").toString().contains("/anything/get")); - assertEquals("config", response.path("args.probe[0]")); - } - @Test @DisplayName("only the mounted route set is served — unmatched paths deny by default") void unmountedPathDeniedByDefault() { diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ErrorContractIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ErrorContractIT.java new file mode 100644 index 0000000..9eeec4d --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ErrorContractIT.java @@ -0,0 +1,87 @@ +/* + * 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 io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Verifies the RFC 9457 {@code application/problem+json} error contract the edge renders for + * rejected requests, and that every rejection is terminated at the gateway without reaching the + * upstream. + *

+ * Each rejection carries the failing category's {@code urn:api-sheriff:problem:*} type, its title, + * and the HTTP status. A forwarded request would carry the {@code go-httpbin} echo (a non-null + * {@code method}); its absence on every rejection is the observable proof the upstream count stayed + * {@code 0}. + */ +class ErrorContractIT extends BaseIntegrationTest { + + @Test + @DisplayName("an unmatched path is denied 404 as an input-validation problem, not forwarded") + void unmatchedPathProblemJson() { + var response = given() + .when() + .get("/no-such-route/resource") + .then() + .statusCode(404) + .contentType("application/problem+json") + .extract(); + + assertTrue(response.path("type").toString().contains("urn:api-sheriff:problem:input-validation")); + assertEquals("Input Validation", response.path("title")); + assertEquals(Integer.valueOf(404), response.path("status")); + assertNull(response.path("method"), "a denied request must not reach the go-httpbin upstream"); + } + + @Test + @DisplayName("a disallowed method is rejected 405 as an input-validation problem, not forwarded") + void disallowedMethodProblemJson() { + var response = given() + .when() + .patch("/proxy/get") + .then() + .statusCode(405) + .contentType("application/problem+json") + .extract(); + + assertTrue(response.path("type").toString().contains("urn:api-sheriff:problem:input-validation")); + assertEquals(Integer.valueOf(405), response.path("status")); + assertNull(response.path("method"), "a rejected request must not reach the go-httpbin upstream"); + } + + @Test + @DisplayName("a bearer rejection renders an authentication problem, not forwarded") + void bearerRejectionProblemJson() { + var response = given() + .when() + .get("/secure/get") + .then() + .statusCode(401) + .contentType("application/problem+json") + .extract(); + + assertTrue(response.path("type").toString().contains("urn:api-sheriff:problem:authentication")); + assertEquals("Authentication", response.path("title")); + assertEquals(Integer.valueOf(401), response.path("status")); + assertNull(response.path("method"), "a rejected request must not reach the go-httpbin upstream"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/GracefulShutdownIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/GracefulShutdownIT.java new file mode 100644 index 0000000..5c2c53c --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/GracefulShutdownIT.java @@ -0,0 +1,85 @@ +/* + * 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 io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Verifies the runtime health and in-flight-completion properties the edge's graceful-shutdown + * drain depends on. A black-box suite cannot signal {@code SIGTERM} to the container mid-request + * (the failsafe harness owns the container lifecycle and stops it at teardown), so this IT asserts + * the observable preconditions of a clean drain: the liveness and readiness probes report the + * process healthy, and a burst of concurrent in-flight requests all complete without a dropped or + * refused connection — exactly the requests the {@code SIGTERM} handler must be able to drain. + */ +class GracefulShutdownIT extends BaseIntegrationTest { + + private static final int CONCURRENT_REQUESTS = 16; + + @Test + @DisplayName("the liveness probe reports the process alive") + void livenessUp() { + var response = given() + .baseUri(managementBaseUri()) + .when() + .get("/q/health/live") + .then() + .statusCode(200) + .extract(); + + assertEquals("UP", response.path("status")); + } + + @Test + @DisplayName("the readiness probe reports the gateway ready") + void readinessUp() { + var response = given() + .baseUri(managementBaseUri()) + .when() + .get("/q/health/ready") + .then() + .statusCode(200) + .extract(); + + assertEquals("UP", response.path("status")); + } + + @Test + @DisplayName("a burst of concurrent in-flight requests all complete cleanly") + void concurrentRequestsAllComplete() { + var statuses = new ConcurrentLinkedQueue(); + + IntStream.range(0, CONCURRENT_REQUESTS).parallel().forEach(index -> + statuses.add(given() + .when() + .get("/proxy/get?req=" + index) + .then() + .extract() + .statusCode())); + + assertEquals(CONCURRENT_REQUESTS, statuses.size()); + assertTrue(statuses.stream().allMatch(status -> status == 200), + "every in-flight request must complete 200 — none dropped or refused"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/MetricsIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/MetricsIT.java new file mode 100644 index 0000000..0361e07 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/MetricsIT.java @@ -0,0 +1,146 @@ +/* + * 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 io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Verifies the Prometheus metrics surface is exposed on the management port ({@code /q/metrics}, + * plain HTTP, off the public data-plane port) per {@code architecture.adoc} § Metrics. + *

+ * Traffic is driven through the {@code /proxy} route first so the registry has request activity to + * report, then the management endpoint is scraped and asserted to return the Prometheus exposition + * format with both the Micrometer JVM/HTTP baseline meters and the gateway's own {@code sheriff_*} + * meters. Because Micrometer only exposes a meter once it has been recorded, the presence of a + * {@code sheriff_*} series after proxy traffic is proof the edge records it end-to-end. The + * management port carries no authentication of its own, so the scrape needs no credentials. + */ +class MetricsIT extends BaseIntegrationTest { + + @Test + @DisplayName("the management metrics endpoint serves the Prometheus exposition format") + void metricsEndpointServesPrometheusFormat() { + // Drive one request so the registry has activity to expose. + given() + .when() + .get("/proxy/get?probe=metrics") + .then() + .statusCode(200); + + String body = scrapeMetrics(); + + assertTrue(body.contains("# TYPE") || body.contains("# HELP"), + "the metrics endpoint must serve the Prometheus exposition format"); + assertTrue(body.contains("jvm_"), + "the Micrometer JVM baseline meters must be exposed on the management port"); + } + + @Test + @DisplayName("the sheriff_* meters appear and move on /q/metrics after proxy traffic") + void sheriffMetersAppearAndMoveAfterProxyTraffic() { + // Arrange + Act — drive a successful proxied GET so the edge records its request, duration, + // and upstream-duration meters. A 200 means the request traversed the full pipeline and the + // downstream call completed, exercising every non-error sheriff_* recording surface. + given() + .when() + .get("/proxy/get?probe=sheriff-metrics") + .then() + .statusCode(200); + + String body = scrapeMetrics(); + + // Assert — the gateway's own meters are present (Micrometer only emits recorded meters), so + // their presence proves GatewayEdgeRoute records them end-to-end, not just that the adapter + // registers the names. + assertTrue(body.contains("sheriff_requests_total"), + "sheriff_requests_total must be emitted after a proxied request"); + assertTrue(body.contains("sheriff_request_duration_seconds"), + "sheriff_request_duration_seconds must be emitted after a proxied request"); + assertTrue(body.contains("sheriff_upstream_duration_seconds"), + "sheriff_upstream_duration_seconds must be emitted once the downstream call completes"); + + // Assert — the bounded labels are wired: the config-fixed route id and the status family + // (never the raw status), keeping label cardinality bounded per architecture.adoc § Metrics. + assertTrue(body.contains("route=\"httpbin-proxy\""), + "the request counter must carry the config-fixed, bounded route label"); + assertTrue(body.contains("status_family=\"2xx\""), + "the request counter must carry the bounded status_family label, not the raw status"); + } + + @Test + @DisplayName("sheriff_security_events_total appears and moves on /q/metrics after a security-filter rejection") + void securityEventsMeterAppearsAndMovesAfterRejection() { + // Arrange — the boot-shared cui-http SecurityEventCounter is bound to Micrometer, so the meter + // is exposed from boot for the fixed UrlSecurityFailureType enum; capture its pre-rejection sum. + String before = scrapeMetrics(); + assertTrue(before.contains("sheriff_security_events_total"), + "sheriff_security_events_total must be exposed once the shared counter is bound at boot"); + double baseline = securityEventsTotal(before); + + // Act — drive a GW-01/GW-02 stage-1 filter rejection: a query-parameter value carrying an + // encoded path-traversal attack. urlEncodingEnabled(false) sends the pre-encoded value verbatim + // so cui-http decodes and rejects it (400 SECURITY_FILTER_VIOLATION) before route selection, + // incrementing the shared SecurityEventCounter the meter is bound to. + given() + .urlEncodingEnabled(false) + .when() + .get("/proxy/get?probe=%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd") + .then() + .statusCode(400); + + // Assert — the bound function counter moved: the summed sheriff_security_events_total series is + // strictly greater than the baseline, proving the counter → Micrometer bridge works end-to-end. + String after = scrapeMetrics(); + assertTrue(after.contains("sheriff_security_events_total"), + "sheriff_security_events_total must remain exposed after the rejection"); + assertTrue(securityEventsTotal(after) > baseline, + "a security-filter rejection must move sheriff_security_events_total on /q/metrics"); + } + + /** + * Sums every {@code sheriff_security_events_total} sample in a Prometheus exposition body across all + * {@code failure_type} series. Micrometer may append the {@code _total} counter suffix, so lines are + * matched by prefix and filtered to the labelled series (skipping {@code # HELP} / {@code # TYPE} + * comment lines, which begin with {@code #}). + */ + private static double securityEventsTotal(String body) { + double sum = 0.0; + for (String line : body.split("\n")) { + if (line.startsWith("sheriff_security_events_total") && line.contains("failure_type=")) { + int lastSpace = line.lastIndexOf(' '); + if (lastSpace >= 0) { + sum += Double.parseDouble(line.substring(lastSpace + 1).strip()); + } + } + } + return sum; + } + + private static String scrapeMetrics() { + return given() + .baseUri(managementBaseUri()) + .when() + .get("/q/metrics") + .then() + .statusCode(200) + .extract() + .asString(); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/PipelineVerbIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/PipelineVerbIT.java new file mode 100644 index 0000000..a4a0605 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/PipelineVerbIT.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.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the verb gate of the request pipeline end-to-end over the public HTTPS edge. + *

+ * The mounted {@code gateway.yaml} allows {@code GET, POST, PUT, DELETE}; the {@code /proxy} + * route forwards to the {@code go-httpbin} echo backend ({@code /anything/*}), which reflects the + * received method and body. Each allowed verb must forward and echo; a method outside the allowed + * set must be rejected {@code 405} at the gateway without reaching the upstream (the + * response is the gateway's rejection, never the echo). + */ +class PipelineVerbIT extends BaseIntegrationTest { + + @Test + @DisplayName("GET is forwarded and echoed by the upstream") + void getIsForwarded() { + var response = given() + .when() + .get("/proxy/get?probe=verb") + .then() + .statusCode(200) + .extract(); + + assertEquals("GET", response.path("method")); + assertTrue(response.path("url").toString().contains("/anything/get")); + assertEquals("verb", response.path("args.probe[0]")); + } + + @Test + @DisplayName("GET forwards the multi-segment path remainder and multi-value query verbatim") + void getForwardsPathRemainderAndMultiValueQuery() { + var response = given() + .when() + .get("/proxy/orders/42?page=2&size=10") + .then() + .statusCode(200) + .extract(); + + assertTrue(response.contentType().contains("application/json")); + assertEquals("GET", response.path("method")); + assertTrue(response.path("url").toString().contains("/anything/orders/42")); + assertEquals("2", response.path("args.page[0]")); + assertEquals("10", response.path("args.size[0]")); + } + + @Test + @DisplayName("POST forwards the request body to the upstream") + void postForwardsBody() { + var response = given() + .contentType("text/plain") + .body("verb-post-body") + .when() + .post("/proxy/post") + .then() + .statusCode(200) + .extract(); + + assertEquals("POST", response.path("method")); + assertEquals("verb-post-body", response.path("data")); + } + + @Test + @DisplayName("PUT is forwarded and echoed by the upstream") + void putIsForwarded() { + var response = given() + .contentType("text/plain") + .body("verb-put-body") + .when() + .put("/proxy/put") + .then() + .statusCode(200) + .extract(); + + assertEquals("PUT", response.path("method")); + assertEquals("verb-put-body", response.path("data")); + } + + @Test + @DisplayName("DELETE is forwarded and echoed by the upstream") + void deleteIsForwarded() { + var response = given() + .when() + .delete("/proxy/delete") + .then() + .statusCode(200) + .extract(); + + assertEquals("DELETE", response.path("method")); + } + + @Test + @DisplayName("a method outside allowed_methods is rejected 405 and never forwarded") + void disallowedMethodRejectedWithoutForward() { + var response = given() + .when() + .patch("/proxy/get") + .then() + .statusCode(405) + .extract(); + + // A forwarded request would carry the go-httpbin echo (a non-null "method"); its absence + // is the observable proof the upstream count stayed 0 for this rejection. + assertNull(response.path("method")); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ProxyIntegrationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ProxyIntegrationIT.java deleted file mode 100644 index 92c0563..0000000 --- a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ProxyIntegrationIT.java +++ /dev/null @@ -1,75 +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.integration; - -import org.junit.jupiter.api.Test; - -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * End-to-end integration tests for the minimal proxy, exercising the native - * container over its public HTTPS port with the proxy route pointed at the - * {@code go-httpbin} echo backend ({@code http://go-httpbin:8080/anything}). - *

- * go-httpbin's {@code /anything/*} endpoint echoes the complete received request - * back as JSON, so these tests assert exactly what the gateway forwarded (method, - * path remainder, query and body) and that unmatched paths are denied by default. - */ -class ProxyIntegrationIT extends BaseIntegrationTest { - - @Test - void shouldForwardGetWithPathAndQuery() { - var response = given() - .when() - .get("/proxy/orders/42?page=2&size=10") - .then() - .statusCode(200) - .extract(); - - assertTrue(response.contentType().contains("application/json")); - assertEquals("GET", response.path("method")); - assertTrue(response.path("url").toString().contains("/anything/orders/42")); - assertEquals("2", response.path("args.page[0]")); - assertEquals("10", response.path("args.size[0]")); - } - - @Test - void shouldForwardPostBody() { - var response = given() - .contentType("text/plain") - .body("hello-upstream") - .when() - .post("/proxy/submit") - .then() - .statusCode(200) - .extract(); - - assertEquals("POST", response.path("method")); - assertTrue(response.path("url").toString().contains("/anything/submit")); - assertEquals("hello-upstream", response.path("data")); - } - - @Test - void shouldReturn404ForUnmatchedPath() { - given() - .when() - .get("/not-proxied/resource") - .then() - .statusCode(404); - } -}