Repository: gratipay/gratipay.com

Issue:
Minor text injection vulnerability
See https://gratipay.com/on/twitter/who%20you%20say%20you%20are...%20Visit%20gratipay.evilsite.com%20to%20verify%20your%20account%20and%20send%20me%20money.%20Maybe%20you%20don%27t%20have%20twitter/failure.html?action

Pretty minor I admit but could potentially be exploited with encoding. We should validate twitter/github/facebook usernames.



Discussion:
So, how to exploit that? Can you inject JavaScript or links there?

No, it's pretty minor. https://www.owasp.org/index.php/Content_Spoofing#Text_Injection

So the fix is to validate the elsewhere username on the failure page?

Yup :).
I can understand if that's quite a lot of work for such a simple problem.
I just noticed that there wasn't any verification going on.

[IRC](https://botbot.me/freenode/gratipay/2014-12-02/?msg=26665460&page=1)

Twitter handles are max 15 characters.

https://support.twitter.com/articles/14609-changing-your-username

Facebook is 50, according to a StackOverflow question answer. 

Limiting by size isn't probably the right way to go, though. We do know that they have to work in URLs, so one check could bounce if the input contains characters invalid in URLs. 

In practice, I think all the elsewhere sites handles are Latin characters without diacritics.

It would still be better to check validity with the platform in question.

> Limiting by size isn't probably the right way to go, though.

FTR, @colindean was responding to some [live-coding I was doing](http://www.meetup.com/Pittsburgh-Code-Supply/events/218580471/). PR forthcoming ...

How does this non-proper name injection exploit works anyway?

Fixed. Thanks @whit537.


Resolving patch:
diff --git a/gratipay/elsewhere/__init__.py b/gratipay/elsewhere/__init__.py
--- a/gratipay/elsewhere/__init__.py
+++ b/gratipay/elsewhere/__init__.py
@@ -135,12 +135,16 @@ def check_ratelimit_headers(self, response):
         """
         prefix = getattr(self, 'ratelimit_headers_prefix', None)
         if prefix:
-            limit = response.headers[prefix+'limit']
-            remaining = response.headers[prefix+'remaining']
-            reset = response.headers[prefix+'reset']
+            limit = response.headers.get(prefix+'limit')
+            remaining = response.headers.get(prefix+'remaining')
+            reset = response.headers.get(prefix+'reset')
+
             try:
                 limit, remaining, reset = int(limit), int(remaining), int(reset)
             except (TypeError, ValueError):
+                limit, remaining, reset = None, None, None
+
+            if None in (limit, remaining, reset):
                 d = dict(limit=limit, remaining=remaining, reset=reset)
                 log('Got weird rate headers from %s: %s' % (self.name, d))
             else:


Test patch:
diff --git a/tests/py/fixtures/TestElsewhere.yml b/tests/py/fixtures/TestElsewhere.yml
new file mode 100644
--- /dev/null
+++ b/tests/py/fixtures/TestElsewhere.yml
@@ -0,0 +1,44 @@
+interactions:
+- request:
+    body: null
+    headers: {}
+    method: GET
+    uri: https://api.twitter.com:443/1.1/users/show.json?screen_name=Gratipay
+  response:
+    body:
+      string: !!binary |
+        H4sIAAAAAAAAALxW227jNhD9FVYPRYu6sW7WxUBRbOOkfci2WGQXLbAOBEoa2WwoUiUpJ27gf9+h
+        ZdmKL4ttUdQvJufG4cyZQ704rHSmXuAFcTTxJ/EI95k2ypk6B6EzcgStAWU/K2pYQ9co0YUCENmp
+        gssC11KgFHeNkhXjkB2kouV85JSAAVizM9y6m7aEH0kf6SvyO8AjXxNc1yCMJkaSBmTDgVBREgO0
+        1mQtW5IDZ7ACwsQVHtgqjgGXxjR6Oh/Px+aqkPNxHUH4529eWIdogtGYYaCd6Utnvv3D7ceXL3J/
+        bjABKLNj28UudXSp52O0LJluOF3vDIdqVDJRssIm8dEd+cHD5mFzVJV9Vg+bzbaQBgoD2K6Kcg0j
+        p5KcyydQOitkKwy2MYoTFCsGotwLfewpZxode0nijxxsHrUiinvnVjHylirip8QLpt5k6gbkOxd/
+        xHe9AFOt6Eq2ihnYRw2x0KbIZFVpwO33XoLmI8ewGrK/pbCQuKF4qBLkPcrINx/uydfkmgpa0m8x
+        4gJkBoLmfHCfFShWsYFAG2pafTg0TgK8CxULjA7C6Q1spV7d560UZAYFcT3ie9PAxxoc7mM7aDE/
+        CdJJ4E9c3wtSL4pi3x1g/1SJXgaebfQ3jeRygfAZESwKmf16T7A3K1aCIkwTRAfYDhRMLAgls5m8
+        J9QYWjxOiYXKAFWz6s0v+j78I5pdkTspH60HglxB2RZAqta0Cohi+pHghY4h+c7c3NUq/XB/Z1Gv
+        MZXCVn3eum5QULJUUP0wdw4nPjGD7dhC08EjOCqF7CA077zgfWeDg5eTa5wpYbpo8zHtDGwRVCsK
+        OoQhE5mChq8zI7OuIZmtbzfmZ5VdkU8NsNfqgu9OdcnzFRl1akRYvyykVCUTmLXuRTiWBRz0wiiW
+        t0aqvYEC8wRgeui53RDYGRiIhkSypHpp6GI7r9iPdZ1Lvttsk7cchmPdi0755iwwztPN1rQUmtXI
+        hl1ZO85hCLvSkuV8vHKfw2U8WTxX8fMJFX2B77z1XT96xVOTdJR4SFOXWPIAyc+yJDPLNu/O7Cnx
+        mDuZ1i1gIn4aR6c8ejnAmaQ9LxrhFHf82vfwgN5dnweSRmrNcsSVBqySYSvYqw7cs3mNmhMuYzoz
+        igrNKarPSREKJ079W5kjWSwUosxSNrf+zrU7u7mZOWdtWE0XcIyOJtdXOPP1oqvTJTescRJMkjCN
+        42Q+TvMwoHnh+bSKwzCpgAZ+4VU0TxI/L8r8qsH7fz6HbNvoVw3/n1MxuD8p6T+pUZ9NECf2VXNd
+        Lw7dOEiTCcomKQ2Dqsq9oKhwSYM09KMk9YqySBO3yoRUNeVH2f2L6vz3SeRUCOSh43G82B1rjQkc
+        PgNxHbqpH0dxHAy/7Jh43OPUdZPwp3Cg1cgpOVVZjhSMp/d2t9vfGTvc8L3VDEF/Gw2s7BO81wbb
+        30CLPHuCqj0SSqhoy022M74kP/LqHkhmx757FzoBvjx/IUEZyxGmVwlp8Pul+8zdvSObTwAAAP//
+        AwBEpthWYAsAAA==
+    headers:
+      cache-control: ['no-cache, no-store, must-revalidate, pre-check=0, post-check=0']
+      content-encoding: [gzip]
+      content-length: ['1150']
+      content-type: [application/json;charset=utf-8]
+      expires: ['Tue, 31 Mar 1981 05:00:00 GMT']
+      last-modified: ['Wed, 03 Dec 2014 19:07:28 GMT']
+      pragma: [no-cache]
+      set-cookie: ['guest_id=v1%3A141763364870903280; Domain=.twitter.com; Path=/;
+          Expires=Fri, 02-Dec-2016 19:07:28 UTC']
+      status: [200 OK]
+      strict-transport-security: [max-age=631138519]
+    status: {code: 200, message: OK}
+version: 1
diff --git a/tests/py/test_elsewhere.py b/tests/py/test_elsewhere.py
--- a/tests/py/test_elsewhere.py
+++ b/tests/py/test_elsewhere.py
@@ -103,3 +103,13 @@ def test_user_pages(self):
             response = self.client.GET('/on/%s/alice/' % platform.name)
             assert response.code == 200
             assert 'has not joined' in response.body.decode('utf8')
+
+
+    def test_failure_page_requires_valid_username(self):
+        response = self.client.GxT('/on/twitter/nmjhgfcftyuikjnbvftyujhbgtfgh/failure.html?action')
+        assert response.code == 404
+
+    def test_failure_page_accepts_valid_username(self):
+        self.client.GET('/on/twitter/Gratipay/')  # normal case will have the db primed
+        response = self.client.GET('/on/twitter/Gratipay/failure.html?action')
+        assert response.code == 200