Repository: liberapay/liberapay.com

Issue:
Passing bad data to basic auth can result in a 500
https://sentry.changaco.oy.lc/share/issue/322e3532/

To reproduce: `curl --basic -u admin:admin 'http://localhost:8339/'`



Discussion:
Got another one: https://sentry.changaco.oy.lc/share/issue/322e3737/

This is an easy issue to fix.


Resolving patch:
diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py
--- a/liberapay/security/authentication.py
+++ b/liberapay/security/authentication.py
@@ -109,10 +109,12 @@ def authenticate_user_if_possible(request, response, state, user, _):
         if not header.startswith(b'Basic '):
             raise response.error(401, 'Unsupported authentication method')
         try:
-            creds = binascii.a2b_base64(header[len('Basic '):]).decode('utf8').split(':', 1)
-        except (binascii.Error, UnicodeDecodeError):
+            uid, pwd = binascii.a2b_base64(header[len('Basic '):]).decode('utf8').split(':', 1)
+        except (binascii.Error, UnicodeDecodeError, ValueError):
             raise response.error(400, 'Malformed "Authorization" header')
-        participant = Participant.authenticate('id', 'password', *creds)
+        if not uid.isdigit():
+            raise response.error(401, 'Invalid user id: expected an integer, got `%s`' % uid)
+        participant = Participant.authenticate('id', 'password', uid, pwd)
         if not participant:
             raise response.error(401, 'Invalid credentials')
         return {'user': participant}
diff --git a/liberapay/security/csrf.py b/liberapay/security/csrf.py
--- a/liberapay/security/csrf.py
+++ b/liberapay/security/csrf.py
@@ -1,4 +1,4 @@
-"""Cross Site Request Forgery middleware, borrowed from Django.
+"""Cross Site Request Forgery middleware, originally borrowed from Django.
 
 See also:
 
@@ -18,6 +18,7 @@
 TOKEN_LENGTH = 32
 CSRF_TOKEN = str('csrf_token')  # bytes in python2, unicode in python3
 CSRF_TIMEOUT = timedelta(days=7)
+SAFE_METHODS = {'GET', 'HEAD', 'OPTIONS', 'TRACE'}
 
 _get_new_token = lambda: get_random_string(TOKEN_LENGTH)
 _token_re = re.compile(r'^[a-zA-Z0-9]{%d}$' % TOKEN_LENGTH)
@@ -27,34 +28,36 @@
 def extract_token_from_cookie(request):
     """Given a Request object, return a csrf_token.
     """
-    try:
-        token = request.headers.cookie[CSRF_TOKEN].value
-    except KeyError:
-        token = None
-    else:
-        token = _sanitize_token(token)
 
-    # Don't set a CSRF cookie on assets, to avoid busting the cache.
-    # Don't set it on callbacks, because we don't need it there.
+    off = (
+        # Turn off CSRF protection on assets, to avoid busting the cache.
+        request.path.raw.startswith('/assets/') or
+        # Turn off CSRF protection on callbacks, so they can receive POST requests.
+        request.path.raw.startswith('/callbacks/') or
+        # Turn off CSRF when using HTTP auth, so API users can use POST and others.
+        b'Authorization' in request.headers
+    )
 
-    if request.path.raw.startswith('/assets/') or request.path.raw.startswith('/callbacks/'):
+    if off:
         token = None
     else:
-        token = token or _get_new_token()
+        try:
+            token = request.headers.cookie[CSRF_TOKEN].value
+        except KeyError:
+            token = _get_new_token()
+        else:
+            token = _sanitize_token(token) or _get_new_token()
 
     return {'csrf_token': token}
 
 
 def reject_forgeries(request, response, csrf_token):
-    # Assume that anything not defined as 'safe' by RC2616 needs protection.
-    if request.line.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'):
-
-        # except webhooks
-        if request.line.uri.startswith('/callbacks/'):
-            return
-        # and requests using HTTP auth
-        if b'Authorization' in request.headers:
-            return
+    if csrf_token is None:
+        # CSRF protection is turned off for this request
+        return
+
+    # Assume that anything not defined as 'safe' by RFC7231 needs protection.
+    if request.line.method not in SAFE_METHODS:
 
         # Check non-cookie token for match.
         second_token = ""


