Repository: pallets/werkzeug

Issue:
Response allows invalid status
I was accidentally passing a string status like '200' from Flask.

```python
>>> from werkzeug import Response
>>> Response(status='200').status
'200'
```

This status isn't valid according to PEP 3333 and e.g. breaks Twisted's WSGI runner. Obviously the error was mine, but I thought perhaps Werkzeug should raise an error earlier.

Werkzeug already does some validation or coercion into the correct format:

```python
>>> Response(status='abc').status
'0 abc'
>>> Response(status='').status
Traceback (most recent call last):
  File "/Users/frazer/.pyenv/versions/venv/lib/python3.7/site-packages/werkzeug/wrappers/base_response.py", line 314, in _set_status
    self._status_code = int(self._status.split(None, 1)[0])
IndexError: list index out of range

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/frazer/.pyenv/versions/venv/lib/python3.7/site-packages/werkzeug/wrappers/base_response.py", line 202, in __init__
    self.status = status
  File "/Users/frazer/.pyenv/versions/venv/lib/python3.7/site-packages/werkzeug/wrappers/base_response.py", line 319, in _set_status
    raise ValueError("Empty status argument")
ValueError: Empty status argument
```

`status` only becomes invalid if both of these conditions are met:
1. `int(status)` doesn't raise
2. `len(status.split(None, 1)) == 1`

The current implementation:
https://github.com/pallets/werkzeug/blob/e7ba08f209477cb453f15113f9a4d527a6e81bfe/src/werkzeug/wrappers/base_response.py#L307-L320

If line 315 used the property setter instead (`self.status_code = `), the result would be valid:

```python
>>> Response(status='200').status
'200 OK'
```



Discussion:

Resolving patch:
diff --git a/src/werkzeug/wrappers/base_response.py b/src/werkzeug/wrappers/base_response.py
--- a/src/werkzeug/wrappers/base_response.py
+++ b/src/werkzeug/wrappers/base_response.py
@@ -193,10 +193,7 @@ def __init__(
             self.headers["Content-Type"] = content_type
         if status is None:
             status = self.default_status
-        if isinstance(status, int):
-            self.status_code = status
-        else:
-            self.status = status
+        self.status = status
 
         self.direct_passthrough = direct_passthrough
         self._on_close = []
@@ -290,11 +287,7 @@ def status_code(self):
 
     @status_code.setter
     def status_code(self, code):
-        self._status_code = code
-        try:
-            self._status = f"{code} {HTTP_STATUS_CODES[code].upper()}"
-        except KeyError:
-            self._status = f"{code} UNKNOWN"
+        self.status = code
 
     @property
     def status(self):
@@ -303,19 +296,40 @@ def status(self):
 
     @status.setter
     def status(self, value):
-        if not isinstance(value, (str, bytes)):
+        if not isinstance(value, (str, bytes, int)):
             raise TypeError("Invalid status argument")
 
-        self._status = _to_str(value)
+        self._status, self._status_code = self._clean_status(value)
 
-        try:
-            self._status_code = int(self._status.split(None, 1)[0])
-        except ValueError:
-            self._status_code = 0
-            self._status = f"0 {self._status}"
-        except IndexError:
+    def _clean_status(self, value):
+        status = _to_str(value, self.charset)
+        split_status = status.split(None, 1)
+
+        if len(split_status) == 0:
             raise ValueError("Empty status argument")
 
+        if len(split_status) > 1:
+            if split_status[0].isdigit():
+                # code and message
+                return status, int(split_status[0])
+
+            # multi-word message
+            return f"0 {status}", 0
+
+        if split_status[0].isdigit():
+            # code only
+            status_code = int(split_status[0])
+
+            try:
+                status = f"{status_code} {HTTP_STATUS_CODES[status_code].upper()}"
+            except KeyError:
+                status = f"{status_code} UNKNOWN"
+
+            return status, status_code
+
+        # one-word message
+        return f"0 {status}", 0
+
     def get_data(self, as_text=False):
         """The string representation of the request body.  Whenever you call
         this property the request iterable is encoded and flattened.  This


Test patch:
diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py
--- a/tests/test_wrappers.py
+++ b/tests/test_wrappers.py
@@ -355,27 +355,56 @@ def close(self):
     assert len(closed) == 1
 
 
-def test_response_status_codes():
+@pytest.mark.parametrize(
+    ("status_code", "expected_status"),
+    [
+        (200, "200 OK"),
+        (404, "404 NOT FOUND"),
+        (588, "588 UNKNOWN"),
+        (999, "999 UNKNOWN"),
+    ],
+)
+def test_response_set_status_code(status_code, expected_status):
     response = wrappers.BaseResponse()
-    response.status_code = 404
-    assert response.status == "404 NOT FOUND"
-    response.status = "200 OK"
-    assert response.status_code == 200
-    response.status = "999 WTF"
-    assert response.status_code == 999
-    response.status_code = 588
-    assert response.status_code == 588
-    assert response.status == "588 UNKNOWN"
-    response.status = "wtf"
-    assert response.status_code == 0
-    assert response.status == "0 wtf"
+    response.status_code = status_code
+    assert response.status_code == status_code
+    assert response.status == expected_status
 
+
+@pytest.mark.parametrize(
+    ("status", "expected_status_code", "expected_status"),
+    [
+        ("404", 404, "404 NOT FOUND"),
+        ("588", 588, "588 UNKNOWN"),
+        ("999", 999, "999 UNKNOWN"),
+        ("200 OK", 200, "200 OK"),
+        ("999 WTF", 999, "999 WTF"),
+        ("wtf", 0, "0 wtf"),
+        ("200 TEA POT", 200, "200 TEA POT"),
+        (200, 200, "200 OK"),
+        (400, 400, "400 BAD REQUEST"),
+    ],
+)
+def test_response_set_status(status, expected_status_code, expected_status):
+    response = wrappers.BaseResponse()
+    response.status = status
+    assert response.status_code == expected_status_code
+    assert response.status == expected_status
+
+    response = wrappers.Response(status=status)
+    assert response.status_code == expected_status_code
+    assert response.status == expected_status
+
+
+def test_response_init_status_empty_string():
     # invalid status codes
     with pytest.raises(ValueError) as info:
         wrappers.BaseResponse(None, "")
 
     assert "Empty status argument" in str(info.value)
 
+
+def test_response_init_status_tuple():
     with pytest.raises(TypeError) as info:
         wrappers.BaseResponse(None, tuple())