Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix value accumulation for case-sensitive keys in NettyHeadersAdapter #33730

Closed
wants to merge 2 commits into from

Conversation

qnnn
Copy link

@qnnn qnnn commented Oct 17, 2024

The current NettyHeaders supports case-sensitive storage of entry values when using the add method. This causes the NettyHeadersAdapter to accumulate the values for the corresponding key when retrieving headers(see unit test).

Add a unit test testHeadersOutput to verify the final output of the headers.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Oct 17, 2024
@qnnn
Copy link
Author

qnnn commented Oct 17, 2024

addAll has the same issue, and it has also been addressed.

@qnnn qnnn marked this pull request as draft October 17, 2024 12:04
@qnnn qnnn marked this pull request as ready for review October 17, 2024 12:43
@simonbasle simonbasle self-assigned this Oct 17, 2024
@simonbasle
Copy link
Contributor

hey @qnnn thanks for the PR. Can you elaborate on the use case that lead you to come up with this change? I'm guessing this is a matter of enumerating the headers or representing them as a whole?

@simonbasle simonbasle added the status: waiting-for-feedback We need additional information before we can continue label Oct 18, 2024
@qnnn
Copy link
Author

qnnn commented Oct 18, 2024

hey @qnnn thanks for the PR. Can you elaborate on the use case that lead you to come up with this change? I'm guessing this is a matter of enumerating the headers or representing them as a whole?

@simonbasle I discovered this problem while using the AddRequestHeader GatewayFilter in Spring Cloud Gateway. Through network packet capture, it can be seen that the HTTP requests sent by the Gateway aggregate the values of case-sensitive keys in the HTTP headers.
The expected request header should be testheader=first, second, but the actual result is testheader=first, second, first, second (the same as the output in the unit test).

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Oct 18, 2024
@simonbasle
Copy link
Contributor

Thanks for your feedback. We're exploring what we can do to improve enumeration/iteration of headers through the framework's MultiValueMap adapters, which seems to be the root of the issue. Basically, iterating over the entrySet or dumping a string representation in logs is susceptible to display fabricated duplicates for header names that have been added with multiple casing.

While all native implementations of http headers enforce case-insensitive access (as mandated by the spec), some still store headers as name-value arrays / lists where each entry's might have differences in casing.

This leads to apparent inconsistencies for "whole collection" methods when we try to offer a MultiValueMap view over these implementations, as they do not align well with that representation. Most notably:

  • size()
  • entrySet() (set of Entry<String, List<String>>>) which is used by HttpHeaders#formatHeaders
  • keySet()
  • values()

Can you confirm whether or not the duplication of values for a given header name happens on the wire however? (eg. using Wireshark)

@simonbasle simonbasle added status: waiting-for-feedback We need additional information before we can continue and removed status: feedback-provided Feedback has been provided labels Oct 22, 2024
@qnnn
Copy link
Author

qnnn commented Oct 23, 2024

@simonbasle Thanks for your patience in explaining this. Yes, I can confirm that the duplication of values for a given header name happens on the wire (confirmed via Wireshark).

It seems that some Spring Cloud Gateway's header filters are causing this issue when they transform the NettyHeaderAdapter into MultiValueMapAdapter<>(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)) (such as in org.springframework.cloud.gateway.filter.headers.RemoveHopByHopHeadersFilter#filter).
image-20241023100244781

I feel that MultiValueMapAdapter<>(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)) is a standards-compliant header implementation, and when other header adapters are converted to it, they should produce the same result (case-insensitive).

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Oct 23, 2024
@sdeleuze
Copy link
Contributor

@qnnn Is this use case of headers filtering/reconstruction in org.springframework.cloud.gateway.filter.headers.RemoveHopByHopHeadersFilter is the only concrete bug you face or are there other ones (if yes, which ones)?

@sdeleuze sdeleuze added status: waiting-for-feedback We need additional information before we can continue and removed status: feedback-provided Feedback has been provided labels Oct 28, 2024
@qnnn
Copy link
Author

qnnn commented Oct 28, 2024

@sdeleuze Yes, I have verified that there are others as well:

  • org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter
  • org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter

Any implementation that iterates through the Netty Headers Adapter's entrySet and then uses addAll may have this issue. Such as(I haven’t verified this yet; it's just a guess):

  • org.springframework.cloud.gateway.filter.WebsocketRoutingFilter
  • org.springframework.cloud.gateway.filter.headers.TransferEncodingNormalizationHeadersFilter
  • org.springframework.cloud.gateway.filter.factory.cache.CachedResponse$Builder#headers

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Oct 28, 2024
@sdeleuze
Copy link
Contributor

