CVE-2023-43810 (published 2023-10-06T14:15Z)

OpenTelemetry, also known as OTel for short, is a vendor-neutral open-source Observability framework for instrumenting, generating, collecting, and exporting telemetry data such as traces, metrics, logs. Autoinstrumentation out of the box adds the label `http_method` that has unbound cardinality. It leads to the server's potential memory exhaustion when many malicious requests are sent. HTTP method for requests can be easily set by an attacker to be random and long. In order to be affected program has to be instrumented for HTTP handlers and does not filter any unknown HTTP methods on the level of CDN, LB, previous middleware, etc. This issue has been patched in version 0.41b0.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)

Weakness information:
cwe_id: CWE-400
cwe_name: Uncontrolled Resource Consumption
description: The product does not properly control the allocation and maintenance of a limited resource, thereby enabling an actor to influence the amount of resources consumed, eventually leading to the exhaustion of available resources.
extended_description: 
            Limited resources include memory, file system storage, database connection pool entries, and CPU. If an attacker can trigger the allocation of these limited resources, but the number or size of the resources is not controlled, then the attacker could cause a denial of service that consumes all available resources. This would prevent valid users from accessing the product, and it could potentially have an impact on the surrounding environment. For example, a memory exhaustion attack against an application could slow down the application as well as its host operating system.
            There are at least three distinct scenarios which can commonly lead to resource exhaustion:
               
                  Lack of throttling for the number of allocated resources
                  Losing all references to a resource before reaching the shutdown stage
                  Not closing/returning a resource after processing
               
            Resource exhaustion problems are often result due to an incorrect implementation of the following situations:
               
                  Error conditions and other exceptional circumstances.
                  Confusion over which part of the program is responsible for releasing the resource.
               
         
         

Fix information:
repo_url: https://github.com/open-telemetry/opentelemetry-python-contrib
commit_details: repo_url: https://github.com/open-telemetry/opentelemetry-python-contrib
author: Jakub Warczarek
author_date: 2023-08-15 22:17:14+02:00
msg: Merge pull request from GHSA-5rv5-6h4r-h22v

* Fix unbound cardinality for label http_method in wsgi based middlewares

* cr: rename file

* cr: change label UNKNOWN to NONSTANDARD

* Update instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py

---------

Co-authored-by: Diego Hurtado <ocelotl@users.noreply.github.com>
repository: repo_url: https://github.com/open-telemetry/opentelemetry-python-contrib
repo_name: open-telemetry/opentelemetry-python-contrib
description: OpenTelemetry instrumentation for Python modules
repo_language: Python
owner: open-telemetry
file_changes: filename: test_programmatic.py
change_type: ModificationType.MODIFY
diff: @@ -40,6 +40,8 @@ from opentelemetry.util.http import (
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
+    OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
+    OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS,
     get_excluded_urls,
 )
 
@@ -326,6 +328,25 @@ class TestProgrammatic(InstrumentationTest, WsgiTestBase):
                         if isinstance(point, NumberDataPoint):
                             self.assertEqual(point.value, 0)
 
+    def _assert_basic_metric(self, expected_duration_attributes, expected_requests_count_attributes):
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        for resource_metric in metrics_list.resource_metrics:
+            for scope_metrics in resource_metric.scope_metrics:
+                for metric in scope_metrics.metrics:
+                    for point in list(metric.data.data_points):
+                        if isinstance(point, HistogramDataPoint):
+                            self.assertDictEqual(
+                                expected_duration_attributes,
+                                dict(point.attributes),
+                            )
+                            self.assertEqual(point.count, 1)
+                        elif isinstance(point, NumberDataPoint):
+                            self.assertDictEqual(
+                                expected_requests_count_attributes,
+                                dict(point.attributes),
+                            )
+                            self.assertEqual(point.value, 0)
+
     def test_basic_metric_success(self):
         self.client.get("/hello/756")
         expected_duration_attributes = {
@@ -344,23 +365,62 @@ class TestProgrammatic(InstrumentationTest, WsgiTestBase):
             "http.flavor": "1.1",
             "http.server_name": "localhost",
         }
-        metrics_list = self.memory_metrics_reader.get_metrics_data()
-        for resource_metric in metrics_list.resource_metrics:
-            for scope_metrics in resource_metric.scope_metrics:
-                for metric in scope_metrics.metrics:
-                    for point in list(metric.data.data_points):
-                        if isinstance(point, HistogramDataPoint):
-                            self.assertDictEqual(
-                                expected_duration_attributes,
-                                dict(point.attributes),
-                            )
-                            self.assertEqual(point.count, 1)
-                        elif isinstance(point, NumberDataPoint):
-                            self.assertDictEqual(
-                                expected_requests_count_attributes,
-                                dict(point.attributes),
-                            )
-                            self.assertEqual(point.value, 0)
+        self._assert_basic_metric(
+            expected_duration_attributes,
+            expected_requests_count_attributes,
+        )
+
+    def test_basic_metric_nonstandard_http_method_success(self):
+        self.client.open("/hello/756", method="NONSTANDARD")
+        expected_duration_attributes = {
+            "http.method": "UNKNOWN",
+            "http.host": "localhost",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "localhost",
+            "net.host.port": 80,
+            "http.status_code": 405,
+        }
+        expected_requests_count_attributes = {
+            "http.method": "UNKNOWN",
+            "http.host": "localhost",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "localhost",
+        }
+        self._assert_basic_metric(
+            expected_duration_attributes,
+            expected_requests_count_attributes,
+        )
+
+    @patch.dict(
+    "os.environ",
+        {
+            OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS: "1",
+        },
+    )
+    def test_basic_metric_nonstandard_http_method_allowed_success(self):
+        self.client.open("/hello/756", method="NONSTANDARD")
+        expected_duration_attributes = {
+            "http.method": "NONSTANDARD",
+            "http.host": "localhost",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "localhost",
+            "net.host.port": 80,
+            "http.status_code": 405,
+        }
+        expected_requests_count_attributes = {
+            "http.method": "NONSTANDARD",
+            "http.host": "localhost",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "localhost",
+        }
+        self._assert_basic_metric(
+            expected_duration_attributes,
+            expected_requests_count_attributes,
+        )
 
     def test_metric_uninstrument(self):
         self.client.delete("/hello/756")

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -197,6 +197,12 @@ will replace the value of headers such as ``session-id`` and ``set-cookie`` with
 Note:
     The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change.
 
