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

ServerHttpRequest content-type cannot be mutated #26615

Closed
Thorn1089 opened this issue Feb 26, 2021 · 9 comments
Closed

ServerHttpRequest content-type cannot be mutated #26615

Thorn1089 opened this issue Feb 26, 2021 · 9 comments
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) status: backported An issue that has been backported to maintenance branches type: bug A general bug
Milestone

Comments

@Thorn1089
Copy link

Affects: 5.3.3
Library: spring-web

The ReadOnlyHttpHeaders type wraps an existing (mutable) MultiValueMap. That map can be updated independently without going through the ReadOnlyHttpHeaders interface. This makes it inappropriate to cache the Accept and Content-Type headers.

For example: I am working on a Spring Cloud Gateway project. Because the multipart/form-data parser does not correctly handle quoted boundary values (a separate issue) I tried to write both a GlobalFilter and a WebFilter that would unwrap the boundary value before I attempt to use the built-in decoders for form data. This doesn't work, though, because the original quoted value is cached in a ReadOnlyHttpHeaders instance, even though its backing MultiValueMap was updated by my filter.

See an example snippet below:

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        var headers = exchange.getRequest().getHeaders();
        if(headers.getContentType().isCompatibleWith(MediaType.MULTIPART_FORM_DATA)) {
            LOG.trace("Examining multipart/form-data request");
            // Check the boundary value and rewrite it if necessary
            return chain.filter(exchange.mutate().request(req -> req.headers(original -> {
                var contentTypeValue = original.getFirst("Content-Type");
                LOG.trace("Original Content-Type header:" + contentTypeValue);
                var matcher = BOUNDARY.matcher(contentTypeValue);
                var found = matcher.find();
                if(!found) {
                    throw new IllegalStateException("A Content-Type header must specify a boundary for a multipart/form-data request (" + contentTypeValue + ")");
                }
                var boundary = matcher.group("boundary");
                if(boundary.startsWith("\"") && boundary.endsWith("\"")) {
                    //original.setContentType(new MediaType("multipart/form-data; boundary=" + boundary.substring(1, boundary.length() - 1)));
                    original.set("Content-Type", "multipart/form-data; boundary=" + boundary.substring(1, boundary.length() - 1));
                }
                LOG.trace("Modified Content-Type header:" + original.getContentType());
            })).build());
        }
        
        return chain.filter(exchange);
    }

The headers passed to the builder method are mutable and are intended to allow for this use case -- great! But then when I get to the actual boundary detection code in the multipart codec shipped with Spring Cloud Gateway, it does the following:

	@Nullable
	private static byte[] boundary(HttpMessage message) {
		MediaType contentType = message.getHeaders().getContentType();
		if (contentType != null) {
			String boundary = contentType.getParameter("boundary");
			if (boundary != null) {
				return boundary.getBytes(StandardCharsets.ISO_8859_1);
			}
		}
		return null;
	}

That HttpMessage#getHeaders() returns a ReadOnlyHttpHeaders instance, which someone else has already called getContentType() on before my filter ran. So I'm stuck with the 'broken' value of the boundary and my code dies. I have no other way to rewrite that request short of running a whole separate gateway instance in front of my actual gateway to rewrite that header and forward it.

It's not obvious why those two headers require caching. There are no comments describing reasons why they are particularly expensive to compute, for example.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Feb 26, 2021
@bclozel
Copy link
Member

bclozel commented Feb 26, 2021

See #21783 for background.

@Thorn1089
Copy link
Author

@bclozel thanks, that's useful background. Might be good to put a reference in the code to that discussion. :)

So is there a way to have our cake and eat it too? Or should it be a documentation fix that one should never attempt to change the Content-Type/Accepts headers with a WebFilter or GlobalFilter? (Actually, it's not even that straightforward; if I changed the header but then just forwarded the request through Cloud Gateway I imagine my modified value would be propagated -- the issue is when I first try to inspect the multipart content before forwarding...) This took a considerable amount of time to track down since the stack trace does not point to where the header is inspected initially at all.

@poutsma
Copy link
Contributor

poutsma commented Mar 1, 2021

Besides the specific reasons mentioned in #21783, we favor immutable data structures in WebFlux in general, because mutable variants introduce a whole new range of potential concurrency issues.

@rstoyanchev
Copy link
Contributor

You're currently changing the exchange and trying to modify the (immutable) request. What you need is to change the exchange, and change the request:

ServerHttpRequest updatedRequest = request.mutate().headers(headers-> ...).build();
exchange.mutate().request(updatedRequest).build();

@rstoyanchev rstoyanchev added status: invalid An issue that we don't feel is valid and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Mar 1, 2021
@Thorn1089
Copy link
Author

Thorn1089 commented Mar 1, 2021

@rstoyanchev That is incorrect. I am using .request(Builder) which, as the Javadoc describes, is identical to what you suggest. Please reopen this issue.

Per the Javadoc:

Configure a consumer to modify the current request using a builder.
Effectively this:
exchange.mutate().request(builder-> builder.method(HttpMethod.PUT));
// vs...
ServerHttpRequest request = exchange.getRequest().mutate()
.method(HttpMethod.PUT)
.build();
exchange.mutate().request(request);

@rstoyanchev
Copy link
Contributor

rstoyanchev commented Mar 1, 2021

True, .request(Builder) does work just as well. So it is possible to mutate request values and passing the modified exchange/request via chain#filter means downstream handling should sees the mutated values.

But then when I get to the actual boundary detection code in the multipart codec shipped with Spring Cloud Gateway, it does the following

So is Spring Cloud Gateway using the original exchange somehow instead of the mutated one?

@rstoyanchev rstoyanchev reopened this Mar 1, 2021
@rstoyanchev rstoyanchev added status: waiting-for-feedback We need additional information before we can continue status: waiting-for-triage An issue we've not yet triaged or decided on and removed status: invalid An issue that we don't feel is valid labels Mar 1, 2021
@bclozel
Copy link
Member

bclozel commented Mar 1, 2021

I think I'm getting this now.

So this is not about changing the behavior of ReadOnlyHttpHeaders or misusing the mutation API for the exchange, but rather a bug with request mutation.

The following test case fails with the second assertion.

MockServerHttpRequest request = MockServerHttpRequest.post("https://example.com")
		.contentType(MediaType.APPLICATION_JSON)
		.header("some", "original")
		.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);

