Repository: open-telemetry/opentelemetry-python

Issue:
Baggage value is not parsed correctly in downstream server
**Environment:**
Python 3.7.9, Ubuntu 20.04
opentelemetry-api 1.4.0
opentelemetry-sdk 1.4.0
flask 1.1.3
requests 2.26.0

Setup: A flask upstream server (to set_baggage) and downstream server (to get_baggage).
Attached [requirements.txt](https://github.com/open-telemetry/opentelemetry-python/files/6907720/requirements.txt) if any additional dependency information is needed



**Steps to reproduce**
Upon setting the baggage in the upstream server and trying to get baggage in downstream server, I am not able to get the original value of the baggage.
Whitespaces are replaced by '+' symbol. e.g. Value in baggage `My Random Value` changes to `My+Random+Value`

In the upstream server I am able to extract the original value of the baggage.
Sample code for upstream
```
# UPSTREAM SERVER
from opentelemetry.context import get_current
from opentelemetry.baggage import set_baggage, get_all
from opentelemetry.propagate import inject, extract
from opentelemetry.baggage import get_baggage
@app.route('/upstream')
def hello_world():
    current_context = get_current()
    current_context = set_baggage('My key', 'My Random Value', current_context)
    headers = {}
    inject(headers, context=current_context)
    print(get_baggage('My key', current_context))
    # output of print is: 'My Random Value'
    r = requests.get('http://localhost:9023/downstream', headers=headers)
    return r.content
```
But in downstream server when I extract the baggage using get_baggage I am not able to get the original value.
```
# DOWNSTREAM SERVER
from flask import request
from opentelemetry.propagate import inject, extract
from opentelemetry.baggage import get_baggage
import opentelemetry.instrumentation.wsgi as otel_wsgi
@app.route('/downstream')
def hello_world():
    extracted_context = extract(request.environ, getter=otel_wsgi.wsgi_getter)
    print(get_baggage('My key', extracted_context))
    # output of print is: 'My+Random+Value'
    return 'Called downstream server!'
```

**Expected behaviour**
Downstream server should be able to get original value of baggage.



Discussion:
Issue is that we use quote_plus to encode but quote to decode.

https://github.com/open-telemetry/opentelemetry-python/blob/71e3a7a192c0fc8a7503fac967ada36a74b79e58/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py#L100
https://github.com/open-telemetry/opentelemetry-python/blob/71e3a7a192c0fc8a7503fac967ada36a74b79e58/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py#L68

We need to figure out which encoding the spec recommends and other otel projects use before fixing this.

https://w3c.github.io/baggage/#definition

```
baggage-string         =  list-member 0*179( OWS "," OWS list-member )
list-member            =  key OWS "=" OWS value *( OWS ";" OWS property )
property               =  key OWS "=" OWS value
property               =/ key OWS
key                    =  token ; as defined in RFC 2616, Section 2.2
value                  =  *baggage-octet
baggage-octet          =  %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
                          ; US-ASCII characters excluding CTLs,
                          ; whitespace, DQUOTE, comma, semicolon,
                          ; and backslash
OWS                    =  *( SP / HTAB ) ; optional white space, as defined in RFC 7230, Section 3.2.3
```

By this definition baggage values with space shouldn't be allowed in the first place, isn't it?
Go has the validation for baggage items

https://github.com/open-telemetry/opentelemetry-go/blob/4bf6150fa94e18bdf01c96ed78ee6d1c76f8e308/baggage/baggage.go#L36-L55

Resolving patch:
diff --git a/opentelemetry-api/src/opentelemetry/baggage/__init__.py b/opentelemetry-api/src/opentelemetry/baggage/__init__.py
--- a/opentelemetry-api/src/opentelemetry/baggage/__init__.py
+++ b/opentelemetry-api/src/opentelemetry/baggage/__init__.py
@@ -12,18 +12,30 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import typing
+from logging import getLogger
+from re import compile
 from types import MappingProxyType
+from typing import Mapping, Optional
 
 from opentelemetry.context import create_key, get_value, set_value
 from opentelemetry.context.context import Context
+from opentelemetry.util.re import (
+    _BAGGAGE_PROPERTY_FORMAT,
+    _KEY_FORMAT,
+    _VALUE_FORMAT,
+)
 
 _BAGGAGE_KEY = create_key("baggage")
+_logger = getLogger(__name__)
+
+_KEY_PATTERN = compile(_KEY_FORMAT)
+_VALUE_PATTERN = compile(_VALUE_FORMAT)
+_PROPERT_PATTERN = compile(_BAGGAGE_PROPERTY_FORMAT)
 
 
 def get_all(
-    context: typing.Optional[Context] = None,
-) -> typing.Mapping[str, object]:
+    context: Optional[Context] = None,
+) -> Mapping[str, object]:
     """Returns the name/value pairs in the Baggage
 
     Args:
@@ -39,8 +51,8 @@ def get_all(
 
 
 def get_baggage(
-    name: str, context: typing.Optional[Context] = None
-) -> typing.Optional[object]:
+    name: str, context: Optional[Context] = None
+) -> Optional[object]:
     """Provides access to the value for a name/value pair in the
     Baggage
 
