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

Sanitize headers nominated by the Connection header #8862

Merged
merged 19 commits into from
Nov 18, 2019
Merged

Sanitize headers nominated by the Connection header #8862

merged 19 commits into from
Nov 18, 2019

Conversation

abaptiste
Copy link
Contributor

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

@@ -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_;
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing const

Copy link
Contributor Author

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?

Copy link
Contributor

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.

absl::StrSplit(connection_header_value.getStringView(), ',');

// If we have 10 or more nominated headers, fail this request
if (connection_header_tokens.size() >= 10) {
Copy link
Contributor

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

}

// Build the LowerCaseString for header lookup
const std::string header_to_remove = std::string(token_sv);
Copy link
Contributor

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.

Copy link
Contributor Author

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.

// Determine whether the nominated header contains invalid values
HeaderEntry* nominated_header = NULL;

if (StringUtil::CaseInsensitiveCompare()(token_sv, Http::Headers::get().Connection.get())) {
Copy link
Contributor

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

keep_header = true;
headers_to_remove.emplace(token_sv);

} else if (StringUtil::CaseInsensitiveCompare()(token_sv,
Copy link
Contributor

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.

}

if (nominated_header) {
const HeaderString& nominated_header_value = nominated_header->value();
Copy link
Contributor

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.


if (nominated_header) {
const HeaderString& nominated_header_value = nominated_header->value();
auto nominated_heaader_value_sv = nominated_header_value.getStringView();
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo in variable name

Copy link
Contributor Author

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;
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Certainly

// 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)) {
Copy link
Contributor

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.

Copy link
Contributor Author

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]>
Copy link
Contributor

@yanavlasov yanavlasov left a 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.


// 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) ||
Copy link
Contributor

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.

Copy link
Contributor Author

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]>
@abaptiste abaptiste requested a review from yanavlasov November 6, 2019 16:48
Copy link
Contributor

@yanavlasov yanavlasov left a 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.


// 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)) ||
Copy link
Contributor

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.

std::string::npos)) {

// Reset the TE header to just trailers only if other values exist
if (nominated_header_value_sv.find(',') != std::string::npos) {
Copy link
Contributor

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.


// 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) !=
Copy link
Contributor

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.

Copy link
Contributor Author

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.

const absl::string_view header_sv = StringUtil::trim(header_value);

// Remove everything from TE header except "trailers"
if (!StringUtil::CaseInsensitiveCompare()(header_sv,
Copy link
Contributor

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:

  1. leave the keep_header unchanged at the beginning of the loop
  2. reverse the if statement condition inside the loop, so it it is true when the header_sv == "trailers"
  3. 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.

yanavlasov
yanavlasov previously approved these changes Nov 9, 2019
Copy link
Contributor

@yanavlasov yanavlasov left a 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.

Copy link
Contributor

@alyssawilk alyssawilk left a 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()) {
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure thing!


StringUtil::CaseUnorderedSet headers_to_remove{};
std::vector<std::string> connection_header_tokens =
absl::StrSplit(connection_header_value.getStringView(), ',');
Copy link
Contributor

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?

(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
Copy link
Contributor

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.

Copy link
Contributor

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;
Copy link
Contributor

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?

Copy link
Contributor Author

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
Copy link
Contributor

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(),
Copy link
Contributor

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

Copy link
Contributor Author

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);
Copy link
Contributor

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?

@@ -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) {

Copy link
Contributor

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?

Copy link
Contributor Author

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.

@abaptiste
Copy link
Contributor Author

/azp run envoy-macos

@azure-pipelines
Copy link

Commenter does not have sufficient privileges for PR 8862 in repo envoyproxy/envoy

Signed-off-by: Alvin Baptiste <[email protected]>
Copy link
Contributor

@alyssawilk alyssawilk left a 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!

@@ -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.
Copy link
Contributor

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do.

Copy link
Contributor Author

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! :)

(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
Copy link
Contributor

Choose a reason for hiding this comment

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

Ping on this one?

Copy link
Contributor

@alyssawilk alyssawilk left a 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!

@@ -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.
Copy link
Contributor

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

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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));
}
Copy link
Contributor

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.

Copy link
Contributor Author

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]>
@abaptiste
Copy link
Contributor Author

/retest

@repokitteh-read-only
Copy link

🤷‍♀️ nothing to rebuild.

🐱

Caused by: a #8862 (comment) was created by @abaptiste.

see: more, trace.

Signed-off-by: Alvin Baptiste <[email protected]>
Copy link
Contributor

@alyssawilk alyssawilk left a 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 :-)

@alyssawilk alyssawilk merged commit b0cbc87 into envoyproxy:master Nov 18, 2019
@abaptiste abaptiste deleted the te_header_sanitzation branch November 20, 2019 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Envoy not removing HTTP/1.1 TE and Connection header for HTTP/2 backend.
4 participants