Repository: pallets/werkzeug

Issue:
multipart disposition parameter parser is case-sensitive in violation of RFC6266
I was trying to implement a file uploader for a very-legacy system that is using a `content-disposition: form-data; name="message"; fileName="sample.txt"` header in multipart/form-data uploads.

Flask/werkzeug/sansio is [only looking for a `filename` extra](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/sansio/multipart.py#L167), so it misses the `fileName` extra.

Therefore, the files are misdetected as fields instead of as files.

However, [RFC6266 §4.3](https://datatracker.ietf.org/doc/html/rfc6266#section-4.3) says that

> The parameters "filename" and "filename*", to be matched case-insensitively, provide information on how to construct a filename for storing the message payload.

Minimal example:

```py
from flask import Flask, request, make_response

app = Flask(__name__)

@app.route('/',methods = ['POST', 'GET'])
def sendmail():
    if request.method == 'POST':
        print('files', request.files)
        print('form', request.form)
        return make_response("Yay", 201)
    return make_response("Nay", 200)

if __name__ == '__main__':
    app.run(debug = True)
```

Payload to trigger the issue:
```http
POST / HTTP/1.0
Content-Type: multipart/form-data; boundary=---------------------------7d93b9550d4a
Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*
Content-Length: 350

-----------------------------7d93b9550d4a
content-disposition: form-data; name="message"; fileName="sample.txt"
content-Type: multipart/form-data;

message

-----------------------------7d93b9550d4a
content-disposition: form-data; name="binary"; fileName="none.jpg"
content-Type: image/jpeg;


-----------------------------7d93b9550d4a--

```

Save the above into request.http and run to test:
```
nc 127.0.0.1 5000 < request.http
```

Expected output: request.files has two elements, "message" and "binary"

Actual output: request.form has two elements, "message" and "binary", request.files has none


Environment:

- Python version: 3.9.10
- Werkzeug version: 2.1.2

P.S: the legacy system I'm using fails to submit an epilogue, instead sending a regular boundary (I've fixed that in the above example), causing the parsing to [fail with a ValueError](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/sansio/multipart.py#L212-L213) which is however silently ignored somewhere upstack, resulting in a "successful" parsing of the form that has neither form fields nor files on it. I'm not sure if this silent ignoring of a form error should be reported as a separate bug.


Discussion:
Hmm, it might make sense for `parse_options_header` to lowercase the options name - I don't think it is considered case sensitive anywhere.

Resolving patch:
diff --git a/src/werkzeug/http.py b/src/werkzeug/http.py
--- a/src/werkzeug/http.py
+++ b/src/werkzeug/http.py
@@ -390,6 +390,9 @@ def parse_options_header(
 
     :param value: The header value to parse.
 
+    .. versionchanged:: 2.2
+        Option names are always converted to lowercase.
+
     .. versionchanged:: 2.1
         The ``multiple`` parameter is deprecated and will be removed in
         Werkzeug 2.2.
@@ -440,7 +443,7 @@ def parse_options_header(
                 if not encoding:
                     encoding = continued_encoding
                 continued_encoding = encoding
-            option = unquote_header_value(option)
+            option = unquote_header_value(option).lower()
 
             if option_value is not None:
                 option_value = unquote_header_value(option_value, option == "filename")


Test patch:
diff --git a/tests/test_http.py b/tests/test_http.py
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -358,6 +358,10 @@ def test_parse_options_header_broken_values(self):
         assert http.parse_options_header(" , a ") == ("", {})
         assert http.parse_options_header(" ; a ") == ("", {})
 
+    def test_parse_options_header_case_insensitive(self):
+        _, options = http.parse_options_header(r'something; fileName="File.ext"')
+        assert options["filename"] == "File.ext"
+
     def test_dump_options_header(self):
         assert http.dump_options_header("foo", {"bar": 42}) == "foo; bar=42"
         assert http.dump_options_header("foo", {"bar": 42, "fizz": None}) in (