Repository: bugsnag/bugsnag-python

Issue:
WSGI middleware breaks when handling badly encoded URLs
### Description

In public-facing applications, we cannot control what kinds of URLs will be requested by clients -- which means we may have to deal with wrong or broken encoding.

### Issue

`bugsnag.wsgi.middleware.add_wsgi_request_data_to_notification` uses `webob.Request.path_url`, which raises an exception if the URL path is encoded improperly. It would not break if it instead e.g. used the already existing helper function `bugsnag.wsgi.request_path`.

### Environment

Library versions:

- python version: both 2.7 and 3.7
- bugsnag-python version: 3.6.0

### Example code snippet

```python
import unittest
import unittest.mock
import urllib.parse

class PathEncodingTest(unittest.TestCase):

    environ = {
        # https://github.com/python/cpython/blob/master/Lib/wsgiref/simple_server.py#L85
        'PATH_INFO': urllib.parse.unquote('/%83', 'latin-1'),
        'SCRIPT_NAME': '',
        'SERVER_NAME': 'localhost',
        'SERVER_PORT': '80',
        'wsgi.url_scheme': 'http',
    }

    def test_wrongly_encoded_url_should_not_raise(self):
        import bugsnag.wsgi.middleware
        notification = unittest.mock.Mock()
        notification.request_config.wsgi_environ = self.environ
        # This currently raises UnicodeDecodeError
        bugsnag.wsgi.middleware.add_wsgi_request_data_to_notification(
            notification)
        # but I'd expect something like this:
        self.assertEqual(
            'http://localhost/%83',
            notification.add_tab.call_args_list[0][0][1]['url'])

    def test_using_the_already_existing_helper_function_works_somewhat(self):
        import bugsnag.wsgi
        import webob
        r = webob.Request(self.environ)
        self.assertEqual(
            # Not sure how correct this is, but at least it doesn't break
            'http://localhost/%C2%83',
            r.application_url + bugsnag.wsgi.request_path(self.environ))

    def test_werkzeug_instead_of_webob_also_handles_it_without_error(self):
        import werkzeug
        r = werkzeug.Request(self.environ)
        self.assertEqual('http://localhost/%83', r.base_url)

if __name__ == '__main__':
    unittest.main()
```

<details><summary>Error messages:</summary>

```
..E
======================================================================
ERROR: test_wrongly_encoded_url_should_not_raise (__main__.EncodingTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "../foo.py", line 21, in test_wrongly_encoded_url_should_not_raise
    notification)
  File "/Users/schnerring/Downloads/venv/lib/python3.7/site-packages/bugsnag/wsgi/middleware.py", line 28, in add_wsgi_request_data_to_notification
    "url": request.path_url,
  File "/Users/schnerring/Downloads/venv/lib/python3.7/site-packages/webob/request.py", line 467, in path_url
    bpath_info = bytes_(self.path_info, self.url_encoding)
  File "/Users/schnerring/Downloads/venv/lib/python3.7/site-packages/webob/descriptors.py", line 70, in fget
    return req.encget(key, encattr=encattr)
  File "/Users/schnerring/Downloads/venv/lib/python3.7/site-packages/webob/request.py", line 165, in encget
    return bytes_(val, 'latin-1').decode(encoding)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x83 in position 1: invalid start byte

----------------------------------------------------------------------
Ran 3 tests in 0.099s

FAILED (errors=1)
```
</details>


Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a658f72..64f8a09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,13 @@
 Changelog
 =========
 