+Sanitizing methods
+******************
+In order to prevent unbound cardinality for HTTP methods by default nonstandard ones are labeled as ``NONSTANDARD``.
+To record all of the names set the environment variable  ``OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS``
+to a value that evaluates to true, e.g. ``1``.
+
 API
 ---
 """
@@ -226,6 +232,7 @@ from opentelemetry.util.http import (
     normalise_request_header_name,
     normalise_response_header_name,
     remove_url_credentials,
+    sanitize_method,
 )
 
 _HTTP_VERSION_PREFIX = "HTTP/"
@@ -295,7 +302,7 @@ def collect_request_attributes(environ):
     """
 
     result = {
-        SpanAttributes.HTTP_METHOD: environ.get("REQUEST_METHOD"),
+        SpanAttributes.HTTP_METHOD: sanitize_method(environ.get("REQUEST_METHOD")),
         SpanAttributes.HTTP_SERVER_NAME: environ.get("SERVER_NAME"),
         SpanAttributes.HTTP_SCHEME: environ.get("wsgi.url_scheme"),
     }
@@ -450,7 +457,7 @@ def get_default_span_name(environ):
     Returns:
         The span name.
     """
-    method = environ.get("REQUEST_METHOD", "").strip()
+    method = sanitize_method(environ.get("REQUEST_METHOD", "").strip())
     path = environ.get("PATH_INFO", "").strip()
     if method and path:
         return f"{method} {path}"

programming_language: Python
filename: test_wsgi_middleware.py
change_type: ModificationType.MODIFY
diff: @@ -33,6 +33,7 @@ from opentelemetry.util.http import (
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
+    OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS,
 )
 
 
@@ -284,6 +285,24 @@ class TestWsgiApplication(WsgiTestBase):
                             )
         self.assertTrue(number_data_point_seen and histogram_data_point_seen)
 
+    def test_nonstandard_http_method(self):
+        self.environ["REQUEST_METHOD"]= "NONSTANDARD"
+        app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)
+        response = app(self.environ, self.start_response)
+        self.validate_response(response, span_name="HTTP UNKNOWN", http_method="UNKNOWN")
+
+    @mock.patch.dict(
+    "os.environ",
+        {
+            OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS: "1",
+        },
+    )
+    def test_nonstandard_http_method_allowed(self):
+        self.environ["REQUEST_METHOD"]= "NONSTANDARD"
+        app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)
+        response = app(self.environ, self.start_response)
+        self.validate_response(response, span_name="HTTP NONSTANDARD", http_method="NONSTANDARD")
+
     def test_default_span_name_missing_path_info(self):
         """Test that default span_names with missing path info."""
         self.environ.pop("PATH_INFO")

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -31,6 +31,10 @@ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE = (
     "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE"
 )
 
+OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS = (
+    "OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS"
+)
+
 # List of recommended metrics attributes
 _duration_attrs = {
     SpanAttributes.HTTP_METHOD,
@@ -186,6 +190,15 @@ def normalise_response_header_name(header: str) -> str:
     key = header.lower().replace("-", "_")
     return f"http.response.header.{key}"
 
+def sanitize_method(method: str | None) -> str | None:
+    if method is None:
+        return None
+    method = method.upper()
+    if (environ.get(OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS) or
+        # Based on https://www.rfc-editor.org/rfc/rfc7231#section-4.1 and https://www.rfc-editor.org/rfc/rfc5789#section-2.
+        method in ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"]):
+        return method
+    return "NONSTANDARD"
 
 def get_custom_headers(env_var: str) -> List[str]:
     custom_headers = environ.get(env_var, [])

programming_language: Python
filename: test_sanitize_method.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,44 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+from unittest.mock import patch
+
+from opentelemetry.util.http import (
+    OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS,
+    sanitize_method,
+)
+
+class TestSanitizeMethod(unittest.TestCase):
+    def test_standard_method_uppercase(self):
+        method = sanitize_method("GET")
+        self.assertEqual(method, "GET")
+
+    def test_standard_method_lowercase(self):
+        method = sanitize_method("get")
+        self.assertEqual(method, "GET")
+
+    def test_nonstandard_method(self):
+        method = sanitize_method("UNKNOWN")
+        self.assertEqual(method, "NONSTANDARD")
+
+    @patch.dict(
+    "os.environ",
+        {
+            OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS: "1",
+        },
+    )
+    def test_nonstandard_method_allowed(self):
+        method = sanitize_method("UNKNOWN")
+        self.assertEqual(method, "NONSTANDARD")

programming_language: Python