Skip to content

Commit

Permalink
http: header map security fixes for duplicate headers (istio#197)
Browse files Browse the repository at this point in the history
Previously header matching did not match on all headers for
non-inline headers. This patch changes the default behavior to
always logically match on all headers. Multiple individual
headers will be logically concatenated with ',' similar to what
is done with inline headers. This makes the behavior effectively
consistent. This behavior can be temporary reverted by setting
the runtime value "envoy.reloadable_features.header_match_on_all_headers"
to "false".

Targeted fixes have been additionally performed on the following
extensions which make them consider all duplicate headers by default as
a comma concatenated list:
1) Any extension using CEL matching on headers.
2) The header to metadata filter.
3) The JWT filter.
4) The Lua filter.
Like primary header matching used in routing, RBAC, etc. this behavior
can be disabled by setting the runtime value
"envoy.reloadable_features.header_match_on_all_headers" to false.

Finally, the setCopy() header map API previously only set the first
header in the case of duplicate non-inline headers. setCopy() now
behaves similiarly to the other set*() APIs and replaces all found
headers with a single value. This may have had security implications
in the extauth filter which uses this API. This behavior can be disabled
by setting the runtime value
"envoy.reloadable_features.http_set_copy_replace_all_headers" to false.

Fixes https://github.com/envoyproxy/envoy-setec/issues/188

Signed-off-by: Matt Klein <mklein@lyft.com>
  • Loading branch information
mattklein123 authored and antoniovicente committed Sep 29, 2020
1 parent 41e65e2 commit 2c60632
Show file tree
Hide file tree
Showing 29 changed files with 503 additions and 78 deletions.
27 changes: 27 additions & 0 deletions docs/root/version_history/v1.15.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
1.15.1 (TBD)
============

Changes
-------
* http: fixed CVE-2020-25017. Previously header matching did not match on all headers for non-inline
headers. This patch changes the default behavior to always logically match on all headers.
Multiple individual headers will be logically concatenated with ',' similar to what is done with
inline headers. This makes the behavior effectively consistent. This behavior can be temporary
reverted by setting the runtime value "envoy.reloadable_features.header_match_on_all_headers" to
"false".

Targeted fixes have been additionally performed on the following extensions which make them
consider all duplicate headers by default as a comma concatenated list:

1. Any extension using CEL matching on headers.
2. The header to metadata filter.
3. The JWT filter.
4. The Lua filter.

Like primary header matching used in routing, RBAC, etc. this behavior can be disabled by setting
the runtime value "envoy.reloadable_features.header_match_on_all_headers" to false.
* http: The setCopy() header map API previously only set the first header in the case of duplicate
non-inline headers. setCopy() now behaves similarly to the other set*() APIs and replaces all found
headers with a single value. This may have had security implications in the extauth filter which
uses this API. This behavior can be disabled by setting the runtime value
"envoy.reloadable_features.http_set_copy_replace_all_headers" to false.
1 change: 1 addition & 0 deletions docs/root/version_history/version_history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Version history
:titlesonly:

current
v1.15.1
v1.15.0
v1.14.3
v1.14.2
Expand Down
26 changes: 26 additions & 0 deletions include/envoy/http/header_map.h
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,32 @@ class HeaderMap {
*/
virtual const HeaderEntry* get(const LowerCaseString& key) const PURE;

/**
* This is a wrapper for the return result from getAll(). It avoids a copy when translating from
* non-const HeaderEntry to const HeaderEntry and only provides const access to the result.
*/
using NonConstGetResult = absl::InlinedVector<HeaderEntry*, 1>;
class GetResult {
public:
GetResult() = default;
explicit GetResult(NonConstGetResult&& result) : result_(std::move(result)) {}
void operator=(GetResult&& rhs) noexcept { result_ = std::move(rhs.result_); }

bool empty() const { return result_.empty(); }
size_t size() const { return result_.size(); }
const HeaderEntry* operator[](size_t i) const { return result_[i]; }

private:
NonConstGetResult result_;
};

/**
* Get a header by key.
* @param key supplies the header key.
* @return all header entries matching the key.
*/
virtual GetResult getAll(const LowerCaseString& key) const PURE;

// aliases to make iterate() and iterateReverse() callbacks easier to read
enum class Iterate { Continue, Break };

Expand Down
1 change: 1 addition & 0 deletions source/common/http/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ envoy_cc_library(
"//source/common/common:empty_string",
"//source/common/common:non_copyable",
"//source/common/common:utility_lib",
"//source/common/runtime:runtime_features_lib",
"//source/common/singleton:const_singleton",
],
)
Expand Down
52 changes: 30 additions & 22 deletions source/common/http/header_map_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "common/common/assert.h"
#include "common/common/dump_state_utils.h"
#include "common/common/empty_string.h"
#include "common/runtime/runtime_features.h"
#include "common/singleton/const_singleton.h"

