diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java index b12513482..ba76233ba 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java @@ -49,14 +49,21 @@ * {@link AudienceResource#parseCustomForm(String, String)}. *

The first form is tried at all only when {@link #DNS_FORMAT_ENABLED_PARAM} is {@code true}, * its default; when it is {@code false}, every entry is evaluated only as the custom form. - *

A token carrying a delegation {@code act} claim is always routed through this validator; see - * {@link #validate(HttpServletRequest, JWT, List)}. A token with no {@code act} claim is too, - * unless {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}. + *

A token carrying a delegation {@code act} claim is always destination-validated; see + * {@link #validate(HttpServletRequest, JWT, List)}. The one departure is ingress source routing: + * when {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code true} and the request's + * source SPIFFE id names one of the {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, such a token is + * rejected outright, because a delegation token must not arrive through the ingress. + * A token with no {@code act} claim is destination-validated too, unless ingress source routing + * applies to it or {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}. * *

Which segments are actually compared depends entirely on which of this validator's header * parameters are configured; for an entry in the second, custom form, the cluster-domain segment * is the only one always enforced. Segments with no configured source are not compared at all, so * an {@code aud} entry that differs from the request only in an unconfigured segment is accepted. + * Those comparisons, and the rule that every configured header must be present and parseable, + * apply to a request that reaches destination validation, which a token with no {@code act} claim + * routed by ingress source routing does not. * *

One case departs from the rule that a segment with no configured source is not * compared. An {@code aud} entry written as a k8s service DNS name whose host carries no @@ -251,9 +258,12 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * {@link #ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM} is enabled. * *

When this parameter names a header, that header must be present and parseable as a - * SPIFFE id on every request, or audience validation fails, whether or not the value turns - * out to be needed for the {@code aud} entries actually presented. Every other header this - * validator is configured to read behaves the same way. + * SPIFFE id on every request that reaches destination validation, or audience validation + * fails, whether or not the value turns out to be needed for the {@code aud} entries actually + * presented. Every other header this validator is configured to read behaves the same way. A + * token with no {@code act} claim routed by + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} does not reach destination + * validation, so that requirement does not apply to it. * *

The value of this header is trusted at face value, with no independent verification by * this filter. It must be populated only by a component trusted to set or overwrite it @@ -281,6 +291,10 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator * *

When {@code false}, the {@code aud} claim of a token with no {@code act} claim is * validated against the fixed configured allowed-audience list. + * + *

Independent of this parameter, a token with no {@code act} claim whose source SPIFFE id + * names a configured ingress source is routed as described at + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM}. */ public static final String VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM = PARAM_PREFIX + "validate.audiences.without.act.claim"; @@ -331,6 +345,115 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator public static final String ENFORCE_ACT_SUB_IS_SERVICE_ACCOUNT_PARAM = PARAM_PREFIX + "enforce.act.sub.is.service.account"; + /** + * Whether a request presented by an ingress gateway is routed to a different audience check + * than validation against the request destination. A request is from an ingress gateway when + * the SPIFFE id in the header named by {@link #SOURCE_SPIFFE_ID_HEADER_PARAM} matches an entry + * of {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}. Defaults to {@code false}, the fail-closed + * setting: with routing off, a request from an ingress gateway is handled like a request from + * any other source. + * + *

When {@code true}, a request from an ingress gateway is routed by whether its token + * carries an {@code act} claim. + * + *

A token with no {@code act} claim is not destination-validated. What it is checked + * against depends on {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM}: either + * no audience validation at all, or the fixed configured allowed-audience list. Either way the + * token does not reach destination validation, so the requirement that every configured header + * be present and parseable does not apply to it. + * + *

A token with an {@code act} claim is rejected, because a delegation token must not + * arrive through the ingress. Routing never lets such a token through, whatever the skip setting. + * + *

This parameter reads {@link #SOURCE_SPIFFE_ID_HEADER_PARAM} and + * {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM}, neither of which has a default. Setting this to + * {@code true} without both of them is a configuration error: initialization fails, rather than + * leaving routing a deployment asked for silently inactive. + * + *

Routing is driven by the value of the header named by + * {@link #SOURCE_SPIFFE_ID_HEADER_PARAM}, which is trusted at face value. A caller able to set + * that header can have its request routed as an ingress request, so it must be set or overwritten by a trusted + * component before the request reaches Knox. This matters most with + * {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM}, which disables audience + * checking for the requests it routes. + * + *

A request whose configured source SPIFFE id header is missing or unparseable does not name + * an ingress source, so it is not routed; it continues to the ordinary flow. There it is + * rejected by the existing requirement that a configured header be present and parseable, + * unless {@link #VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM} is {@code false}, in which case a + * token with no {@code act} claim is validated against the fixed configured allowed-audience + * list. + */ + public static final String INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-id.routing.enabled"; + + /** + * Comma-separated list of the source workload SPIFFE ids that identify a request as having + * arrived through an ingress gateway, each a full SPIFFE id, for example + * {@code spiffe://trust-domain/ns/namespace/sa/service-account}. No default. Currently consulted + * only by ingress source routing, so only when + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code true}, which requires this + * to be set. + * + *

Each entry is compared against the value of the header named by + * {@link #SOURCE_SPIFFE_ID_HEADER_PARAM}. A request matches if any one entry matches. + * + *

Namespace and service account are compared exactly, ignoring case. No wildcard or partial + * form is supported in any portion. The trust domain is compared too, unless + * {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM} is {@code true}. + * + *

Every entry must be a full, parseable SPIFFE id including a trust domain, even when + * trust-domain matching is switched off, in which case the trust domain is parsed and then + * ignored. An entry that does not parse, including an empty entry left by a stray, leading or + * trailing comma, fails initialization, the same as any other malformed operator-supplied + * value. Entries are validated whether or not + * {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code true}. + */ + public static final String INGRESS_SOURCE_SPIFFE_IDS_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids"; + + /** + * Whether ingress source routing ignores the trust-domain portion of an entry in + * {@link #INGRESS_SOURCE_SPIFFE_IDS_PARAM} when deciding whether the request's source SPIFFE id + * matches it, leaving only the namespace and the service account compared. Defaults to + * {@code false}, the fail-closed setting: all three portions must match. + * + *

This relaxes security and exists for a deployment that must trust an ingress identity + * whose trust domain is not known in advance. Setting it to {@code true} means any trust domain + * is accepted for that namespace and service-account pair. If the same namespace and + * service-account pair is used in more than one trust domain, those identities become + * indistinguishable to this check, and an ingress identity in one trust domain is accepted as + * the ingress identity of another. + * + *

Only consulted when {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is + * {@code true}. Setting this to {@code true} while that parameter is {@code false} has no + * effect. + */ + public static final String INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids.routing.skip.trust.domain.match"; + + /** + * Whether ingress source routing skips audience validation entirely for a token with no + * {@code act} claim whose source SPIFFE id names a configured ingress source. Defaults to + * {@code false}. + * + *

When {@code true}, no audience check of any kind is performed on such a token: not against + * the request destination, and not against the fixed configured allowed-audience list. A token + * with no {@code aud} claim at all is accepted, and so is one whose {@code aud} names something + * unrelated to this request. + * + *

When {@code false}, the default, such a token's {@code aud} claim is validated against the + * fixed configured allowed-audience list instead of against the request destination. Note that + * the fixed-list check accepts any audience when no list is configured. + * + *

This affects no other token. A token with an {@code act} claim is unaffected by it, so is a + * token whose source SPIFFE id does not name a configured ingress source, and so is every token + * when {@link #INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM} is {@code false}. Setting this + * to {@code true} while that parameter is {@code false} has no effect. + */ + public static final String INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM = + PARAM_PREFIX + "ingress.source.spiffe-ids.routing.skip.validation"; + private String namespaceFromSpiffeIdHeader; private String serverNameHeader; private String serverNameClusterSuffix; @@ -345,6 +468,10 @@ public class K8sDestinationAudienceValidator implements RequestAudienceValidator private boolean validateAudiencesWithoutActClaim; private boolean enforceActSubMatchesSourceSpiffeId; private boolean enforceActSubIsServiceAccount; + private boolean ingressSourceRoutingEnabled; + private List ingressSourceSpiffeIds; + private boolean ingressSourceRoutingSkipTrustDomainMatch; + private boolean ingressSourceRoutingSkipValidation; @Override public void init(final FilterConfig filterConfig) throws Exception { @@ -399,19 +526,84 @@ public void init(final FilterConfig filterConfig) throws Exception { enforceActSubIsServiceAccount = Boolean.parseBoolean(filterConfig.getInitParameter(ENFORCE_ACT_SUB_IS_SERVICE_ACCOUNT_PARAM)); + ingressSourceRoutingEnabled = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); + ingressSourceRoutingSkipTrustDomainMatch = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM)); + ingressSourceRoutingSkipValidation = Boolean.parseBoolean( + filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM)); + + // Parsed strictly whether or not routing is enabled, so a malformed entry fails the + // topology at startup instead of sitting inert. + final String ingressSourceSpiffeIdsParam = + blankToNull(filterConfig.getInitParameter(INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + ingressSourceSpiffeIds = new ArrayList<>(); + if (ingressSourceSpiffeIdsParam != null) { + for (final String entry : parseCommaSeparated(ingressSourceSpiffeIdsParam)) { + final Optional parsed = SpiffeId.parse(entry); + if (parsed.isEmpty()) { + throw new ServletException(String.format(Locale.ROOT, + "%s contains an invalid SPIFFE id entry: %s", INGRESS_SOURCE_SPIFFE_IDS_PARAM, entry)); + } + ingressSourceSpiffeIds.add(lowerCased(parsed.get())); + } + } + if (enforceActSubMatchesSourceSpiffeId && sourceSpiffeIdHeader == null) { throw new ServletException(String.format(Locale.ROOT, "%s is true but %s is not configured", ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM, SOURCE_SPIFFE_ID_HEADER_PARAM)); } + + if (ingressSourceRoutingEnabled) { + if (sourceSpiffeIdHeader == null) { + throw new ServletException(String.format(Locale.ROOT, + "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, + SOURCE_SPIFFE_ID_HEADER_PARAM)); + } + if (ingressSourceSpiffeIds.isEmpty()) { + throw new ServletException(String.format(Locale.ROOT, + "%s is true but %s is not configured", INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, + INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + } } @Override public AudienceValidationResult validate(final HttpServletRequest request, final JWT token, final List configuredAudiences) { final boolean hasActClaim = token.getClaimAsObject(JWTToken.ACT_CLAIM) != null; - if (!hasActClaim && !validateAudiencesWithoutActClaim) { - return AudienceValidationResult.of(AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); + + // Resolved before the early returns below because the routing decision needs it. An + // absent value is not rejected here: that rejection happens further down, after the destination + // header and server-name checks, so a request whose source header is missing or unparseable + // is reported only once those earlier checks have passed. + final Optional sourceSpiffeId = sourceSpiffeIdHeader == null + ? Optional.empty() : sourceSpiffeIdFromHeader(request); + final boolean fromIngressSource = ingressSourceRoutingEnabled + && isConfiguredIngressSource(sourceSpiffeId); + + if (!hasActClaim) { + if (fromIngressSource) { + // A token with no act claim arriving through the ingress names no in-cluster destination, + // so its audience is either not checked at all or checked against the fixed configured + // list. Both decisions are made before the aud claim is required to be present, because + // the fixed-list check has its own handling for a token that carries no aud claim. + return ingressSourceRoutingSkipValidation + ? AudienceValidationResult.of(true) + : AudienceValidationResult.of( + AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); + } + if (!validateAudiencesWithoutActClaim) { + return AudienceValidationResult.of( + AbstractJWTFilter.matchesConfiguredAudiences(token, configuredAudiences)); + } + } else if (fromIngressSource) { + // An act claim means the token authorizes one workload to call another inside the cluster. + // Arriving from an ingress identity means it was presented from outside, which no such call + // does. + return new AudienceValidationResult(false, "Token has an act claim but its source SPIFFE id in header " + + sourceSpiffeIdHeader + " is a configured ingress source: " + request.getHeader(sourceSpiffeIdHeader)); } final String[] audienceClaims = token.getAudienceClaims(); @@ -436,8 +628,6 @@ public AudienceValidationResult validate(final HttpServletRequest request, final + serverNameHeader + ": " + request.getHeader(serverNameHeader)); } - final Optional sourceSpiffeId = sourceSpiffeIdHeader == null - ? Optional.empty() : sourceSpiffeIdFromHeader(request); if (sourceSpiffeIdHeader != null && sourceSpiffeId.isEmpty()) { return new AudienceValidationResult(false, "Missing or unparseable source SPIFFE id in header " + sourceSpiffeIdHeader + ": " + request.getHeader(sourceSpiffeIdHeader)); @@ -658,20 +848,19 @@ private Optional destinationServiceNameFromHeader(final } /** - * All three fields are lower-cased here, once, on read -- the same convention - * {@link #namespaceFromHeader(HttpServletRequest)} and {@link DestinationServiceName} follow -- - * so every comparison against this value elsewhere in this class is a plain, case-sensitive - * {@code equals} rather than each comparison site remembering to lower-case its own copy. - * {@code trustDomain} is lowered too, for consistency, even though this validator never - * compares it. + * All three fields are lower-cased here, once, on read, by {@link #lowerCased(SpiffeId)} -- the + * same convention {@link #namespaceFromHeader(HttpServletRequest)} and + * {@link DestinationServiceName} follow -- so every comparison against this value elsewhere in + * this class is a plain, case-sensitive {@code equals} rather than each comparison site + * remembering to lower-case its own copy. {@code trustDomain} is lowered too because + * {@link #isConfiguredIngressSource(Optional)} compares it against the configured ingress ids. */ private Optional sourceSpiffeIdFromHeader(final HttpServletRequest request) { final String headerValue = request.getHeader(sourceSpiffeIdHeader); if (headerValue == null || headerValue.isEmpty()) { return Optional.empty(); } - return SpiffeId.parse(headerValue).map(id -> new SpiffeId(id.trustDomain().toLowerCase(Locale.ROOT), - id.namespace().toLowerCase(Locale.ROOT), id.serviceAccount().toLowerCase(Locale.ROOT))); + return SpiffeId.parse(headerValue).map(K8sDestinationAudienceValidator::lowerCased); } private Optional pathFromHeader(final HttpServletRequest request) { @@ -724,6 +913,31 @@ private Optional actSubMatchFailureReason(final Map most + " does not match source SPIFFE id service account " + sourceNamespace + ":" + sourceServiceAccount); } + /** + * Whether {@code sourceSpiffeId} names one of the configured ingress gateway identities. The + * namespace and service account must match an entry exactly; the trust domain must match too + * unless {@link #INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM} is + * enabled. An absent value, which is what a missing or unparseable source header produces, + * names no ingress identity and is reported as no match here rather than as a failure, so such + * a request is not routed and continues to the ordinary flow, where the rules for a missing or + * unparseable source header apply. + */ + private boolean isConfiguredIngressSource(final Optional sourceSpiffeId) { + if (sourceSpiffeId.isEmpty()) { + return false; + } + final SpiffeId source = sourceSpiffeId.get(); + for (final SpiffeId allowed : ingressSourceSpiffeIds) { + if (allowed.namespace().equals(source.namespace()) + && allowed.serviceAccount().equals(source.serviceAccount()) + && (ingressSourceRoutingSkipTrustDomainMatch + || allowed.trustDomain().equals(source.trustDomain()))) { + return true; + } + } + return false; + } + @Override public String getName() { return VALIDATION_METHOD_VALUE; @@ -760,6 +974,17 @@ static Optional parse(final String entry) { } } + /** + * A copy of {@code id} with all three components lower-cased, so every comparison against a + * value read from a request is a plain, case-sensitive {@code equals}. The same convention + * {@link #sourceSpiffeIdFromHeader(HttpServletRequest)} applies on read. + */ + private static SpiffeId lowerCased(final SpiffeId id) { + return new SpiffeId(id.trustDomain().toLowerCase(Locale.ROOT), + id.namespace().toLowerCase(Locale.ROOT), + id.serviceAccount().toLowerCase(Locale.ROOT)); + } + private static String blankToNull(final String value) { return (value == null || value.trim().isEmpty()) ? null : value.trim(); } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java new file mode 100644 index 000000000..94139bdb0 --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sIngressSourceRoutingTest.java @@ -0,0 +1,644 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.knox.gateway.provider.federation.jwt.filter; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.junit.Test; + +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.actorChain; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationTokenWithActClaim; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.filterConfig; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.nonDelegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.requestWithHeaders; + +/** + * Covers ingress source routing: the configuration it requires at initialization, which + * requests and tokens it applies to, the two audience checks a token with no {@code act} claim can + * be sent to (none at all, or the fixed configured allowed-audience list), how a configured + * ingress identity is matched against the request's source SPIFFE id, and the rejection of a token + * carrying an {@code act} claim that arrives from an ingress source. + * + *

Every request in this class is for the same destination: the namespace + * {@link #DEST_NAMESPACE}, taken from the destination SPIFFE id header ({@link #DEST_SPIFFE_VALUE}). + * Audiences are written as full cluster DNS names so that the destination namespace is always + * checked against the destination SPIFFE id header and never inferred from the source SPIFFE id + * (that inference is for a namespace-less audience and is covered in + * {@link K8sDestinationAudienceValidatorTest}). Only the source SPIFFE id varies between requests. + * + *

How a route is proven. A token that reaches the validator is handled by one of three + * routes, and a test that names a route must show a result that only that route produces. These + * are the inputs used and what each route does with them, given {@link #CONFIGURED_AUDIENCES} + * (always non-null, because a null list accepts every audience and would hide the difference): + *

+ *   token aud                          destination      configured list    skipped
+ *   MATCHING_AUD                       accept           reject             accept
+ *   LISTED_AUD                         reject           accept             accept
+ *   UNLISTED_OTHER_NAMESPACE_AUD       reject           reject             accept
+ *   no aud claim                       reject           reject             accept
+ * 
+ * {@code assertDestinationValidated} uses the first two rows, {@code assertConfiguredAudiencesChecked} + * the first two in the opposite sense, and {@code assertAudienceValidationSkipped} the last two, + * which neither of the other routes accepts. + * + *

Assumptions. The table, and so the helpers, rely on how the validator works today: + * these three routes are the only ones, and the audience constants were chosen so the table holds. + * {@link #testRouteProofInputsBehaveAsTheTableStates()} checks the table directly. If it fails, + * or a new route is added to the validator (for example a second allowed-audience list), every + * helper must be revisited and given inputs that the new route answers differently, and the table + * above extended with a column for it. + * + *

How the configured-list check itself treats every combination of list and token audience is + * covered by {@link MatchesConfiguredAudiencesTest}; here it is only shown to be reached. + */ +public class K8sIngressSourceRoutingTest { + + private static final String DEST_SPIFFE_HEADER = "x-dest-spiffe-id"; + private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; + private static final String PATH_HEADER = "x-dest-path"; + private static final String SERVER_NAME_HEADER = "x-dest-server-name"; + private static final String CLUSTER_DOMAIN = "cluster.local"; + private static final String CLUSTER_SUFFIX = ".svc." + CLUSTER_DOMAIN; + + private static final String DEST_NAMESPACE = "dest-namespace"; + private static final String OTHER_NAMESPACE = "other-namespace"; + private static final String DEST_SERVICE = "dest-service-name"; + private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/" + DEST_NAMESPACE + "/sa/dest-sa"; + + private static final String INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/istio-system/sa/ingressgateway"; + private static final String SECOND_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/edge/sa/edge-gateway"; + private static final String INGRESS_SPIFFE_OTHER_TRUST_DOMAIN = + "spiffe://other-domain/ns/istio-system/sa/ingressgateway"; + private static final String NON_INGRESS_SPIFFE_ID = "spiffe://trust-domain/ns/caller-namespace/sa/caller-sa"; + + /** Names the destination namespace: accepted by destination validation, not in the configured list. */ + private static final String MATCHING_AUD = "https://" + DEST_SERVICE + "." + DEST_NAMESPACE + CLUSTER_SUFFIX; + /** Names another namespace, so destination validation rejects it; it is the only entry in the configured list. */ + private static final String LISTED_AUD = "https://" + DEST_SERVICE + "." + OTHER_NAMESPACE + CLUSTER_SUFFIX; + /** Names yet another namespace: neither the destination nor in the configured list. */ + private static final String UNLISTED_OTHER_NAMESPACE_AUD = + "https://" + DEST_SERVICE + ".unlisted-namespace" + CLUSTER_SUFFIX; + private static final List CONFIGURED_AUDIENCES = Collections.singletonList(LISTED_AUD); + + private static final String DESTINATION_MISMATCH_MESSAGE = "does not match the destination"; + /** The entry that makes a token with no aud claim acceptable to the configured list check. */ + private static final String NONE_ENTRY = "NONE"; + private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; + private static final String INGRESS_DELEGATION_MESSAGE = "is a configured ingress source"; + + private static final Function NON_DELEGATION = K8sAudienceTestSupport::nonDelegationToken; + private static final Function DELEGATION = K8sAudienceTestSupport::delegationToken; + + private static K8sDestinationAudienceValidator init(final Map params) throws Exception { + final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); + validator.init(filterConfig(params)); + return validator; + } + + /** + * Destination validation only: the destination namespace is read from the destination SPIFFE id + * header {@link #DEST_SPIFFE_HEADER}, and {@link #CLUSTER_DOMAIN} is the cluster domain used to + * parse DNS-form audiences. No source SPIFFE id header is configured and no ingress routing is set. + */ + private static Map destinationValidationParams() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, DEST_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + return params; + } + + /** + * {@link #destinationValidationParams()} plus the source SPIFFE id header name and ingress source + * routing on, with {@link #INGRESS_SPIFFE_ID} the only ingress identity. + */ + private static Map ingressRoutingParams() { + final Map params = destinationValidationParams(); + params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + return params; + } + + /** {@link #ingressRoutingParams()} plus skipping audience validation on the ingress route. */ + private static Map ingressRoutingSkipValidationParams() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); + return params; + } + + /** + * {@link #ingressRoutingParams()} with routing explicitly off but the ingress list and both + * routing skip flags set, so any effect of those settings with routing off would show. + */ + private static Map ingressRoutingDisabledParams() { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM, "false"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + return params; + } + + /** Every request goes to the same destination; this is its destination SPIFFE id header. */ + private static Map destinationHeaders() { + final Map headers = new HashMap<>(); + headers.put(DEST_SPIFFE_HEADER, DEST_SPIFFE_VALUE); + return headers; + } + + /** A request to the destination from {@code sourceSpiffeId}; with null, no source header is sent. */ + private static HttpServletRequest requestFromSource(final String sourceSpiffeId) { + final Map headers = destinationHeaders(); + if (sourceSpiffeId != null) { + headers.put(SOURCE_SPIFFE_HEADER, sourceSpiffeId); + } + return requestWithHeaders(headers); + } + + private static void assertRejected(final AudienceValidationResult result, final String messageFragment) { + assertFalse(result.isValid()); + assertNotNull("a rejection from this validator carries a message", result.message()); + assertTrue(result.message(), result.message().contains(messageFragment)); + } + + private static void assertAccepted(final AudienceValidationResult result) { + assertTrue(result.message(), result.isValid()); + } + + /** Destination validation: first two rows of the table, accepting {@link #MATCHING_AUD} only. */ + private static void assertDestinationValidated(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId, final Function tokens) { + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + tokens.apply(new String[]{MATCHING_AUD}), CONFIGURED_AUDIENCES)); + assertRejected(validator.validate(requestFromSource(sourceSpiffeId), + tokens.apply(new String[]{LISTED_AUD}), CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + } + + /** + * Configured list: first two rows, accepting {@link #LISTED_AUD} only. The list check's + * rejection carries no message, unlike every rejection from this validator. + */ + private static void assertConfiguredAudiencesChecked(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId) { + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + final AudienceValidationResult rejected = validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES); + assertFalse(rejected.isValid()); + assertNull(rejected.message()); + } + + /** Skipped validation: last two rows, which neither of the other routes accepts. */ + private static void assertAudienceValidationSkipped(final K8sDestinationAudienceValidator validator, + final String sourceSpiffeId) { + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), CONFIGURED_AUDIENCES)); + assertAccepted(validator.validate(requestFromSource(sourceSpiffeId), + nonDelegationToken((String[]) null), CONFIGURED_AUDIENCES)); + } + + private static void assertIngressDelegationRejected(final AudienceValidationResult result) { + assertRejected(result, INGRESS_DELEGATION_MESSAGE); + assertTrue(result.message(), result.message().contains("act claim")); + assertTrue(result.message(), result.message().contains(SOURCE_SPIFFE_HEADER)); + } + + private static void assertInitRejectsIngressIds(final Map params) { + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage(), + e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + // ---- the table in the class javadoc ---- + + @Test + public void testRouteProofInputsBehaveAsTheTableStates() throws Exception { + // Configured list on its own. + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences(nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES)); + assertTrue(AbstractJWTFilter.matchesConfiguredAudiences(nonDelegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences( + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), CONFIGURED_AUDIENCES)); + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences( + nonDelegationToken((String[]) null), CONFIGURED_AUDIENCES)); + + // Destination validation on its own: routing is not enabled, so there is no other route. + final K8sDestinationAudienceValidator validator = init(destinationValidationParams()); + assertAccepted(validator.validate(requestFromSource(null), nonDelegationToken(MATCHING_AUD), + CONFIGURED_AUDIENCES)); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken(LISTED_AUD), + CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), + CONFIGURED_AUDIENCES), DESTINATION_MISMATCH_MESSAGE); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken((String[]) null), + CONFIGURED_AUDIENCES), "no aud claim"); + } + + // ---- init() requirements ---- + + @Test + public void testInitThrowsWhenRoutingEnabledButSourceHeaderUnconfigured() { + final Map params = ingressRoutingParams(); + params.remove(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains( + K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM)); + } + + @Test + public void testInitThrowsWhenRoutingEnabledButIngressIdListUnconfigured() { + final Map params = ingressRoutingParams(); + params.remove(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains( + K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_ID_ROUTING_ENABLED_PARAM)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + @Test + public void testInitThrowsWhenRoutingEnabledAndIngressIdListIsBlank() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, " "); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + } + + @Test + public void testInitThrowsOnUnparseableIngressSpiffeIdEntry() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); + final ServletException e = assertThrows(ServletException.class, () -> init(params)); + assertTrue(e.getMessage().contains(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM)); + assertTrue(e.getMessage().contains("not-a-spiffe-id")); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdEntryWithoutNamespaceAndServiceAccountPath() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "spiffe://trust-domain/ns/only"); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithStrayInteriorComma() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + ",," + SECOND_INGRESS_SPIFFE_ID); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithTrailingComma() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID + ","); + assertInitRejectsIngressIds(params); + } + + @Test + public void testInitThrowsOnIngressSpiffeIdsWithLeadingComma() { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "," + INGRESS_SPIFFE_ID); + assertInitRejectsIngressIds(params); + } + + @Test + public void testIngressSpiffeIdListEntriesAreTrimmed() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + " " + INGRESS_SPIFFE_ID + " , " + SECOND_INGRESS_SPIFFE_ID + " "); + final K8sDestinationAudienceValidator validator = init(params); + assertAudienceValidationSkipped(validator, INGRESS_SPIFFE_ID); + assertAudienceValidationSkipped(validator, SECOND_INGRESS_SPIFFE_ID); + } + + @Test + public void testInitSucceedsWithIngressIdListConfiguredWhileRoutingDisabled() throws Exception { + final Map params = destinationValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, INGRESS_SPIFFE_ID); + init(params); + } + + @Test + public void testInitSucceedsWithRoutingSkipFlagsSetWhileRoutingDisabled() throws Exception { + final Map params = destinationValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_VALIDATION_PARAM, "true"); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + init(params); + } + + @Test + public void testInitThrowsOnUnparseableIngressSpiffeIdEntryEvenWhenRoutingDisabled() { + final Map params = destinationValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, "not-a-spiffe-id"); + assertInitRejectsIngressIds(params); + } + + // ---- routing on, skip.validation at its default of false: ingress source, no act claim ---- + + @Test + public void testIngressSourceWithoutActClaimIsCheckedAgainstConfiguredAudiencesOnly() throws Exception { + assertConfiguredAudiencesChecked(init(ingressRoutingParams()), INGRESS_SPIFFE_ID); + } + + @Test + public void testIngressSourceWithoutActClaimIsUnrestrictedWhenNoAudiencesAreConfigured() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + // A null configured list makes the list check accept any audience, so on this route the + // ingress is only as strict as the deployment's configured audiences... + assertAccepted(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), null)); + // ...whereas any other source with the same token is destination-validated and rejected. + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), + nonDelegationToken(UNLISTED_OTHER_NAMESPACE_AUD), null), DESTINATION_MISMATCH_MESSAGE); + } + + @Test + public void testIngressSourceWithoutActClaimAndNoAudClaimFollowsTheConfiguredListNoneRule() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + final JWT noAudience = nonDelegationToken((String[]) null); + // No NONE in the list: rejected by the list check, which carries no message, so this + // validator's own "token has no aud claim" rejection never ran. + final AudienceValidationResult withoutNone = validator.validate(requestFromSource(INGRESS_SPIFFE_ID), noAudience, + CONFIGURED_AUDIENCES); + assertFalse(withoutNone.isValid()); + assertNull(withoutNone.message()); + // NONE in the list: accepted, which destination validation never does for a token with no aud. + final List listWithNone = Arrays.asList(LISTED_AUD, NONE_ENTRY); + assertAccepted(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), noAudience, listWithNone)); + // Contrast: any other source with the same token is rejected by this validator, with a message. + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), noAudience, listWithNone), + "no aud claim"); + } + + // ---- routing on, skip.validation true: ingress source, no act claim ---- + + @Test + public void testSkipValidationSkipsAudienceValidationForIngressSourceWithoutActClaim() throws Exception { + assertAudienceValidationSkipped(init(ingressRoutingSkipValidationParams()), INGRESS_SPIFFE_ID); + } + + @Test + public void testSkipValidationPrecedesTheRequirementThatTheConfiguredPathHeaderBePresent() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); + final K8sDestinationAudienceValidator validator = init(params); + // The configured path header is missing from the request, yet the skipping route accepts it... + assertAudienceValidationSkipped(validator, INGRESS_SPIFFE_ID); + // ...while the same missing header is a rejection for any other source. + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), nonDelegationToken(MATCHING_AUD), null), + PATH_HEADER); + } + + @Test + public void testSkipValidationHasNoEffectWhenRoutingDisabled() throws Exception { + // The ingress list and skip.validation are set, but routing is off: an ingress source is + // destination-validated like any other. + assertDestinationValidated(init(ingressRoutingDisabledParams()), INGRESS_SPIFFE_ID, NON_DELEGATION); + } + + @Test + public void testSkipValidationStillSkipsWhenValidateWithoutActClaimFalse() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + assertAudienceValidationSkipped(init(params), INGRESS_SPIFFE_ID); + } + + // ---- routing on, source is not an ingress identity ---- + + @Test + public void testNonIngressSourceWithoutActClaimIsDestinationValidated() throws Exception { + assertDestinationValidated(init(ingressRoutingParams()), NON_INGRESS_SPIFFE_ID, NON_DELEGATION); + } + + @Test + public void testNonIngressSourceWithoutActClaimIsCheckedAgainstConfiguredAudiencesWhenValidateWithoutActClaimFalse() + throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + assertConfiguredAudiencesChecked(init(params), NON_INGRESS_SPIFFE_ID); + } + + @Test + public void testMissingSourceHeaderIsNotAnIngressSourceAndIsRejectedAsMissing() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingSkipValidationParams()); + assertRejected(validator.validate(requestFromSource(null), nonDelegationToken(MATCHING_AUD), CONFIGURED_AUDIENCES), + MISSING_SOURCE_MESSAGE); + } + + @Test + public void testUnparseableSourceHeaderIsNotAnIngressSourceAndIsRejectedAsMissing() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingSkipValidationParams()); + assertRejected(validator.validate(requestFromSource("not-a-spiffe-id"), nonDelegationToken(MATCHING_AUD), + CONFIGURED_AUDIENCES), MISSING_SOURCE_MESSAGE); + } + + @Test + public void testMissingSourceHeaderIsCheckedAgainstConfiguredAudiencesWhenValidateWithoutActClaimFalse() + throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + // Not skipped, although skip.validation is true: with no source header there is no ingress. + assertConfiguredAudiencesChecked(init(params), null); + } + + @Test + public void testSourceMatchingNoneOfSeveralConfiguredIngressIdsIsDestinationValidated() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); + assertDestinationValidated(init(params), NON_INGRESS_SPIFFE_ID, NON_DELEGATION); + } + + @Test + public void testSourceMatchingTheSecondConfiguredIngressIdIsAnIngressSource() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + INGRESS_SPIFFE_ID + "," + SECOND_INGRESS_SPIFFE_ID); + assertAudienceValidationSkipped(init(params), SECOND_INGRESS_SPIFFE_ID); + } + + // ---- trust-domain matching ---- + + @Test + public void testDifferentTrustDomainIsNotAnIngressSourceByDefault() throws Exception { + assertDestinationValidated(init(ingressRoutingSkipValidationParams()), + INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); + } + + @Test + public void testDifferentTrustDomainIsAnIngressSourceWhenTrustDomainMatchSkipped() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + assertAudienceValidationSkipped(init(params), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN); + } + + @Test + public void testSkippingTrustDomainMatchStillRequiresNamespaceToMatch() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + assertDestinationValidated(init(params), + "spiffe://other-domain/ns/" + OTHER_NAMESPACE + "/sa/ingressgateway", NON_DELEGATION); + } + + @Test + public void testSkippingTrustDomainMatchStillRequiresServiceAccountToMatch() throws Exception { + final Map params = ingressRoutingSkipValidationParams(); + params.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_ROUTING_SKIP_TRUST_DOMAIN_MATCH_PARAM, + "true"); + assertDestinationValidated(init(params), "spiffe://other-domain/ns/istio-system/sa/not-the-gateway", + NON_DELEGATION); + } + + @Test + public void testIngressSpiffeIdMatchingIsCaseInsensitive() throws Exception { + final Map mixedCaseConfigured = ingressRoutingSkipValidationParams(); + mixedCaseConfigured.put(K8sDestinationAudienceValidator.INGRESS_SOURCE_SPIFFE_IDS_PARAM, + "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); + assertAudienceValidationSkipped(init(mixedCaseConfigured), INGRESS_SPIFFE_ID); + + assertAudienceValidationSkipped(init(ingressRoutingSkipValidationParams()), + "spiffe://Trust-Domain/ns/Istio-System/sa/IngressGateway"); + } + + @Test + public void testSkipTrustDomainMatchHasNoEffectWhenRoutingDisabled() throws Exception { + assertDestinationValidated(init(ingressRoutingDisabledParams()), INGRESS_SPIFFE_OTHER_TRUST_DOMAIN, NON_DELEGATION); + } + + // ---- a token with an act claim from an ingress source ---- + + @Test + public void testDelegationTokenFromIngressSourceIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationToken(MATCHING_AUD), null)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsNotCheckedAgainstConfiguredAudiences() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + // LISTED_AUD is in the configured list, which would accept a token with no act claim. + assertIngressDelegationRejected( + validator.validate(requestFromSource(INGRESS_SPIFFE_ID), delegationToken(LISTED_AUD), CONFIGURED_AUDIENCES)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsRejectedEvenWhenSkipValidationTrue() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingSkipValidationParams()); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationToken(MATCHING_AUD), null)); + } + + @Test + public void testDelegationTokenFromIngressSourceIsRejectedAsIngressWhenItHasNoAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + // The rejection for a missing aud claim would otherwise have been reported first. + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationToken((String[]) null), null)); + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), delegationToken((String[]) null), null), + "Token has an act claim but no aud claim"); + } + + @Test + public void testDelegationTokenFromIngressSourceIsDestinationValidatedWhenRoutingDisabled() throws Exception { + assertDestinationValidated(init(ingressRoutingDisabledParams()), INGRESS_SPIFFE_ID, DELEGATION); + } + + @Test + public void testDelegationTokenFromNonIngressSourceIsDestinationValidated() throws Exception { + assertDestinationValidated(init(ingressRoutingParams()), NON_INGRESS_SPIFFE_ID, DELEGATION); + } + + @Test + public void testMalformedActClaimFromIngressSourceIsRejectedAsIngress() throws Exception { + final K8sDestinationAudienceValidator validator = init(ingressRoutingParams()); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + delegationTokenWithActClaim("not-a-map", MATCHING_AUD), null)); + } + + @Test + public void testDelegationTokenFromIngressSourceRejectedBeforeActSubBindingRuns() throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.ENFORCE_ACT_SUB_MATCHES_SOURCE_SPIFFE_ID_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // The act sub names a different service account than the source, so if the binding check ran + // first the rejection would be about the actor mismatch rather than about the ingress. + final JWT otherActor = delegationTokenWithActClaim( + actorChain("system:serviceaccount:" + OTHER_NAMESPACE + ":other-sa"), MATCHING_AUD); + assertIngressDelegationRejected(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), otherActor, null)); + // Contrast: the same token from a non-ingress source is rejected for the actor mismatch. + assertRejected(validator.validate(requestFromSource(NON_INGRESS_SPIFFE_ID), otherActor, null), + "does not match source SPIFFE id service account"); + } + + // ---- the destination-validation checks that follow the routing ---- + + @Test + public void testRequireAllAudiencesMatchStillAppliesWhenRoutingEnabledButNotIngress() throws Exception { + final Map params = ingressRoutingParams(); + final JWT matchingAndOtherNamespace = delegationToken(MATCHING_AUD, LISTED_AUD); + // Without the flag one matching audience is enough... + assertAccepted(init(params).validate( + requestFromSource(NON_INGRESS_SPIFFE_ID), matchingAndOtherNamespace, null)); + // ...with it, the second audience, which names another namespace, causes a rejection. + params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); + assertRejected(init(params).validate( + requestFromSource(NON_INGRESS_SPIFFE_ID), matchingAndOtherNamespace, null), DESTINATION_MISMATCH_MESSAGE); + } + + @Test + public void testRequireAllAudiencesMatchDoesNotApplyToTheConfiguredAudiencesCheck() throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.REQUIRE_ALL_AUDIENCES_MATCH_PARAM, "true"); + final K8sDestinationAudienceValidator validator = init(params); + // UNLISTED_OTHER_NAMESPACE_AUD is in neither the list nor the destination, so a require-all + // check would reject this token; the list check accepts it because one audience is in the list. + assertAccepted(validator.validate(requestFromSource(INGRESS_SPIFFE_ID), + nonDelegationToken(LISTED_AUD, UNLISTED_OTHER_NAMESPACE_AUD), CONFIGURED_AUDIENCES)); + } + + @Test + public void testBothNamespaceSourcesStillMustAgreeWhenRoutingEnabledButNotIngress() throws Exception { + final Map params = ingressRoutingParams(); + params.put(K8sDestinationAudienceValidator.SERVER_NAME_HEADER_PARAM, SERVER_NAME_HEADER); + final K8sDestinationAudienceValidator validator = init(params); + final Map headers = destinationHeaders(); + headers.put(SOURCE_SPIFFE_HEADER, NON_INGRESS_SPIFFE_ID); + headers.put(SERVER_NAME_HEADER, DEST_SERVICE + "." + OTHER_NAMESPACE + CLUSTER_SUFFIX); + assertRejected(validator.validate(requestWithHeaders(headers), nonDelegationToken(MATCHING_AUD), null), + "disagrees with namespace"); + } +} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java new file mode 100644 index 000000000..996bb0610 --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sNonDelegationTokenRoutingTest.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.knox.gateway.provider.federation.jwt.filter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.junit.Test; + +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.delegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.filterConfig; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.nonDelegationToken; +import static org.apache.knox.gateway.provider.federation.jwt.filter.K8sAudienceTestSupport.requestWithHeaders; + +/** + * Pins how a token is routed by the presence or absence of its {@code act} claim, and pins the + * order in which {@code validate()}'s early rejections fire relative to one another, since several + * of those rejections are distinguishable only by their message. + * + *

Every test configures the source SPIFFE id header, because the order in which that header is + * resolved and rejected relative to the other checks is part of what is pinned here. + */ +public class K8sNonDelegationTokenRoutingTest { + + private static final String SPIFFE_HEADER = "x-dest-spiffe-id"; + private static final String SOURCE_SPIFFE_HEADER = "x-source-spiffe-id"; + private static final String PATH_HEADER = "x-dest-path"; + private static final String CLUSTER_DOMAIN = "cluster.local"; + private static final String DEST_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/dest-sa"; + private static final String SOURCE_SPIFFE_VALUE = "spiffe://trust-domain/ns/ns/sa/caller-sa"; + private static final String DEST_PATH_VALUE = "/some/path"; + private static final String MATCHING_AUD = "https://svc.ns"; + private static final String OTHER_NAMESPACE_AUD = "https://svc.other-ns"; + private static final String CONFIGURED_AUD = "configured-audience"; + private static final String MISSING_SOURCE_MESSAGE = "Missing or unparseable source SPIFFE id in header"; + private static final String MISSING_DESTINATION_MESSAGE = "Missing or unparseable destination SPIFFE id in header"; + + private static K8sDestinationAudienceValidator init(final Map params) throws Exception { + final K8sDestinationAudienceValidator validator = new K8sDestinationAudienceValidator(); + validator.init(filterConfig(params)); + return validator; + } + + private static Map sourceHeaderParams() { + final Map params = new HashMap<>(); + params.put(K8sDestinationAudienceValidator.NAMESPACE_FROM_SPIFFE_ID_HEADER_PARAM, SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.SOURCE_SPIFFE_ID_HEADER_PARAM, SOURCE_SPIFFE_HEADER); + params.put(K8sDestinationAudienceValidator.CLUSTER_DOMAINS_PARAM, CLUSTER_DOMAIN); + return params; + } + + private static Map sourceHeaderAndPathParams() { + final Map params = sourceHeaderParams(); + params.put(K8sDestinationAudienceValidator.PATH_HEADER_PARAM, PATH_HEADER); + return params; + } + + private static Map fixedListFallbackParams() { + final Map params = sourceHeaderParams(); + params.put(K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM, "false"); + return params; + } + + private static Map allHeaders() { + final Map headers = new HashMap<>(); + headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); + headers.put(SOURCE_SPIFFE_HEADER, SOURCE_SPIFFE_VALUE); + headers.put(PATH_HEADER, DEST_PATH_VALUE); + return headers; + } + + private static Map destinationHeaderOnly() { + final Map headers = new HashMap<>(); + headers.put(SPIFFE_HEADER, DEST_SPIFFE_VALUE); + return headers; + } + + // ---- routing by act claim, source header configured ---- + + @Test + public void testNonDelegationTokenWithSourceHeaderConfiguredIsDestinationValidated() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderAndPathParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken(MATCHING_AUD), null).isValid()); + } + + @Test + public void testNonDelegationTokenWithSourceHeaderConfiguredRejectsNonMatchingAud() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderAndPathParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + final AudienceValidationResult result = + validator.validate(request, nonDelegationToken(OTHER_NAMESPACE_AUD), null); + assertFalse(result.isValid()); + assertTrue(result.message().contains("does not match the destination")); + assertTrue(result.message().contains("namespace other-ns does not match destination namespace ns")); + } + + @Test + public void testDelegationTokenWithSourceHeaderConfiguredIsDestinationValidated() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderAndPathParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, delegationToken(MATCHING_AUD), null).isValid()); + assertFalse(validator.validate(request, delegationToken(OTHER_NAMESPACE_AUD), null).isValid()); + } + + // ---- fixed-list fallback, source header configured ---- + + @Test + public void testNonDelegationTokenFallsBackToWhitelistWhenValidateWithoutActClaimFalse() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken(CONFIGURED_AUD), + Collections.singletonList(CONFIGURED_AUD)).isValid()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackRejectsAudienceNotInList() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken("some-other-audience"), + Collections.singletonList(CONFIGURED_AUD)); + assertFalse(result.isValid()); + // Destination validation always explains a rejection; the fixed-list check does not. + assertNull(result.message()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackAcceptsWhenConfiguredAudiencesNull() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken("some-other-audience"), null).isValid()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackRejectsNoneSentinelWithEmptyAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + // An empty aud array is not null, so the fixed-list check iterates zero entries and never + // reaches its NONE branch. + assertFalse(validator.validate(request, nonDelegationToken(), Collections.singletonList("NONE")).isValid()); + } + + @Test + public void testNonDelegationTokenWhitelistFallbackAcceptsNoneSentinelWithNullAudClaim() throws Exception { + final K8sDestinationAudienceValidator validator = init(fixedListFallbackParams()); + final HttpServletRequest request = requestWithHeaders(allHeaders()); + assertTrue(validator.validate(request, nonDelegationToken((String[]) null), + Collections.singletonList("NONE")).isValid()); + } + + // ---- order of early rejections ---- + + @Test + public void testDelegationTokenWithNoAudClaimRejectedBeforeSourceHeaderIsChecked() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(destinationHeaderOnly()); + final AudienceValidationResult result = validator.validate(request, delegationToken((String[]) null), null); + assertFalse(result.isValid()); + assertEquals("Token has an act claim but no aud claim", result.message()); + } + + @Test + public void testNonDelegationTokenWithNoAudClaimRejectedBeforeSourceHeaderIsChecked() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(destinationHeaderOnly()); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken((String[]) null), null); + assertFalse(result.isValid()); + assertEquals("Token has no act claim and no aud claim, and " + + K8sDestinationAudienceValidator.VALIDATE_AUDIENCES_WITHOUT_ACT_CLAIM_PARAM + " is true", + result.message()); + } + + @Test + public void testDestinationSpiffeHeaderRejectionPrecedesSourceSpiffeHeaderRejection() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(new HashMap<>()); + final AudienceValidationResult result = validator.validate(request, delegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertEquals(MISSING_DESTINATION_MESSAGE + " " + SPIFFE_HEADER + ": null", result.message()); + } + + @Test + public void testNonDelegationTokenWithMissingSourceHeaderIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final HttpServletRequest request = requestWithHeaders(destinationHeaderOnly()); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertEquals(MISSING_SOURCE_MESSAGE + " " + SOURCE_SPIFFE_HEADER + ": null", result.message()); + } + + @Test + public void testNonDelegationTokenWithUnparseableSourceHeaderIsRejected() throws Exception { + final K8sDestinationAudienceValidator validator = init(sourceHeaderParams()); + final Map headers = destinationHeaderOnly(); + headers.put(SOURCE_SPIFFE_HEADER, "not-a-spiffe-id"); + final HttpServletRequest request = requestWithHeaders(headers); + final AudienceValidationResult result = validator.validate(request, nonDelegationToken(MATCHING_AUD), null); + assertFalse(result.isValid()); + assertEquals(MISSING_SOURCE_MESSAGE + " " + SOURCE_SPIFFE_HEADER + ": not-a-spiffe-id", result.message()); + } +} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java index 0b5af22bd..6b3373e5a 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/MatchesConfiguredAudiencesTest.java @@ -78,4 +78,31 @@ public void testNoTokenAudienceWithoutNoneConfiguredIsInvalid() { final List configuredAudiences = Collections.singletonList("bar"); assertFalse(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, configuredAudiences)); } + + @Test + public void testNoConfiguredAudiencesIsValidWhenTokenHasNoAudience() { + final JWT jwtToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwtToken.getAudienceClaims()).andReturn(null).anyTimes(); + EasyMock.replay(jwtToken); + + assertTrue(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, null)); + } + + @Test + public void testSingleTokenAudienceMatchingOnlyConfiguredAudienceIsValid() { + final JWT jwtToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwtToken.getAudienceClaims()).andReturn(new String[]{"A"}).anyTimes(); + EasyMock.replay(jwtToken); + + assertTrue(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, Collections.singletonList("A"))); + } + + @Test + public void testNoneConfiguredDoesNotAcceptANonMatchingTokenAudience() { + final JWT jwtToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwtToken.getAudienceClaims()).andReturn(new String[]{"foo"}).anyTimes(); + EasyMock.replay(jwtToken); + + assertFalse(AbstractJWTFilter.matchesConfiguredAudiences(jwtToken, Collections.singletonList("NONE"))); + } } diff --git a/knox-site/docs/config_request_audience_validator.md b/knox-site/docs/config_request_audience_validator.md index 422a8d650..3b5e77e85 100644 --- a/knox-site/docs/config_request_audience_validator.md +++ b/knox-site/docs/config_request_audience_validator.md @@ -32,11 +32,11 @@ Leaving `request.audience.validator` unset keeps the fixed-list behavior describ The Kubernetes Destination Audience Validator (`request.audience.k8s.destination.validation`) checks a token's `aud` claim against the request's actual destination rather than against a fixed list. -It always validates a token carrying a delegation `act` claim, i.e. a token that was produced by [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) token exchange. A token with no `act` claim is validated the same way by default, as described in Non-delegated tokens below; setting `request.audience.k8s.validate.audiences.without.act.claim` to `false` sends such a token to the fixed `knox.token.audiences` check instead. +It validates a token carrying a delegation `act` claim, i.e. a token that was produced by [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) token exchange, in this way regardless of any other setting, except that with ingress source routing enabled such a token is rejected when it arrives from a configured ingress source (see Ingress source routing below). A token with no `act` claim is validated the same way by default, as described in Non-delegated tokens below; setting `request.audience.k8s.validate.audiences.without.act.claim` to `false` sends such a token to the fixed `knox.token.audiences` check instead. Ingress source routing can also send a token with no `act` claim to that check, or skip audience validation for it, when it arrives from a configured ingress source. ##### Audience format ##### -Each entry in the `aud` claim of a token in scope is expected to be a URL in one of two forms. By default, both forms are tried for every entry, in this order: +Each entry in the `aud` claim of a token that reaches destination validation is expected to be a URL in one of two forms. By default, both forms are tried for every entry, in this order: 1. The **DNS form**, the Kubernetes service DNS name a local caller uses to reach the destination: @@ -88,7 +88,7 @@ A DNS-form entry has no leading segments to skip, since its whole path is always ##### How this validator matches a request to an audience entry ##### -Which of the URL's segments are actually compared against the request depends entirely on which of this validator's header parameters are configured. A segment with no configured source is not compared at all (the DNS form's namespace-less host is the one exception, described above), so an `aud` entry that differs from the request only in an unconfigured segment is accepted. At least one of the destination SPIFFE-id header, the server-name header, or the path header must be configured, or the topology fails to start. +Which of the URL's segments are actually compared against the request depends entirely on which of this validator's header parameters are configured. A segment with no configured source is not compared at all (the DNS form's namespace-less host is the one exception, described above), so an `aud` entry that differs from the request only in an unconfigured segment is accepted. At least one of the destination SPIFFE-id header, the server-name header, or the path header must be configured, or the topology fails to start. The comparisons described in this section apply to a token that reaches destination validation; a token with no `act` claim that ingress source routing sends to another check (see Ingress source routing below) does not reach it. Cluster domain is checked differently for each form, since the two forms carry it differently: @@ -122,7 +122,7 @@ request.audience.k8s.server.name.header.name | HTTP header carrying the destinat request.audience.k8s.server.name.cluster-suffix | Suffix that terminates the FQDN read from the header above, e.g. `.svc.cluster.local`. Only read when that header parameter is itself configured. Independent of `request.audience.k8s.dns.format.cluster.domain` below, which plays the same role for a DNS-form `aud` entry rather than for this header. | `.svc.cluster.local` request.audience.k8s.path.header.name | HTTP header carrying the path to match against the `aud` entry's resource-path segment. | n/a (unset; resource path is then not matched) request.audience.k8s.path.header.from.url | Whether the header above carries a full URL whose path component should be extracted, rather than already being the bare path to match. Only read when that header parameter is itself configured. | `false` -request.audience.k8s.source.spiffe-id.header.name | HTTP header carrying the calling (source) workload's SPIFFE id. Supplies the namespace for a namespace-less DNS-form `aud` entry, and is the identity compared against the actor subject when actor-subject binding is enabled. | n/a (unset; a namespace-less DNS-form entry can then never match, and actor-subject binding cannot be enabled) +request.audience.k8s.source.spiffe-id.header.name | HTTP header carrying the calling (source) workload's SPIFFE id. Supplies the namespace for a namespace-less DNS-form `aud` entry, is the identity compared against the actor subject when actor-subject binding is enabled, and is the value ingress source routing compares against the configured ingress source ids when it is enabled. | n/a (unset; a namespace-less DNS-form entry can then never match, and actor-subject binding and ingress source routing cannot be enabled) ###### DNS-form parameters ###### @@ -143,7 +143,7 @@ request.audience.k8s.audience.path.prefix | Optional path prefix searched for in Name | Description | Default ---------|-------------|-------- request.audience.k8s.require-all-audiences-match | Whether at least one `aud` entry matching the destination is sufficient (`false`, the ordinary "am I an intended audience" semantic of RFC 7519 section 4.1.3), or whether the claim must be non-empty and every entry must match (`true`). See the note below on combining this with the minting-side flags. | `false` -request.audience.k8s.validate.audiences.without.act.claim | Whether a token with no `act` claim is validated against the request destination (`true`) or against the fixed `knox.token.audiences` list (`false`). See Non-delegated tokens below. Has no effect on a token that does carry an `act` claim. | `true` +request.audience.k8s.validate.audiences.without.act.claim | Whether a token with no `act` claim is validated against the request destination (`true`) or against the fixed `knox.token.audiences` list (`false`). See Non-delegated tokens below. Has no effect on a token that does carry an `act` claim, nor on a token with no `act` claim that ingress source routing routes (see Ingress source routing below). | `true` request.audience.k8s.enforce.act.sub.service-account.matches.source.spiffeid | Whether the most recent actor in the token's `act` chain must match the source workload's SPIFFE id. See Actor-subject binding below. Requires `source.spiffe-id.header.name` to be configured; setting this `true` without it fails topology startup. | `false` request.audience.k8s.enforce.act.sub.is.service.account | Whether an actor subject that cannot be read as a k8s service-account subject fails validation (`true`) or passes unchecked (`false`). Only consulted when the parameter above is `true`. | `false` @@ -151,13 +151,59 @@ request.audience.k8s.enforce.act.sub.is.service.account | Whether an actor subje Limiting how many audiences a token carries is a minting-side concern. `JWTFederationFilter` exposes `delegation.enforce.requested.audience.required` and `delegation.enforce.requested.audience.max.one`, which constrain what a delegation token may be minted with. A deployment that wants every delegation token to carry exactly one audience must set both of those; `require-all-audiences-match` is not a substitute for them. Setting it as well is still worthwhile as a destination-side backstop: a multi-audience token that is minted anyway, for example by an older or misconfigured issuer, is accepted only where every one of its audiences names that destination. +###### Ingress source routing parameters ###### + +These control ingress source routing, described in Ingress source routing below. The other three are inert unless `request.audience.k8s.ingress.source.spiffe-id.routing.enabled` is `true`, except that every entry of `request.audience.k8s.ingress.source.spiffe-ids` is validated at startup whether or not routing is enabled. + +Name | Description | Default +---------|-------------|-------- +request.audience.k8s.ingress.source.spiffe-id.routing.enabled | Whether a request that arrives from a configured ingress source is routed to a different audience check than validation against the request destination. When `true`, both `request.audience.k8s.source.spiffe-id.header.name` and `request.audience.k8s.ingress.source.spiffe-ids` must be configured, or topology startup fails. | `false` +request.audience.k8s.ingress.source.spiffe-ids | Comma-separated list of full SPIFFE ids that identify a request as arriving from an ingress source, e.g. `spiffe://example.org/ns/ingress/sa/gateway`. A request matches if the SPIFFE id in the source SPIFFE-id header matches any one entry. Each entry must be a full SPIFFE id of the form `spiffe:///ns//sa/`, even when `skip.trust.domain.match` is `true`; an entry that does not parse, including the empty entry left by a stray, leading or trailing comma, fails topology startup whether or not routing is enabled. Surrounding whitespace on an entry is ignored. | n/a (unset; required when routing is enabled) +request.audience.k8s.ingress.source.spiffe-ids.routing.skip.trust.domain.match | Whether the trust domain of a configured ingress source id is left out of the comparison, so that only the namespace and the service account must match. This relaxes security: any trust domain is then accepted for that namespace and service-account pair, so identities in different trust domains that share the pair become indistinguishable to this check. | `false` (all three portions must match) +request.audience.k8s.ingress.source.spiffe-ids.routing.skip.validation | Whether a token with no `act` claim that arrives from a configured ingress source gets no audience validation at all (`true`), or is checked against the fixed `knox.token.audiences` list (`false`). With `false`, the fixed-list check accepts any `aud` if `knox.token.audiences` is not configured. Has no effect on a token with an `act` claim, on a token that does not arrive from a configured ingress source, or when routing is disabled. | `false` + ##### Path matching ##### An `aud` entry with no resource path, or with a resource path of exactly `/`, matches ANY request path, for either form, including when `request.audience.k8s.path.header.name` is configured and the header is present on the request. This is deliberate, not an oversight: an entry with an empty path is a statement about which destination service a token may be presented to, not about which operations on that service it may invoke. An audience meant to be scoped to one endpoint must carry that endpoint's path. ##### Non-delegated tokens ##### -`request.audience.k8s.validate.audiences.without.act.claim` controls how a token with no `act` claim, that is, a token that is not a delegation token, is handled. It defaults to `true`: such a token's `aud` claim is validated against the request's actual destination in exactly the same way as a delegation token's, and the request is rejected if the token carries no `aud` claim, or an `aud` that is not a valid destination for this request. Setting it to `false` sends a non-delegated token's `aud` claim to the fixed `knox.token.audiences` check (`AbstractJWTFilter.matchesConfiguredAudiences`) instead, the same as if this validator were not selected for it. This parameter has no effect on a token that does carry an `act` claim: that token is always destination-validated by this validator. +`request.audience.k8s.validate.audiences.without.act.claim` controls how a token with no `act` claim, that is, a token that is not a delegation token, is handled. It defaults to `true`: such a token's `aud` claim is validated against the request's actual destination in exactly the same way as a delegation token's, and the request is rejected if the token carries no `aud` claim, or an `aud` that is not a valid destination for this request. Setting it to `false` sends a non-delegated token's `aud` claim to the fixed `knox.token.audiences` check (`AbstractJWTFilter.matchesConfiguredAudiences`) instead, the same as if this validator were not selected for it. This parameter has no effect on a token that does carry an `act` claim: that token is destination-validated by this validator, or rejected if ingress source routing applies to it. Nor does it affect a token with no `act` claim that ingress source routing routes. + +##### Ingress source routing ##### + +A request arrives from an *ingress source* when the SPIFFE id in the header named by `request.audience.k8s.source.spiffe-id.header.name` matches an entry of `request.audience.k8s.ingress.source.spiffe-ids`. An entry matches when the namespace and the service account are equal, and the trust domain is equal too unless `request.audience.k8s.ingress.source.spiffe-ids.routing.skip.trust.domain.match` is `true`. Each portion is compared ignoring case. No wildcard or partial form is supported in any portion. A header that is missing, or does not parse as a SPIFFE id, matches no entry, so such a request is not from an ingress source. + +Ingress source routing is off by default (`request.audience.k8s.ingress.source.spiffe-id.routing.enabled` is `false`), and with it off nothing in this section applies: a request from an ingress source is handled like a request from any other source. When it is on, a request from an ingress source is routed by whether its token carries an `act` claim: + +* **A token with an `act` claim is rejected.** A delegation token authorizes one in-cluster workload to call another, and is not expected to arrive from an ingress source. The rejection does not depend on `skip.validation`, and is decided before any other check, so the token's `aud` claim and the other configured headers are not examined. The request gets the bad-request response, and the log message names the source SPIFFE-id header and its value. +* **A token with no `act` claim is not validated against the request destination.** What it is checked against depends on `request.audience.k8s.ingress.source.spiffe-ids.routing.skip.validation`. When it is `false` (the default), the token's `aud` claim is checked against the fixed `knox.token.audiences` list, the same check `validate.audiences.without.act.claim=false` uses. When it is `true`, no audience check of any kind is made, so a token with no `aud` claim is accepted, and so is one whose `aud` names something unrelated to this request. + +Either way a token with no `act` claim from an ingress source does not reach destination validation, so the destination SPIFFE-id, server-name and path headers are not required for it, none of the matching described under How this validator matches applies, and `validate.audiences.without.act.claim` and `require-all-audiences-match` have no effect on it. + +If `knox.token.audiences` is not configured, the fixed-list check accepts every token, including one with no `aud` claim. If it is configured, a token with an `aud` claim must share at least one entry with it, and a token with no `aud` claim is accepted only if the list contains `NONE`. With routing enabled and `skip.validation` left `false`, a token with no `act` claim from an ingress source is therefore checked only against `knox.token.audiences`; configure that list on the provider to restrict it at all. + +A request that is not from an ingress source, including one whose source SPIFFE-id header is missing or unparseable, is handled exactly as if routing were disabled. A token with an `act` claim is destination-validated. A token with no `act` claim is destination-validated when `validate.audiences.without.act.claim` is `true`, which includes rejecting the request when a configured source SPIFFE-id header is missing or unparseable, or is checked against the fixed list when it is `false`. + +The outcome for every combination of settings: + +Ingress source routing | Token | Source SPIFFE id header | `skip.validation` | `validate.audiences.without.act.claim` | Outcome +---------|---------|---------|---------|---------|--------- +disabled | with `act` claim | any | any | not consulted | Destination validation +disabled | no `act` claim | any | any | `true` | Destination validation +disabled | no `act` claim | any | any | `false` | Fixed list only +enabled | with `act` claim | matches an ingress source | any | not consulted | Rejected +enabled | with `act` claim | matches none, or is missing or unparseable | any | not consulted | Destination validation +enabled | no `act` claim | matches an ingress source | `true` | not consulted | Audience check passes, none is made +enabled | no `act` claim | matches an ingress source | `false` | not consulted | Fixed list only +enabled | no `act` claim | matches none, or is missing or unparseable | any | `true` | Destination validation +enabled | no `act` claim | matches none, or is missing or unparseable | any | `false` | Fixed list only + +"Destination validation" is everything described above in this document. "Fixed list only" is the `knox.token.audiences` check and nothing else. "Rejected" and the audience outcomes concern the audience check only; the other token checks (signature, expiry and so on) still apply to every row. + +Starting the topology fails if routing is enabled and either `request.audience.k8s.source.spiffe-id.header.name` or `request.audience.k8s.ingress.source.spiffe-ids` is not configured, or if any entry of `ingress.source.spiffe-ids` is not a full, parseable SPIFFE id. The entry check runs whether or not routing is enabled. `skip.trust.domain.match` and `skip.validation` have no effect while routing is disabled. + +Routing is driven entirely by the value of the source SPIFFE-id header, which is trusted at face value; see Prerequisites below. A caller able to set that header to a configured ingress source id gets its request routed as an ingress request, and with `skip.validation` set to `true` that request gets no audience check at all. `skip.validation` and `skip.trust.domain.match` each relax a check, and should be enabled only when the header is set or overwritten by a trusted component and the deployment requires it. ##### Actor-subject binding ##### @@ -171,15 +217,15 @@ A token whose `act` claim is present but is not a JSON object, so no actor subje ##### Prerequisites ##### -Each of the four header parameters below (destination SPIFFE-id, server-name, path, and source SPIFFE-id) names a header this validator trusts at face value, with no independent verification. Every one of them **must** be populated only by a component upstream of Knox that is trusted to set or overwrite it correctly with a value it independently determined, never a header an untrusted caller could set or influence. If that guarantee does not hold for a configured header, an untrusted caller controls the corresponding side of the comparison, and the destination binding this validator provides is defeated. Establishing that guarantee is the responsibility of the deployment's network/ingress layer, not something this validator can check. +Each of the four header parameters below (destination SPIFFE-id, server-name, path, and source SPIFFE-id) names a header this validator trusts at face value, with no independent verification. Every one of them **must** be populated only by a component upstream of Knox that is trusted to set or overwrite it correctly with a value it independently determined, never a header an untrusted caller could set or influence. If that guarantee does not hold for a configured header, an untrusted caller controls the corresponding side of the comparison, and the destination binding this validator provides is defeated. Establishing that guarantee is the responsibility of the deployment's network/ingress layer, not something this validator can check. This matters most for the source SPIFFE-id header when ingress source routing is enabled, since the value of that header then decides which audience check a request gets, and with `skip.validation` set to `true` whether it gets one. -When a header parameter is configured, that header must be present and parseable in the shape this validator requires for it on every request, or validation fails, whether or not the `aud` entries actually presented on a given request would have needed that value. This holds for all four header parameters. +When a header parameter is configured, that header must be present and parseable in the shape this validator requires for it on every request that reaches destination validation, or validation fails, whether or not the `aud` entries actually presented on a given request would have needed that value. This holds for all four header parameters. A token with no `act` claim that ingress source routing sends to another check does not reach destination validation, so none of these headers is required for it; only the source SPIFFE-id header is read, to decide the routing. If the path this validator checks can be changed by the surrounding infrastructure after this validator runs (for example, a routing rule that rewrites the request path), configure the path header to carry the path as it was *before* any such rewrite: the audience being validated describes the resource the caller requested, not the path an internal rewrite happens to route it to. ##### Example topology ##### -This example is the combination that validates the destination namespace and path for every token (including non-delegated ones), binds a delegation token's actor to the workload presenting it, and deliberately does not validate service-name. It is written for service DNS audiences such as `https://reporting-svc.analytics.svc.cluster.local/v1/reports`. The custom `aud` form is still enabled since it cannot be disabled. `request.audience.k8s.cluster-domains` is left at its default of `service.local`, so a custom-form entry whose host is exactly `service.local` (for example `https://service.local/analytics/reporting-svc/v1/reports`) is still accepted when it passes the namespace and path checks below, while a custom-form entry for any other host is rejected. Every parameter this validator reads is shown, each marked `(default)` or `(non-default)`, so the full configuration surface, and what it does and does not bind, is visible in one place. Header names are examples; the real ones depend on what the service mesh sets. +This example is the combination that validates the destination namespace and path for every token that reaches destination validation (including non-delegated ones), binds a delegation token's actor to the workload presenting it, deliberately does not validate service-name, and routes requests from one ingress source. It is written for service DNS audiences such as `https://reporting-svc.analytics.svc.cluster.local/v1/reports`. The custom `aud` form is still enabled since it cannot be disabled. `request.audience.k8s.cluster-domains` is left at its default of `service.local`, so a custom-form entry whose host is exactly `service.local` (for example `https://service.local/analytics/reporting-svc/v1/reports`) is still accepted when it passes the namespace and path checks below, while a custom-form entry for any other host is rejected. Every parameter this validator reads is shown, each marked `(default)` or `(non-default)`, so the full configuration surface, and what it does and does not bind, is visible in one place. Header names are examples; the real ones depend on what the service mesh sets. federation @@ -247,7 +293,8 @@ This example is the combination that validates the destination namespace and pat + extends destination checking beyond delegated tokens. A token with no act claim + from the ingress source configured below is not validated this way. --> request.audience.k8s.validate.audiences.without.act.claim true @@ -295,6 +342,42 @@ This example is the combination that validates the destination namespace and pat true + + + request.audience.k8s.ingress.source.spiffe-id.routing.enabled + true + + + + + request.audience.k8s.ingress.source.spiffe-ids + spiffe://example.org/ns/ingress/sa/gateway + + + + + request.audience.k8s.ingress.source.spiffe-ids.routing.skip.trust.domain.match + false + + + + + request.audience.k8s.ingress.source.spiffe-ids.routing.skip.validation + false + +