Repository: pallets/werkzeug (python)

Issue:
Improper Content-Length Parsing
## Description
werkzeug currently parses Content-Length using `int`. This causes problems because `int` accepts a lot more than should be acceptable in that field. The biggest problem characters are, '-', '+', and '_'.

# Examples
## HTTP requests in which werkzeug misinterprets content-length (assume all newlines are CRLF):

### Underscore between digits
```
GET / HTTP/1.1
Connection: close
Host: whatever
Content-Length: 0_1


```

### Plus sign prefix
```
GET / HTTP/1.1
Connection: close
Host: whatever
Content-Length: +1


```

# Solution
The set of things you want to accept in a Content-Length header is not equal to the set of things that can get through `int` without error. During Content-Length parsing, check that the value is all ASCII digits before accepting. A more principled approach would reject all invalid Content-Length fields instead of just ignoring them. This is the approach taken by a large majority of other HTTP servers.

# Versions
CPython version: 3.10.11
werkzeug version: main

Pull request:
fail on Python's extended int/float syntax
Python's `int` and `float` allow a leading plus `+123`, underscore separators `1_12`, and non-ASCII decimal digits `🯱🯲🯳`. `float` additionally allows no number before the decimal `.123`. None of these are valid when parsing HTTP header values. Use a regex to quickly check if the value is a "plain" int or float, raising `ValueError` if not. fixes #2716 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 5ea84fd0..1c02c319 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -12,6 +12,9 @@ Unreleased
     encoding. :issue:`2700`
 -   ``iri_to_uri`` shows a deprecation warning instead of an error when passing bytes.
     :issue:`2708`
+-   When parsing numbers in HTTP request headers such as ``Content-Length``, only ASCII
+    digits are accepted rather than any format that Python's ``int`` and ``float``
+    accept. :issue:`2716`
 
 
 Version 2.3.4
diff --git a/src/werkzeug/_internal.py b/src/werkzeug/_internal.py
index d2d281cf..4981f811 100644
--- a/src/werkzeug/_internal.py
+++ b/src/werkzeug/_internal.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import logging
 import operator
+import re
 import sys
 import typing as t
 from datetime import datetime
@@ -309,3 +310,32 @@ def _decode_idna(domain: str) -> str:
             parts.append(part.decode("ascii"))
 
     return ".".join(parts)
+
+
+_plain_int_re = re.compile(r"-?\d+", re.ASCII)
+_plain_float_re = re.compile(r"-?\d+\.\d+", re.ASCII)
+
+
+def _plain_int(value: str) -> int:
+    """Parse an int only if it is only ASCII digits and ``-``.
+
+    This disallows ``+``, ``_``, and non-ASCII digits, which are accepted by ``int`` but
+    are not allowed in HTTP header values.
+    """
+    if _plain_int_re.fullmatch(value) is None:
+        raise ValueError
+
+    return int(value)
+
+
+def _plain_float(value: str) -> float:
+    """Parse a float only if it is only ASCII digits and ``-``, and contains digits
+    before and after the ``.``.
+
+    This disallows ``+``, ``_``, non-ASCII digits, and ``.123``, which are accepted by
+    ``float`` but are not allowed in HTTP header values.
+    """
+    if _plain_float_re.fullmatch(value) is None:
+        raise ValueError
+
+    return float(value)
diff --git a/src/werkzeug/datastructures/file_storage.py b/src/werkzeug/datastructures/file_storage.py
index 5879c6f1..2903d46f 100644
--- a/src/werkzeug/datastructures/file_storage.py
+++ b/src/werkzeug/datastructures/file_storage.py
@@ -5,6 +5,7 @@ from io import BytesIO
 from os import fsdecode
 from os import fspath
 
+from .._internal import _plain_int
 from .structures import MultiDict
 
 
@@ -67,7 +68,7 @@ class FileStorage:
     def content_length(self):
         """The content-length sent in the header.  Usually not available"""
         try:
-            return int(self.headers.get("content-length") or 0)
+            return _plain_int(self.headers.get("content-length") or 0)
         except ValueError:
             return 0
 
diff --git a/src/werkzeug/formparser.py b/src/werkzeug/formparser.py
index dcedd339..f96f367d 100644
--- a/src/werkzeug/formparser.py
+++ b/src/werkzeug/formparser.py
@@ -5,6 +5,7 @@ import warnings
 from io import BytesIO
 from urllib.parse import parse_qsl
 
+from ._internal import _plain_int
 from .datastructures import FileStorage
 from .datastructures import Headers
 from .datastructures import MultiDict
@@ -465,7 +466,7 @@ class MultiPartParser:
         content_type = event.headers.get("content-type")
 
         try:
-            content_length = int(event.headers["content-length"])
+            content_length = _plain_int(event.headers["content-length"])
         except (KeyError, ValueError):
             content_length = 0
 
