Copilot commented on code in PR #1448:
URL: https://github.com/apache/knox/pull/1448#discussion_r4238398278
##########
gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AudienceResource.java:
##########
@@ -66,20 +77,29 @@ public record AudienceResource(String host, int
effectivePort, String namespace,
static final int DEFAULT_HTTPS_PORT = 443;
/**
- * Parses a single {@code aud} claim entry as a k8s-style destination URL.
Returns
- * {@link Optional#empty()} for any entry that is not a well-formed
+ * Parses a single {@code aud} claim entry in the custom form. Returns
{@link Optional#empty()}
+ * for any entry that is not a well-formed
* {@code
https://host[:port]/[skipped-segments/]namespace/service-name[/resource-path]}
URL --
* including a {@code null} or blank entry, a non-{@code https} scheme, a
missing or
* userinfo-carrying authority, a path that never contains a non-blank
{@code pathPrefix},
* fewer than two path segments once past any such prefix, or a resource
path that is not
- * already in RFC 3986 remove_dot_segments normal form -- rather than
throwing.
+ * already in RFC 3986 remove_dot_segments normal form -- rather than
throwing. The result's
+ * {@code host} and {@code effectivePort} are always set, unlike a {@link
#parseDnsForm(String,
+ * String)} result.
*
* <p>See the class javadoc for exactly how {@code pathPrefix} is searched
for and consumed.
*
* <p>A {@code ?query} or {@code #fragment} on the entry is discarded before
the path is parsed,
* so it plays no part in {@code pathPrefix} matching or in the resulting
resource path.
+ *
+ * @param audEntry the raw {@code aud} claim entry to parse
+ * @param pathPrefix the leading path segment(s) to search for and skip
past, as described
+ * above, or {@code null}/blank to require namespace and service-name to
begin straight
+ * after the authority
+ * @return the parsed result, or {@link Optional#empty()} if {@code
audEntry} is not a
+ * well-formed custom-form URL as described above
*/
- public static Optional<AudienceResource> parse(String audEntry, String
pathPrefix) {
+ public static Optional<AudienceResource> parseCustomForm(String audEntry,
String pathPrefix) {
Review Comment:
Renaming the public `parse` method removes an existing API even though its
custom-form behavior remains available. Preserve a compatibility alias so
downstream code does not have to change immediately.
##########
gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java:
##########
@@ -128,13 +158,16 @@ public class K8sDestinationAudienceValidator implements
RequestAudienceValidator
* since the header is expected to carry the routed request path directly.
*/
public static final String PATH_HEADER_FROM_URL_PARAM = PARAM_PREFIX +
"path.header.from.url";
Review Comment:
Removing this public default constant is a source-compatibility break for
consumers of the validator API. Keep the existing constant (it can be
deprecated separately if needed) because the runtime default remains unchanged.
This issue also appears on line 188 of the same file.
##########
gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AudienceResource.java:
##########
@@ -209,4 +229,128 @@ static int indexOfFirst(String value, int fromIndex, char
a, char b) {
}
return Math.min(idxA, idxB);
}
+
+ /**
+ * Parses a single {@code aud} claim entry as the alternate, DNS-shaped
destination URL whose
+ * host is {@code <service>.<namespace>.svc.<cluster-domain>}, truncated at
a label boundary
+ * from the right -- the same shortening an ordinary DNS resolver search
path allows. Returns
+ * {@link Optional#empty()} for any entry that is not a well-formed URL of
that shape --
+ * including a {@code null} or blank entry, a non-{@code https} scheme, a
missing or
+ * userinfo-carrying authority, a host with a trailing {@code "."}, an empty
host label, a host
+ * of three or more labels whose third label is not literally {@code "svc"},
a host whose labels
+ * past {@code "svc"} are not a label-boundary prefix of {@code
clusterDomain} taken from its
+ * left, or a resource path that is not already in RFC 3986
remove_dot_segments normal form.
+ *
+ * <p>The result's {@code namespace} is {@code null} when the host is a
single label (service
+ * name only); a namespace-less host has no destination-namespace
information of its own, and it
+ * is entirely up to the caller matching this result to decide what, if
anything, it is compared
+ * against. {@code host} and {@code effectivePort} are not meaningful for a
DNS-form result and
+ * are always set to {@code null} and {@code -1} respectively -- the
cluster-domain host and any
+ * port in the entry are consumed only to parse the authority and are not
retained; see the next
+ * paragraph for why the port specifically is discarded.
+ *
+ * <p>The port, when present, is parsed only so that it does not break
parsing of the rest of the
+ * authority. It is then discarded rather than retained in the result: a
mesh or sidecar in front
+ * of the destination may rewrite the port in flight, so pinning it here
would risk rejecting a
+ * correctly-routed request.
+ *
+ * <p>The resource path here is never prefix-searched the way {@link
#parseCustomForm(String,
+ * String)}'s is: a DNS-form entry has nowhere else for namespace and
service-name to live, so
+ * the whole path -- after tidying -- is the resource path.
+ *
+ * <p>A {@code ?query} or {@code #fragment} on the entry is discarded before
the path is parsed,
+ * exactly as in {@link #parseCustomForm(String, String)}.
+ *
+ * @param audEntry the raw {@code aud} claim entry to parse
+ * @param clusterDomain the cluster domain a host of three or more labels
must truncate to a
+ * label-boundary prefix of, past the required {@code "svc"} label, as
described above
+ * @return the parsed result, or {@link Optional#empty()} if {@code
audEntry} is not a
+ * well-formed DNS-form URL as described above
+ */
+ static Optional<AudienceResource> parseDnsForm(String audEntry, String
clusterDomain) {
+ if (audEntry == null) {
+ return Optional.empty();
+ }
+ final String entry = audEntry.trim();
+ if (entry.isEmpty()) {
+ return Optional.empty();
+ }
+
+ final int schemeSep = entry.indexOf("://");
+ if (schemeSep <= 0 || !"https".equalsIgnoreCase(entry.substring(0,
schemeSep))) {
+ return Optional.empty();
+ }
+
+ final int pathStart = entry.indexOf('/', schemeSep + 3);
+ final String authorityPart;
+ String rawPath;
+ if (pathStart < 0) {
+ authorityPart = entry;
+ rawPath = "/";
+ } else {
+ authorityPart = entry.substring(0, pathStart);
+ rawPath = entry.substring(pathStart);
+ }
+ final int queryOrFragment = indexOfFirst(rawPath, 0, '?', '#');
+ if (queryOrFragment >= 0) {
+ rawPath = rawPath.substring(0, queryOrFragment);
+ }
Review Comment:
The path slash is located before the query/fragment delimiter, so
`https://svc.ns?redirect=/orders` is parsed with `/orders` as its resource path
even though that slash is inside the query. Locate the end of the hierarchical
URI portion first so query/fragment text cannot become a path.
##########
gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/K8sDestinationAudienceValidator.java:
##########
@@ -401,10 +768,16 @@ private static String paramOrDefault(final FilterConfig
filterConfig, final Stri
return (value == null || value.isEmpty()) ? defaultValue : value;
}
+ /**
+ * Splits on {@code ,} and trims each entry, but does not drop an entry that
trims to empty --
+ * for example a stray {@code ,,} or a trailing {@code ,} in {@link
#CLUSTER_DOMAINS_PARAM}.
+ * This is operator-supplied configuration, not request data, so it is
validated strictly: an
+ * empty entry is passed through to {@link ClusterDomain#parse(String)},
which rejects it, the
+ * same as any other malformed entry, rather than being silently discarded
here.
+ */
private static List<String> parseCommaSeparated(final String
commaSeparatedList) {
return Arrays.stream(commaSeparatedList.split(","))
Review Comment:
`String.split(",")` discards trailing empty fields, so a value such as
`cluster.local,` still initializes successfully instead of passing an empty
entry to `ClusterDomain.parse` as documented. Preserve trailing fields (and add
a trailing-comma regression case).
##########
knox-site/docs/config_request_audience_validator.md:
##########
@@ -30,116 +30,320 @@ Leaving `request.audience.validator` unset keeps the
fixed-list behavior describ
#### Kubernetes Destination Audience Validator ####
-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
only ever replaces the audience check for a token that carries 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 still goes through the fixed-list check regardless of whether this
validator is selected.
+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.
##### Audience format #####
-For a token in scope, each entry in its `aud` claim is expected to be a URL of
the form:
+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:
- https://cluster-domain[:port]/namespace/service-name[/resource-path]
+1. The **DNS form**, the Kubernetes service DNS name a local caller uses to
reach the destination:
+
+
https://<service>[.<namespace>[.svc[.<cluster-domain>]]][:port][/resource-path]
+
+2. The **custom form**:
+
+
https://cluster-domain[:port]/[skipped-segments/]namespace/service-name[/resource-path]
+
+An entry is accepted as a match candidate if either form accepts it, and is
rejected only if neither does. `request.audience.k8s.dns.format.enabled`
(default `true`) controls whether the DNS form is tried at all; see DNS form
below for why a deployment might turn it off. When it is `false`, every entry
is evaluated only as the custom form, and
`request.audience.k8s.dns.format.cluster.domain` is unused.
+
+Neither form is validated on the token-minting side by Knox itself or by any
RFC; it is a deployment convention that an operator's delegation policies and
the callers requesting a delegated token are responsible for following. An
entry that does not parse as either shape it is tried against fails validation.
+
+###### DNS form ######
+
+The host may be shortened from the right, the same way an ordinary DNS
resolver search path allows. With a service named `reporting-svc`, a namespace
of `analytics`, and `request.audience.k8s.dns.format.cluster.domain` left at
its default `cluster.local`, all of the following hosts parse:
+
+Host | Meaning
+---------|-----------
+`reporting-svc` | Service name only. The entry carries no namespace of its
own; see below.
+`reporting-svc.analytics` | Service and namespace.
+`reporting-svc.analytics.svc` | Service and namespace, with the literal `svc`
label.
+`reporting-svc.analytics.svc.cluster` | As above, plus the first label of the
cluster domain.
+`reporting-svc.analytics.svc.cluster.local` | The full cluster domain.
-This is the simple case, and applies whenever
`request.audience.k8s.audience.path.prefix` (see below) is left unset:
namespace and service-name must begin straight after the authority.
+A host of three or more labels is rejected unless its third label is literally
`svc`, and the labels after `svc`, if any, must be a label-boundary prefix of
`dns.format.cluster.domain`'s own labels, taken from the left. For example,
with `dns.format.cluster.domain` left at `cluster.local`, a host ending in
`.svc.local` alone (skipping `cluster`) does not parse, even though `local` is
itself one of `cluster.local`'s labels. A trailing dot on the host, or an empty
label anywhere in it, is also rejected; a trailing dot is deliberately not
accepted, since no normal client request produces one.
-This format is a deployment convention, not something any RFC prescribes or
anything else in Knox validates on the token-minting side — an operator's
delegation policies and the callers requesting a delegated token are what make
the `aud` values actually take this shape. An entry that does not parse as this
shape (wrong scheme, missing authority, userinfo present, fewer than two path
segments, an unnormalized or empty path segment, and so on) is never treated as
a match candidate, even for a check this validator is not currently configured
to perform.
+A one-label host carries no namespace. For such an entry, the namespace
instead comes from the SOURCE workload's SPIFFE id (see
`request.audience.k8s.source.spiffe-id.header.name` below), and that value must
equal the destination namespace. If no destination namespace is configured at
all, or no source SPIFFE id is available, the entry does not match. This is the
one case in this validator where a segment with no configured source is still
compared against, rather than being skipped.
-###### Optional path prefix ######
+A port in the DNS-form entry is parsed only so it does not break parsing of
the rest of the host, then discarded: it is never compared against anything,
since a mesh or sidecar in front of the destination may rewrite the port in
flight.
-`request.audience.k8s.audience.path.prefix` is an optional parameter for
deployments that need an `aud` entry's path to carry extra leading segments
before namespace and service-name, for example for network routing. Left unset
(the default), an entry must take the simple form shown above. When set, an
entry instead takes the form:
+A deployment that fronts more than one Kubernetes cluster, or more than one
trust domain, behind a single validator instance should set
`request.audience.k8s.dns.format.enabled` to `false`. A DNS-form host carries
no cluster or trust-domain identifier of its own, so there is no way to tell,
from the entry alone, which cluster or trust domain a bare
`<service>.<namespace>` host belongs to. The custom form does not have this
ambiguity, since its cluster-domain segment is checked against an explicit
allow-list.
+
+###### Custom form ######
+
+ https://cluster-domain[:port]/namespace/service-name[/resource-path]
+
+This is the simple case, and applies whenever
`request.audience.k8s.audience.path.prefix` (see below) is left unset:
namespace and service-name must begin straight after the authority. The
cluster-domain segment (host and effective port) is always checked against the
allow-list in `request.audience.k8s.cluster-domains`, with no way to disable
the check. This is a separate check from the DNS form's
`request.audience.k8s.dns.format.cluster.domain` above, not the same check
applied to both forms: the custom form matches its authority segment literally
against this allow-list, while the DNS form instead matches a label-boundary
suffix of the whole host against a single configured domain. See How this
validator matches a request below for both side by side.
+
+###### Optional path prefix (custom form only) ######
+
+`request.audience.k8s.audience.path.prefix` is an optional parameter for
deployments that need a custom-form `aud` entry's path to carry extra leading
segments before namespace and service-name, for example for network routing.
Left unset (the default), a custom-form entry must take the simple form shown
above. When set, a custom-form entry instead takes the form:
https://cluster-domain[:port]/[skipped-segments/]path-prefix/namespace/service-name[/resource-path]
-The path is searched for the first (leftmost) occurrence of `/path-prefix/`;
namespace, service-name, and resource-path are then parsed starting right after
it. Everything before that point — the `skipped-segments`, if any — is skipped
over unparsed, not validated: `audience.path.prefix` only locates where parsing
resumes. It is ordinarily a single path segment, but it may itself contain `/`
to require several contiguous segments to appear together, as one indivisible
token, before parsing resumes. An entry whose path never contains
`/path-prefix/` does not parse, the same as any other ill-shaped entry — this
parameter relocates where namespace and service-name are read from, it does not
make the check more permissive.
+The path is searched for the first (leftmost) occurrence of `/path-prefix/`;
namespace, service-name, and resource-path are then parsed starting right after
it. Everything before that point, the `skipped-segments` if any, is skipped
over unparsed, not validated: `audience.path.prefix` only locates where parsing
resumes. It is ordinarily a single path segment, but it may itself contain `/`
to require several contiguous segments to appear together, as one indivisible
token, before parsing resumes. An entry whose path never contains
`/path-prefix/` does not parse, the same as any other ill-shaped entry: this
parameter relocates where namespace and service-name are read from, it does not
make the check more permissive.
-##### How it works #####
+A DNS-form entry has no leading segments to skip, since its whole path is
always the resource path, so `audience.path.prefix` plays no part in matching a
DNS-form 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, so an `aud` entry
that differs from the request only in an unconfigured segment is accepted:
+##### How this validator matches a request to an audience entry #####
-* **cluster-domain** — always compared, against the allow-list in
`request.audience.k8s.cluster-domains`. This is the only segment with no way to
disable the check.
-* **namespace** — compared when
`request.audience.k8s.namespace.from.destination.spiffe-id.header.name` and/or
`request.audience.k8s.server.name.header.name` is configured. Configuring both
is supported and gives two independent sources for the same value; when both
are configured, both are read and their namespaces must agree, or the request
is rejected.
-* **service-name** — compared only when
`request.audience.k8s.server.name.header.name` is configured.
-* **resource-path** — compared when `request.audience.k8s.path.header.name` is
configured.
+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.
-A configuration that sets a SPIFFE-id header and a path header but no
server-name header — validating cluster-domain, namespace, and path, but not
service-name — is a supported and expected configuration. Under it, a
delegation token minted to authorize calling one service in a namespace at a
given path is also accepted when presented to a *different* service in that
same namespace at that same path. The token is still bound to one cluster, one
namespace, and one path, which is a large reduction from no destination binding
at all, but it is not a binding to one specific service; closing that gap
requires configuring the server-name header as well, which requires a trusted
source for it (see Prerequisites below).
+Cluster domain is checked differently for each form, since the two forms carry
it differently:
-At least one of the three header parameters must be configured, or the
topology fails to start.
+* **Cluster domain, DNS form.** Checked against
`request.audience.k8s.dns.format.cluster.domain`: the labels after the required
`svc` label must be a label-boundary prefix of it, taken from the left. See DNS
form above for the exact rule.
+* **Cluster domain, custom form.** Checked as a literal match of the authority
against the `request.audience.k8s.cluster-domains` allow-list. See Custom form
above.
-Namespace and service-name segments are compared case-insensitively. The
resource path is compared case-sensitively, and never percent-decoded on either
side — a raw and a percent-encoded candidate are both derived from the `aud`
entry's path and either is accepted, but an escaped `%2F` in either value is
never treated as equal to a literal `/`. A path taken from a header is required
to already be free of `.` and `..` segments and of empty segments; such a value
is rejected rather than normalized, since normalizing it here could disagree
with however the component that actually routes the request resolves it.
+Both checks are always enforced, with no way to disable either one. They are
two different mechanisms, not two configurations of a single mechanism, so
configuring one has no effect on the other.
-##### Prerequisites #####
+The remaining segments are compared the same way regardless of which form an
entry parsed as:
+
+* **Namespace.** Compared when
`request.audience.k8s.namespace.from.destination.spiffe-id.header.name` and/or
`request.audience.k8s.server.name.header.name` is configured. Configuring both
is supported and gives two independent sources for the same value; when both
are configured, both are read and their namespaces must agree, or the request
is rejected.
+* **Service name.** Compared only when
`request.audience.k8s.server.name.header.name` is configured.
+* **Resource path.** Compared when `request.audience.k8s.path.header.name` is
configured, and when the `aud` entry itself carries a path other than exactly
`/`. See Path matching below.
-Each of the three header parameters below 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.
+A configuration that sets a SPIFFE-id header and a path header but no
server-name header (validating cluster domain, namespace, and path, but not
service name) is supported. Under it, a delegation token minted to authorize
calling one service in a namespace at a given path is also accepted when
presented to a *different* service in that same namespace at that same path.
The token is still bound to one cluster, one namespace, and one path, which is
a large reduction from no destination binding at all, but it is not a binding
to one specific service; closing that gap requires configuring the server-name
header as well, which requires a trusted source for it (see Prerequisites
below).
-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.
+Namespace and service-name segments are compared case-insensitively. The
resource path is compared case-sensitively, and never percent-decoded on either
side: a raw and a percent-encoded candidate are both derived from the `aud`
entry's path and either is accepted, but an escaped `%2F` in either value is
never treated as equal to a literal `/`. A path taken from a header is required
to already be free of `.` and `..` segments and of empty segments; such a value
is rejected rather than normalized, since normalizing it here could disagree
with however the component that actually routes the request resolves it.
##### Configuration parameters #####
-All parameters are set as `<param>` entries on the `JWTProvider` provider,
alongside `request.audience.validator`.
+All parameters are set as `<param>` entries on the `JWTProvider` provider,
alongside `request.audience.validator`. They are grouped below by what they
apply to.
+
+###### Trusted-header parameters ######
+
+These name the headers this validator reads; see Prerequisites below for why
each one must come from a trusted source. They apply regardless of which `aud`
form an entry takes.
Name | Description | Default
----------|-----------|--------
-request.audience.k8s.namespace.from.destination.spiffe-id.header.name | HTTP
header carrying the destination workload's SPIFFE id, e.g.
`spiffe://trust-domain/ns/namespace/sa/service-account`. When set, the
namespace inside the SPIFFE id is compared against the `aud` entry's namespace
segment. Trusted-header parameter — see Prerequisites. | n/a (unset; namespace
is then not matched from this source)
-request.audience.k8s.server.name.header.name | HTTP header carrying the
destination workload's FQDN, in the form
`server-name.namespace<cluster-suffix>`, optionally followed by `:port`. When
set, both the server-name and namespace segments are compared. Trusted-header
parameter — see Prerequisites. | n/a (unset; this header is then not read at
all)
-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. | `.svc.cluster.local`
-request.audience.k8s.path.header.name | HTTP header carrying the path to match
against the `aud` entry's resource-path segment. Trusted-header parameter — see
Prerequisites. | n/a (unset; resource path is then not matched)
+---------|-------------|--------
+request.audience.k8s.namespace.from.destination.spiffe-id.header.name | HTTP
header carrying the destination workload's SPIFFE id, e.g.
`spiffe://trust-domain/ns/namespace/sa/service-account`. When set, the
namespace inside the SPIFFE id is compared against the `aud` entry's namespace
segment. | n/a (unset; namespace is then not matched from this source)
+request.audience.k8s.server.name.header.name | HTTP header carrying the
destination workload's FQDN, in the form
`service-name.namespace<cluster-suffix>`, optionally followed by `:port`. When
set, both the service-name and namespace segments are compared. | n/a (unset;
this header is then not read at all)
+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.cluster-domains | Comma-separated allow-list of cluster
domains (host, with an optional `:port`, defaulting to `443`) an `aud` entry's
authority may match. Always enforced; there is no way to disable this check. |
`service.local` (a placeholder that fails closed until set to the deployment's
own cluster domain(s))
-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
§4.1.3](https://www.rfc-editor.org/rfc/rfc7519#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.audience.path.prefix | Optional path prefix searched for
in each `aud` entry's path before namespace and service-name are parsed out of
it — see Optional path prefix above. | n/a (unset; an entry's path must then
begin with namespace and service-name straight after the authority)
+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.require-all-audiences-match=true` is independent of, and
does not by itself prevent, a delegation token being minted with more than one
audience in the first place — it only changes how this validator reacts to one
once presented. `JWTFederationFilter` separately 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 should set all three: the two minting-side
flags stop a multi-audience token from being issued, and
`require-all-audiences-match` independently rejects one at the destination even
if it is minted anyway, for example by an older or misconfigured issuer.
+###### DNS-form parameters ######
+
+Name | Description | Default
+---------|-------------|--------
+request.audience.k8s.dns.format.enabled | Whether an `aud` entry written as a
k8s service DNS name is accepted at all. See DNS form above for why a
multi-cluster or multi-trust-domain deployment may want this `false`. When
`false`, `request.audience.k8s.dns.format.cluster.domain` is unused. | `true`
+request.audience.k8s.dns.format.cluster.domain | The cluster domain a DNS-form
`aud` entry's host is checked against, e.g. the `cluster.local` in
`https://reporting-svc.analytics.svc.cluster.local`. See DNS form above for the
exact shortening and matching rule. Only read when DNS-form entries are
enabled. Named apart from `cluster-domains` below specifically so the two are
not mistaken for each other. | `cluster.local`
+
+###### Custom-form parameters ######
+
+Name | Description | Default
+---------|-------------|--------
+request.audience.k8s.cluster-domains | Comma-separated allow-list of cluster
domains (host, with an optional `:port`, defaulting to `443`) a custom-form
`aud` entry's authority may match. Always enforced for a custom-form entry;
there is no way to disable this check. Does not apply to a DNS-form entry; see
`dns.format.cluster.domain` above. | `service.local` (a placeholder that fails
closed until set to the deployment's own cluster domain(s))
+request.audience.k8s.audience.path.prefix | Optional path prefix searched for
in a custom-form `aud` entry's path before namespace and service-name are
parsed out of it; see Optional path prefix above. Never applies to a DNS-form
entry. | n/a (unset; a custom-form entry's path must then begin with namespace
and service-name straight after the authority)
+
+###### Cross-cutting behavior parameters ######
+
+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.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`
+
+`request.audience.k8s.require-all-audiences-match=true` is independent of, and
does not by itself prevent, a delegation token being minted with more than one
audience in the first place; it only changes how this validator reacts to one
once presented. `JWTFederationFilter` separately 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 should set all three: the two minting-side
flags stop a multi-audience token from being issued, and
`require-all-audiences-match` independently rejects one at the destination even
if it is minted anyway, for example by an older or misconfigured issuer.
+
+##### 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.
+
+##### Actor-subject binding #####
+
+Setting
`request.audience.k8s.enforce.act.sub.service-account.matches.source.spiffeid`
(default `false`) to `true` enables validating that the source SPIFFE id's
service-account identity matches the most recent actor's service-account
identity in the token's `act` chain, provided that actor's `sub` claim is of
the form `system:serviceaccount:<namespace>:<service-account-name>`. This
enforces that a delegated token can only be used by the service account that
exchanged for it. Trust domain and issuer are not compared, since the two
subject forms carry no comparable value for either, so this check is meaningful
only within a single trust domain. If the actor's `sub` claim is not of the
expected form, the check is skipped (the request passes), unless
`request.audience.k8s.enforce.act.sub.is.service.account` is also `true`, in
which case it fails. See below for that parameter.
+
+Enabling this parameter requires
`request.audience.k8s.source.spiffe-id.header.name` to also be configured;
leaving it unset while this is `true` fails the topology at startup, rather
than leaving a check the deployment asked for silently inactive.
+
+`request.audience.k8s.enforce.act.sub.is.service.account` (default `false`) is
only consulted when the parameter above is `true`. It controls what happens
when the actor subject cannot be read as a Kubernetes service-account subject
at all: `false` (the default) passes such an actor without comparing it, since
a legitimate actor subject may take another form; `true` fails validation
instead.
+
+A token whose `act` claim is present but is not a JSON object, so no actor
subject can be read from it at all, fails validation when
`enforce.act.sub.service-account.matches.source.spiffeid` is `true`, the same
as any other actor subject this check rejects. A token with no `act` claim
carries no actor at all, so this check does not apply to it regardless of
either parameter's value.
+
+##### 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.
+
+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.
+
+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 or accept the custom `aud` form. 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.
+
<provider>
<role>federation</role>
<name>JWTProvider</name>
<enabled>true</enabled>
+
+ <!-- Selects this validator. No default: without it, no destination
checking happens. -->
<param>
<name>request.audience.validator</name>
<value>request.audience.k8s.destination.validation</value>
</param>
+
+ <!-- (default) The k8s cluster domain, for aud entries written as a
service DNS name.
+ Set explicitly so the cluster domain is visible in the topology,
not implied. -->
+ <param>
+ <name>request.audience.k8s.dns.format.cluster.domain</name>
+ <value>cluster.local</value>
+ </param>
+
+ <!-- (non-default: no default exists) Destination workload SPIFFE id.
+ Must be set by a trusted upstream component; see Prerequisites.
+ This is what makes the namespace check active. -->
<param>
- <!-- Must be set by a trusted upstream component; see
Prerequisites -->
<name>request.audience.k8s.namespace.from.destination.spiffe-id.header.name</name>
<value>x-destination-spiffe-id</value>
</param>
+
+ <!-- DELIBERATELY NOT SET. Setting this would turn on service-name
validation and add a
+ second source for the destination namespace. It is omitted
because the service name
+ is not available in this deployment. Shown here so its absence is
a visible,
+ intentional choice rather than an oversight:
+
+ request.audience.k8s.server.name.header.name
+ -->
+
+ <!-- (default) Suffix terminating the FQDN in the server-name header.
INERT in this
+ configuration, since that header is not set. Listed for
completeness. -->
+ <param>
+ <name>request.audience.k8s.server.name.cluster-suffix</name>
+ <value>.svc.cluster.local</value>
+ </param>
+
+ <!-- (non-default: no default exists) Routed request path.
+ Must be set by a trusted upstream component; see Prerequisites.
+ This is what makes the path check active, for aud entries that
carry a path. -->
<param>
- <!-- Must be set by a trusted upstream component; see
Prerequisites -->
<name>request.audience.k8s.path.header.name</name>
<value>x-destination-path</value>
</param>
+
+ <!-- (default) The path header carries a bare path, not a full URL. -->
<param>
- <name>request.audience.k8s.cluster-domains</name> <!-- default =
service.local -->
- <value>my-cluster.example.org</value>
+ <name>request.audience.k8s.path.header.from.url</name>
+ <value>false</value>
</param>
+
+ <!-- (non-default: no default exists) Source workload SPIFFE id.
+ Must be set by a trusted upstream component; see Prerequisites.
+ Supplies the namespace for an aud entry whose host, in DNS form,
omits one,
+ and is the identity the actor subject is compared against below.
-->
<param>
- <name>request.audience.k8s.require-all-audiences-match</name> <!--
default = false -->
+ <name>request.audience.k8s.source.spiffe-id.header.name</name>
+ <value>x-source-spiffe-id</value>
+ </param>
+
+ <!-- (default) Validate the aud claim of non-delegated tokens, those
with no act claim,
+ against the request destination. Set explicitly because this is
the parameter that
+ extends destination checking beyond delegated tokens. -->
+ <param>
+
<name>request.audience.k8s.validate.audiences.without.act.claim</name>
+ <value>true</value>
+ </param>
+
+ <!-- (non-default: the default is false) Bind the most recent actor in
the act chain to
+ the source workload's service account. Requires the source SPIFFE
id header above;
+ setting this true without it fails startup. -->
+ <param>
+
<name>request.audience.k8s.enforce.act.sub.service-account.matches.source.spiffeid</name>
+ <value>true</value>
+ </param>
+
+ <!-- (default) Do NOT require every actor subject to be a k8s service
account. An actor
+ subject that is not in system:serviceaccount:<namespace>:<name>
form is passed
+ without being compared, rather than failed. Set explicitly
because leaving the
+ default implicit here would hide a real policy choice: only
service-account actors
+ are checked, others are not. -->
+ <param>
+
<name>request.audience.k8s.enforce.act.sub.is.service.account</name>
<value>false</value>
</param>
+
+ <!-- (default) Allow-list of cluster domains for aud entries in the
custom
+ https://cluster-domain/namespace/service-name/path form. Left at
the fail-closed
+ placeholder: this deployment presents service DNS audiences,
which do not consult
+ this list, so any custom-form audience is rejected. That is the
intended outcome
+ here, not an omission. -->
+ <param>
+ <name>request.audience.k8s.cluster-domains</name>
+ <value>service.local</value>
+ </param>
+
+ <!-- (non-default) Every aud claim must match the destination. This is
not the ordinary
+ RFC 7519 semantic where one matching aud entry is sufficient.
Use this to enforce
+ that every token be exchanged for only one use.-->
Review Comment:
`require-all-audiences-match` only requires every presented entry to match;
it neither limits a token to one audience nor makes it single-use. This wording
overstates the security property and conflicts with the explanation above that
minting-side controls are required to enforce one audience.
##########
knox-site/docs/config_request_audience_validator.md:
##########
@@ -30,116 +30,320 @@ Leaving `request.audience.validator` unset keeps the
fixed-list behavior describ
#### Kubernetes Destination Audience Validator ####
-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
only ever replaces the audience check for a token that carries 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 still goes through the fixed-list check regardless of whether this
validator is selected.
+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.
##### Audience format #####
-For a token in scope, each entry in its `aud` claim is expected to be a URL of
the form:
+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:
- https://cluster-domain[:port]/namespace/service-name[/resource-path]
+1. The **DNS form**, the Kubernetes service DNS name a local caller uses to
reach the destination:
+
+
https://<service>[.<namespace>[.svc[.<cluster-domain>]]][:port][/resource-path]
+
+2. The **custom form**:
+
+
https://cluster-domain[:port]/[skipped-segments/]namespace/service-name[/resource-path]
+
+An entry is accepted as a match candidate if either form accepts it, and is
rejected only if neither does. `request.audience.k8s.dns.format.enabled`
(default `true`) controls whether the DNS form is tried at all; see DNS form
below for why a deployment might turn it off. When it is `false`, every entry
is evaluated only as the custom form, and
`request.audience.k8s.dns.format.cluster.domain` is unused.
+
+Neither form is validated on the token-minting side by Knox itself or by any
RFC; it is a deployment convention that an operator's delegation policies and
the callers requesting a delegated token are responsible for following. An
entry that does not parse as either shape it is tried against fails validation.
+
+###### DNS form ######
+
+The host may be shortened from the right, the same way an ordinary DNS
resolver search path allows. With a service named `reporting-svc`, a namespace
of `analytics`, and `request.audience.k8s.dns.format.cluster.domain` left at
its default `cluster.local`, all of the following hosts parse:
+
+Host | Meaning
+---------|-----------
+`reporting-svc` | Service name only. The entry carries no namespace of its
own; see below.
+`reporting-svc.analytics` | Service and namespace.
+`reporting-svc.analytics.svc` | Service and namespace, with the literal `svc`
label.
+`reporting-svc.analytics.svc.cluster` | As above, plus the first label of the
cluster domain.
+`reporting-svc.analytics.svc.cluster.local` | The full cluster domain.
-This is the simple case, and applies whenever
`request.audience.k8s.audience.path.prefix` (see below) is left unset:
namespace and service-name must begin straight after the authority.
+A host of three or more labels is rejected unless its third label is literally
`svc`, and the labels after `svc`, if any, must be a label-boundary prefix of
`dns.format.cluster.domain`'s own labels, taken from the left. For example,
with `dns.format.cluster.domain` left at `cluster.local`, a host ending in
`.svc.local` alone (skipping `cluster`) does not parse, even though `local` is
itself one of `cluster.local`'s labels. A trailing dot on the host, or an empty
label anywhere in it, is also rejected; a trailing dot is deliberately not
accepted, since no normal client request produces one.
-This format is a deployment convention, not something any RFC prescribes or
anything else in Knox validates on the token-minting side — an operator's
delegation policies and the callers requesting a delegated token are what make
the `aud` values actually take this shape. An entry that does not parse as this
shape (wrong scheme, missing authority, userinfo present, fewer than two path
segments, an unnormalized or empty path segment, and so on) is never treated as
a match candidate, even for a check this validator is not currently configured
to perform.
+A one-label host carries no namespace. For such an entry, the namespace
instead comes from the SOURCE workload's SPIFFE id (see
`request.audience.k8s.source.spiffe-id.header.name` below), and that value must
equal the destination namespace. If no destination namespace is configured at
all, or no source SPIFFE id is available, the entry does not match. This is the
one case in this validator where a segment with no configured source is still
compared against, rather than being skipped.
-###### Optional path prefix ######
+A port in the DNS-form entry is parsed only so it does not break parsing of
the rest of the host, then discarded: it is never compared against anything,
since a mesh or sidecar in front of the destination may rewrite the port in
flight.
-`request.audience.k8s.audience.path.prefix` is an optional parameter for
deployments that need an `aud` entry's path to carry extra leading segments
before namespace and service-name, for example for network routing. Left unset
(the default), an entry must take the simple form shown above. When set, an
entry instead takes the form:
+A deployment that fronts more than one Kubernetes cluster, or more than one
trust domain, behind a single validator instance should set
`request.audience.k8s.dns.format.enabled` to `false`. A DNS-form host carries
no cluster or trust-domain identifier of its own, so there is no way to tell,
from the entry alone, which cluster or trust domain a bare
`<service>.<namespace>` host belongs to. The custom form does not have this
ambiguity, since its cluster-domain segment is checked against an explicit
allow-list.
+
+###### Custom form ######
+
+ https://cluster-domain[:port]/namespace/service-name[/resource-path]
+
+This is the simple case, and applies whenever
`request.audience.k8s.audience.path.prefix` (see below) is left unset:
namespace and service-name must begin straight after the authority. The
cluster-domain segment (host and effective port) is always checked against the
allow-list in `request.audience.k8s.cluster-domains`, with no way to disable
the check. This is a separate check from the DNS form's
`request.audience.k8s.dns.format.cluster.domain` above, not the same check
applied to both forms: the custom form matches its authority segment literally
against this allow-list, while the DNS form instead matches a label-boundary
suffix of the whole host against a single configured domain. See How this
validator matches a request below for both side by side.
+
+###### Optional path prefix (custom form only) ######
+
+`request.audience.k8s.audience.path.prefix` is an optional parameter for
deployments that need a custom-form `aud` entry's path to carry extra leading
segments before namespace and service-name, for example for network routing.
Left unset (the default), a custom-form entry must take the simple form shown
above. When set, a custom-form entry instead takes the form:
https://cluster-domain[:port]/[skipped-segments/]path-prefix/namespace/service-name[/resource-path]
-The path is searched for the first (leftmost) occurrence of `/path-prefix/`;
namespace, service-name, and resource-path are then parsed starting right after
it. Everything before that point — the `skipped-segments`, if any — is skipped
over unparsed, not validated: `audience.path.prefix` only locates where parsing
resumes. It is ordinarily a single path segment, but it may itself contain `/`
to require several contiguous segments to appear together, as one indivisible
token, before parsing resumes. An entry whose path never contains
`/path-prefix/` does not parse, the same as any other ill-shaped entry — this
parameter relocates where namespace and service-name are read from, it does not
make the check more permissive.
+The path is searched for the first (leftmost) occurrence of `/path-prefix/`;
namespace, service-name, and resource-path are then parsed starting right after
it. Everything before that point, the `skipped-segments` if any, is skipped
over unparsed, not validated: `audience.path.prefix` only locates where parsing
resumes. It is ordinarily a single path segment, but it may itself contain `/`
to require several contiguous segments to appear together, as one indivisible
token, before parsing resumes. An entry whose path never contains
`/path-prefix/` does not parse, the same as any other ill-shaped entry: this
parameter relocates where namespace and service-name are read from, it does not
make the check more permissive.
-##### How it works #####
+A DNS-form entry has no leading segments to skip, since its whole path is
always the resource path, so `audience.path.prefix` plays no part in matching a
DNS-form 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, so an `aud` entry
that differs from the request only in an unconfigured segment is accepted:
+##### How this validator matches a request to an audience entry #####
-* **cluster-domain** — always compared, against the allow-list in
`request.audience.k8s.cluster-domains`. This is the only segment with no way to
disable the check.
-* **namespace** — compared when
`request.audience.k8s.namespace.from.destination.spiffe-id.header.name` and/or
`request.audience.k8s.server.name.header.name` is configured. Configuring both
is supported and gives two independent sources for the same value; when both
are configured, both are read and their namespaces must agree, or the request
is rejected.
-* **service-name** — compared only when
`request.audience.k8s.server.name.header.name` is configured.
-* **resource-path** — compared when `request.audience.k8s.path.header.name` is
configured.
+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.
-A configuration that sets a SPIFFE-id header and a path header but no
server-name header — validating cluster-domain, namespace, and path, but not
service-name — is a supported and expected configuration. Under it, a
delegation token minted to authorize calling one service in a namespace at a
given path is also accepted when presented to a *different* service in that
same namespace at that same path. The token is still bound to one cluster, one
namespace, and one path, which is a large reduction from no destination binding
at all, but it is not a binding to one specific service; closing that gap
requires configuring the server-name header as well, which requires a trusted
source for it (see Prerequisites below).
+Cluster domain is checked differently for each form, since the two forms carry
it differently:
-At least one of the three header parameters must be configured, or the
topology fails to start.
+* **Cluster domain, DNS form.** Checked against
`request.audience.k8s.dns.format.cluster.domain`: the labels after the required
`svc` label must be a label-boundary prefix of it, taken from the left. See DNS
form above for the exact rule.
+* **Cluster domain, custom form.** Checked as a literal match of the authority
against the `request.audience.k8s.cluster-domains` allow-list. See Custom form
above.
-Namespace and service-name segments are compared case-insensitively. The
resource path is compared case-sensitively, and never percent-decoded on either
side — a raw and a percent-encoded candidate are both derived from the `aud`
entry's path and either is accepted, but an escaped `%2F` in either value is
never treated as equal to a literal `/`. A path taken from a header is required
to already be free of `.` and `..` segments and of empty segments; such a value
is rejected rather than normalized, since normalizing it here could disagree
with however the component that actually routes the request resolves it.
+Both checks are always enforced, with no way to disable either one. They are
two different mechanisms, not two configurations of a single mechanism, so
configuring one has no effect on the other.
-##### Prerequisites #####
+The remaining segments are compared the same way regardless of which form an
entry parsed as:
+
+* **Namespace.** Compared when
`request.audience.k8s.namespace.from.destination.spiffe-id.header.name` and/or
`request.audience.k8s.server.name.header.name` is configured. Configuring both
is supported and gives two independent sources for the same value; when both
are configured, both are read and their namespaces must agree, or the request
is rejected.
+* **Service name.** Compared only when
`request.audience.k8s.server.name.header.name` is configured.
+* **Resource path.** Compared when `request.audience.k8s.path.header.name` is
configured, and when the `aud` entry itself carries a path other than exactly
`/`. See Path matching below.
-Each of the three header parameters below 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.
+A configuration that sets a SPIFFE-id header and a path header but no
server-name header (validating cluster domain, namespace, and path, but not
service name) is supported. Under it, a delegation token minted to authorize
calling one service in a namespace at a given path is also accepted when
presented to a *different* service in that same namespace at that same path.
The token is still bound to one cluster, one namespace, and one path, which is
a large reduction from no destination binding at all, but it is not a binding
to one specific service; closing that gap requires configuring the server-name
header as well, which requires a trusted source for it (see Prerequisites
below).
-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.
+Namespace and service-name segments are compared case-insensitively. The
resource path is compared case-sensitively, and never percent-decoded on either
side: a raw and a percent-encoded candidate are both derived from the `aud`
entry's path and either is accepted, but an escaped `%2F` in either value is
never treated as equal to a literal `/`. A path taken from a header is required
to already be free of `.` and `..` segments and of empty segments; such a value
is rejected rather than normalized, since normalizing it here could disagree
with however the component that actually routes the request resolves it.
##### Configuration parameters #####
-All parameters are set as `<param>` entries on the `JWTProvider` provider,
alongside `request.audience.validator`.
+All parameters are set as `<param>` entries on the `JWTProvider` provider,
alongside `request.audience.validator`. They are grouped below by what they
apply to.
+
+###### Trusted-header parameters ######
+
+These name the headers this validator reads; see Prerequisites below for why
each one must come from a trusted source. They apply regardless of which `aud`
form an entry takes.
Name | Description | Default
----------|-----------|--------
-request.audience.k8s.namespace.from.destination.spiffe-id.header.name | HTTP
header carrying the destination workload's SPIFFE id, e.g.
`spiffe://trust-domain/ns/namespace/sa/service-account`. When set, the
namespace inside the SPIFFE id is compared against the `aud` entry's namespace
segment. Trusted-header parameter — see Prerequisites. | n/a (unset; namespace
is then not matched from this source)
-request.audience.k8s.server.name.header.name | HTTP header carrying the
destination workload's FQDN, in the form
`server-name.namespace<cluster-suffix>`, optionally followed by `:port`. When
set, both the server-name and namespace segments are compared. Trusted-header
parameter — see Prerequisites. | n/a (unset; this header is then not read at
all)
-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. | `.svc.cluster.local`
-request.audience.k8s.path.header.name | HTTP header carrying the path to match
against the `aud` entry's resource-path segment. Trusted-header parameter — see
Prerequisites. | n/a (unset; resource path is then not matched)
+---------|-------------|--------
+request.audience.k8s.namespace.from.destination.spiffe-id.header.name | HTTP
header carrying the destination workload's SPIFFE id, e.g.
`spiffe://trust-domain/ns/namespace/sa/service-account`. When set, the
namespace inside the SPIFFE id is compared against the `aud` entry's namespace
segment. | n/a (unset; namespace is then not matched from this source)
+request.audience.k8s.server.name.header.name | HTTP header carrying the
destination workload's FQDN, in the form
`service-name.namespace<cluster-suffix>`, optionally followed by `:port`. When
set, both the service-name and namespace segments are compared. | n/a (unset;
this header is then not read at all)
+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.cluster-domains | Comma-separated allow-list of cluster
domains (host, with an optional `:port`, defaulting to `443`) an `aud` entry's
authority may match. Always enforced; there is no way to disable this check. |
`service.local` (a placeholder that fails closed until set to the deployment's
own cluster domain(s))
-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
§4.1.3](https://www.rfc-editor.org/rfc/rfc7519#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.audience.path.prefix | Optional path prefix searched for
in each `aud` entry's path before namespace and service-name are parsed out of
it — see Optional path prefix above. | n/a (unset; an entry's path must then
begin with namespace and service-name straight after the authority)
+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.require-all-audiences-match=true` is independent of, and
does not by itself prevent, a delegation token being minted with more than one
audience in the first place — it only changes how this validator reacts to one
once presented. `JWTFederationFilter` separately 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 should set all three: the two minting-side
flags stop a multi-audience token from being issued, and
`require-all-audiences-match` independently rejects one at the destination even
if it is minted anyway, for example by an older or misconfigured issuer.
+###### DNS-form parameters ######
+
+Name | Description | Default
+---------|-------------|--------
+request.audience.k8s.dns.format.enabled | Whether an `aud` entry written as a
k8s service DNS name is accepted at all. See DNS form above for why a
multi-cluster or multi-trust-domain deployment may want this `false`. When
`false`, `request.audience.k8s.dns.format.cluster.domain` is unused. | `true`
+request.audience.k8s.dns.format.cluster.domain | The cluster domain a DNS-form
`aud` entry's host is checked against, e.g. the `cluster.local` in
`https://reporting-svc.analytics.svc.cluster.local`. See DNS form above for the
exact shortening and matching rule. Only read when DNS-form entries are
enabled. Named apart from `cluster-domains` below specifically so the two are
not mistaken for each other. | `cluster.local`
+
+###### Custom-form parameters ######
+
+Name | Description | Default
+---------|-------------|--------
+request.audience.k8s.cluster-domains | Comma-separated allow-list of cluster
domains (host, with an optional `:port`, defaulting to `443`) a custom-form
`aud` entry's authority may match. Always enforced for a custom-form entry;
there is no way to disable this check. Does not apply to a DNS-form entry; see
`dns.format.cluster.domain` above. | `service.local` (a placeholder that fails
closed until set to the deployment's own cluster domain(s))
+request.audience.k8s.audience.path.prefix | Optional path prefix searched for
in a custom-form `aud` entry's path before namespace and service-name are
parsed out of it; see Optional path prefix above. Never applies to a DNS-form
entry. | n/a (unset; a custom-form entry's path must then begin with namespace
and service-name straight after the authority)
+
+###### Cross-cutting behavior parameters ######
+
+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.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`
+
+`request.audience.k8s.require-all-audiences-match=true` is independent of, and
does not by itself prevent, a delegation token being minted with more than one
audience in the first place; it only changes how this validator reacts to one
once presented. `JWTFederationFilter` separately 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 should set all three: the two minting-side
flags stop a multi-audience token from being issued, and
`require-all-audiences-match` independently rejects one at the destination even
if it is minted anyway, for example by an older or misconfigured issuer.
+
+##### 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.
+
+##### Actor-subject binding #####
+
+Setting
`request.audience.k8s.enforce.act.sub.service-account.matches.source.spiffeid`
(default `false`) to `true` enables validating that the source SPIFFE id's
service-account identity matches the most recent actor's service-account
identity in the token's `act` chain, provided that actor's `sub` claim is of
the form `system:serviceaccount:<namespace>:<service-account-name>`. This
enforces that a delegated token can only be used by the service account that
exchanged for it. Trust domain and issuer are not compared, since the two
subject forms carry no comparable value for either, so this check is meaningful
only within a single trust domain. If the actor's `sub` claim is not of the
expected form, the check is skipped (the request passes), unless
`request.audience.k8s.enforce.act.sub.is.service.account` is also `true`, in
which case it fails. See below for that parameter.
+
+Enabling this parameter requires
`request.audience.k8s.source.spiffe-id.header.name` to also be configured;
leaving it unset while this is `true` fails the topology at startup, rather
than leaving a check the deployment asked for silently inactive.
+
+`request.audience.k8s.enforce.act.sub.is.service.account` (default `false`) is
only consulted when the parameter above is `true`. It controls what happens
when the actor subject cannot be read as a Kubernetes service-account subject
at all: `false` (the default) passes such an actor without comparing it, since
a legitimate actor subject may take another form; `true` fails validation
instead.
+
+A token whose `act` claim is present but is not a JSON object, so no actor
subject can be read from it at all, fails validation when
`enforce.act.sub.service-account.matches.source.spiffeid` is `true`, the same
as any other actor subject this check rejects. A token with no `act` claim
carries no actor at all, so this check does not apply to it regardless of
either parameter's value.
+
+##### 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.
+
+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.
+
+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 or accept the custom `aud` form. 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.
Review Comment:
This example does accept the custom form: `cluster-domains=service.local`
allows an entry such as
`https://service.local/analytics/reporting-svc/v1/reports`, and the table at
line 301 correctly says both forms are accepted. Either add/use a custom-form
disable control or revise the example introduction and configuration comment so
operators are not told this setup rejects that form.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]