Skip to content

Commit 0ce7b79

Browse files
committed
feat(xds): Add header mutations library
This commit introduces a library for handling header mutations as specified by the xDS protocol. This library provides the core functionality for modifying request and response headers based on a set of rules. The main components of this library are: - `HeaderMutator`: Applies header mutations to `Metadata` objects. - `HeaderMutationFilter`: Filters header mutations based on a set of configurable rules, such as disallowing mutations of system headers. - `HeaderMutations`: A value class that represents the set of mutations to be applied to request and response headers. - `HeaderMutationDisallowedException`: An exception that is thrown when a disallowed header mutation is attempted. This commit also includes comprehensive unit tests for the new library.
1 parent 99f9b3a commit 0ce7b79

File tree

7 files changed

+1011
-0
lines changed

7 files changed

+1011
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2024 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.headermutations;
18+
19+
import io.grpc.Status;
20+
import io.grpc.StatusException;
21+
22+
/**
23+
* Exception thrown when a header mutation is disallowed.
24+
*/
25+
public final class HeaderMutationDisallowedException extends StatusException {
26+
27+
private static final long serialVersionUID = 1L;
28+
29+
public HeaderMutationDisallowedException(String message) {
30+
super(Status.INTERNAL.withDescription(message));
31+
}
32+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.headermutations;
18+
19+
import com.google.common.collect.ImmutableList;
20+
import com.google.common.collect.ImmutableSet;
21+
import io.envoyproxy.envoy.config.core.v3.HeaderValueOption;
22+
import io.grpc.xds.internal.headermutations.HeaderMutations.RequestHeaderMutations;
23+
import io.grpc.xds.internal.headermutations.HeaderMutations.ResponseHeaderMutations;
24+
import java.util.Collection;
25+
import java.util.Locale;
26+
import java.util.Optional;
27+
import java.util.function.Predicate;
28+
29+
/**
30+
* The HeaderMutationFilter class is responsible for filtering header mutations based on a given set
31+
* of rules.
32+
*/
33+
public interface HeaderMutationFilter {
34+
35+
/**
36+
* A factory for creating {@link HeaderMutationFilter} instances.
37+
*/
38+
@FunctionalInterface
39+
interface Factory {
40+
/**
41+
* Creates a new instance of {@code HeaderMutationFilter}.
42+
*
43+
* @param mutationRules The rules for header mutations. If an empty {@code Optional} is
44+
* provided, all header mutations are allowed by default, except for certain system
45+
* headers. If a {@link HeaderMutationRulesConfig} is provided, mutations will be
46+
* filtered based on the specified rules.
47+
*/
48+
HeaderMutationFilter create(Optional<HeaderMutationRulesConfig> mutationRules);
49+
}
50+
51+
/**
52+
* The default factory for creating {@link HeaderMutationFilter} instances.
53+
*/
54+
Factory INSTANCE = HeaderMutationFilterImpl::new;
55+
56+
/**
57+
* Filters the given header mutations based on the configured rules and returns the allowed
58+
* mutations.
59+
*
60+
* @param mutations The header mutations to filter
61+
* @return The allowed header mutations.
62+
* @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules
63+
* specify that this should be an error.
64+
*/
65+
HeaderMutations filter(HeaderMutations mutations) throws HeaderMutationDisallowedException;
66+
67+
/** Default implementation of {@link HeaderMutationFilter}. */
68+
final class HeaderMutationFilterImpl implements HeaderMutationFilter {
69+
private final Optional<HeaderMutationRulesConfig> mutationRules;
70+
71+
/**
72+
* Set of HTTP/2 pseudo-headers and the host header that are critical for routing and protocol
73+
* correctness. These headers cannot be mutated by user configuration.
74+
*/
75+
private static final ImmutableSet<String> IMMUTABLE_HEADERS =
76+
ImmutableSet.of("host", ":authority", ":scheme", ":method");
77+
78+
private HeaderMutationFilterImpl(Optional<HeaderMutationRulesConfig> mutationRules) { // NOPMD
79+
this.mutationRules = mutationRules;
80+
}
81+
82+
@Override
83+
public HeaderMutations filter(HeaderMutations mutations)
84+
throws HeaderMutationDisallowedException {
85+
ImmutableList<HeaderValueOption> allowedRequestHeaders =
86+
filterCollection(mutations.requestMutations().headers(),
87+
header -> isHeaderMutationAllowed(header.getHeader().getKey())
88+
&& !appendsSystemHeader(header));
89+
ImmutableList<String> allowedRequestHeadersToRemove =
90+
filterCollection(mutations.requestMutations().headersToRemove(),
91+
header -> isHeaderMutationAllowed(header) && isHeaderRemovalAllowed(header));
92+
ImmutableList<HeaderValueOption> allowedResponseHeaders =
93+
filterCollection(mutations.responseMutations().headers(),
94+
header -> isHeaderMutationAllowed(header.getHeader().getKey())
95+
&& !appendsSystemHeader(header));
96+
return HeaderMutations.create(
97+
RequestHeaderMutations.create(allowedRequestHeaders, allowedRequestHeadersToRemove),
98+
ResponseHeaderMutations.create(allowedResponseHeaders));
99+
}
100+
101+
/**
102+
* A generic helper to filter a collection based on a predicate.
103+
*
104+
* @param items The collection of items to filter.
105+
* @param isAllowedPredicate The predicate to apply to each item.
106+
* @param <T> The type of items in the collection.
107+
* @return An immutable list of allowed items.
108+
* @throws HeaderMutationDisallowedException if an item is disallowed and disallowIsError is
109+
* true.
110+
*/
111+
private <T> ImmutableList<T> filterCollection(Collection<T> items,
112+
Predicate<T> isAllowedPredicate) throws HeaderMutationDisallowedException {
113+
ImmutableList.Builder<T> allowed = ImmutableList.builder();
114+
for (T item : items) {
115+
if (isAllowedPredicate.test(item)) {
116+
allowed.add(item);
117+
} else if (disallowIsError()) {
118+
throw new HeaderMutationDisallowedException(
119+
"Header mutation disallowed for header: " + item);
120+
}
121+
}
122+
return allowed.build();
123+
}
124+
125+
private boolean isHeaderRemovalAllowed(String headerKey) {
126+
return !isSystemHeaderKey(headerKey);
127+
}
128+
129+
private boolean appendsSystemHeader(HeaderValueOption headerValueOption) {
130+
String key = headerValueOption.getHeader().getKey();
131+
boolean isAppend = headerValueOption
132+
.getAppendAction() == HeaderValueOption.HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD;
133+
return isAppend && isSystemHeaderKey(key);
134+
}
135+
136+
private boolean isSystemHeaderKey(String key) {
137+
return key.startsWith(":") || key.toLowerCase(Locale.ROOT).equals("host");
138+
}
139+
140+
private boolean isHeaderMutationAllowed(String headerName) {
141+
String lowerCaseHeaderName = headerName.toLowerCase(Locale.ROOT);
142+
if (IMMUTABLE_HEADERS.contains(lowerCaseHeaderName)) {
143+
return false;
144+
}
145+
return mutationRules.map(rules -> isHeaderMutationAllowed(lowerCaseHeaderName, rules))
146+
.orElse(true);
147+
}
148+
149+
private boolean isHeaderMutationAllowed(String lowerCaseHeaderName,
150+
HeaderMutationRulesConfig rules) {
151+
// TODO(sauravzg): The priority is slightly unclear in the spec.
152+
// Both `disallowAll` and `disallow_expression` take precedence over `all other
153+
// settings`.
154+
// `allow_expression` takes precedence over everything except `disallow_expression`.
155+
// This is a conflict between ordering for `allow_expression` and `disallowAll`.
156+
// Choosing to proceed with current envoy implementation which favors `allow_expression` over
157+
// `disallowAll`.
158+
if (rules.disallowExpression().isPresent()
159+
&& rules.disallowExpression().get().matcher(lowerCaseHeaderName).matches()) {
160+
return false;
161+
}
162+
if (rules.allowExpression().isPresent()) {
163+
return rules.allowExpression().get().matcher(lowerCaseHeaderName).matches();
164+
}
165+
return !rules.disallowAll();
166+
}
167+
168+
private boolean disallowIsError() {
169+
return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false);
170+
}
171+
}
172+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.headermutations;
18+
19+
import com.google.auto.value.AutoValue;
20+
import com.google.common.collect.ImmutableList;
21+
import io.envoyproxy.envoy.config.core.v3.HeaderValueOption;
22+
23+
/** A collection of header mutations for both request and response headers. */
24+
@AutoValue
25+
public abstract class HeaderMutations {
26+
27+
public static HeaderMutations create(RequestHeaderMutations requestMutations,
28+
ResponseHeaderMutations responseMutations) {
29+
return new AutoValue_HeaderMutations(requestMutations, responseMutations);
30+
}
31+
32+
public abstract RequestHeaderMutations requestMutations();
33+
34+
public abstract ResponseHeaderMutations responseMutations();
35+
36+
/** Represents mutations for request headers. */
37+
@AutoValue
38+
public abstract static class RequestHeaderMutations {
39+
public static RequestHeaderMutations create(ImmutableList<HeaderValueOption> headers,
40+
ImmutableList<String> headersToRemove) {
41+
return new AutoValue_HeaderMutations_RequestHeaderMutations(headers, headersToRemove);
42+
}
43+
44+
public abstract ImmutableList<HeaderValueOption> headers();
45+
46+
public abstract ImmutableList<String> headersToRemove();
47+
}
48+
49+
/** Represents mutations for response headers. */
50+
@AutoValue
51+
public abstract static class ResponseHeaderMutations {
52+
public static ResponseHeaderMutations create(ImmutableList<HeaderValueOption> headers) {
53+
return new AutoValue_HeaderMutations_ResponseHeaderMutations(headers);
54+
}
55+
56+
public abstract ImmutableList<HeaderValueOption> headers();
57+
}
58+
}

0 commit comments

Comments
 (0)