Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,7 +83,7 @@ public final class RouteTableBuilder {
private static final List<HttpMethod> 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. */
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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).
* <p>
* 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<AnchorConfig> anchor, AuthConfig effectiveAuth) {
public static AccessLevel effectiveAccessLevel(Optional<AnchorConfig> 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 <em>resolved effective</em>
* 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.
* <p>
* 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@
/**
* The global {@code security_defaults} block of {@code gateway.yaml}.
* <p>
* {@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.
* <p>
* <strong>Fallback.</strong> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* <strong>Two contracts this enum makes explicit.</strong>
* <ol>
* <li><strong>An omitted block resolves to {@link #STRICT}</strong> ({@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.</li>
* <li><strong>{@link #NONE} contributes no limits policy.</strong> {@code none} is a statement
* about <em>validation</em>, never about <em>limits</em>: 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}.</li>
* </ol>
* <p>
* {@code none} is a <strong>partial</strong> 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.
* <p>
* 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<SecurityProfile> 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 <em>limits</em> 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;
}
}
Loading
Loading