@@ -56,7 +68,7 @@ def get_baggage(
 
 
 def set_baggage(
-    name: str, value: object, context: typing.Optional[Context] = None
+    name: str, value: object, context: Optional[Context] = None
 ) -> Context:
     """Sets a value in the Baggage
 
@@ -69,13 +81,20 @@ def set_baggage(
         A Context with the value updated
     """
     baggage = dict(get_all(context=context))
-    baggage[name] = value
+    if not _is_valid_key(name):
+        _logger.warning(
+            "Baggage key `%s` does not match format, ignoring", name
+        )
+    elif not _is_valid_value(str(value)):
+        _logger.warning(
+            "Baggage value `%s` does not match format, ignoring", value
+        )
+    else:
+        baggage[name] = value
     return set_value(_BAGGAGE_KEY, baggage, context=context)
 
 
-def remove_baggage(
-    name: str, context: typing.Optional[Context] = None
-) -> Context:
+def remove_baggage(name: str, context: Optional[Context] = None) -> Context:
     """Removes a value from the Baggage
 
     Args:
@@ -91,7 +110,7 @@ def remove_baggage(
     return set_value(_BAGGAGE_KEY, baggage, context=context)
 
 
-def clear(context: typing.Optional[Context] = None) -> Context:
+def clear(context: Optional[Context] = None) -> Context:
     """Removes all values from the Baggage
 
     Args:
@@ -101,3 +120,22 @@ def clear(context: typing.Optional[Context] = None) -> Context:
         A Context with all baggage entries removed
     """
     return set_value(_BAGGAGE_KEY, {}, context=context)
+
+
+def _is_valid_key(name: str) -> bool:
+    return _KEY_PATTERN.fullmatch(str(name)) is not None
+
+
+def _is_valid_value(value: object) -> bool:
+    parts = str(value).split(";")
+    is_valid_value = _VALUE_PATTERN.fullmatch(parts[0]) is not None
+    if len(parts) > 1:  # one or more properties metadata
+        for property in parts[1:]:
+            if _PROPERT_PATTERN.fullmatch(property) is None:
+                is_valid_value = False
+                break
+    return is_valid_value
+
+
+def _is_valid_pair(key: str, value: str) -> bool:
+    return _is_valid_key(key) and _is_valid_value(value)
diff --git a/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py b/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py
--- a/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py
+++ b/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py
@@ -12,13 +12,18 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-import typing
+from logging import getLogger
+from re import split
+from typing import Iterable, Mapping, Optional, Set
 from urllib.parse import quote_plus, unquote_plus
 
-from opentelemetry.baggage import get_all, set_baggage
+from opentelemetry.baggage import _is_valid_pair, get_all, set_baggage
 from opentelemetry.context import get_current
 from opentelemetry.context.context import Context
 from opentelemetry.propagators import textmap
+from opentelemetry.util.re import _DELIMITER_PATTERN
+
+_logger = getLogger(__name__)
 
 
 class W3CBaggagePropagator(textmap.TextMapPropagator):
@@ -32,7 +37,7 @@ class W3CBaggagePropagator(textmap.TextMapPropagator):
     def extract(
         self,
         carrier: textmap.CarrierT,
-        context: typing.Optional[Context] = None,
+        context: Optional[Context] = None,
         getter: textmap.Getter = textmap.default_getter,
     ) -> Context:
         """Extract Baggage from the carrier.
@@ -49,20 +54,46 @@ def extract(
         )
 
         if not header or len(header) > self._MAX_HEADER_LENGTH:
+            _logger.warning(
+                "Baggage header `%s` exceeded the maximum number of bytes per baggage-string",
+                header,
+            )
             return context
 
-        baggage_entries = header.split(",")
+        baggage_entries = split(_DELIMITER_PATTERN, header)
         total_baggage_entries = self._MAX_PAIRS
+
+        if len(baggage_entries) > self._MAX_PAIRS:
+            _logger.warning(
+                "Baggage header `%s` exceeded the maximum number of list-members",
+                header,
+            )
+
         for entry in baggage_entries:
             if len(entry) > self._MAX_PAIR_LENGTH:
+                _logger.warning(
+                    "Baggage entry `%s` exceeded the maximum number of bytes per list-member",
+                    entry,
+                )
+                continue
+            if not entry:  # empty string
                 continue
             try:
                 name, value = entry.split("=", 1)
             except Exception:  # pylint: disable=broad-except
+                _logger.warning(
+                    "Baggage list-member `%s` doesn't match the format", entry
+                )
                 continue
+            name = unquote_plus(name).strip().lower()
+            value = unquote_plus(value).strip()
+            if not _is_valid_pair(name, value):
+                _logger.warning("Invalid baggage entry: `%s`", entry)
+                continue
+
             context = set_baggage(
-                unquote_plus(name).strip(),
-                unquote_plus(value).strip(),
+                name,
+                value,
                 context=context,
             )
             total_baggage_entries -= 1
@@ -74,7 +105,7 @@ def extract(
     def inject(
         self,
         carrier: textmap.CarrierT,
-        context: typing.Optional[Context] = None,
+        context: Optional[Context] = None,
         setter: textmap.Setter = textmap.default_setter,
     ) -> None:
         """Injects Baggage into the carrier.
@@ -90,12 +121,12 @@ def inject(
         setter.set(carrier, self._BAGGAGE_HEADER_NAME, baggage_string)
 
     @property
-    def fields(self) -> typing.Set[str]:
+    def fields(self) -> Set[str]:
         """Returns a set with the fields set in `inject`."""
         return {self._BAGGAGE_HEADER_NAME}
 
 
-def _format_baggage(baggage_entries: typing.Mapping[str, object]) -> str:
+def _format_baggage(baggage_entries: Mapping[str, object]) -> str:
     return ",".join(
         quote_plus(str(key)) + "=" + quote_plus(str(value))
         for key, value in baggage_entries.items()
@@ -103,8 +134,8 @@ def _format_baggage(baggage_entries: typing.Mapping[str, object]) -> str:
 
 
 def _extract_first_element(
-    items: typing.Optional[typing.Iterable[textmap.CarrierT]],
-) -> typing.Optional[textmap.CarrierT]:
+    items: Optional[Iterable[textmap.CarrierT]],
+) -> Optional[textmap.CarrierT]:
     if items is None:
         return None
     return next(iter(items), None)
diff --git a/opentelemetry-api/src/opentelemetry/util/re.py b/opentelemetry-api/src/opentelemetry/util/re.py
--- a/opentelemetry-api/src/opentelemetry/util/re.py
+++ b/opentelemetry-api/src/opentelemetry/util/re.py
@@ -27,10 +27,12 @@
 )
 # A value contains a URL encoded UTF-8 string.
 _VALUE_FORMAT = r"[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*"
-_HEADER_FORMAT = _KEY_FORMAT + _OWS + r"=" + _OWS + _VALUE_FORMAT
-_HEADER_PATTERN = compile(_HEADER_FORMAT)
+_KEY_VALUE_FORMAT = rf"{_OWS}{_KEY_FORMAT}{_OWS}={_OWS}{_VALUE_FORMAT}{_OWS}"
+_HEADER_PATTERN = compile(_KEY_VALUE_FORMAT)
 _DELIMITER_PATTERN = compile(r"[ \t]*,[ \t]*")
 
+_BAGGAGE_PROPERTY_FORMAT = rf"{_KEY_VALUE_FORMAT}|{_OWS}{_KEY_FORMAT}{_OWS}"
+
 
 # pylint: disable=invalid-name
 def parse_headers(s: str) -> Mapping[str, str]:


Test patch:
diff --git a/opentelemetry-api/tests/baggage/test_baggage_propagation.py b/opentelemetry-api/tests/baggage/test_baggage_propagation.py
--- a/opentelemetry-api/tests/baggage/test_baggage_propagation.py
+++ b/opentelemetry-api/tests/baggage/test_baggage_propagation.py
@@ -15,6 +15,7 @@
 # type: ignore
 
 import unittest
+from logging import WARNING
 from unittest.mock import Mock, patch
 
 from opentelemetry import baggage
@@ -62,19 +63,27 @@ def test_valid_header_with_space(self):
         self.assertEqual(self._extract(header), expected)
 
     def test_valid_header_with_properties(self):
-        header = "key1=val1,key2=val2;prop=1"
-        expected = {"key1": "val1", "key2": "val2;prop=1"}
+        header = "key1=val1,key2=val2;prop=1;prop2;prop3=2"
+        expected = {"key1": "val1", "key2": "val2;prop=1;prop2;prop3=2"}
         self.assertEqual(self._extract(header), expected)
 
-    def test_valid_header_with_url_escaped_comma(self):
-        header = "key%2C1=val1,key2=val2%2Cval3"
-        expected = {"key,1": "val1", "key2": "val2,val3"}
+    def test_valid_header_with_url_escaped_values(self):
+        header = "key1=val1,key2=val2%3Aval3,key3=val4%40%23%24val5"
+        expected = {
+            "key1": "val1",
+            "key2": "val2:val3",
+            "key3": "val4@#$val5",
+        }
         self.assertEqual(self._extract(header), expected)
 
-    def test_valid_header_with_invalid_value(self):
+    def test_header_with_invalid_value(self):
         header = "key1=val1,key2=val2,a,val3"
-        expected = {"key1": "val1", "key2": "val2"}
-        self.assertEqual(self._extract(header), expected)
+        with self.assertLogs(level=WARNING) as warning:
+            self._extract(header)
+            self.assertIn(
+                "Baggage list-member `a` doesn't match the format",
+                warning.output[0],
+            )
 
     def test_valid_header_with_empty_value(self):
         header = "key1=,key2=val2"
@@ -82,9 +91,8 @@ def test_valid_header_with_empty_value(self):
         self.assertEqual(self._extract(header), expected)
 
     def test_invalid_header(self):
-        header = "header1"
-        expected = {}
-        self.assertEqual(self._extract(header), expected)
+        self.assertEqual(self._extract("header1"), {})
+        self.assertEqual(self._extract(" = "), {})
 
     def test_header_too_long(self):
         long_value = "s" * (W3CBaggagePropagator._MAX_HEADER_LENGTH + 1)
@@ -107,62 +115,85 @@ def test_header_contains_pair_too_long(self):
         long_value = "s" * (W3CBaggagePropagator._MAX_PAIR_LENGTH + 1)
         header = "key1=value1,key2={},key3=value3".format(long_value)
         expected = {"key1": "value1", "key3": "value3"}
-        self.assertEqual(self._extract(header), expected)
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(self._extract(header), expected)
+            self.assertIn(
+                "exceeded the maximum number of bytes per list-member",
+                warning.output[0],
+            )
 
     def test_extract_unquote_plus(self):
         self.assertEqual(
-            self._extract("key+key=value+value"), {"key key": "value value"}
+            self._extract("keykey=value%5Evalue"), {"keykey": "value^value"}
         )
         self.assertEqual(
-            self._extract("key%2Fkey=value%2Fvalue"),
-            {"key/key": "value/value"},
+            self._extract("key%23key=value%23value"),
+            {"key#key": "value#value"},
         )
 
     def test_header_max_entries_skip_invalid_entry(self):
 
-        self.assertEqual(
-            self._extract(
-                ",".join(
-                    [
-                        f"key{index}=value{index}"
-                        if index != 2
-                        else (
-                            f"key{index}="
-                            f"value{'s' * (W3CBaggagePropagator._MAX_PAIR_LENGTH + 1)}"
-                        )
-                        for index in range(W3CBaggagePropagator._MAX_PAIRS + 1)
-                    ]
-                )
-            ),
-            {
-                f"key{index}": f"value{index}"
-                for index in range(W3CBaggagePropagator._MAX_PAIRS + 1)
-                if index != 2
-            },
-        )
-        self.assertEqual(
-            self._extract(
-                ",".join(
-                    [
-                        f"key{index}=value{index}"
-                        if index != 2
-                        else f"key{index}xvalue{index}"
-                        for index in range(W3CBaggagePropagator._MAX_PAIRS + 1)
-                    ]
-                )
-            ),
-            {
-                f"key{index}": f"value{index}"
-                for index in range(W3CBaggagePropagator._MAX_PAIRS + 1)
-                if index != 2
-            },
-        )
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self._extract(
+                    ",".join(
+                        [
+                            f"key{index}=value{index}"
+                            if index != 2
+                            else (
+                                f"key{index}="
+                                f"value{'s' * (W3CBaggagePropagator._MAX_PAIR_LENGTH + 1)}"
+                            )
+                            for index in range(
+                                W3CBaggagePropagator._MAX_PAIRS + 1
+                            )
+                        ]
+                    )
+                ),
+                {
+                    f"key{index}": f"value{index}"
+                    for index in range(W3CBaggagePropagator._MAX_PAIRS + 1)
+                    if index != 2
+                },
+            )
+            self.assertIn(
+                "exceeded the maximum number of list-members",
+                warning.output[0],
+            )
+
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self._extract(
+                    ",".join(
+                        [
+                            f"key{index}=value{index}"
+                            if index != 2
+                            else f"key{index}xvalue{index}"
+                            for index in range(
+                                W3CBaggagePropagator._MAX_PAIRS + 1
+                            )
+                        ]
+                    )
+                ),
+                {
+                    f"key{index}": f"value{index}"
+                    for index in range(W3CBaggagePropagator._MAX_PAIRS + 1)
+                    if index != 2
+                },
+            )
+            self.assertIn(
+                "exceeded the maximum number of list-members",
+                warning.output[0],
+            )
 
     def test_inject_no_baggage_entries(self):
         values = {}
         output = self._inject(values)
         self.assertEqual(None, output)
 
+    def test_inject_invalid_entries(self):
+        self.assertEqual(None, self._inject({"key": "val ue"}))
+
     def test_inject(self):
         values = {
             "key1": "val1",
@@ -178,7 +209,6 @@ def test_inject_escaped_values(self):
             "key2": "val3=4",
         }
         output = self._inject(values)
-        self.assertIn("key1=val1%2Cval2", output)
         self.assertIn("key2=val3%3D4", output)
 
     def test_inject_non_string_values(self):