ServerWebExchange mutated = exchange.mutate()
		.request(req -> req.headers(headers -> headers.setContentType(MediaType.APPLICATION_CBOR)).build())
		.request(req -> req.headers(headers -> headers.set("some", "updated")).build())
		.build();

assertThat(mutated.getRequest().getHeaders().getFirst("some")).isEqualTo("updated"); // OK
assertThat(mutated.getRequest().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_CBOR); // FAILS

Here's what's happening:

  1. the original exchange is created with the request; the AbstractServerHttpRequest turns the headers into ReadOnlyHttpHeaders right away
  2. during the DefaultServerWebExchange instantiation, we're initializing as well the form data in the request which calls the getContentType method on the read-only headers and caches the value
  3. later, when building the MutatedServerHttpRequest we're getting headers from the original request with originalRequest.getHeaders() (this returns the read-only version).
  4. HttpHeaders.readOnlyHttpHeaders(headers) returns the input parameter if it's already a readonly type.

This means that we're copying the right header values in the mutated request but we're keeping the original read-only value.

Changing HttpHeaders to always return return new ReadOnlyHttpHeaders(headers.headers); fixes this particular problem, but I'm wondering if we're not just fixing the symptom rather than the bigger problem.

@rstoyanchev do you think that we've missed something with our mutability story here in the implementation hierarchy?

@Thorn1089
Copy link
Author

Thorn1089 commented Mar 1, 2021

I think the problem is the "ownership" of the original HttpHeaders that the read-only variant wraps. If those are effectively discarded after creating the readonly version there is probably no issue. But in this case, the mutable headers are still used, and affected by the changes in the filter chain. And for every other header besides Content-Type and Accept this works fine.

The problem as I see it is that ReadOnlyHttpHeaders is doing two things -- preventing mutation (by overriding setter methods and throwing UnsupportedOperationException) and caching expensive values (for content-type negotiation purposes it seems). The latter is not at all what I'd expect this class to do and I agree that creating a "fresh" ReadOnlyHttpHeaders every time is just hiding the problem.

As an analogy, this would be like if Collections#unmodifiableCollection() from the JDK not only overrode the mutation methods, but also cached the results of the underlying collection's iterator. If the mutable collection was changed, re-iterating the readonly collection should still show those changes and not cache the prior state.

@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 Mar 1, 2021
@bclozel bclozel self-assigned this Mar 1, 2021
@bclozel bclozel added type: bug A general bug and removed status: feedback-provided Feedback has been provided status: waiting-for-triage An issue we've not yet triaged or decided on labels Mar 1, 2021
@bclozel bclozel added this to the 5.3.5 milestone Mar 1, 2021
@bclozel
Copy link
Member

bclozel commented Mar 1, 2021

@TomRK1089 I don't think we can frame this like any collections wrapping problem.

Here, ReadOnlyHttpHeaders is preventing developers from modifying the request headers because the request is considered as immutable during certain phases of the exchange lifecycle (for example, when the request is mapped to a handler). If we didn't then mapping or content negotiation could be invalidated because the request has changed in the meantime - a handler could then get a request that it doesn't know how to process...

So ReadOnlyHttpHeaders can indeed cache values for performance reasons, because we only allow mutation of the request during certain phases. I think that once you're given a read-only view of the request headers, you should be confident that it's immutable.

I've found and fixed the actual issue: instead of creating a new request using the writable view of the HTTP headers we were using during request mutation, we passed the original (so read-only) headers. Changing that solves the problem and it looks like it's aligning better with the intended design.

This also fixes a similar problem on the "Accept" headers which are also cached by the read-only version.

@bclozel bclozel changed the title ReadOnlyHttpHeaders should not cache values ServerHttpRequest content-type cannot be mutated Mar 1, 2021
@bclozel bclozel added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Mar 1, 2021
@bclozel bclozel closed this as completed in 5a11569 Mar 1, 2021
This was referenced Mar 17, 2021
lxbzmy pushed a commit to lxbzmy/spring-framework that referenced this issue Mar 26, 2022
Prior to this commit, `ServerHttpRequest.mutate()` would not reflect
changes made on the "Accept" and "Content-Type" HTTP headers.
This was due to the fact that the instantiation of a new request based
on the mutated values would not use the writable HTTP headers used
during the mutation, but rather a read-only view of the headers backed
by `ReadOnlyHttpHeaders`.

`ReadOnlyHttpHeaders` caches those values for performance reasons, so
getting those from the new request would not reflect the changes made
during the mutation phase.

This commit ensures that the new request uses the mutated headers.

Fixes spring-projectsgh-26615
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) status: backported An issue that has been backported to maintenance branches type: bug A general bug
Projects
None yet
Development

No branches or pull requests

5 participants