Skip to content

Commit ae1b7a4

Browse files
authored
Merge commit from fork
* Fix baggage metadata injection and reject unsafe curl headers * Avoid noexcept substring warnings and clarify security release notes * Preserve existing baggage when Set receives invalid metadata * Reuse baggage tokenizer while preserving whitespace compatibility * Add direct test includes required by IWYU
1 parent d3785b4 commit ae1b7a4

9 files changed

Lines changed: 467 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ Increment the:
1515

1616
## [Unreleased]
1717

18+
* [SECURITY] Reject invalid baggage metadata during extraction and injection.
19+
After the existing trimming of leading and trailing whitespace, members
20+
containing raw control bytes other than permitted HTAB are dropped.
21+
Members with empty metadata properties (`;;` or a trailing `;`), non-token
22+
property keys, or non-ASCII metadata are also dropped. `Baggage::Set()` rejects
23+
invalid metadata without replacing existing entries or adding new ones.
24+
The curl client now fails the whole request for non-token header names or
25+
header values containing control bytes other than HTAB, or DEL. This
26+
includes configured exporter headers. Applications must recompile to
27+
receive the header-only baggage fix.
28+
[GHSA-j5cq-83xw-rx48](https://github.com/open-telemetry/opentelemetry-cpp/security/advisories/GHSA-j5cq-83xw-rx48)
29+
1830
* [API/SDK] Add ASCII helpers and remove regex from InstrumentMetaDataValidator
1931
[#4715](https://github.com/open-telemetry/opentelemetry-cpp/pull/4715)
2032

‎api/include/opentelemetry/baggage/baggage.h‎

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class OPENTELEMETRY_EXPORT Baggage
5555
{
5656

5757
nostd::shared_ptr<Baggage> baggage(new Baggage(kv_properties_->Size() + 1));
58-
const bool valid_kv = IsValidKey(key) && IsValidValue(value);
58+
const bool valid_kv = IsValidKey(key) && IsValidValue(value) && IsValidValueMetadata(value);
5959

6060
if (valid_kv)
6161
{
@@ -125,9 +125,14 @@ class OPENTELEMETRY_EXPORT Baggage
125125

126126
while (kv_str_tokenizer.next(kv_valid, key, value) && baggage->kv_properties_->Size() < cnt)
127127
{
128-
if (!kv_valid || (key.size() + value.size() > kMaxKeyValueSize))
128+
if (!kv_valid || key.size() + value.size() > kMaxKeyValueSize)
129+
{
130+
continue;
131+
}
132+
// Preserve the tokenizer's edge-whitespace normalization, but reject
133+
// unsafe bytes before decoding or separating metadata from the value.
134+
if (!IsValidWireString(key) || !IsValidWireString(value))
129135
{
130-
// if kv pair is not valid, skip it
131136
continue;
132137
}
133138

@@ -145,7 +150,8 @@ class OPENTELEMETRY_EXPORT Baggage
145150
auto key_str = UrlDecode(common::StringUtil::Trim(key), err);
146151
auto value_str = UrlDecode(common::StringUtil::Trim(value), err);
147152

148-
if (err == false && IsValidKey(key_str) && IsValidValue(value_str))
153+
if (err == false && IsValidKey(key_str) && IsValidValue(value_str) &&
154+
IsValidMetadata(metadata))
149155
{
150156
if (!metadata.empty())
151157
{
@@ -164,6 +170,12 @@ class OPENTELEMETRY_EXPORT Baggage
164170
std::string header_s;
165171
bool first = true;
166172
kv_properties_->GetAllEntries([&](nostd::string_view key, nostd::string_view value) {
173+
// Constructors can populate baggage without going through FromHeader or Set.
174+
auto metadata_separator = value.find(kMetadataSeparator);
175+
if (!IsValidValueMetadata(value))
176+
{
177+
return true;
178+
}
167179
if (!first)
168180
{
169181
header_s.push_back(kMembersSeparator);
@@ -176,11 +188,10 @@ class OPENTELEMETRY_EXPORT Baggage
176188
header_s.push_back(kKeyValueSeparator);
177189

178190
// extracting metadata from value. We do not encode metadata
179-
auto metadata_separator = value.find(kMetadataSeparator);
180191
if (metadata_separator != std::string::npos)
181192
{
182-
header_s.append(UrlEncode(value.substr(0, metadata_separator)));
183-
auto metadata = value.substr(metadata_separator);
193+
header_s.append(UrlEncode(common::StringUtil::Substr(value, 0, metadata_separator)));
194+
auto metadata = common::StringUtil::Substr(value, metadata_separator);
184195
header_s.append(std::string(metadata.data(), metadata.size()));
185196
}
186197
else
@@ -193,6 +204,93 @@ class OPENTELEMETRY_EXPORT Baggage
193204
}
194205

195206
private:
207+
static bool IsValidValueMetadata(nostd::string_view value) noexcept
208+
{
209+
auto separator = value.find(kMetadataSeparator);
210+
return separator == nostd::string_view::npos ||
211+
IsValidMetadata(common::StringUtil::Substr(value, separator));
212+
}
213+
214+
static nostd::string_view TrimOws(nostd::string_view value) noexcept
215+
{
216+
while (!value.empty() && (value[0] == ' ' || value[0] == '\t'))
217+
{
218+
value = common::StringUtil::Substr(value, 1);
219+
}
220+
while (!value.empty() && (value[value.size() - 1] == ' ' || value[value.size() - 1] == '\t'))
221+
{
222+
value = common::StringUtil::Substr(value, 0, value.size() - 1);
223+
}
224+
return value;
225+
}
226+
227+
static bool IsValidWireString(nostd::string_view value) noexcept
228+
{
229+
for (unsigned char ch : value)
230+
{
231+
if ((ch < 0x20 && ch != '\t') || ch > 0x7e)
232+
{
233+
return false;
234+
}
235+
}
236+
return true;
237+
}
238+
239+
static bool IsToken(nostd::string_view value) noexcept
240+
{
241+
if (value.empty())
242+
{
243+
return false;
244+
}
245+
for (char ch : value)
246+
{
247+
if (!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
248+
nostd::string_view("!#$%&'*+-.^_`|~").find(ch) != nostd::string_view::npos))
249+
{
250+
return false;
251+
}
252+
}
253+
return true;
254+
}
255+
256+
// Metadata is stored in wire form. Validate properties without decoding or
257+
// re-encoding them, so existing percent escapes retain their meaning.
258+
static bool IsValidMetadata(nostd::string_view metadata) noexcept
259+
{
260+
while (!metadata.empty())
261+
{
262+
if (metadata[0] != kMetadataSeparator)
263+
{
264+
return false;
265+
}
266+
metadata = common::StringUtil::Substr(metadata, 1);
267+
auto end = metadata.find(kMetadataSeparator);
268+
auto property = TrimOws(common::StringUtil::Substr(metadata, 0, end));
269+
auto equals = property.find(kKeyValueSeparator);
270+
if (!IsToken(TrimOws(common::StringUtil::Substr(property, 0, equals))))
271+
{
272+
return false;
273+
}
274+
if (equals != nostd::string_view::npos)
275+
{
276+
for (unsigned char ch : TrimOws(common::StringUtil::Substr(property, equals + 1)))
277+
{
278+
// W3C baggage-octet; SP and HTAB are only allowed around properties.
279+
if (ch < 0x21 || ch > 0x7e || ch == '"' || ch == ',' || ch == '\\')
280+
{
281+
return false;
282+
}
283+
}
284+
}
285+
if (end == nostd::string_view::npos)
286+
{
287+
return true;
288+
}
289+
metadata = common::StringUtil::Substr(metadata, end);
290+
}
291+
return true;
292+
}
293+
196294
static bool IsPrintableString(nostd::string_view str)
197295
{
198296
for (const auto ch : str)

‎api/test/baggage/baggage_test.cc‎

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
#include <gtest/gtest.h>
55
#include <cassert>
66
#include <cstddef>
7+
#include <initializer_list>
78
#include <string>
9+
#include <utility>
810
#include <vector>
911

1012
#include "opentelemetry/baggage/baggage.h"
@@ -15,6 +17,135 @@
1517

1618
using namespace opentelemetry::baggage;
1719

20+
TEST(BaggageTest, RejectInvalidMetadataOnExtractAndInject)
21+
{
22+
const std::vector<std::string> invalid = {
23+
";a\r\nX-Injected: 1",
24+
";a\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: localhost\r\nX-Smuggled: 1",
25+
";a\rb",
26+
";a\nb",
27+
std::string(";a\0b", 4),
28+
";a=\x7f",
29+
";a=\xc3\xa9",
30+
";",
31+
";a;;b",
32+
";=value",
33+
";bad key=value",
34+
";a=bad value",
35+
";a=bad\tvalue",
36+
";a=\"quoted\"",
37+
";a=back\\slash"};
38+
for (const auto &metadata : invalid)
39+
{
40+
SCOPED_TRACE(metadata);
41+
auto baggage = Baggage::FromHeader("before=ok,bad=value" + metadata + ",after=ok");
42+
std::string value;
43+
EXPECT_FALSE(baggage->GetValue("bad", value));
44+
EXPECT_TRUE(baggage->GetValue("before", value));
45+
EXPECT_TRUE(baggage->GetValue("after", value));
46+
EXPECT_EQ(baggage->ToHeader(), "before=ok,after=ok");
47+
48+
// Direct construction does not validate input. Output must still be safe.
49+
const std::vector<std::pair<std::string, std::string>> entries = {
50+
{"before", "ok"}, {"bad", "value" + metadata}, {"after", "ok"}};
51+
// KeyValueProperties exposes NUL-terminated strings, so direct construction
52+
// already truncates at an embedded NUL. Extraction must reject it above.
53+
if (metadata.find('\0') == std::string::npos)
54+
{
55+
EXPECT_EQ(Baggage(entries).ToHeader(), "before=ok,after=ok");
56+
}
57+
EXPECT_EQ(Baggage().Set("bad", "value" + metadata)->ToHeader(), "");
58+
}
59+
}
60+
61+
TEST(BaggageTest, RejectMemberInjectionFromSet)
62+
{
63+
auto baggage = Baggage::FromHeader("good=ok")->Set("k", "v;a,evil=1");
64+
EXPECT_EQ(baggage->ToHeader(), "good=ok");
65+
}
66+
67+
TEST(BaggageTest, SetWithInvalidMetadataPreservesExistingEntry)
68+
{
69+
auto original = Baggage::FromHeader("k=good;flag,other=kept");
70+
for (const auto *invalid : {"v;a,evil=1", "v;", "v;;x", "v;p:x", "v;p=bad value"})
71+
{
72+
SCOPED_TRACE(invalid);
73+
auto updated = original->Set("k", invalid);
74+
std::string value;
75+
ASSERT_TRUE(updated->GetValue("k", value));
76+
EXPECT_EQ(value, "good;flag");
77+
EXPECT_EQ(updated->ToHeader(), "k=good;flag,other=kept");
78+
EXPECT_EQ(original->ToHeader(), "k=good;flag,other=kept");
79+
80+
auto inserted = original->Set("new", invalid);
81+
EXPECT_FALSE(inserted->GetValue("new", value));
82+
EXPECT_EQ(inserted->ToHeader(), "k=good;flag,other=kept");
83+
}
84+
85+
// Valid metadata must still replace the existing value.
86+
auto updated = original->Set("k", "replacement;p=100%");
87+
std::string value;
88+
ASSERT_TRUE(updated->GetValue("k", value));
89+
EXPECT_EQ(value, "replacement;p=100%");
90+
EXPECT_EQ(updated->ToHeader(), "k=replacement;p=100%,other=kept");
91+
}
92+
93+
TEST(BaggageTest, RejectMemberInjectionFromDecodedDelimiters)
94+
{
95+
// Decoding the value introduces a semicolon that output treats as metadata.
96+
auto baggage = Baggage::FromHeader("k=a%3B%2Cx%3Dy,good=ok");
97+
EXPECT_EQ(baggage->ToHeader(), "good=ok");
98+
}
99+
100+
TEST(BaggageTest, PreserveValidMetadataWireRepresentation)
101+
{
102+
for (const std::string metadata :
103+
{";flag", ";empty=", ";a=b=c", ";p=100%", ";a=%20%0D%0A%25", "; flag \t; key \t=\t value",
104+
";a=!#$&'()*+-./:<=>?@[]^_`{|}~"})
105+
{
106+
SCOPED_TRACE(metadata);
107+
const auto header = "key=value" + metadata;
108+
EXPECT_EQ(Baggage::FromHeader(header)->ToHeader(), header);
109+
const std::vector<std::pair<std::string, std::string>> entries = {{"key", "value" + metadata}};
110+
EXPECT_EQ(Baggage(entries).ToHeader(), header);
111+
}
112+
}
113+
114+
TEST(BaggageTest, RejectUnsafeBytesAfterTokenization)
115+
{
116+
for (int byte = 0; byte <= 255; ++byte)
117+
{
118+
if ((byte >= 0x20 && byte <= 0x7e) || byte == '\t')
119+
{
120+
continue;
121+
}
122+
SCOPED_TRACE(byte);
123+
const std::string control(1, static_cast<char>(byte));
124+
// Interior bytes must be checked in both the key and the complete value.
125+
for (const auto &member :
126+
{"bad" + control + "key=value;flag", "bad=value;flag=" + control + "value"})
127+
{
128+
EXPECT_EQ(Baggage::FromHeader(member + ",good=ok")->ToHeader(), "good=ok");
129+
}
130+
// The shared tokenizer has always normalized these whitespace bytes at edges.
131+
const bool trimmed = byte >= 0x0a && byte <= 0x0d;
132+
for (const auto &member : {control + "bad=value;flag", "bad=value;flag" + control})
133+
{
134+
EXPECT_EQ(Baggage::FromHeader(member + ",good=ok")->ToHeader(),
135+
trimmed ? "bad=value;flag,good=ok" : "good=ok");
136+
}
137+
}
138+
}
139+
140+
TEST(BaggageTest, PreserveTokenizerEdgeWhitespaceNormalization)
141+
{
142+
EXPECT_EQ(Baggage::FromHeader(" \t\r\n\v\fk=v;flag\r\n\v\f \t,other=ok")->ToHeader(),
143+
"k=v;flag,other=ok");
144+
// Directly constructed metadata is not tokenizer-normalized and must remain rejected.
145+
const std::vector<std::pair<std::string, std::string>> entries = {{"k", "v;flag\r\n"}};
146+
EXPECT_EQ(Baggage(entries).ToHeader(), "");
147+
}
148+
18149
static std::string header_with_custom_entries(size_t num_entries)
19150
{
20151
std::string header;
@@ -127,8 +258,8 @@ TEST(BaggageTest, ValidateInjectHeader)
127258
{"bar%1", "bar 4"},
128259
"a%2A%2Ffoo-_%2Fbar=bar+4,1a-2f%40foo=bar%251"}, // encoding is done properly
129260
{{"foo 1"},
130-
{"bar 1; metadata ; ;;"},
131-
"foo+1=bar+1; metadata ; ;;"}}; // metadata is added without encoding
261+
{"bar 1; metadata ; flag=encoded%20value"},
262+
"foo+1=bar+1; metadata ; flag=encoded%20value"}};
132263

133264
for (auto &testcase : testcases)
134265
{

‎api/test/baggage/propagation/baggage_propagator_test.cc‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ class BaggageCarrierTest : public context::propagation::TextMapCarrier
4747

4848
static BaggagePropagator format;
4949

50+
TEST(BaggagePropagatorTest, DoesNotPropagateHeaderInjection)
51+
{
52+
for (const auto *payload : {"bad=value;a\r\nX-Injected: 1",
53+
"bad=value;a\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: localhost"})
54+
{
55+
BaggageCarrierTest inbound;
56+
inbound.Set("baggage", std::string(payload) + ",good=ok;flag");
57+
context::Context base;
58+
auto extracted = format.Extract(inbound, base);
59+
BaggageCarrierTest outbound;
60+
format.Inject(outbound, extracted);
61+
EXPECT_EQ(outbound.Get("baggage"), "good=ok;flag");
62+
}
63+
}
64+
5065
TEST(BaggagePropagatorTest, ExtractNoBaggageHeader)
5166
{
5267
BaggageCarrierTest carrier;

‎api/test/common/kv_properties_test.cc‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ TEST(EntryTest, SetValue)
7373
using opentelemetry::common::KeyValueStringTokenizer;
7474
using opentelemetry::common::KeyValueStringTokenizerOptions;
7575

76+
TEST(KVStringTokenizer, PreserveExistingWhitespaceNormalization)
77+
{
78+
// OTLP headers and resource attributes also rely on member-edge trimming.
79+
KeyValueStringTokenizer tk(" \t\r\n\v\fAuthorization=token\r\n,resource=value\v\f");
80+
bool valid = false;
81+
nostd::string_view key, value;
82+
ASSERT_TRUE(tk.next(valid, key, value));
83+
EXPECT_TRUE(valid);
84+
EXPECT_EQ(key, "Authorization");
85+
EXPECT_EQ(value, "token");
86+
ASSERT_TRUE(tk.next(valid, key, value));
87+
EXPECT_TRUE(valid);
88+
EXPECT_EQ(key, "resource");
89+
EXPECT_EQ(value, "value");
90+
EXPECT_FALSE(tk.next(valid, key, value));
91+
}
92+
7693
TEST(KVStringTokenizer, SinglePair)
7794
{
7895
bool valid_kv{};

0 commit comments

Comments
 (0)