#include "absl/strings/match.h"
Expand Down Expand Up @@ -383,39 +384,37 @@ void HeaderMapImpl::addCopy(const LowerCaseString& key, absl::string_view value)

void HeaderMapImpl::appendCopy(const LowerCaseString& key, absl::string_view value) {
// TODO(#9221): converge on and document a policy for coalescing multiple headers.
auto* entry = getExisting(key);
if (entry) {
const uint64_t added_size = appendToHeader(entry->value(), value);
auto entry = getExisting(key);
if (!entry.empty()) {
const uint64_t added_size = appendToHeader(entry[0]->value(), value);
addSize(added_size);
} else {
addCopy(key, value);
}
}

void HeaderMapImpl::setReference(const LowerCaseString& key, absl::string_view value) {
HeaderString ref_key(key);
HeaderString ref_value(value);
remove(key);
insertByKey(std::move(ref_key), std::move(ref_value));
addReference(key, value);
}

void HeaderMapImpl::setReferenceKey(const LowerCaseString& key, absl::string_view value) {
HeaderString ref_key(key);
HeaderString new_value;
new_value.setCopy(value);
remove(key);
insertByKey(std::move(ref_key), std::move(new_value));
ASSERT(new_value.empty()); // NOLINT(bugprone-use-after-move)
addReferenceKey(key, value);
}

void HeaderMapImpl::setCopy(const LowerCaseString& key, absl::string_view value) {
// Replaces the first occurrence of a header if it exists, otherwise adds by copy.
// TODO(#9221): converge on and document a policy for coalescing multiple headers.
auto* entry = getExisting(key);
if (entry) {
updateSize(entry->value().size(), value.size());
entry->value(value);
if (!Runtime::runtimeFeatureEnabled(
"envoy.reloadable_features.http_set_copy_replace_all_headers")) {
auto entry = getExisting(key);
if (!entry.empty()) {
updateSize(entry[0]->value().size(), value.size());
entry[0]->value(value);
} else {
addCopy(key, value);
}
} else {
remove(key);
addCopy(key, value);
}
}
Expand All @@ -433,17 +432,26 @@ void HeaderMapImpl::verifyByteSizeInternalForTest() const {
}

const HeaderEntry* HeaderMapImpl::get(const LowerCaseString& key) const {
return const_cast<HeaderMapImpl*>(this)->getExisting(key);
const auto result = getAll(key);
return result.empty() ? nullptr : result[0];
}

HeaderEntry* HeaderMapImpl::getExisting(const LowerCaseString& key) {
HeaderMap::GetResult HeaderMapImpl::getAll(const LowerCaseString& key) const {
return HeaderMap::GetResult(const_cast<HeaderMapImpl*>(this)->getExisting(key));
}

HeaderMap::NonConstGetResult HeaderMapImpl::getExisting(const LowerCaseString& key) {
// Attempt a trie lookup first to see if the user is requesting an O(1) header. This may be
// relatively common in certain header matching / routing patterns.
// TODO(mattklein123): Add inline handle support directly to the header matcher code to support
// this use case more directly.
HeaderMap::NonConstGetResult ret;
auto lookup = staticLookup(key.get());
if (lookup.has_value()) {
return *lookup.value().entry_;
if (*lookup.value().entry_ != nullptr) {
ret.push_back(*lookup.value().entry_);
}
return ret;
}

// If the requested header is not an O(1) header we do a full scan. Doing the trie lookup is
Expand All @@ -454,11 +462,11 @@ HeaderEntry* HeaderMapImpl::getExisting(const LowerCaseString& key) {
// implementation or potentially create a lazy map if the size of the map is above a threshold.
for (HeaderEntryImpl& header : headers_) {
if (header.key() == key.get().c_str()) {
return &header;
ret.push_back(&header);
}
}

return nullptr;
return ret;
}

void HeaderMapImpl::iterate(HeaderMap::ConstIterateCb cb) const {
Expand Down
6 changes: 5 additions & 1 deletion source/common/http/header_map_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class HeaderMapImpl : NonCopyable {
void setCopy(const LowerCaseString& key, absl::string_view value);
uint64_t byteSize() const;
const HeaderEntry* get(const LowerCaseString& key) const;
HeaderMap::GetResult getAll(const LowerCaseString& key) const;
void iterate(HeaderMap::ConstIterateCb cb) const;
void iterateReverse(HeaderMap::ConstIterateCb cb) const;
void clear();
Expand Down Expand Up @@ -241,7 +242,7 @@ class HeaderMapImpl : NonCopyable {
HeaderEntryImpl& maybeCreateInline(HeaderEntryImpl** entry, const LowerCaseString& key);
HeaderEntryImpl& maybeCreateInline(HeaderEntryImpl** entry, const LowerCaseString& key,
HeaderString&& value);
HeaderEntry* getExisting(const LowerCaseString& key);
HeaderMap::NonConstGetResult getExisting(const LowerCaseString& key);
size_t removeInline(HeaderEntryImpl** entry);
void updateSize(uint64_t from_size, uint64_t to_size);
void addSize(uint64_t size);
Expand Down Expand Up @@ -299,6 +300,9 @@ template <class Interface> class TypedHeaderMapImpl : public HeaderMapImpl, publ
const HeaderEntry* get(const LowerCaseString& key) const override {
return HeaderMapImpl::get(key);
}
HeaderMap::GetResult getAll(const LowerCaseString& key) const override {
return HeaderMapImpl::getAll(key);
}
void iterate(HeaderMap::ConstIterateCb cb) const override { HeaderMapImpl::iterate(cb); }
void iterateReverse(HeaderMap::ConstIterateCb cb) const override {
HeaderMapImpl::iterateReverse(cb);
Expand Down
51 changes: 40 additions & 11 deletions source/common/http/header_utility.cc
Original file line number Diff line number Diff line change
Expand Up @@ -105,39 +105,68 @@ bool HeaderUtility::matchHeaders(const HeaderMap& request_headers,
return true;
}

HeaderUtility::GetAllOfHeaderAsStringResult
HeaderUtility::getAllOfHeaderAsString(const HeaderMap& headers, const Http::LowerCaseString& key) {
GetAllOfHeaderAsStringResult result;
const auto header_value = headers.getAll(key);

if (header_value.empty()) {
// Empty for clarity. Avoid handling the empty case in the block below if the runtime feature
// is disabled.
} else if (header_value.size() == 1 ||
!Runtime::runtimeFeatureEnabled(
"envoy.reloadable_features.http_match_on_all_headers")) {
result.result_ = header_value[0]->value().getStringView();
} else {
// In this case we concatenate all found headers using a ',' delimiter before performing the
// final match. We use an InlinedVector of absl::string_view to invoke the optimized join
// algorithm. This requires a copying phase before we invoke join. The 3 used as the inline
// size has been arbitrarily chosen.
// TODO(mattklein123): Do we need to normalize any whitespace here?
absl::InlinedVector<absl::string_view, 3> string_view_vector;
string_view_vector.reserve(header_value.size());
for (size_t i = 0; i < header_value.size(); i++) {
string_view_vector.push_back(header_value[i]->value().getStringView());
}
result.result_backing_string_ = absl::StrJoin(string_view_vector, ",");
}

return result;
}

bool HeaderUtility::matchHeaders(const HeaderMap& request_headers, const HeaderData& header_data) {
const HeaderEntry* header = request_headers.get(header_data.name_);
const auto header_value = getAllOfHeaderAsString(request_headers, header_data.name_);

if (header == nullptr) {
if (!header_value.result().has_value()) {
return header_data.invert_match_ && header_data.header_match_type_ == HeaderMatchType::Present;
}

bool match;
const absl::string_view header_view = header->value().getStringView();
switch (header_data.header_match_type_) {
case HeaderMatchType::Value:
match = header_data.value_.empty() || header_view == header_data.value_;
match = header_data.value_.empty() || header_value.result().value() == header_data.value_;
break;
case HeaderMatchType::Regex:
match = header_data.regex_->match(header_view);
match = header_data.regex_->match(header_value.result().value());
break;
case HeaderMatchType::Range: {
int64_t header_value = 0;
match = absl::SimpleAtoi(header_view, &header_value) &&
header_value >= header_data.range_.start() && header_value < header_data.range_.end();
int64_t header_int_value = 0;
match = absl::SimpleAtoi(header_value.result().value(), &header_int_value) &&
header_int_value >= header_data.range_.start() &&
header_int_value < header_data.range_.end();
break;
}
case HeaderMatchType::Present:
match = true;
break;
case HeaderMatchType::Prefix:
match = absl::StartsWith(header_view, header_data.value_);
match = absl::StartsWith(header_value.result().value(), header_data.value_);
break;
case HeaderMatchType::Suffix:
match = absl::EndsWith(header_view, header_data.value_);
match = absl::EndsWith(header_value.result().value(), header_data.value_);
break;
case HeaderMatchType::Contains:
match = absl::StrContains(header_view, header_data.value_);
match = absl::StrContains(header_value.result().value(), header_data.value_);
break;
default:
NOT_REACHED_GCOVR_EXCL_LINE;
Expand Down
29 changes: 29 additions & 0 deletions source/common/http/header_utility.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,35 @@ class HeaderUtility {
static void getAllOfHeader(const HeaderMap& headers, absl::string_view key,
std::vector<absl::string_view>& out);

/**
* Get all header values as a single string. Multiple headers are concatenated with ','.
*/
class GetAllOfHeaderAsStringResult {
public:
// The ultimate result of the concatenation. If absl::nullopt, no header values were found.
// If the final string required a string allocation, the memory is held in
// backingString(). This allows zero allocation in the common case of a single header
// value.
absl::optional<absl::string_view> result() const {
// This is safe for move/copy of this class as the backing string will be moved or copied.
// Otherwise result_ is valid. The assert verifies that both are empty or only 1 is set.
ASSERT((!result_.has_value() && result_backing_string_.empty()) ||
(result_.has_value() ^ !result_backing_string_.empty()));
return !result_backing_string_.empty() ? result_backing_string_ : result_;
}

const std::string& backingString() const { return result_backing_string_; }

private:
absl::optional<absl::string_view> result_;
// Valid only if result_ relies on memory allocation that must live beyond the call. See above.
std::string result_backing_string_;

friend class HeaderUtility;
};
static GetAllOfHeaderAsStringResult getAllOfHeaderAsString(const HeaderMap& headers,
const Http::LowerCaseString& key);

// A HeaderData specifies one of exact value or regex or range element
// to match in a request's header, specified in the header_match_type_ member.
// It is the runtime equivalent of the HeaderMatchSpecifier proto in RDS API.
Expand Down
2 changes: 2 additions & 0 deletions source/common/runtime/runtime_features.cc
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ constexpr const char* runtime_features[] = {
"envoy.reloadable_features.fixed_connection_close",
"envoy.reloadable_features.hcm_stream_error_on_invalid_message",
"envoy.reloadable_features.http_default_alpn",
"envoy.reloadable_features.http_match_on_all_headers",
"envoy.reloadable_features.http_set_copy_replace_all_headers",
"envoy.reloadable_features.http_transport_failure_reason_in_body",
"envoy.reloadable_features.http2_skip_encoding_empty_trailers",
"envoy.reloadable_features.listener_in_place_filterchain_update",
Expand Down
13 changes: 13 additions & 0 deletions source/extensions/filters/common/expr/context.cc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ absl::optional<CelValue> convertHeaderEntry(const Http::HeaderEntry* header) {
return CelValue::CreateStringView(header->value().getStringView());
}

absl::optional<CelValue>
convertHeaderEntry(Protobuf::Arena& arena,
Http::HeaderUtility::GetAllOfHeaderAsStringResult&& result) {
if (!result.result().has_value()) {
return {};
} else if (!result.backingString().empty()) {
return CelValue::CreateString(
Protobuf::Arena::Create<std::string>(&arena, result.backingString()));
} else {
return CelValue::CreateStringView(result.result().value());
}
}

namespace {

absl::optional<CelValue> extractSslInfo(const Ssl::ConnectionInfo& ssl_info,
Expand Down
Loading

0 comments on commit 2c60632

Please sign in to comment.