Sanitize headers nominated by the Connection header#8862
Sanitize headers nominated by the Connection header#8862alyssawilk merged 19 commits intoenvoyproxy:masterfrom abaptiste:te_header_sanitzation
Conversation
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
| const uint32_t max_headers_count_; | ||
|
|
||
| bool strict_header_validation_; | ||
| bool connection_header_sanitization_; |
There was a problem hiding this comment.
Would that mean that strict_header_validation_ is also missing a const?
There was a problem hiding this comment.
yes, thanks for fixing this as well.
source/common/http/utility.cc
Outdated
| absl::StrSplit(connection_header_value.getStringView(), ','); | ||
|
|
||
| // If we have 10 or more nominated headers, fail this request | ||
| if (connection_header_tokens.size() >= 10) { |
There was a problem hiding this comment.
Nit: I would create a named constant for 10
source/common/http/utility.cc
Outdated
| } | ||
|
|
||
| // Build the LowerCaseString for header lookup | ||
| const std::string header_to_remove = std::string(token_sv); |
There was a problem hiding this comment.
Note that the 'header_to_remove' is not used anywhere other than to initialize lcs_header_to_remove. To eliminate memory allocation here, you can just directly use std::string(token_sv) in the lcs_header_to_remove initialization.
There was a problem hiding this comment.
Yep. With the multiple refactors I missed this.
source/common/http/utility.cc
Outdated
| // Determine whether the nominated header contains invalid values | ||
| HeaderEntry* nominated_header = NULL; | ||
|
|
||
| if (StringUtil::CaseInsensitiveCompare()(token_sv, Http::Headers::get().Connection.get())) { |
There was a problem hiding this comment.
The header names are lowercase already. You can simplify this by making it lcs_header_to_remove == Http::Headers::get().Connection
source/common/http/utility.cc
Outdated
| keep_header = true; | ||
| headers_to_remove.emplace(token_sv); | ||
|
|
||
| } else if (StringUtil::CaseInsensitiveCompare()(token_sv, |
There was a problem hiding this comment.
Same as above and other instances of header name comparison below.
source/common/http/utility.cc
Outdated
| } | ||
|
|
||
| if (nominated_header) { | ||
| const HeaderString& nominated_header_value = nominated_header->value(); |
There was a problem hiding this comment.
You can skip this step and just use nominated_header->value().getStringView() below.
source/common/http/utility.cc
Outdated
|
|
||
| if (nominated_header) { | ||
| const HeaderString& nominated_header_value = nominated_header->value(); | ||
| auto nominated_heaader_value_sv = nominated_header_value.getStringView(); |
There was a problem hiding this comment.
I thought I fixed this. I will double check.
|
|
||
| // reject the request if the TE header is too large | ||
| if (is_te_header && (nominated_heaader_value_sv.size() >= 256)) { | ||
| return false; |
There was a problem hiding this comment.
This may need a log message. Because form the log message printed by the caller it would be unclear why sanitizeConnectionHeader failed.
source/common/http/utility.cc
Outdated
| // If the TE header contains "trailers" we will reset the header to this value since it's | ||
| // the only permitted value | ||
| if (is_te_header && (nominated_heaader_value_sv.find( | ||
| Http::Headers::get().TEValues.Trailers) != std::string::npos)) { |
There was a problem hiding this comment.
I'll defer to other reviewers to see this check is enough. It does not account for a weird edge case where a header name contains the "trailers" keyword. I thought about it more and I think your original version using StrSplit in the for range loop was correct. The StrSplit in the for range loop does not cause memory allocation and with the length check it would be safe. If the find check is deemed to be insufficient you should use check for the trailers keyword using your original method. Sorry abot flip-flopping on this one.
There was a problem hiding this comment.
This check should only execute if the header itself is "TE". I removed the tokenization since it was expensive (per a comment on the old PR). In this change, if the header is "TE" and we find "trailers" and other values (there should be a check for whether a comma exists in the header value), then we would replace the existing value of TE: whatever, and, trailers with just TE: trailers.
There was a second commit where I fixed the variable spelling error and added this check. In any case, I'm open to what others would suggest here.
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
yanavlasov
left a comment
There was a problem hiding this comment.
Thanks, the change looks good. Just one last little thing you missed.
source/common/http/utility.cc
Outdated
|
|
||
| // If the Connection token value is not a nominated header, ignore it here since | ||
| // the connection header is removed elsewhere when the H1 request is upgraded to H2 | ||
| if (StringUtil::CaseInsensitiveCompare()(token_sv, cv.Close) || |
There was a problem hiding this comment.
One more instance of header comparison.
There was a problem hiding this comment.
OK, let me work on this
- Remove CaseInsensitiveCompare in favor of == operator Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
yanavlasov
left a comment
There was a problem hiding this comment.
Look awesome, thanks for bearing with me. Just a couple of minor changes, please.
source/common/http/utility.cc
Outdated
|
|
||
| // If the Connection token value is not a nominated header, ignore it here since | ||
| // the connection header is removed elsewhere when the H1 request is upgraded to H2 | ||
| if ((lcs_header_to_remove == LowerCaseString(cv.Close)) || |
There was a problem hiding this comment.
Ah, yes ConnectionValues are std::string not LowerCaseString. Conversion from std::string to LowerCaseString may cause heap allocations. For the maximum of 10 tokens this part will do 40 allocations, copies and lower case conversions. We know that ConnectionValues are lowercase. I think a better approach would be to use (lcs_header_to_remove.get() == cv.Close) pattern, in this way a per iteration work is avoided.
source/common/http/utility.cc
Outdated
| std::string::npos)) { | ||
|
|
||
| // Reset the TE header to just trailers only if other values exist | ||
| if (nominated_header_value_sv.find(',') != std::string::npos) { |
There was a problem hiding this comment.
Excellent way to avoid needless clear and copy.
source/common/http/utility.cc
Outdated
|
|
||
| // If the TE header contains "trailers" we will reset the header to this value since it's | ||
| // the only permitted value | ||
| if (is_te_header && (nominated_header_value_sv.find(Http::Headers::get().TEValues.Trailers) != |
There was a problem hiding this comment.
The more I think about it, the more I agree that your original approach of looking for the "trailers" keyword was the correct one. This will fail if request has the "TE: TRAILERS" header which is allowed by the standard (values are case insensitive) and TE would be removed. It will also keep the header if request has the "TE: myabusivetrailersencoding", which I'm certain someone would figure out how to abuse. I think this should go back to the range for loop over the split nominated_header_value_sv and do the trim and lower case conversion before comparing to the "trailers" keyword. Sorry about taking your time with going back and forth on this one.
There was a problem hiding this comment.
Your example is valid. I updated the code and added a test case that exercises this condition.
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
source/common/http/utility.cc
Outdated
| const absl::string_view header_sv = StringUtil::trim(header_value); | ||
|
|
||
| // Remove everything from TE header except "trailers" | ||
| if (!StringUtil::CaseInsensitiveCompare()(header_sv, |
There was a problem hiding this comment.
It is very close. It can be cleaned up a bit more by removing the need for the tokens_to_remove map. Note that the condition for keeping the TE header is the presence of the "trailers" token. You can do the following:
- leave the keep_header unchanged at the beginning of the loop
- reverse the if statement condition inside the loop, so it it is true when the header_sv == "trailers"
- inside the if statement set the keep_header and "break" out of the loop, since we do not care to look at the rest of the tokens
After the loop, check if TE header has to be kept and if so set its value to "trailers". With this change you can remove the if statement below at line 484 entirely.
This avoids the overhead of the building the hash map and calling the "removeTokens" method.
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
yanavlasov
left a comment
There was a problem hiding this comment.
Thank you for making this change.
alyssawilk
left a comment
There was a problem hiding this comment.
Looking great! here's some comments from my side
| ENVOY_CONN_LOG(trace, "codec entering upgrade mode.", connection_); | ||
| handling_upgrade_ = true; | ||
| } | ||
| } else if (connection_header_sanitization_ && current_header_map_->Connection()) { |
There was a problem hiding this comment.
can you add release notes at docs/root/intro/version_history.rst including the flag to flip to revert to prior behavior?
source/common/http/utility.cc
Outdated
|
|
||
| StringUtil::CaseUnorderedSet headers_to_remove{}; | ||
| std::vector<std::string> connection_header_tokens = | ||
| absl::StrSplit(connection_header_value.getStringView(), ','); |
There was a problem hiding this comment.
can we use StringUtil::splitToken, which auto-trims, and also allows removing empty headers?
source/common/http/utility.cc
Outdated
| (lcs_header_to_remove == Http::Headers::get().ForwardedProto) || | ||
| !token_sv.find(':')) { | ||
| // If pseudo headers or X-Forwarded* headers are nominated, this could be | ||
| // an invalid request. Reject the request |
There was a problem hiding this comment.
can we comment for posterity why we disallow these? If it's in the spec, we can link. If we just think it's high risk we can say that too.
| !token_sv.find(':')) { | ||
| // If pseudo headers or X-Forwarded* headers are nominated, this could be | ||
| // an invalid request. Reject the request | ||
| return false; |
There was a problem hiding this comment.
should we trace log this as well for consistency?
There was a problem hiding this comment.
Yep. Added a trace log here
|
|
||
| const absl::string_view header_sv = StringUtil::trim(header_value); | ||
|
|
||
| // If trailers exist in the TE value tokens, keep the header, removing any other values |
There was a problem hiding this comment.
here I assume we don't want to use StringUtil::caseFindToken because it doesn't allow the limitation.
I think that's the right call just because while we could refactor to pass in MAX_ALLOWED_TE_VALUE_SIZE it'd result in complicated return values?
I'd still use StringUtil::splitToken though.
|
|
||
| if (keep_header) { | ||
| nominated_header->value().clear(); | ||
| nominated_header->value().setCopy(Http::Headers::get().TEValues.Trailers.data(), |
There was a problem hiding this comment.
I believe setCopy implicitly clears
There was a problem hiding this comment.
I will remove the clear call
| if (new_value.empty()) { | ||
| headers.removeConnection(); | ||
| } else { | ||
| headers.Connection()->value(new_value); |
There was a problem hiding this comment.
does removeTokens remove empty fields?
Maybe a unit test for "foo, ,bar" and removing foo and bar?
test/integration/http_integration.cc
Outdated
| absl::optional<uint64_t> | ||
| HttpIntegrationTest::waitForNextUpstreamRequest(const std::vector<uint64_t>& upstream_indices, | ||
| std::chrono::milliseconds connection_wait_timeout) { | ||
|
|
There was a problem hiding this comment.
I think you can revert this change?
There was a problem hiding this comment.
Yep, will clean this up.
|
/azp run envoy-macos |
|
Commenter does not have sufficient privileges for PR 8862 in repo envoyproxy/envoy |
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
alyssawilk
left a comment
There was a problem hiding this comment.
Two quick thoughts, and I owe you a final pass. As I'm out tomorrow and at EnvoyCon next week it may be laggy - sorry in advance!
docs/root/intro/version_history.rst
Outdated
|
|
||
| 1.13.0 (pending) | ||
| ================ | ||
| * http: added the ability to sanitize headers nominated by the Connection header. This new behavior is guarded by envoy.reloadable_features.connection_header_sanitization which defaults to true. |
There was a problem hiding this comment.
mind moving this down? we try for alphabetic order here
There was a problem hiding this comment.
Regarding the X-Forwarded for headers. I added a comment below the existing one. I will replace the lines on L445-446 with the updated comment.
See you at EnvoyCon! :)
source/common/http/utility.cc
Outdated
| (lcs_header_to_remove == Http::Headers::get().ForwardedProto) || | ||
| !token_sv.find(':')) { | ||
| // If pseudo headers or X-Forwarded* headers are nominated, this could be | ||
| // an invalid request. Reject the request |
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
alyssawilk
left a comment
There was a problem hiding this comment.
one final nit, and one optional, and you're good to go!
docs/root/intro/version_history.rst
Outdated
| * health check: gRPC health checker sets the gRPC deadline to the configured timeout duration. | ||
| * http: support :ref:`auto_host_rewrite_header<envoy_api_field_config.filter.http.dynamic_forward_proxy.v2alpha.PerRouteConfig.auto_host_rewrite_header>` in the dynamic forward proxy. | ||
| * logger: added :ref:`--log-format-escaped <operations_cli>` command line option to escape newline characters in application logs. | ||
| * http: added the ability to sanitize headers nominated by the Connection header. This new behavior is guarded by envoy.reloadable_features.connection_header_sanitization which defaults to true. |
There was a problem hiding this comment.
Sorry, but can you move this to above line 12 (http: support")?
Also I think backticks around the runtime name is standard
There was a problem hiding this comment.
ugh, vim sort failed me :( Will fix ... again.
| } | ||
|
|
||
| // Validates TE header is forwarded if it contains a supported value | ||
| TEST_P(HeaderIntegrationTest, TestTeHeaderPassthrough) { |
There was a problem hiding this comment.
You know, it only occurs to me now, but we should probably leave one of these tests here to verify connection sanitization works end to end, and do the rest as unit tests of the new function in test/common/http/utility_test.cc
I'm going to say this is 100% optional, since you've done all the work and I forgot to call it out like 8 reviews ago, but if you want to earn alyssawilk brownie points (probably not actually redeemable for brownies) it would be cheaper/faster for CI.
There was a problem hiding this comment.
lol :) It's a deal!!
- Move tests to utility_test.cc Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
| }; | ||
| // Expect that the set of headers is valid and can be sanitized | ||
| EXPECT_TRUE(Utility::sanitizeConnectionHeader(request_headers)); | ||
| } |
There was a problem hiding this comment.
My brownie points are not nearly as valuable as Alyssa's. But here a last suggestion I have. I think it will make these tests more informative by also checking the expected header map after sanitization. You can add something like this:
TestHeaderMapImpl sanitized_headers = {
{":method", "GET"},
{":path", "/"},
{":scheme", "http"},
{":authority", "no-headers.com"},
{"x-request-foo", "downstram"},
{"connection", "te,close"},
{"te", "trailers"},
};
EXPECT_EQ(sanitized_headers, request_headers);Note that multivalue headers are compared as single value, so no space between "te,close".
I really appreciate your patience here.
There was a problem hiding this comment.
Likewise. I will update. Thanks for the suggestion.
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
|
/retest |
|
🤷♀️ nothing to rebuild. |
Signed-off-by: Alvin Baptiste <alvinsb@gmail.com>
alyssawilk
left a comment
There was a problem hiding this comment.
Fantastic work, thanks especially for the extra test work at the end :-)
Description: Sanitize headers nominated by the Connection header
Risk Level: Medium
Testing: Added several test cases to verify the header manipulation. Also executed
bazel test //test/...Docs Changes: N/A
Release Notes: N/A
Fixes: #8623