-
Notifications
You must be signed in to change notification settings - Fork 4.9k
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
Sanitize headers nominated by the Connection header #8862
Sanitize headers nominated by the Connection header #8862
Conversation
Signed-off-by: Alvin Baptiste <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
@@ -309,6 +309,7 @@ class ConnectionImpl : public virtual Connection, protected Logger::Loggable<Log | |||
const uint32_t max_headers_count_; | |||
|
|||
bool strict_header_validation_; | |||
bool connection_header_sanitization_; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing const
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would that mean that strict_header_validation_
is also missing a const?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo in variable name
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may need a log message. Because form the log message printed by the caller it would be unclear why sanitizeConnectionHeader failed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Certainly
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One more instance of header comparison.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, let me work on this
- Remove CaseInsensitiveCompare in favor of == operator Signed-off-by: Alvin Baptiste <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your example is valid. I updated the code and added a test case that exercises this condition.
Signed-off-by: Alvin Baptiste <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for making this change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking great! here's some comments from my side
@@ -532,6 +534,15 @@ int ConnectionImpl::onHeadersCompleteBase() { | |||
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you add release notes at docs/root/intro/version_history.rst including the flag to flip to revert to prior behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure thing!
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ping on this one?
!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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we trace log this as well for consistency?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe setCopy implicitly clears
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does removeTokens remove empty fields?
Maybe a unit test for "foo, ,bar" and removing foo and bar?
test/integration/http_integration.cc
Outdated
@@ -337,7 +337,9 @@ void HttpIntegrationTest::verifyResponse(IntegrationStreamDecoderPtr response, | |||
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you can revert this change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -3,6 +3,7 @@ Version history | |||
|
|||
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mind moving this down? we try for alphabetic order here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ping on this one?
Signed-off-by: Alvin Baptiste <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
one final nit, and one optional, and you're good to go!
docs/root/intro/version_history.rst
Outdated
@@ -11,6 +11,7 @@ Version history | |||
* 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ugh, vim sort failed me :( Will fix ... again.
@@ -1067,4 +1068,453 @@ TEST_P(HeaderIntegrationTest, TestPathAndRouteOnNormalizedPath) { | |||
{":status", "200"}, | |||
}); | |||
} | |||
|
|||
// Validates TE header is forwarded if it contains a supported value | |||
TEST_P(HeaderIntegrationTest, TestTeHeaderPassthrough) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lol :) It's a deal!!
- Move tests to utility_test.cc Signed-off-by: Alvin Baptiste <[email protected]>
}; | ||
// 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Likewise. I will update. Thanks for the suggestion.
Signed-off-by: Alvin Baptiste <[email protected]>
/retest |
🤷♀️ nothing to rebuild. |
Signed-off-by: Alvin Baptiste <[email protected]>
Signed-off-by: Alvin Baptiste <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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