Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a7e25f4
feat(tls-edge): accept-time SNI split with opaque L4 passthrough rela…
cuioss-oliver Jul 23, 2026
e8ed7f2
feat(tls-edge): mTLS termination with client-CA verification (D2)
cuioss-oliver Jul 23, 2026
d7e96a2
feat(tls-edge): runtime Host-vs-SNI smuggle 404 guard (D3)
cuioss-oliver Jul 23, 2026
599ef13
test(tls-edge): Toxiproxy + TLS backend compose and TLS/fault IT suit…
cuioss-oliver Jul 23, 2026
719d278
docs(tls-edge): author three-layer TLS-edge documentation (D5)
cuioss-oliver Jul 23, 2026
1d05c80
docs(adr): correct ADR-0004 forward-URI reconstruction wording (D6)
cuioss-oliver Jul 23, 2026
768d425
feat(benchmarks): add passthrough-relay benchmark and empty-SNI no-re…
cuioss-oliver Jul 23, 2026
400c49e
style: apply pre-commit OpenRewrite formatting normalizations
cuioss-oliver Jul 23, 2026
4437e6d
test(coverage): raise api-sheriff branch coverage to >=0.80 for TLS-e…
cuioss-oliver Jul 23, 2026
4f9d4ef
chore(simplify): collapse triplicated TrustAllManager test helper
cuioss-oliver Jul 24, 2026
2385691
style(tests): apply OpenRewrite import-order normalization
cuioss-oliver Jul 24, 2026
4ee9dba
fix(tls): drop null-check on Optional in ClientHelloSniParser.Result …
cuioss-oliver Jul 24, 2026
984725d
fix(integration-tests): activate the TLS edge in the IT deployment
cuioss-oliver Jul 25, 2026
ec0405d
fix(tls-edge): address CodeRabbit review on PR #100
cuioss-oliver Jul 25, 2026
127baa2
docs(adr): add ADR-0017 for the accept-time SNI split
cuioss-oliver Jul 25, 2026
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 @@ -28,7 +28,7 @@
* assertable. This catalogue's identifier ranges are disjoint from
* {@link de.cuioss.sheriff.gateway.config.ConfigLogMessages}'s (the boot-time configuration
* subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1} / {@code 4} /
* {@code 100} / {@code 103-106} here vs {@code 2-3} / {@code 101-102} / {@code 200-201}
* {@code 6-7} / {@code 100} / {@code 103-108} 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 <em>type</em> and route id —
* never the raw offending payload. {@code DEBUG} / {@code TRACE} diagnostics use the logger
Expand Down Expand Up @@ -61,6 +61,20 @@ public static final class INFO {
.identifier(4)
.template("WebSocket relay established for route '%s'")
.build();

/** An opaque L4 passthrough relay to the resolved backend was established for a mapped SNI. */
public static final LogRecord PASSTHROUGH_RELAY_ESTABLISHED = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(6)
.template("Passthrough relay established for SNI '%s' to upstream '%s'")
.build();

/** The accept-time SNI front listener bound the public TLS port at boot. */
public static final LogRecord SNI_FRONT_LISTENER_STARTED = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(7)
.template("Accept-time SNI front listener started on port %s (%s passthrough SNI mapping(s))")
.build();
}

/**
Expand Down Expand Up @@ -110,5 +124,27 @@ public static final class WARN {
.identifier(106)
.template("WebSocket relay on route '%s' reclaimed after idle timeout of %s seconds")
.build();

/**
* A TLS ClientHello was failed closed to the terminated-strict path (GW-06): it carried no
* usable SNI, was malformed, or exceeded the reassembly bound. Records the disposition only —
* never the raw ClientHello bytes.
*/
public static final LogRecord CLIENT_HELLO_FAIL_CLOSED = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(107)
.template("TLS ClientHello failed closed to terminated path: %s")
.build();

