Skip to content

Commit 3e276b0

Browse files
authored
xds: Add header mutations library (#12494)
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 b7e01a6 commit 3e276b0

9 files changed

Lines changed: 987 additions & 0 deletions

File tree

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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils;
21+
import java.util.Collection;
22+
import java.util.Optional;
23+
import java.util.function.Predicate;
24+
25+
/**
26+
* The HeaderMutationFilter class is responsible for filtering header mutations based on a given set
27+
* of rules.
28+
*/
29+
public class HeaderMutationFilter {
30+
private final Optional<HeaderMutationRulesConfig> mutationRules;
31+
32+
33+
34+
public HeaderMutationFilter(Optional<HeaderMutationRulesConfig> mutationRules) {
35+
this.mutationRules = mutationRules;
36+
}
37+
38+
/**
39+
* Filters the given header mutations based on the configured rules and returns the allowed
40+
* mutations.
41+
*
42+
* @param mutations The header mutations to filter
43+
* @return The allowed header mutations.
44+
* @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules
45+
* specify that this should be an error.
46+
*/
47+
public HeaderMutations filter(HeaderMutations mutations)
48+
throws HeaderMutationDisallowedException {
49+
ImmutableList<HeaderValueOption> allowedHeaders =
50+
filterCollection(mutations.headers(), this::isDisallowed, this::isHeaderMutationAllowed);
51+
ImmutableList<String> allowedHeadersToRemove =
52+
filterCollection(mutations.headersToRemove(), this::isDisallowed,
53+
this::isHeaderMutationAllowed);
54+
return HeaderMutations.create(allowedHeaders, allowedHeadersToRemove);
55+
}
56+
57+
/**
58+
* A generic helper to filter a collection based on a predicate.
59+
*/
60+
private <T> ImmutableList<T> filterCollection(Collection<T> items,
61+
Predicate<T> isIgnoredPredicate, Predicate<T> isAllowedPredicate)
62+
throws HeaderMutationDisallowedException {
63+
ImmutableList.Builder<T> allowed = ImmutableList.builder();
64+
for (T item : items) {
65+
boolean isIgnored = isIgnoredPredicate.test(item);
66+
boolean isAllowed = isAllowedPredicate.test(item);
67+
68+
// TODO(sauravzg): The specification is ambiguous regarding whether system headers
69+
// should be silently ignored or trigger an error when disallowIsError is enabled.
70+
// We default to triggering errors matching Envoy's implementation.
71+
// Ref: https://github.com/grpc/proposal/pull/481#discussion_r3124453674
72+
if (!isIgnored && isAllowed) {
73+
allowed.add(item);
74+
} else if (disallowIsError()) {
75+
throw new HeaderMutationDisallowedException("Header mutation disallowed");
76+
}
77+
}
78+
return allowed.build();
79+
}
80+
81+
private boolean isDisallowed(String key) {
82+
return HeaderValueValidationUtils.isDisallowed(key);
83+
}
84+
85+
private boolean isDisallowed(HeaderValueOption option) {
86+
return HeaderValueValidationUtils.isDisallowed(option.header());
87+
}
88+
89+
private boolean isHeaderMutationAllowed(HeaderValueOption option) {
90+
return isHeaderMutationAllowed(option.header().key());
91+
}
92+
93+
private boolean isHeaderMutationAllowed(String headerName) {
94+
return mutationRules.map(rules -> isHeaderMutationAllowed(headerName, rules))
95+
.orElse(true);
96+
}
97+
98+
private boolean isHeaderMutationAllowed(String headerName,
99+
HeaderMutationRulesConfig rules) {
100+
if (rules.disallowExpression().isPresent()
101+
&& rules.disallowExpression().get().matcher(headerName).matches()) {
102+
return false;
103+
}
104+
if (rules.allowExpression().isPresent()
105+
&& rules.allowExpression().get().matcher(headerName).matches()) {
106+
return true;
107+
}
108+
return !rules.disallowAll();
109+
}
110+
111+
private boolean disallowIsError() {
112+
return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false);
113+
}
114+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
22+
/** A collection of header mutations. */
23+
@AutoValue
24+
public abstract class HeaderMutations {
25+
26+
public static HeaderMutations create(ImmutableList<HeaderValueOption> headers,
27+
ImmutableList<String> headersToRemove) {
28+
return new AutoValue_HeaderMutations(headers, headersToRemove);
29+
}
30+
31+
public abstract ImmutableList<HeaderValueOption> headers();
32+
33+
public abstract ImmutableList<String> headersToRemove();
34+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
20+
import io.grpc.Metadata;
21+
import io.grpc.xds.internal.grpcservice.HeaderValue;
22+
import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction;
23+
import java.util.logging.Logger;
24+
25+
/**
26+
* The HeaderMutator provides methods to apply header mutations to a given set of headers based on a
27+
* given set of rules.
28+
*/
29+
public class HeaderMutator {
30+
31+
private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName());
32+
33+
/**
34+
* Creates a new instance of {@code HeaderMutator}.
35+
*/
36+
public static HeaderMutator create() {
37+
return new HeaderMutator();
38+
}
39+
40+
HeaderMutator() {}
41+
42+
/**
43+
* Applies the given header mutations to the provided metadata headers.
44+
*
45+
* @param mutations The header mutations to apply.
46+
* @param headers The metadata headers to which the mutations will be applied.
47+
*/
48+
public void applyMutations(final HeaderMutations mutations, Metadata headers) {
49+
// TODO(sauravzg): The specification is not clear on order of header removals and additions.
50+
// in case of conflicts. Copying the order from Envoy here, which does removals at the end.
51+
applyHeaderUpdates(mutations.headers(), headers);
52+
for (String headerToRemove : mutations.headersToRemove()) {
53+
Metadata.Key<?> key = headerToRemove.endsWith(Metadata.BINARY_HEADER_SUFFIX)
54+
? Metadata.Key.of(headerToRemove, Metadata.BINARY_BYTE_MARSHALLER)
55+
: Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER);
56+
headers.discardAll(key);
57+
}
58+
}
59+
60+
private void applyHeaderUpdates(final Iterable<HeaderValueOption> headerOptions,
61+
Metadata headers) {
62+
for (HeaderValueOption headerOption : headerOptions) {
63+
updateHeader(headerOption, headers);
64+
}
65+
}
66+
67+
private void updateHeader(final HeaderValueOption option, Metadata mutableHeaders) {
68+
HeaderValue header = option.header();
69+
HeaderAppendAction action = option.appendAction();
70+
boolean keepEmptyValue = option.keepEmptyValue();
71+
72+
if (header.key().endsWith(Metadata.BINARY_HEADER_SUFFIX)) {
73+
if (header.rawValue().isPresent()) {
74+
byte[] value = header.rawValue().get().toByteArray();
75+
if (value.length > 0 || keepEmptyValue) {
76+
updateHeader(action, Metadata.Key.of(header.key(), Metadata.BINARY_BYTE_MARSHALLER),
77+
value, mutableHeaders);
78+
}
79+
} else {
80+
logger.fine("Missing binary rawValue for header: " + header.key());
81+
}
82+
} else {
83+
if (header.value().isPresent()) {
84+
String value = header.value().get();
85+
if (!value.isEmpty() || keepEmptyValue) {
86+
updateHeader(action, Metadata.Key.of(header.key(), Metadata.ASCII_STRING_MARSHALLER),
87+
value, mutableHeaders);
88+
}
89+
} else {
90+
logger.fine("Missing value for header: " + header.key());
91+
}
92+
}
93+
}
94+
95+
private <T> void updateHeader(final HeaderAppendAction action, final Metadata.Key<T> key,
96+
final T value, Metadata mutableHeaders) {
97+
switch (action) {
98+
case APPEND_IF_EXISTS_OR_ADD:
99+
mutableHeaders.put(key, value);
100+
break;
101+
case ADD_IF_ABSENT:
102+
if (!mutableHeaders.containsKey(key)) {
103+
mutableHeaders.put(key, value);
104+
}
105+
break;
106+
case OVERWRITE_IF_EXISTS_OR_ADD:
107+
mutableHeaders.discardAll(key);
108+
mutableHeaders.put(key, value);
109+
break;
110+
case OVERWRITE_IF_EXISTS:
111+
if (mutableHeaders.containsKey(key)) {
112+
mutableHeaders.discardAll(key);
113+
mutableHeaders.put(key, value);
114+
}
115+
break;
116+
117+
default:
118+
// Should be unreachable unless there's a proto schema mismatch.
119+
logger.fine("Unknown HeaderAppendAction: " + action);
120+
}
121+
}
122+
}
123+
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2026 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 io.grpc.xds.internal.grpcservice.HeaderValue;
21+
22+
/**
23+
* Represents a header option to be appended or mutated as part of xDS configuration.
24+
* Avoids direct dependency on Envoy's proto objects.
25+
*/
26+
@AutoValue
27+
public abstract class HeaderValueOption {
28+
29+
public static HeaderValueOption create(
30+
HeaderValue header, HeaderAppendAction appendAction, boolean keepEmptyValue) {
31+
return new AutoValue_HeaderValueOption(header, appendAction, keepEmptyValue);
32+
}
33+
34+
public abstract HeaderValue header();
35+
36+
public abstract HeaderAppendAction appendAction();
37+
38+
public abstract boolean keepEmptyValue();
39+
40+
/**
41+
* Defines the action to take when appending headers.
42+
* Mirrors io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction.
43+
*/
44+
public enum HeaderAppendAction {
45+
APPEND_IF_EXISTS_OR_ADD,
46+
ADD_IF_ABSENT,
47+
OVERWRITE_IF_EXISTS_OR_ADD,
48+
OVERWRITE_IF_EXISTS
49+
}
50+
}

0 commit comments

Comments
 (0)