Test patch:
diff --git a/tests/py/test_hooks.py b/tests/py/test_hooks.py
--- a/tests/py/test_hooks.py
+++ b/tests/py/test_hooks.py
@@ -54,31 +54,6 @@ def test_no_cookies_over_http(self):
         assert redirect.code == 302
         assert not redirect.headers.cookie
 
-    def test_session_cookie_not_set_under_basic_auth(self):
-        alice = self.make_participant('alice')
-        password = 'password'
-        alice.update_password(password)
-
-        auth_header = b'Basic ' + b64encode(('%s:%s' % (alice.id, password)).encode('ascii'))
-        response = self.client.GET('/alice/public.json',
-                                   HTTP_AUTHORIZATION=auth_header,
-                                   HTTP_X_FORWARDED_PROTO=b'https',
-                                   HTTP_HOST=b'example.com',
-                                   )
-
-        assert response.code == 200
-        assert SESSION not in response.headers.cookie
-
-    def test_bad_userid_returns_401(self):
-        self.make_participant('alice')
-        auth_header = b'Basic ' + b64encode(b'foo:')
-        response = self.client.GxT('/alice/public.json',
-                                   HTTP_AUTHORIZATION=auth_header,
-                                   HTTP_X_FORWARDED_PROTO=b'https',
-                                   HTTP_HOST=b'example.com',
-                                   )
-        assert response.code == 401
-
     def test_early_failures_dont_break_everything(self):
         old_from_wsgi = Request.from_wsgi
         def broken_from_wsgi(*a, **kw):
@@ -125,6 +100,33 @@ def test_csrf_cookie_properties(self):
 
 class Tests2(Harness):
 
+    def test_basic_auth_works_and_doesnt_return_a_session_cookie(self):
+        alice = self.make_participant('alice')
+        password = 'password'
+        alice.update_password(password)
+        auth_header = b'Basic ' + b64encode(('%s:%s' % (alice.id, password)).encode('ascii'))
+        r = self.client.GET('/', HTTP_AUTHORIZATION=auth_header)
+        assert r.code == 200
+        assert SESSION not in r.headers.cookie
+
+    def test_basic_auth_malformed_header_returns_400(self):
+        auth_header = b'Basic ' + b64encode(b'bad')
+        r = self.client.GxT('/', HTTP_AUTHORIZATION=auth_header)
+        assert r.code == 400
+        assert r.text == 'Malformed "Authorization" header'
+
+    def test_basic_auth_bad_userid_returns_401(self):
+        auth_header = b'Basic ' + b64encode(b'admin:admin')
+        r = self.client.GxT('/', HTTP_AUTHORIZATION=auth_header)
+        assert r.code == 401
+
+    def test_basic_auth_no_password_returns_401(self):
+        alice = self.make_participant('alice')
+        assert alice.id == 1
+        auth_header = b'Basic ' + b64encode(b'1:')
+        r = self.client.GxT('/', HTTP_AUTHORIZATION=auth_header)
+        assert r.code == 401
+
     def test_accept_header_is_respected(self):
         r = self.client.GET('/about/stats', HTTP_ACCEPT=b'application/json')
         assert r.headers[b'Content-Type'] == b'application/json; charset=UTF-8'
@@ -166,6 +168,11 @@ def test_no_csrf_cookie(self):
         assert "Bad CSRF cookie" in r.text
         assert csrf.CSRF_TOKEN in r.headers.cookie
 
+    def test_no_csrf_cookie_unknown_method_on_asset(self):
+        r = self.client.hit('UNKNOWN', '/assets/base.css', csrf_token=False,
+                            raise_immediately=False)
+        assert r.code == 200  # this should be a 405, that's a "bug" in aspen
+
     def test_bad_csrf_cookie(self):
         r = self.client.POST('/', csrf_token='bad_token', raise_immediately=False)
         assert r.code == 403