+## TBD
+
+### Fixes
+
+* WSGI middleware will no longer raise when given a badly encoded URL
+  [#188](https://github.com/bugsnag/bugsnag-python/pull/188)
+
 ## 3.6.0 (2019-06-25)
 
 ### Enhancements
diff --git a/bugsnag/wsgi/middleware.py b/bugsnag/wsgi/middleware.py
index b4b0aad..62adddf 100644
--- a/bugsnag/wsgi/middleware.py
+++ b/bugsnag/wsgi/middleware.py
@@ -21,11 +21,12 @@ def add_wsgi_request_data_to_notification(notification):
 
     environ = notification.request_config.wsgi_environ
     request = Request(environ)
+    path = request_path(environ)
 
-    notification.context = "%s %s" % (request.method, request_path(environ))
+    notification.context = "%s %s" % (request.method, path)
     notification.set_user(id=request.remote_addr)
     notification.add_tab("request", {
-        "url": request.path_url,
+        "url": "%s%s" % (request.application_url, path),
         "headers": dict(request.headers),
         "params": dict(request.params),
     })


Test patch:
diff --git a/tests/test_path_encoding.py b/tests/test_path_encoding.py
new file mode 100644
index 0000000..47a10e7
--- /dev/null
+++ b/tests/test_path_encoding.py
@@ -0,0 +1,121 @@
+# coding=utf-8
+
+import unittest
+from six.moves import urllib
+
+from bugsnag.notification import Notification
+from bugsnag.configuration import (Configuration, RequestConfiguration)
+
+
+class PathEncodingTest(unittest.TestCase):
+    environ = {
+        'SCRIPT_NAME': '',
+        'SERVER_NAME': 'localhost',
+        'SERVER_PORT': '80',
+        'wsgi.url_scheme': 'http',
+    }
+
+    def test_path_supports_ascii_characters(self):
+        import bugsnag.wsgi.middleware
+
+        environ = self.environ.copy()
+        environ['PATH_INFO'] = '/hello/world'
+
+        bugsnag.configure_request(wsgi_environ=environ)
+
+        config = Configuration()
+        notification = Notification(
+            Exception("oops"),
+            config,
+            RequestConfiguration.get_instance()
+        )
+
+        bugsnag.wsgi.middleware.add_wsgi_request_data_to_notification(
+            notification
+        )
+
+        self.assertEqual(
+            'http://localhost/hello/world',
+            notification.meta_data['request']['url']
+        )
+
+    def test_wrongly_encoded_url_should_not_raise(self):
+        import bugsnag.wsgi.middleware
+
+        environ = self.environ.copy()
+        environ['PATH_INFO'] = '/%83'
+
+        bugsnag.configure_request(wsgi_environ=environ)
+
+        config = Configuration()
+        notification = Notification(
+            Exception("oops"),
+            config,
+            RequestConfiguration.get_instance()
+        )
+
+        bugsnag.wsgi.middleware.add_wsgi_request_data_to_notification(
+            notification
+        )
+
+        # We have to use "urllib.parse.quote" here because the exact output
+        # differs on different Python versions because of how they handle
+        # invalid encoding sequences
+        self.assertEqual(
+            'http://localhost/%s' % urllib.parse.quote('%83'),
+            notification.meta_data['request']['url']
+        )
+
+    def test_path_supports_emoji(self):
+        import bugsnag.wsgi.middleware
+
+        environ = self.environ.copy()
+        environ['PATH_INFO'] = '/😇'
+
+        config = Configuration()
+        notification = Notification(
+            Exception("oops"),
+            config,
+            RequestConfiguration.get_instance()
+        )
+
+        bugsnag.configure_request(wsgi_environ=environ)
+
+        bugsnag.wsgi.middleware.add_wsgi_request_data_to_notification(
+            notification
+        )
+
+        # You can validate this by using "encodeURIComponent" in a browser.
+        self.assertEqual(
+            'http://localhost/%F0%9F%98%87',
+            notification.meta_data['request']['url']
+        )
+
+    def test_path_supports_non_ascii_characters(self):
+        import bugsnag.wsgi.middleware
+
+        environ = self.environ.copy()
+        environ['PATH_INFO'] = '/ôßłガ'
+
+        config = Configuration()
+        notification = Notification(
+            Exception("oops"),
+            config,
+            RequestConfiguration.get_instance()
+        )
+
+        bugsnag.configure_request(wsgi_environ=environ)
+
+        bugsnag.wsgi.middleware.add_wsgi_request_data_to_notification(
+            notification
+        )
+
+        # You can validate this by using "encodeURIComponent" in a browser.
+        self.assertEqual(
+            'http://localhost/%C3%B4%C3%9F%C5%82%E3%82%AC',
+            notification.meta_data['request']['url']
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()