diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java new file mode 100644 index 00000000000..b8d4eb582fb --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import io.grpc.Status; +import io.grpc.StatusException; + +/** + * Exception thrown when a header mutation is disallowed. + */ +public final class HeaderMutationDisallowedException extends StatusException { + + private static final long serialVersionUID = 1L; + + public HeaderMutationDisallowedException(String message) { + super(Status.INTERNAL.withDescription(message)); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java new file mode 100644 index 00000000000..35cab17d928 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java @@ -0,0 +1,114 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import com.google.common.collect.ImmutableList; +import io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils; +import java.util.Collection; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * The HeaderMutationFilter class is responsible for filtering header mutations based on a given set + * of rules. + */ +public class HeaderMutationFilter { + private final Optional mutationRules; + + + + public HeaderMutationFilter(Optional mutationRules) { + this.mutationRules = mutationRules; + } + + /** + * Filters the given header mutations based on the configured rules and returns the allowed + * mutations. + * + * @param mutations The header mutations to filter + * @return The allowed header mutations. + * @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules + * specify that this should be an error. + */ + public HeaderMutations filter(HeaderMutations mutations) + throws HeaderMutationDisallowedException { + ImmutableList allowedHeaders = + filterCollection(mutations.headers(), this::isDisallowed, this::isHeaderMutationAllowed); + ImmutableList allowedHeadersToRemove = + filterCollection(mutations.headersToRemove(), this::isDisallowed, + this::isHeaderMutationAllowed); + return HeaderMutations.create(allowedHeaders, allowedHeadersToRemove); + } + + /** + * A generic helper to filter a collection based on a predicate. + */ + private ImmutableList filterCollection(Collection items, + Predicate isIgnoredPredicate, Predicate isAllowedPredicate) + throws HeaderMutationDisallowedException { + ImmutableList.Builder allowed = ImmutableList.builder(); + for (T item : items) { + boolean isIgnored = isIgnoredPredicate.test(item); + boolean isAllowed = isAllowedPredicate.test(item); + + // TODO(sauravzg): The specification is ambiguous regarding whether system headers + // should be silently ignored or trigger an error when disallowIsError is enabled. + // We default to triggering errors matching Envoy's implementation. + // Ref: https://github.com/grpc/proposal/pull/481#discussion_r3124453674 + if (!isIgnored && isAllowed) { + allowed.add(item); + } else if (disallowIsError()) { + throw new HeaderMutationDisallowedException("Header mutation disallowed"); + } + } + return allowed.build(); + } + + private boolean isDisallowed(String key) { + return HeaderValueValidationUtils.isDisallowed(key); + } + + private boolean isDisallowed(HeaderValueOption option) { + return HeaderValueValidationUtils.isDisallowed(option.header()); + } + + private boolean isHeaderMutationAllowed(HeaderValueOption option) { + return isHeaderMutationAllowed(option.header().key()); + } + + private boolean isHeaderMutationAllowed(String headerName) { + return mutationRules.map(rules -> isHeaderMutationAllowed(headerName, rules)) + .orElse(true); + } + + private boolean isHeaderMutationAllowed(String headerName, + HeaderMutationRulesConfig rules) { + if (rules.disallowExpression().isPresent() + && rules.disallowExpression().get().matcher(headerName).matches()) { + return false; + } + if (rules.allowExpression().isPresent() + && rules.allowExpression().get().matcher(headerName).matches()) { + return true; + } + return !rules.disallowAll(); + } + + private boolean disallowIsError() { + return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java new file mode 100644 index 00000000000..a456413c899 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; + +/** A collection of header mutations. */ +@AutoValue +public abstract class HeaderMutations { + + public static HeaderMutations create(ImmutableList headers, + ImmutableList headersToRemove) { + return new AutoValue_HeaderMutations(headers, headersToRemove); + } + + public abstract ImmutableList headers(); + + public abstract ImmutableList headersToRemove(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java new file mode 100644 index 00000000000..e6cdc126f22 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + + +import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import java.util.logging.Logger; + +/** + * The HeaderMutator provides methods to apply header mutations to a given set of headers based on a + * given set of rules. + */ +public class HeaderMutator { + + private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName()); + + /** + * Creates a new instance of {@code HeaderMutator}. + */ + public static HeaderMutator create() { + return new HeaderMutator(); + } + + HeaderMutator() {} + + /** + * Applies the given header mutations to the provided metadata headers. + * + * @param mutations The header mutations to apply. + * @param headers The metadata headers to which the mutations will be applied. + */ + public void applyMutations(final HeaderMutations mutations, Metadata headers) { + // TODO(sauravzg): The specification is not clear on order of header removals and additions. + // in case of conflicts. Copying the order from Envoy here, which does removals at the end. + applyHeaderUpdates(mutations.headers(), headers); + for (String headerToRemove : mutations.headersToRemove()) { + Metadata.Key key = headerToRemove.endsWith(Metadata.BINARY_HEADER_SUFFIX) + ? Metadata.Key.of(headerToRemove, Metadata.BINARY_BYTE_MARSHALLER) + : Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER); + headers.discardAll(key); + } + } + + private void applyHeaderUpdates(final Iterable headerOptions, + Metadata headers) { + for (HeaderValueOption headerOption : headerOptions) { + updateHeader(headerOption, headers); + } + } + + private void updateHeader(final HeaderValueOption option, Metadata mutableHeaders) { + HeaderValue header = option.header(); + HeaderAppendAction action = option.appendAction(); + boolean keepEmptyValue = option.keepEmptyValue(); + + if (header.key().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + if (header.rawValue().isPresent()) { + byte[] value = header.rawValue().get().toByteArray(); + if (value.length > 0 || keepEmptyValue) { + updateHeader(action, Metadata.Key.of(header.key(), Metadata.BINARY_BYTE_MARSHALLER), + value, mutableHeaders); + } + } else { + logger.fine("Missing binary rawValue for header: " + header.key()); + } + } else { + if (header.value().isPresent()) { + String value = header.value().get(); + if (!value.isEmpty() || keepEmptyValue) { + updateHeader(action, Metadata.Key.of(header.key(), Metadata.ASCII_STRING_MARSHALLER), + value, mutableHeaders); + } + } else { + logger.fine("Missing value for header: " + header.key()); + } + } + } + + private void updateHeader(final HeaderAppendAction action, final Metadata.Key key, + final T value, Metadata mutableHeaders) { + switch (action) { + case APPEND_IF_EXISTS_OR_ADD: + mutableHeaders.put(key, value); + break; + case ADD_IF_ABSENT: + if (!mutableHeaders.containsKey(key)) { + mutableHeaders.put(key, value); + } + break; + case OVERWRITE_IF_EXISTS_OR_ADD: + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + break; + case OVERWRITE_IF_EXISTS: + if (mutableHeaders.containsKey(key)) { + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + } + break; + + default: + // Should be unreachable unless there's a proto schema mismatch. + logger.fine("Unknown HeaderAppendAction: " + action); + } + } +} + diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java new file mode 100644 index 00000000000..6cb96da864d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import com.google.auto.value.AutoValue; +import io.grpc.xds.internal.grpcservice.HeaderValue; + +/** + * Represents a header option to be appended or mutated as part of xDS configuration. + * Avoids direct dependency on Envoy's proto objects. + */ +@AutoValue +public abstract class HeaderValueOption { + + public static HeaderValueOption create( + HeaderValue header, HeaderAppendAction appendAction, boolean keepEmptyValue) { + return new AutoValue_HeaderValueOption(header, appendAction, keepEmptyValue); + } + + public abstract HeaderValue header(); + + public abstract HeaderAppendAction appendAction(); + + public abstract boolean keepEmptyValue(); + + /** + * Defines the action to take when appending headers. + * Mirrors io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction. + */ + public enum HeaderAppendAction { + APPEND_IF_EXISTS_OR_ADD, + ADD_IF_ABSENT, + OVERWRITE_IF_EXISTS_OR_ADD, + OVERWRITE_IF_EXISTS + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java new file mode 100644 index 00000000000..c0598590ebc --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java @@ -0,0 +1,242 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.ByteString; +import com.google.re2j.Pattern; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationFilterTest { + + private static final int MAX_HEADER_LENGTH = 16384; + + private static HeaderValueOption header(String key, ByteString value) { + return HeaderValueOption.create(io.grpc.xds.internal.grpcservice.HeaderValue.create(key, value), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); + } + + private static HeaderValueOption header(String key, String value) { + return HeaderValueOption.create(io.grpc.xds.internal.grpcservice.HeaderValue.create(key, value), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); + } + + @Test + public void filter_validationRules_dropsInvalidHeaders() throws Exception { + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); + String longString = Strings.repeat("a", MAX_HEADER_LENGTH + 1); + ByteString longBytes = ByteString.copyFrom(new byte[MAX_HEADER_LENGTH + 1]); + + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of( + header("add-key", "add-value"), header(":authority", "new-authority"), + header("host", "new-host"), header(":scheme", "https"), header(":method", "PUT"), + header("resp-add-key", "resp-add-value"), header(":scheme", "https"), + header(":path", "/new-path"), header(":grpc-trace-bin", "binary-value"), + header(":alt-svc", "h3=:443"), header("user-agent", "new-agent"), + header("Valid-Key", "value"), header("", "value"), header(longString, "value"), + header("long-value-key", longString), header("long-bin-key-bin", longBytes), + header("grpc-timeout", "10S"), header("valid-key-lower", "value")), + ImmutableList.of("remove-key", "host", ":authority", ":scheme", ":method", ":foo", ":bar", + "Valid-Key", "", longString, "grpc-timeout", "UPPER-REMOVE", "lower-remove")); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.headersToRemove()).containsExactly("remove-key", "lower-remove"); + assertThat(filtered.headers()).containsExactly( + header("add-key", "add-value"), header("resp-add-key", "resp-add-value"), + header("user-agent", "new-agent"), header("valid-key-lower", "value")); + } + + @Test + public void filter_validationRules_throwsOnInvalidHeaders() throws Exception { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowIsError(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + String longString = Strings.repeat("a", MAX_HEADER_LENGTH + 1); + + // Test system headers modification + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header(":path", "/new-path")), ImmutableList.of()))); + + // Test system headers removal + assertThrows(HeaderMutationDisallowedException.class, + () -> filter.filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of(":path")))); + + // Test uppercase header modification + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("Valid-Key", "value")), ImmutableList.of()))); + + // Test uppercase header removal + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of("UPPER-REMOVE")))); + + // Test empty header + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(header("", "value")), ImmutableList.of()))); + + // Test long header key + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of(longString)))); + } + + + @Test + public void filter_mutationRules_disallowAll_dropsAll() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of(header("add-key", "add-value"), header("resp-add-key", "resp-add-value")), + ImmutableList.of("remove-key")); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.headers()).isEmpty(); + assertThat(filtered.headersToRemove()).isEmpty(); + } + + @Test + public void filter_mutationRules_disallowAll_throws() throws Exception { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Test add header + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("add-key", "add-value")), ImmutableList.of()))); + + // Test remove header + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of("remove-key")))); + + // Test response header + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("resp-add-key", "resp-add-value")), ImmutableList.of()))); + } + + + @Test + public void filter_mutationRules_disallowExpression_dropsMatching() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowExpression(Pattern.compile("^x-private-.*")).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of(header("x-public", "value"), header("x-private-key", "value"), + header("x-public-resp", "value"), header("x-private-resp", "value")), + ImmutableList.of("x-public-remove", "x-private-remove")); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.headersToRemove()).containsExactly("x-public-remove"); + assertThat(filtered.headers()).containsExactly(header("x-public", "value"), + header("x-public-resp", "value")); + } + + @Test + public void filter_mutationRules_disallowExpression_throws() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowExpression(Pattern.compile("^x-private-.*")).disallowIsError(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Test disallowed key modification + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("x-private-key", "value")), ImmutableList.of()))); + + // Test disallowed key removal + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of("x-private-remove")))); + } + + + @Test + public void filter_mutationRules_precedence() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .allowExpression(Pattern.compile("^x-allowed-.*")) + .disallowExpression(Pattern.compile("^x-allowed-but-disallowed-.*")) + .build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Case 1: allowExpression overrides disallowAll + HeaderMutations mutations1 = HeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value"), header("not-allowed", "value")), + ImmutableList.of("x-allowed-remove", "not-allowed-remove")); + HeaderMutations filtered1 = filter.filter(mutations1); + assertThat(filtered1.headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered1.headers()).containsExactly(header("x-allowed-key", "value")); + + // Case 2: disallowExpression overrides allowExpression + HeaderMutations mutations2 = HeaderMutations.create( + ImmutableList.of(header("x-allowed-but-disallowed-key", "value")), + ImmutableList.of("x-allowed-but-disallowed-remove")); + HeaderMutations filtered2 = filter.filter(mutations2); + assertThat(filtered2.headers()).isEmpty(); + assertThat(filtered2.headersToRemove()).isEmpty(); + } + + @Test + public void filter_mutationRules_precedence_throws() throws Exception { + // Case 1: allowExpression overrides disallowAll (does not throw) + HeaderMutationRulesConfig rules1 = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .allowExpression(Pattern.compile("^x-allowed-.*")) + .disallowIsError(true) + .build(); + HeaderMutationFilter filter1 = new HeaderMutationFilter(Optional.of(rules1)); + HeaderMutations mutations1 = HeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value")), ImmutableList.of("x-allowed-remove")); + HeaderMutations filtered1 = filter1.filter(mutations1); + assertThat(filtered1.headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered1.headers()).containsExactly(header("x-allowed-key", "value")); + + // Case 2: disallowExpression overrides allowExpression (throws) + HeaderMutationRulesConfig rules2 = HeaderMutationRulesConfig.builder() + .allowExpression(Pattern.compile("^x-allowed-.*")) + .disallowExpression(Pattern.compile("^x-allowed-but-disallowed-.*")) + .disallowIsError(true) + .build(); + HeaderMutationFilter filter2 = new HeaderMutationFilter(Optional.of(rules2)); + assertThrows(HeaderMutationDisallowedException.class, + () -> filter2.filter(HeaderMutations.create( + ImmutableList.of(header("x-allowed-but-disallowed-key", "value")), + ImmutableList.of()))); + + assertThrows(HeaderMutationDisallowedException.class, () -> filter2.filter(HeaderMutations + .create( + ImmutableList.of(), ImmutableList.of("x-allowed-but-disallowed-remove")))); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java new file mode 100644 index 00000000000..5f820b62306 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import org.junit.Test; + +public class HeaderMutationsTest { + @Test + public void testCreate() { + HeaderValueOption header = HeaderValueOption.create( + HeaderValue.create("key", "value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of(header), ImmutableList.of("remove-key")); + assertThat(mutations.headers()).containsExactly(header); + assertThat(mutations.headersToRemove()).containsExactly("remove-key"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java new file mode 100644 index 00000000000..b6806760f9b --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java @@ -0,0 +1,315 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.testing.TestLogHandler; +import com.google.protobuf.ByteString; +import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutatorTest { + + private static final Metadata.Key BINARY_KEY = + Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + private static final Metadata.Key APPEND_KEY = + Metadata.Key.of("append-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key ADD_KEY = + Metadata.Key.of("add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_KEY = + Metadata.Key.of("overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key REMOVE_KEY = + Metadata.Key.of("remove-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_ADD_KEY = + Metadata.Key.of("new-add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_OVERWRITE_KEY = + Metadata.Key.of("new-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_KEY = + Metadata.Key.of("overwrite-if-exists-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_ABSENT_KEY = + Metadata.Key.of("overwrite-if-exists-absent-key", Metadata.ASCII_STRING_MARSHALLER); + + private final HeaderMutator headerMutator = HeaderMutator.create(); + + private static final TestLogHandler logHandler = new TestLogHandler(); + private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName()); + + @Before + public void setUp() { + logHandler.clear(); + logger.addHandler(logHandler); + logger.setLevel(Level.WARNING); + } + + @After + public void tearDown() { + logger.removeHandler(logHandler); + } + + private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { + return HeaderValueOption.create(HeaderValue.create(key, value), action, false); + } + + @Test + public void applyMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + headers.put(REMOVE_KEY, "remove-value-original"); + headers.put(OVERWRITE_IF_EXISTS_KEY, "original-value"); + + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header( + APPEND_KEY.name(), + "append-value-2", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header( + OVERWRITE_KEY.name(), + "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + NEW_OVERWRITE_KEY.name(), + "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + OVERWRITE_IF_EXISTS_KEY.name(), + "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS), + header( + OVERWRITE_IF_EXISTS_ABSENT_KEY.name(), + "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + assertThat(headers.get(OVERWRITE_IF_EXISTS_KEY)).isEqualTo("new-value"); + assertThat(headers.containsKey(OVERWRITE_IF_EXISTS_ABSENT_KEY)).isFalse(); + } + + @Test + public void applyMutations_removalHasPriority() { + Metadata headers = new Metadata(); + headers.put(REMOVE_KEY, "value"); + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header( + REMOVE_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + } + + @Test + public void applyMutations_binary() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = + HeaderValueOption.create( + HeaderValue.create(BINARY_KEY.name(), ByteString.copyFrom(value)), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, + false); + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyResponseMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header( + APPEND_KEY.name(), + "append-value-2", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header( + OVERWRITE_KEY.name(), + "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + NEW_OVERWRITE_KEY.name(), + "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), ImmutableList.of()); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + } + + @Test + public void applyResponseMutations_binary() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = + HeaderValueOption.create( + HeaderValue.create(BINARY_KEY.name(), ByteString.copyFrom(value)), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, + false); + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyMutations_keepEmptyValue() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "existing-value"); + headers.put(OVERWRITE_KEY, "existing-value"); + headers.put(OVERWRITE_IF_EXISTS_KEY, "existing-value"); + + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header(NEW_ADD_KEY.name(), "", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(APPEND_KEY.name(), "", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(OVERWRITE_KEY.name(), "", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "", HeaderAppendAction.ADD_IF_ABSENT), + header(OVERWRITE_IF_EXISTS_KEY.name(), "", HeaderAppendAction.OVERWRITE_IF_EXISTS), + HeaderValueOption.create( + HeaderValue.create("keep-empty-key", ""), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, + true), + HeaderValueOption.create( + HeaderValue.create("keep-empty-overwrite-key", ""), + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD, + true), + HeaderValueOption.create( + HeaderValue.create("keep-empty-bin-key-bin", ByteString.EMPTY), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, true), + HeaderValueOption.create( + HeaderValue.create("ignore-empty-bin-key-bin", ByteString.EMPTY), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false), + HeaderValueOption.create( + HeaderValue.create("overwrite-empty-bin-key-bin", ByteString.EMPTY), + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD, false)), + ImmutableList.of()); + + headers.put( + Metadata.Key.of("keep-empty-overwrite-key", Metadata.ASCII_STRING_MARSHALLER), "old"); + + Metadata.Key overwriteEmptyBinKey = + Metadata.Key.of("overwrite-empty-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + byte[] originalBinValue = new byte[] {1, 2, 3}; + headers.put(overwriteEmptyBinKey, originalBinValue); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.containsKey(NEW_ADD_KEY)).isFalse(); + assertThat(headers.getAll(APPEND_KEY)).containsExactly("existing-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("existing-value"); + assertThat(headers.containsKey(ADD_KEY)).isFalse(); + assertThat(headers.get(OVERWRITE_IF_EXISTS_KEY)).isEqualTo("existing-value"); + + Metadata.Key keepEmptyKey = + Metadata.Key.of("keep-empty-key", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key keepEmptyOverwriteKey = + Metadata.Key.of("keep-empty-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + + assertThat(headers.containsKey(keepEmptyKey)).isTrue(); + assertThat(headers.get(keepEmptyKey)).isEqualTo(""); + assertThat(headers.containsKey(keepEmptyOverwriteKey)).isTrue(); + assertThat(headers.get(keepEmptyOverwriteKey)).isEqualTo(""); + + Metadata.Key keepEmptyBinKey = + Metadata.Key.of("keep-empty-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + Metadata.Key ignoreEmptyBinKey = + Metadata.Key.of("ignore-empty-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + + assertThat(headers.containsKey(keepEmptyBinKey)).isTrue(); + assertThat(headers.get(keepEmptyBinKey)).isEqualTo(new byte[0]); + assertThat(headers.containsKey(ignoreEmptyBinKey)).isFalse(); + assertThat(headers.get(overwriteEmptyBinKey)).isEqualTo(originalBinValue); + } + + @Test + public void applyMutations_binaryRemoval() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + headers.put(BINARY_KEY, value); + HeaderMutations mutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of(BINARY_KEY.name())); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.containsKey(BINARY_KEY)).isFalse(); + } + + @Test + public void applyMutations_stringValueWithBinaryKey_ignored() { + Metadata headers = new Metadata(); + HeaderValueOption option = HeaderValueOption.create(HeaderValue.create("some-key-bin", "value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); + + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + + Metadata.Key key = Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + assertThat(headers.containsKey(key)).isFalse(); + } + + @Test + public void applyMutations_binaryValueWithAsciiKey_ignored() { + Metadata headers = new Metadata(); + HeaderValueOption option = HeaderValueOption.create( + HeaderValue.create("some-key", ByteString.copyFrom(new byte[] {1})), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, false); + + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + + Metadata.Key key = Metadata.Key.of("some-key", Metadata.ASCII_STRING_MARSHALLER); + assertThat(headers.containsKey(key)).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java new file mode 100644 index 00000000000..49c43749135 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderValueOptionTest { + + @Test + public void create_withAllFields_success() { + HeaderValue header = HeaderValue.create("key1", "value1"); + HeaderValueOption option = HeaderValueOption.create( + header, HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD, true); + + assertThat(option.header()).isEqualTo(header); + assertThat(option.appendAction()).isEqualTo(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + assertThat(option.keepEmptyValue()).isTrue(); + } +}