/**
* A terminated request's {@code Host} header named a reserved passthrough SNI (Host-vs-SNI
* smuggle), rejected 404 before route selection. Records a fixed disposition only — never the
* raw {@code Host} value.
*/
public static final LogRecord PASSTHROUGH_HOST_SMUGGLED = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(108)
.template("Host-vs-SNI smuggle rejected before route selection: %s")
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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.TlsConfig;
import de.cuioss.sheriff.gateway.events.EventCategory;
import de.cuioss.sheriff.gateway.events.EventType;
import de.cuioss.sheriff.gateway.events.GatewayEventCounter;
Expand All @@ -62,6 +63,7 @@
import de.cuioss.sheriff.gateway.pipeline.CanonicalPathGuard;
import de.cuioss.sheriff.gateway.pipeline.FramingGate;
import de.cuioss.sheriff.gateway.pipeline.OriginValidationStage;
import de.cuioss.sheriff.gateway.pipeline.PassthroughHostGuardStage;
import de.cuioss.sheriff.gateway.pipeline.PipelineRequest;
import de.cuioss.sheriff.gateway.pipeline.RouteSelectionStage;
import de.cuioss.sheriff.gateway.pipeline.SecurityHeadersStage;
Expand Down Expand Up @@ -160,6 +162,7 @@ public class GatewayEdgeRoute {
private final BasicChecksStage basicChecksStage;
private final CanonicalPathGuard canonicalPathGuard;
private final FramingGate framingGate;
private final PassthroughHostGuardStage passthroughHostGuardStage;
private final RouteSelectionStage routeSelectionStage;
private final VerbGateStage verbGateStage;
private final ThoroughChecksStage thoroughChecksStage;
Expand Down Expand Up @@ -230,6 +233,8 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig,
this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter);
this.canonicalPathGuard = new CanonicalPathGuard();
this.framingGate = new FramingGate();
this.passthroughHostGuardStage = new PassthroughHostGuardStage(
gatewayConfig.tls().map(TlsConfig::passthroughSni).map(Map::keySet).orElse(Set.of()));
this.routeSelectionStage = new RouteSelectionStage(routes);
this.verbGateStage = new VerbGateStage();
this.thoroughChecksStage = new ThoroughChecksStage(defaultConfiguration, securityEventCounter);
Expand Down Expand Up @@ -382,6 +387,7 @@ private void process(RoutingContext ctx) {
writeShortCircuit(ctx, request);
return;
}
passthroughHostGuardStage.process(request);
routeSelectionStage.process(request);
verbGateStage.process(request);
RouteRuntime route = requireSelectedRoute(request);
Expand Down Expand Up @@ -409,6 +415,10 @@ private void process(RoutingContext ctx) {
// 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());
} else if (rejected.getEventType() == EventType.PASSTHROUGH_HOST_SMUGGLED) {
// Security-relevant WARN: a terminated Host named a reserved passthrough SNI. The
// message is a fixed disposition (never the raw Host value).
LOGGER.warn(ApiSheriffLogMessages.WARN.PASSTHROUGH_HOST_SMUGGLED, rejected.getMessage());
}
recordError(ctx, rejected.getEventType());
renderRejection(ctx, request, rejected.getEventType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ public enum EventType {
PARAMETER_LIMIT_EXCEEDED(EventCategory.INPUT_VALIDATION, 400),
/** No route matched the canonical path (deny-by-default routing). */
NO_ROUTE_MATCHED(EventCategory.INPUT_VALIDATION, 404),
/**
* A terminated request's {@code Host} header names a {@code passthrough_sni} hostname reserved
* for the accept-time L4 split (Host-vs-SNI smuggle); rejected {@code 404} before route
* selection so the passthrough backend identity cannot be reached through the terminated
* listener.
*/
PASSTHROUGH_HOST_SMUGGLED(EventCategory.INPUT_VALIDATION, 404),
/** The request method is outside the route's effective {@code allowed_methods}. */
METHOD_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 405),

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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.pipeline;

import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;


import de.cuioss.sheriff.gateway.events.EventType;
import de.cuioss.sheriff.gateway.events.GatewayException;

/**
* A pipeline pre-check that rejects a Host-vs-SNI smuggle before stage-2 route selection: a
* <em>terminated</em> request whose {@code Host} header names a {@code passthrough_sni} hostname is
* rejected {@code 404} ({@link EventType#PASSTHROUGH_HOST_SMUGGLED}) rather than being routed.
* <p>
* A passthrough hostname is meant to be split off at L4 by the accept-time front listener and never
* terminated here; a terminated request carrying that hostname in its {@code Host} header is
* therefore attempting to reach the passthrough backend's identity through the terminated listener
* (a request-smuggling / host-confusion vector). Matching is case-insensitive and normalized
* (lower-cased, trailing FQDN dot and any {@code :port} suffix removed), mirroring the front
* listener's SNI normalization.
* <p>
* This is the genuinely-new runtime half of the guard, distinct from the framing / anti-smuggling
* {@code FramingGate} and from the boot-time collision rule
* ({@code ConfigValidator.validatePassthroughHostCollision}). The stage is inert when
* {@code passthrough_sni} is empty, and framework-agnostic (ADR-0005) — it consumes only the
* agnostic {@link PipelineRequest}.
*
* @author API Sheriff Team
* @since 1.0
*/
public final class PassthroughHostGuardStage {

private final Set<String> passthroughHosts;

/**
* @param passthroughSniHosts the {@code tls.passthrough_sni} hostnames (SNI keys); normalized
* into the reserved-host set the guard rejects a terminated
* {@code Host} match against
*/
public PassthroughHostGuardStage(Collection<String> passthroughSniHosts) {
Objects.requireNonNull(passthroughSniHosts, "passthroughSniHosts");
Set<String> normalized = new HashSet<>();
for (String host : passthroughSniHosts) {
normalized.add(normalize(host));
}
this.passthroughHosts = Set.copyOf(normalized);
}

/**
* Rejects the request when its {@code Host} names a passthrough SNI; a no-op otherwise.
*
* @param request the in-flight request context
* @throws GatewayException with {@link EventType#PASSTHROUGH_HOST_SMUGGLED} when the terminated
* request's {@code Host} names a reserved passthrough hostname
*/
public void process(PipelineRequest request) {
Objects.requireNonNull(request, "request");
if (passthroughHosts.isEmpty()) {
return;
}
String host = request.host();
if (host == null) {
return;
}
if (passthroughHosts.contains(normalize(host))) {
throw new GatewayException(EventType.PASSTHROUGH_HOST_SMUGGLED,
"Terminated request Host names a reserved passthrough SNI");
}
}

private static String normalize(String host) {
String lower = host.toLowerCase(Locale.ROOT).strip();
int colon = lower.indexOf(':');
if (colon > 0 && lower.lastIndexOf(':') == colon) {
String maybePort = lower.substring(colon + 1);
if (!maybePort.isEmpty() && maybePort.chars().allMatch(Character::isDigit)) {
lower = lower.substring(0, colon);
}
}
return lower.endsWith(".") ? lower.substring(0, lower.length() - 1) : lower;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public class ConfigProducer {

private GatewayConfig gateway;
private RouteTable routeTable;
private ResolvedTopology resolvedTopology;
private boolean built;

/**
Expand Down Expand Up @@ -122,6 +123,26 @@ public RouteTable routeTable() {
return routeTable;
}

/**
* Produces the immutable, fully-resolved topology assembled at boot: every topology alias
* referenced by an enabled endpoint or a {@code tls.passthrough_sni} target, decomposed into its
* upstream endpoint. Published (rather than recomputed) so {@code tls.TlsEdgeProducer} can build
* the accept-time SNI relay map from the same resolved data the validator already accepted.
* <p>
* {@link Singleton} (a pseudo-scope, no client proxy) because {@link ResolvedTopology} is a
* {@code record}: ArC cannot subclass a final type to build the proxy a normal scope such as
* {@code @ApplicationScoped} would require. The bean is immutable and assembled once at boot, so
* a single instance is exact.
*
* @return the immutable {@link ResolvedTopology}
*/
@Produces
@Singleton
public ResolvedTopology resolvedTopology() {
buildOnce();
return resolvedTopology;
}

private synchronized void buildOnce() {
if (built) {
return;
Expand All @@ -138,6 +159,7 @@ private synchronized void buildOnce() {
abort(violations);
}
this.gateway = loaded.gateway();
this.resolvedTopology = topology;
this.routeTable = new RouteTableBuilder().build(loaded.gateway(), enabled, topology);
this.built = true;
LOGGER.info(ConfigLogMessages.INFO.CONFIG_LOADED, configVersion(gateway));
Expand Down
Loading
Loading