diff --git a/src/werkzeug/http.py b/src/werkzeug/http.py
index 08b5c538..79425beb 100644
--- a/src/werkzeug/http.py
+++ b/src/werkzeug/http.py
@@ -18,6 +18,8 @@ from urllib.parse import unquote
 from urllib.request import parse_http_list as _parse_list_header
 
 from ._internal import _dt_as_utc
+from ._internal import _plain_float
+from ._internal import _plain_int
 
 if t.TYPE_CHECKING:
     from _typeshed.wsgi import WSGIEnvironment
@@ -656,7 +658,7 @@ def parse_accept_header(
         if "q" in options:
             try:
                 # pop q, remaining options are reconstructed
-                q = float(options.pop("q"))
+                q = _plain_float(options.pop("q"))
             except ValueError:
                 # ignore an invalid q
                 continue
@@ -914,7 +916,7 @@ def parse_range_header(
             if last_end < 0:
                 return None
             try:
-                begin = int(item)
+                begin = _plain_int(item)
             except ValueError:
                 return None
             end = None
@@ -925,7 +927,7 @@ def parse_range_header(
             end_str = end_str.strip()
 
             try:
-                begin = int(begin_str)
+                begin = _plain_int(begin_str)
             except ValueError:
                 return None
 
@@ -933,7 +935,7 @@ def parse_range_header(
                 return None
             if end_str:
                 try:
-                    end = int(end_str) + 1
+                    end = _plain_int(end_str) + 1
                 except ValueError:
                     return None
 
@@ -976,7 +978,7 @@ def parse_content_range_header(
         length = None
     else:
         try:
-            length = int(length_str)
+            length = _plain_int(length_str)
         except ValueError:
             return None
 
@@ -990,8 +992,8 @@ def parse_content_range_header(
 
     start_str, stop_str = rng.split("-", 1)
     try:
-        start = int(start_str)
-        stop = int(stop_str) + 1
+        start = _plain_int(start_str)
+        stop = _plain_int(stop_str) + 1
     except ValueError:
         return None
 
diff --git a/src/werkzeug/sansio/utils.py b/src/werkzeug/sansio/utils.py
index 93a58e17..48ec1bfa 100644
--- a/src/werkzeug/sansio/utils.py
+++ b/src/werkzeug/sansio/utils.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import typing as t
 from urllib.parse import quote
 
+from .._internal import _plain_int
 from ..exceptions import SecurityError
 from ..urls import uri_to_iri
 
@@ -153,6 +154,6 @@ def get_content_length(
         return None
 
     try:
-        return max(0, int(http_content_length))
+        return max(0, _plain_int(http_content_length))
     except ValueError:
         return 0


Test patch:
diff --git a/tests/sansio/test_request.py b/tests/sansio/test_request.py
index 310b2448..4f4bbd6b 100644
--- a/tests/sansio/test_request.py
+++ b/tests/sansio/test_request.py
@@ -12,6 +12,10 @@ from werkzeug.sansio.request import Request
         (Headers({"Transfer-Encoding": "chunked", "Content-Length": "6"}), None),
         (Headers({"Transfer-Encoding": "something", "Content-Length": "6"}), 6),
         (Headers({"Content-Length": "6"}), 6),
+        (Headers({"Content-Length": "-6"}), 0),
+        (Headers({"Content-Length": "+123"}), 0),
+        (Headers({"Content-Length": "1_23"}), 0),
+        (Headers({"Content-Length": "🯱🯲🯳"}), 0),
         (Headers(), None),
     ],
 )
diff --git a/tests/test_http.py b/tests/test_http.py
index 65febf96..2c6c0b29 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -1,4 +1,5 @@
 import base64
+import urllib.parse
 from datetime import date
 from datetime import datetime
 from datetime import timedelta
@@ -760,3 +761,26 @@ def test_parse_date(value, expect):
 )
 def test_http_date(value, expect):
     assert http.http_date(value) == expect
+
+
+@pytest.mark.parametrize("value", [".5", "+0.5", "0.5_1", "🯰.🯵"])
+def test_accept_invalid_float(value):
+    quoted = urllib.parse.quote(value)
+
+    if quoted == value:
+        q = f"q={value}"
+    else:
+        q = f"q*=UTF-8''{value}"
+
+    a = http.parse_accept_header(f"en,jp;{q}")
+    assert list(a.values()) == ["en"]
+
+
+@pytest.mark.parametrize("value", ["🯱🯲🯳", "+1-", "1-1_23"])
+def test_range_invalid_int(value):
+    assert http.parse_range_header(value) is None
+
+
+@pytest.mark.parametrize("value", ["*/🯱🯲🯳", "1-+2/3", "1_23-125/*"])
+def test_content_range_invalid_int(value):
+    assert http.parse_content_range_header(f"bytes {value}") is None