diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java index 0b32aac1..9fb54bd8 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java @@ -42,8 +42,10 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteConfig; import de.cuioss.sheriff.gateway.config.model.RouteTable; +import de.cuioss.sheriff.gateway.config.model.SecurityDefaultsConfig; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; import de.cuioss.sheriff.gateway.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.config.model.UpstreamConfig; import de.cuioss.sheriff.gateway.config.model.UpstreamDefaultsConfig; import de.cuioss.sheriff.gateway.config.model.WebSocketConfig; @@ -81,7 +83,7 @@ public final class RouteTableBuilder { private static final List STANDARD_ALLOWED_METHODS = List.copyOf(EnumSet.allOf(HttpMethod.class)); /** The {@link AuthConfig#require()} value meaning no authentication is required; also the - * display fallback for an absent anchor name / security-filter profile in {@link #logPosture}. */ + * display fallback for an absent anchor name in {@link #logPosture}. */ private static final String NONE = "none"; /** The default {@code websocket.idle_timeout_seconds} applied when a WebSocket route omits it. */ @@ -199,7 +201,7 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou builder.upstream(Optional.of(applyRouteUpstreamPath(upstream, route))); } ResolvedRoute resolved = builder.build(); - logPosture(resolved); + logPosture(resolved, globalProfile(gateway)); return resolved; } @@ -272,19 +274,56 @@ private static ResolvedAsset resolveAsset(RouteConfig route, AssetConfig asset, * {@link AccessLevel#PUBLIC} for an unanchored, effectively-unauthenticated asset route (the * configuration validator rejects an asset action on a non-asset anchor before assembly, so * this default is a defensive floor). + *

+ * A shared seam: {@code ConfigValidator}'s fail-closed {@code profile: none} refusal derives the + * access level through this same method rather than reading {@link AnchorConfig#access()} + * directly, so the boot refusal and the runtime governance can never disagree about which routes + * count as authenticated (ADR-0009 single-reporter, the {@link #normalizePrefix} precedent). + * + * @param anchor the route's resolved anchor, empty when the route is unanchored + * @param effectiveAuth the route's effective auth posture + * @return the effective access level; {@link AccessLevel#AUTHENTICATED} whenever the effective + * auth requires authentication */ - private static AccessLevel effectiveAccessLevel(Optional anchor, AuthConfig effectiveAuth) { + public static AccessLevel effectiveAccessLevel(Optional anchor, AuthConfig effectiveAuth) { if (!NONE.equals(effectiveAuth.require())) { return AccessLevel.AUTHENTICATED; } return anchor.map(AnchorConfig::access).orElse(AccessLevel.PUBLIC); } - private static void logPosture(ResolvedRoute route) { + /** + * Emits the route's boot posture line. The profile logged is the resolved effective + * one — the route's own {@code security_filter.profile} when declared, otherwise the + * gateway-wide {@code security_defaults} fallback. It is deliberately NOT the raw declared + * value with a {@code "none"} placeholder for unset: {@code none} is now a real mode, so that + * placeholder would report a {@code none}-mode posture for every route that merely omits the knob. + */ + private static void logPosture(ResolvedRoute route, SecurityProfile globalProfile) { String anchorName = route.anchor().orElse(NONE); - String filter = route.effectiveSecurityFilter().flatMap(SecurityFilterConfig::profile).orElse(NONE); + SecurityProfile effectiveProfile = route.effectiveSecurityFilter() + .flatMap(SecurityFilterConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(globalProfile); LOGGER.info(ConfigLogMessages.INFO.ROUTE_POSTURE, route.id(), anchorName, route.effectiveAuth().require(), - filter); + effectiveProfile.name().toLowerCase(Locale.ROOT)); + } + + /** + * The gateway-wide effective profile: the declared {@code security_defaults.profile}, or + * {@link SecurityProfile#DEFAULT_PROFILE} when the block (or the knob) is omitted. + *

+ * Shared with {@code ConfigValidator}'s fail-closed {@code profile: none} refusal so the boot + * refusal resolves the gateway-wide fallback through exactly this chain. + * + * @param gateway the bound gateway document + * @return the resolved gateway-wide profile + */ + public static SecurityProfile globalProfile(GatewayConfig gateway) { + return gateway.securityDefaults() + .flatMap(SecurityDefaultsConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(SecurityProfile.DEFAULT_PROFILE); } private static void warnOnWeakeningOverride(RouteConfig route, EndpointConfig endpoint, diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java index a98cb2d0..6b21b8c3 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java @@ -21,10 +21,17 @@ /** * The global {@code security_defaults} block of {@code gateway.yaml}. *

- * {@code profile} selects the inbound-filter baseline preset - * ({@code default} / {@code strict} / {@code lenient}) applied to every route - * that does not declare its own {@code security_filter}. The value set is - * enforced by the configuration validator, not by the model. + * {@code profile} selects the gateway-wide inbound-filter mode — {@code strict} / + * {@code lenient} / {@code none}, see {@link SecurityProfile}. The value range is enforced by the + * bundled JSON Schema (an unrecognized value, the dropped {@code default} preset included, fails + * boot there), not by this model and not by the configuration validator. + *

+ * Fallback. Every route resolves its effective profile through + * {@code route security_filter → anchor security_filter → security_defaults} — the endpoint level + * carries no {@code security_filter} block, so it is not part of the chain. A route that declares no + * {@code security_filter} — or one whose block omits {@code profile} — therefore inherits the value + * carried here, and an entirely omitted {@code security_defaults} block resolves to + * {@link SecurityProfile#DEFAULT_PROFILE}. * * @param profile the baseline security-filter profile, empty when omitted * @author API Sheriff Team diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java index 4fca5f3c..ba3e6947 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java @@ -25,9 +25,16 @@ * The per-route {@code security_filter} block: allowlists and limits (explicitly * not a WAF). The optional baseline {@code profile} is overridden by the limits * below. + *

+ * An omitted {@code profile} inherits the gateway-wide value from + * {@code security_defaults} (which itself resolves to {@link SecurityProfile#DEFAULT_PROFILE} when + * omitted). A {@code none} profile disables only the url-parameter name/value validation and the + * per-route pipeline re-run — the limits below, including {@code max_body_bytes}, keep being + * enforced against the nearest non-{@code none} profile's preset. * - * @param profile the baseline preset, empty when the global default - * applies + * @param profile the inbound-filter mode — {@code strict} / {@code lenient} / + * {@code none}, see {@link SecurityProfile} — empty when the + * {@code security_defaults} fallback applies * @param allowedPaths the path allowlist entries, empty when none * @param maxHeaderCount the maximum header count, empty when omitted * @param maxHeaderValueLength the maximum single-header-value length in diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java new file mode 100644 index 00000000..88589daf --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java @@ -0,0 +1,137 @@ +/* + * Copyright © 2026 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.gateway.config.model; + +import java.util.Locale; +import java.util.Optional; + + +import de.cuioss.http.security.config.SecurityConfiguration; + +import org.jspecify.annotations.Nullable; + +/** + * The canonical inbound-filter mode set — the value space of the {@code profile} knob on both + * {@code security_defaults} and every {@code security_filter} block. The value range itself is + * gated by the bundled JSON Schema (three symmetric enum sites), so this enum never sees an + * unrecognized value at boot; {@link #parse(String)} exists for the model layer and for tests. + *

+ * Two contracts this enum makes explicit. + *

    + *
  1. An omitted block resolves to {@link #STRICT} ({@link #DEFAULT_PROFILE}). + * When {@code security_defaults} is absent, or present without a {@code profile}, the + * gateway-wide effective profile is {@code STRICT} — the fail-closed choice.
  2. + *
  3. {@link #NONE} contributes no limits policy. {@code none} is a statement + * about validation, never about limits: a {@code none} route still needs a + * concrete {@link SecurityConfiguration} so the retained {@code max_body_bytes} guard has a + * {@code maxBodySize} to enforce. {@link #preset()} therefore refuses to answer for + * {@code NONE}; callers resolve the limits policy through + * {@link #limitsProfile(SecurityProfile, SecurityProfile)}, which walks to the nearest + * non-{@code none} profile in the fallback chain and lands on {@code STRICT} when the whole + * chain is {@code none}.
  4. + *
+ *

+ * {@code none} is a partial disable, not "the inbound filter is off": it turns off + * exactly the url-parameter name/value validation and the per-route pipeline re-run. The pre-route + * floor, {@code max_body_bytes} and {@code allowed_paths} all keep running under it. See ADR-0024. + *

+ * Immutable and thread-safe. + * + * @author API Sheriff Team + * @since 1.0 + */ +public enum SecurityProfile { + + /** The tightest inbound posture, backed by {@link SecurityConfiguration#strict()}. */ + STRICT, + + /** The relaxed inbound posture, backed by {@link SecurityConfiguration#lenient()}. */ + LENIENT, + + /** + * The partial disable: url-parameter name/value validation and the per-route pipeline re-run + * are skipped, while the pre-route floor, the body cap and the path allowlist keep running. + * Refused at boot on effectively-authenticated and BFF routes. + */ + NONE; + + /** + * The profile an omitted {@code security_defaults} block — or a {@code security_defaults} block + * without a {@code profile} — resolves to. Fail-closed by construction. + */ + public static final SecurityProfile DEFAULT_PROFILE = STRICT; + + /** + * Parses a configured {@code profile} scalar case-insensitively. + * + * @param value the raw configured value, may be {@code null} + * @return the matching profile, or {@link Optional#empty()} when the value is {@code null} or + * names no mode of this set (the dropped {@code default} preset included) + */ + public static Optional parse(@Nullable String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.strip().toUpperCase(Locale.ROOT); + for (SecurityProfile profile : values()) { + if (profile.name().equals(normalized)) { + return Optional.of(profile); + } + } + return Optional.empty(); + } + + /** + * Resolves the profile whose preset supplies the limits policy for a route, by walking + * to the nearest non-{@link #NONE} entry of the {@code effective → fallback} chain and landing + * on {@link #STRICT} when every level is {@code NONE}. This is what keeps a {@code none} route's + * body cap and collection limits concrete and enforceable. + * + * @param effective the route's (or the gateway's) effective profile + * @param fallback the next profile down the chain — the gateway-wide profile for a route + * @return the profile to take the limits policy from; never {@link #NONE} + */ + public static SecurityProfile limitsProfile(SecurityProfile effective, SecurityProfile fallback) { + if (effective != NONE) { + return effective; + } + return fallback != NONE ? fallback : STRICT; + } + + /** + * @return the backing cui-http preset for this mode + * @throws IllegalStateException when called on {@link #NONE}, which contributes no limits + * policy — resolve one through + * {@link #limitsProfile(SecurityProfile, SecurityProfile)} first + */ + public SecurityConfiguration preset() { + return switch (this) { + case STRICT -> SecurityConfiguration.strict(); + case LENIENT -> SecurityConfiguration.lenient(); + case NONE -> throw new IllegalStateException( + "profile 'none' has no cui-http preset; resolve the limits profile via limitsProfile(..)"); + }; + } + + /** + * @return {@code true} when the skippable half of the per-route checks — the url-parameter + * name/value validation and the pipeline re-run — is active; {@code false} only for + * {@link #NONE} + */ + public boolean skippableValidationEnabled() { + return this != NONE; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java index 7af4a403..e2cc0d92 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java @@ -48,7 +48,9 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedTopology; import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; import de.cuioss.sheriff.gateway.config.model.SecurityHeadersConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.config.model.TlsConfig; import de.cuioss.sheriff.gateway.config.model.WebSocketConfig; import de.cuioss.sheriff.gateway.config.validation.rule.ValidationRule; @@ -82,6 +84,11 @@ * backing block is present; and an {@code access: public} anchor must not declare an * auth block. These collect into the same shared list and never fail fast. *

+ * The fail-closed inbound-filter mode refusal (ADR-0024) adds one more: a route whose effective + * {@code profile} resolves to {@code none} must be neither effectively authenticated nor anchored + * under a {@code type: bff} anchor. The {@code profile} value range is owned by the bundled + * JSON Schema, so no post-binding range rule exists here — only this posture refusal. + *

* Framework-agnostic (ADR-0005): the rule set is supplied at construction and the * validator carries no framework imports. * @@ -132,6 +139,7 @@ public final class ConfigValidator { (gateway, endpoints, topology, errors) -> validateAnchorAuthFloor(gateway, endpoints, errors), (gateway, endpoints, topology, errors) -> validateEffectiveAuth(gateway, endpoints, errors), (gateway, endpoints, topology, errors) -> validateAccessAuthMatrix(gateway, errors), + (gateway, endpoints, topology, errors) -> validateSecurityProfileNone(gateway, endpoints, errors), ConfigValidator::validateTerminalAction, (gateway, endpoints, topology, errors) -> validateMethodMembership(gateway, endpoints, errors), (gateway, endpoints, topology, errors) -> validateForwardedTrust(gateway, errors), @@ -555,6 +563,83 @@ private static void validateAuthenticatedAnchorBacking(GatewayConfig gateway, An } } + /** + * Rule: the fail-closed inbound-filter mode refusal (ADR-0024). A route whose effective + * {@code profile} resolves to {@link SecurityProfile#NONE} must be neither effectively + * authenticated nor anchored under a {@code type: bff} anchor. {@code none} drops the + * url-parameter name/value validation, and dropping it in front of a token- or session-bearing + * surface is never a deliberate posture — so the boot fails rather than serving the weakened + * route. + *

+ * The access level is derived through {@link RouteTableBuilder#effectiveAccessLevel} — the same + * seam the route table uses — rather than from {@link AnchorConfig#access()} directly: a route or + * endpoint may legally strengthen a {@code public}-access anchor's auth floor (ADR-0007 + * forbids weakening it, not strengthening it), and reading the anchor's static {@code access} + * would under-refuse exactly those routes. The gateway-wide case needs no separate rule: a + * {@code security_defaults.profile: none} reaching an authenticated or BFF route through the + * fallback resolves to {@code none} here and is refused identically. + *

+ * The message names the route, the refusing dimension and the remedy, and echoes no configured + * scalar value. Every violation collects into the shared list; the rule never fails fast + * (ADR-0009). + */ + private static void validateSecurityProfileNone(GatewayConfig gateway, List endpoints, + List errors) { + for (EndpointConfig endpoint : endpoints) { + for (RouteConfig route : endpoint.routes()) { + Optional anchor = resolveAnchor(gateway, endpoint, route); + if (effectiveProfile(gateway, route, anchor) != SecurityProfile.NONE) { + continue; + } + List refusals = noneRefusalDimensions(gateway, endpoint, route, anchor); + if (!refusals.isEmpty()) { + errors.add(new ConfigError(endpointFile(endpoint), ENDPOINT_ROUTES_POINTER, + ("route '%s' resolves inbound-filter profile 'none' but %s; 'none' is refused on " + + "authenticated and bff routes — declare profile 'strict' or 'lenient' for this " + + "route, or stop routing it through a 'none' fallback") + .formatted(route.id(), String.join(" and ", refusals)))); + } + } + } + } + + /** + * The dimensions on which a {@code profile: none} route is refused: an effectively-authenticated + * access level, a {@code type: bff} anchor, or both. An empty list means the route may legally + * run under {@code none}. + */ + private static List noneRefusalDimensions(GatewayConfig gateway, EndpointConfig endpoint, + RouteConfig route, Optional anchor) { + List refusals = new ArrayList<>(); + boolean authenticated = effectiveAuth(gateway, endpoint, route) + .map(auth -> RouteTableBuilder.effectiveAccessLevel(anchor, auth)) + .filter(access -> access == AccessLevel.AUTHENTICATED) + .isPresent(); + if (authenticated) { + refusals.add("its effective access level is 'authenticated'"); + } + anchor.filter(resolved -> resolved.type() == AnchorType.BFF) + .ifPresent(resolved -> refusals.add("its anchor '%s' is type 'bff'".formatted(resolved.name()))); + return refusals; + } + + /** + * The route's effective inbound-filter profile, resolved through the same + * {@code route → declared-anchor} wholesale {@code security_filter} replacement chain + * {@code RouteTableBuilder} materializes, falling back to the gateway-wide + * {@link RouteTableBuilder#globalProfile(GatewayConfig) profile} (itself + * {@link SecurityProfile#DEFAULT_PROFILE} when undeclared). The whole {@code security_filter} + * block is replaced, not merged: a route declaring a block without a {@code profile} falls back + * to the gateway-wide value, never to its anchor's profile. + */ + private static SecurityProfile effectiveProfile(GatewayConfig gateway, RouteConfig route, + Optional anchor) { + return route.securityFilter().or(() -> anchor.flatMap(AnchorConfig::securityFilter)) + .flatMap(SecurityFilterConfig::profile) + .flatMap(SecurityProfile::parse) + .orElseGet(() -> RouteTableBuilder.globalProfile(gateway)); + } + /** * Rule: the terminal-action / anchor-type consistency matrix (ADR-0014). A route whose * resolving anchor is {@code type: asset} must declare an {@code asset} terminal action; a diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index c12039b6..e1f6bf65 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java @@ -50,6 +50,7 @@ import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry; import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; +import de.cuioss.sheriff.gateway.config.RouteTableBuilder; import de.cuioss.sheriff.gateway.config.model.ForwardedConfig; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; @@ -59,6 +60,7 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.config.model.TlsConfig; import de.cuioss.sheriff.gateway.events.EventCategory; import de.cuioss.sheriff.gateway.events.EventType; @@ -110,7 +112,9 @@ * 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 SecurityEventCounter}, the gateway-wide {@link SecurityConfiguration} seeded from the + * resolved {@code security_defaults} {@link SecurityProfile} (an omitted block resolving to + * {@link SecurityProfile#DEFAULT_PROFILE}), 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 @@ -274,7 +278,14 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, this.webSocketRelayAdmission = new Semaphore(hardening.webSocketRelayCap()); SecurityEventCounter securityEventCounter = new SecurityEventCounter(); - SecurityConfiguration defaultConfiguration = SecurityConfiguration.defaults(); + // The gateway-wide baseline is seeded from the RESOLVED security_defaults profile rather + // than from a hard-coded SecurityConfiguration.defaults(). This single instance feeds + // BasicChecksStage, ThoroughChecksStage's skip-if-equal baseline, + // ForwardedResolverConfig.securityConfig and defaultMaxBodySize — so seeding it here is + // what makes an omitted security_defaults block genuinely mean 'strict' for every route. + SecurityProfile globalProfile = RouteTableBuilder.globalProfile(gatewayConfig); + SecurityConfiguration defaultConfiguration = + SecurityProfile.limitsProfile(globalProfile, globalProfile).preset(); this.defaultMaxBodySize = defaultConfiguration.maxBodySize(); this.gatewayEventCounter = new GatewayEventCounter(); this.upstreamFailureMapper = new UpstreamFailureMapper(gatewayEventCounter); @@ -291,7 +302,7 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, RouteRuntimeAssembler assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry()); this.routes = assembler.assemble(routeTable, - GatewayEdgeRoute::securityConfigurationFor, + filter -> securityPostureFor(filter, globalProfile), target -> clientFor(vertx, target), this::guardFor, GatewayEdgeRoute::assetSourceFor); @@ -300,13 +311,13 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, // Reserved OIDC endpoints (D2) are carved out of the proxy route table: the registry is // consulted in process() ahead of route selection, so a proxy route such as // path_prefix: /auth never swallows the exact /auth/callback. Empty (and inert) unless the - // global oidc block declares a redirect_uri. Built before basicChecksStage so the latter can - // reuse the same match to exempt gateway-terminated reserved params from the url-parameter - // pipeline (see BasicChecksStage — reserved handlers self-validate their own params). + // global oidc block declares a redirect_uri. The reserved-path exemption from the + // url-parameter pipeline is now STRUCTURAL rather than predicate-driven: handleReservedPath + // returns before route selection, and the parameter pipeline moved after it into + // ThoroughChecksStage, so a reserved path never reaches it (ADR-0019, amended). this.reservedPathRegistry = ReservedPathRegistry.from(gatewayConfig.oidc()); this.securityHeadersStage = new SecurityHeadersStage(gatewayConfig.securityHeaders()); this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter, - (host, canonicalPath) -> reservedPathRegistry.match(host, canonicalPath).isPresent(), cookieHeaderConfigurationFor(gatewayConfig, bffRuntime)); this.canonicalPathGuard = new CanonicalPathGuard(); this.framingGate = new FramingGate(); @@ -1245,12 +1256,43 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT } /** - * 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. + * Resolves a route's inbound-filter posture: the effective {@link SecurityProfile} after the + * {@code security_filter → security_defaults} fallback, plus the cui-http + * {@link SecurityConfiguration} carrying its limits. + *

+ * The limits are seeded from the preset of the nearest non-{@code none} profile in the + * chain (see {@link SecurityProfile#limitsProfile}), never from bare builder defaults, and only + * the dimensions the route actually declared are overridden on top — so an undeclared dimension + * lands exactly on the resolved preset rather than below it. A {@code none} route therefore + * still carries a concrete, enforceable {@code maxBodySize}. + *

+ * Invoked for every route, including one that declares no {@code security_filter} block at all, + * which is what lets a gateway-wide {@code profile} govern a block-less route. + *

+ * Package-private rather than private so the posture resolution — the fallback chain and the + * preset-seeded limit overrides — is asserted directly by {@code GatewayEdgeRouteTest} instead + * of only through a booted edge, whose assembled routes are not observable. + */ + static RouteRuntimeAssembler.SecurityPosture securityPostureFor( + Optional filter, SecurityProfile globalProfile) { + SecurityProfile effective = filter.flatMap(SecurityFilterConfig::profile) + .flatMap(SecurityProfile::parse) + .orElse(globalProfile); + SecurityConfiguration preset = SecurityProfile.limitsProfile(effective, globalProfile).preset(); + return new RouteRuntimeAssembler.SecurityPosture(effective, + filter.map(declared -> applyDeclaredLimits(preset, declared)).orElse(preset)); + } + + /** + * Applies a route's declared {@code security_filter} limits on top of the resolved preset. The + * builder is seeded component-by-component from {@code preset} because the cui-http + * {@link SecurityConfiguration} exposes no preset-seeded builder — {@link SecurityConfiguration#builder()} + * starts from {@code defaults()}, so seeding explicitly is the only way an undeclared dimension + * keeps the preset's value instead of silently reverting to the default policy. */ - private static SecurityConfiguration securityConfigurationFor(SecurityFilterConfig filter) { - SecurityConfigurationBuilder builder = SecurityConfiguration.builder(); + private static SecurityConfiguration applyDeclaredLimits(SecurityConfiguration preset, + SecurityFilterConfig filter) { + SecurityConfigurationBuilder builder = builderSeededFrom(preset); filter.maxBodyBytes().ifPresent(value -> builder.maxBodySize(value.longValue())); filter.maxQueryParams().ifPresent(builder::maxParameterCount); filter.maxHeaderCount().ifPresent(builder::maxHeaderCount); @@ -1268,6 +1310,40 @@ private static SecurityConfiguration securityConfigurationFor(SecurityFilterConf return builder.build(); } + /** + * Returns a {@link SecurityConfigurationBuilder} carrying every component of {@code preset}, so + * a caller overriding one dimension leaves the other twenty-three on the preset's values. Copies + * the record's full component set deliberately: an omitted component would silently fall back to + * the {@code defaults()} policy the builder starts from. + */ + private static SecurityConfigurationBuilder builderSeededFrom(SecurityConfiguration preset) { + return SecurityConfiguration.builder() + .maxPathLength(preset.maxPathLength()) + .allowDoubleEncoding(preset.allowDoubleEncoding()) + .maxParameterNameLength(preset.maxParameterNameLength()) + .maxParameterValueLength(preset.maxParameterValueLength()) + .maxHeaderNameLength(preset.maxHeaderNameLength()) + .maxHeaderValueLength(preset.maxHeaderValueLength()) + .maxCookieNameLength(preset.maxCookieNameLength()) + .maxCookieValueLength(preset.maxCookieValueLength()) + .maxBodySize(preset.maxBodySize()) + .allowNullBytes(preset.allowNullBytes()) + .allowControlCharacters(preset.allowControlCharacters()) + .allowExtendedAscii(preset.allowExtendedAscii()) + .normalizeUnicode(preset.normalizeUnicode()) + .caseSensitiveComparison(preset.caseSensitiveComparison()) + .failOnSuspiciousPatterns(preset.failOnSuspiciousPatterns()) + .requireSecureCookies(preset.requireSecureCookies()) + .requireHttpOnlyCookies(preset.requireHttpOnlyCookies()) + .maxParameterCount(preset.maxParameterCount()) + .maxHeaderCount(preset.maxHeaderCount()) + .maxCookieCount(preset.maxCookieCount()) + .allowedHeaderNames(preset.allowedHeaderNames()) + .blockedHeaderNames(preset.blockedHeaderNames()) + .allowedContentTypes(preset.allowedContentTypes()) + .blockedContentTypes(preset.blockedContentTypes()); + } + /** * 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 diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java index 50efb2a4..aa178ff6 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java @@ -34,6 +34,7 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.gateway.config.model.SecurityProfile; import de.cuioss.sheriff.gateway.events.EventType; import de.cuioss.sheriff.gateway.events.GatewayException; import de.cuioss.sheriff.gateway.routing.ProtocolProcessor; @@ -49,7 +50,11 @@ * {@link RouteRuntime} instances, deduplicating the heavy collaborators so shared shapes * reuse one object rather than copying it: *