Thanks, that helps. We are discussing the best strategy to fix those bugs while keeping a great performance/efficiency. We will get back to you once we have made a decision.

@sdeleuze sdeleuze added status: waiting-for-internal-feedback An issue that needs input from a member or another Spring Team and removed status: feedback-provided Feedback has been provided labels Oct 28, 2024
@qnnn
Copy link
Author

qnnn commented Oct 28, 2024

Thanks, that helps. We are discussing the best strategy to fix those bugs while keeping a great performance/efficiency. We will get back to you once we have made a decision.

@sdeleuze It's ok, I understand.

Perhaps maintaining a caseInsensitiveKeyMap to store case-insensitive keys might help with performance—(please disregard if it’s not useful.)

When operating on NettyHeaders, the following map could be updated simultaneously:

    Map<String, String> map = new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH);
    String headerKey = map.compute("TestHeader", (caseInsensitiveKey, headerKey) -> {
        if (!StringUtils.hasText(headerKey)){
	    return caseInsensitiveKey;
	}
	return headerKey;
    });

Something similar:
未命名文件

Copy link
Contributor

@simonbasle simonbasle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong, but the headers case variants are populated by Netty itself, i.e. it comes from the wire. In that case you are addressing the wrong issue.

The change and tests that you proposed impact the intermediate Netty4HeadersAdapter (adapting Netty DefaultHttpHeaders to MultiValueMap<String, String>)... but if you add the test's headers to DefaultHttpHeader instance itself, then construct a Netty4HeadersAdapter out of it, tests fail again despite your changes:

	@Test
	void prepopulatedNativeNetty4() {
		DefaultHttpHeaders native = new DefaultHttpHeaders();
		native.add("TestHeader", "first");
		native.add("TestHEADER", "second");
		native.add("SecondHeader", "value");
		native.add("TestHeader", "third");

		MultiValueMap<String, String> adapter = new Netty4HeadersAdapter(native);

		//copying native headers into a new HttpHeaders
		HttpHeaders headers = new HttpHeaders();
		for (Map.Entry<String, List<String>> entry : adapter.entrySet()) {
			headers.addAll(entry.getKey(), entry.getValue());
		}

		assertThat(headers.get("TestHeader")).as("TestHeader")
				.containsExactly("first", "second", "third");
	}

@simonbasle
Copy link
Contributor

@qnnn we continue evaluating the possible fixes and their impact. I will probably close this PR in favor of creating a new issue tracking this, in which case I'll ping you in there.

@qnnn
Copy link
Author

qnnn commented Oct 28, 2024

Correct me if I'm wrong, but the headers case variants are populated by Netty itself, i.e. it comes from the wire. In that case you are addressing the wrong issue.

The change and tests that you proposed impact the intermediate Netty4HeadersAdapter (adapting Netty DefaultHttpHeaders to MultiValueMap<String, String>)... but if you add the test's headers to DefaultHttpHeader instance itself, then construct a Netty4HeadersAdapter out of it, tests fail again despite your changes:

	@Test
	void prepopulatedNativeNetty4() {
		DefaultHttpHeaders native = new DefaultHttpHeaders();
		native.add("TestHeader", "first");
		native.add("TestHEADER", "second");
		native.add("SecondHeader", "value");
		native.add("TestHeader", "third");

		MultiValueMap<String, String> adapter = new Netty4HeadersAdapter(native);

		//copying native headers into a new HttpHeaders
		HttpHeaders headers = new HttpHeaders();
		for (Map.Entry<String, List<String>> entry : adapter.entrySet()) {
			headers.addAll(entry.getKey(), entry.getValue());
		}

		assertThat(headers.get("TestHeader")).as("TestHeader")
				.containsExactly("first", "second", "third");
	}

@simonbasle Indeed, perhaps this can be reset during the Adapter initialization? In my understanding, Netty itself can function normally.

@simonbasle simonbasle added status: superseded An issue that has been superseded by another and removed status: waiting-for-triage An issue we've not yet triaged or decided on status: waiting-for-internal-feedback An issue that needs input from a member or another Spring Team labels Oct 30, 2024
@simonbasle
Copy link
Contributor

Superseded by gh-33823

@simonbasle simonbasle closed this Oct 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: superseded An issue that has been superseded by another
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants