Repository: encode/starlette

Issue:
multipart/form-data subpart without name causes internal error
### Checklist

<!-- Please make sure you check all these items before submitting your bug report. -->

- [x] The bug is reproducible against the latest release and/or `master`.
- [x] There are no similar issues or pull requests to fix it yet.

### Describe the bug

<!-- A clear and concise description of what the bug is. -->

If attempting to read a multipart/form-data content type from a request and one of the subparts' Content-Disposition headers doesn't contain a "name" parameter, the form parser throws a KeyError.

### To reproduce

<!-- Provide a *minimal* example with steps to reproduce the bug locally.

NOTE: try to keep any external dependencies *at an absolute minimum*
(middleware, servers, proxies, certificates...).
In other words, remove anything that doesn't make the bug go away.
-->

multipart-buglette.py :
```python
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route


async def handler(request):
    return JSONResponse({k: v for k, v in (await request.form()).items()})


app = Starlette(
    debug=True,
    routes=[
        Route("/", handler, methods=["POST"]),
    ],
)
```
Run with:
```sh
uvicorn multipart-buglette:app
```
OK response:
```sh
% curl -F 'foo=bar' localhost:8000
{"foo":"bar"}
```
Error response:
```sh
% curl -F '=bar' localhost:8000
Traceback (most recent call last):
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/middleware/errors.py", line 159, in __call__
    await self.app(scope, receive, _send)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/exceptions.py", line 82, in __call__
    raise exc
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/exceptions.py", line 71, in __call__
    await self.app(scope, receive, sender)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/routing.py", line 656, in __call__
    await route.handle(scope, receive, send)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/routing.py", line 259, in handle
    await self.app(scope, receive, send)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/routing.py", line 61, in app
    response = await func(request)
  File "/tmp/github/./multipart-buglette.py", line 7, in handler
    return JSONResponse({k: v for k, v in (await request.form()).items()})
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/requests.py", line 247, in form
    self._form = await multipart_parser.parse()
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/formparsers.py", line 212, in parse
    field_name = _user_safe_decode(options[b"name"], charset)
KeyError: b'name'
```

### Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

[RFC 7578 (Returning Values from Forms: multipart/form-data)](https://www.rfc-editor.org/rfc/rfc7578#section-4.2) does state that:
> The Content-Disposition header field MUST also contain an additional parameter of "name"

So, one might expect a parsing ValueError subtype rejecting the multipart/form-data, similar to getting a `json.decoder.JSONDecodeError` when attempting to read bad JSON.

Or, one might be more lenient and map the lack of "name" to the empty string "".

### Actual behavior

<!-- A clear and concise description of what actually happens. -->
An internal KeyError exception is thrown for the missing "name" parameter.

### Debugging material

<!-- Any tracebacks, screenshots, etc. that can help understanding the problem.

NOTE:
- Please list tracebacks in full (don't truncate them).
- Consider using `<details>` to make tracebacks/logs collapsible if they're very large (see https://gist.github.com/ericclemmons/b146fe5da72ca1f706b2ef72a20ac39d).
-->
Please refer to the "to reproduce" section.

### Environment

- OS: Linux
- Python version: 3.9.7
- Starlette version: master @ 3282d2364119c205adcd13b4e1db8a53af2202c7

### Additional context

<!-- Any additional information that can help understanding the problem.

Eg. linked issues, or a description of what you were trying to achieve. -->

Unfortunately, other more egregiously malformed multipart/form-data doesn't fare much better in raising an easily identifiable parsing error; e.g.

```sh
% curl -H 'Content-Type: multipart/form-data' -d 'bogus' localhost:8000
Traceback (most recent call last):
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/middleware/errors.py", line 159, in __call__
    await self.app(scope, receive, _send)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/exceptions.py", line 82, in __call__
    raise exc
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/exceptions.py", line 71, in __call__
    await self.app(scope, receive, sender)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/routing.py", line 656, in __call__
    await route.handle(scope, receive, send)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/routing.py", line 259, in handle
    await self.app(scope, receive, send)
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/routing.py", line 61, in app
    response = await func(request)
  File "/tmp/github/./multipart-buglette.py", line 7, in handler
    return JSONResponse({k: v for k, v in (await request.form()).items()})
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/requests.py", line 247, in form
    self._form = await multipart_parser.parse()
  File "/tmp/github/venv/lib/python3.9/site-packages/starlette/formparsers.py", line 177, in parse
    parser = multipart.MultipartParser(boundary, callbacks)
  File "/tmp/github/venv/lib/python3.9/site-packages/multipart/multipart.py", line 1042, in __init__
    self.boundary = b'\r\n--' + boundary
TypeError: can't concat NoneType to bytes
```
...though in this case it is an issue in the underlying `python-multipart` package and not `starlette` itself.


Discussion:

Patch:
diff --git a/setup.cfg b/setup.cfg
index 734fa81..8dad329 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -30,6 +30,7 @@ filterwarnings=
     error
     ignore: run_until_first_complete is deprecated and will be removed in a future version.:DeprecationWarning
     ignore: starlette\.middleware\.wsgi is deprecated and will be removed in a future release\.*:DeprecationWarning
+    ignore: Async generator 'starlette\.requests\.Request\.stream' was garbage collected before it had been exhausted.*:ResourceWarning
 
 [coverage:run]
 source_pkgs = starlette, tests
diff --git a/starlette/formparsers.py b/starlette/formparsers.py
index 4cde71b..53538c8 100644
--- a/starlette/formparsers.py
+++ b/starlette/formparsers.py
@@ -220,7 +220,13 @@ class MultiPartParser:
                     header_value = b""
                 elif message_type == MultiPartMessage.HEADERS_FINISHED:
                     disposition, options = parse_options_header(content_disposition)
-                    field_name = _user_safe_decode(options[b"name"], charset)
+                    try:
+                        field_name = _user_safe_decode(options[b"name"], charset)
+                    except KeyError:
+                        raise MultiPartException(
+                            'The Content-Disposition header field "name" must be '
+                            "provided."
+                        )
                     if b"filename" in options:
                         filename = _user_safe_decode(options[b"filename"], charset)
                         file = UploadFile(


Test patch:
diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py
index 6710595..7418595 100644
--- a/tests/test_formparsers.py
+++ b/tests/test_formparsers.py
@@ -418,3 +418,35 @@ def test_missing_boundary_parameter(
         )
         assert res.status_code == 400
         assert res.text == "Missing boundary in multipart."
+
+
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (app, pytest.raises(MultiPartException)),
+        (Starlette(routes=[Mount("/", app=app)]), does_not_raise()),
+    ],
+)
+def test_missing_name_parameter_on_content_disposition(
+    app, expectation, test_client_factory: typing.Callable[..., TestClient]
+):
+    client = test_client_factory(app)
+    with expectation:
+        res = client.post(
+            "/",
+            data=(
+                # data
+                b"--a7f7ac8d4e2e437c877bb7b8d7cc549c\r\n"
+                b'Content-Disposition: form-data; ="field0"\r\n\r\n'
+                b"value0\r\n"
+            ),
+            headers={
+                "Content-Type": (
+                    "multipart/form-data; boundary=a7f7ac8d4e2e437c877bb7b8d7cc549c"
+                )
+            },
+        )
+        assert res.status_code == 400
+        assert (
+            res.text == 'The Content-Disposition header field "name" must be provided.'
+        )