Repository: gratipay/gratipay.com

Issue:
Email address verification email bug when email address contains + (plus sign)
Howdy, 

You sent me an email Subject: "Connect to Foo on Gratipay?" containing a link to verify my email address. That link didn't work, because my email address has a + character in it, and you didn't escape the + in the URL you sent in the email.

Incorrect URL:
https://gratipay.com/Foo/emails/verify.html?nonce=...&email=jay+waterpolice@jays.net

Correct URL:
https://gratipay.com/Foo/emails/verify.html?nonce=...&email=jay%2Bwaterpolice@jays.net

`+` becomes `%2B`

Thank you!
## <bountysource-plugin>

Want to back this issue? **[Post a bounty on it!](https://www.bountysource.com/issues/7661855-email-address-verification-email-bug-when-email-address-contains-plus-sign?utm_campaign=plugin&utm_content=tracker%2F85909&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F85909&utm_medium=issues&utm_source=github).
</bountysource-plugin>



Discussion:
Thanks for the report, @jhannah. I thought we paid attention to this case but I guess not sufficiently. :-)

+1 https://gratipay.freshdesk.com/helpdesk/tickets/1613

@rohitpaulk in [IRC](https://botbot.me/freenode/gratipay/2015-01-22/?msg=30233314&page=2):

> [W]e do sanitize the email before creating the link. https://github.com/gratipay/gratipay.com/blob/1774/gratipay/models/participant.py#L556-L557

@Julian on #3141: 

> I've only tried this with my own email address to run into the issue, so I don't know which of the two options in the title it is, but if your email address looks like `Julian+Gratipay@GMail.com`, it's inserted literally into the email query string parameter in the confirmation email's link, which then will get unquoted, and the confirmation will fail because it's interpreted as a space. Emails should be quote(_plused), so that the plus is inserted as %2B, which after manual changing, made the confirmation go through.

+1 https://gratipay.freshdesk.com/helpdesk/tickets/1945

I am getting bit by this trying to set up a team for Aspen:

![screen shot 2016-03-02 at 9 39 58 am](https://cloud.githubusercontent.com/assets/134455/13463578/e44b90b8-e05a-11e5-9fd6-7e0e9dc3c1f3.png)

+1 from @BrettTrotter at https://github.com/gratipay/gratipay.com/issues/3964.


Resolving patch:
diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py
--- a/gratipay/models/participant.py
+++ b/gratipay/models/participant.py
@@ -6,7 +6,6 @@
 from decimal import Decimal
 import pickle
 from time import sleep
-from urllib import quote
 import uuid
 
 from aspen.utils import utcnow
@@ -40,7 +39,14 @@
 from gratipay.models.exchange_route import ExchangeRoute
 from gratipay.models.team import Team
 from gratipay.security.crypto import constant_time_compare
-from gratipay.utils import i18n, is_card_expiring, emails, notifications, pricing
+from gratipay.utils import (
+    i18n,
+    is_card_expiring,
+    emails,
+    notifications,
+    pricing,
+    encode_for_querystring,
+)
 from gratipay.utils.username import safely_reserve_a_username
 
 
@@ -427,8 +433,8 @@ def add_email(self, email, resend_threshold='3 minutes'):
 
         base_url = gratipay.base_url
         username = self.username_lower
-        quoted_email = quote(email)
-        link = "{base_url}/~{username}/emails/verify.html?email={quoted_email}&nonce={nonce}"
+        encoded_email = encode_for_querystring(email)
+        link = "{base_url}/~{username}/emails/verify.html?email2={encoded_email}&nonce={nonce}"
         r = self.send_email('verification',
                             email=email,
                             link=link.format(**locals()),
diff --git a/gratipay/utils/__init__.py b/gratipay/utils/__init__.py
--- a/gratipay/utils/__init__.py
+++ b/gratipay/utils/__init__.py
@@ -2,6 +2,7 @@
 
 from __future__ import absolute_import, division, print_function, unicode_literals
 
+from base64 import urlsafe_b64encode, urlsafe_b64decode
 from datetime import datetime, timedelta
 
 from aspen import Response, json
@@ -132,6 +133,32 @@ def get_team(state):
     return team
 
 
+def encode_for_querystring(s):
+    """Given a unicode, return a unicode that's safe for transport across a querystring.
+    """
+    if not isinstance(s, unicode):
+        raise TypeError('unicode required')
+    return urlsafe_b64encode(s.encode('utf8')).replace(b'=', b'~').decode('ascii')
+
+
+def decode_from_querystring(s, **kw):
+    """Given a unicode computed by encode_for_querystring, return the inverse.
+
+    We raise Response(400) if the input value can't be decoded (i.e., it's not
+    ASCII, not padded properly, or not decodable as UTF-8 once Base64-decoded).
+
+    """
+    if not isinstance(s, unicode):
+        raise TypeError('unicode required')
+    try:
+        return urlsafe_b64decode(s.encode('ascii').replace(b'~', b'=')).decode('utf8')
+    except:
+        if 'default' in kw:
+            # Enable callers to handle errors without using try/except.
+            return kw['default']
+        raise Response(400, "invalid input")
+
+
 def update_cta(website):
     nusers = website.db.one("""
         SELECT nusers FROM paydays


Test patch:
diff --git a/tests/py/test_email.py b/tests/py/test_email.py
--- a/tests/py/test_email.py
+++ b/tests/py/test_email.py
@@ -1,3 +1,5 @@
+from __future__ import absolute_import, division, print_function, unicode_literals
+
 import json
 import time
 
@@ -5,7 +7,7 @@
 from gratipay.exceptions import TooManyEmailAddresses, ResendingTooFast
 from gratipay.models.participant import Participant
 from gratipay.testing.emails import EmailHarness
-from gratipay.utils import emails
+from gratipay.utils import emails, encode_for_querystring
 
 
 class TestEmail(EmailHarness):
@@ -22,11 +24,13 @@ def tearDown(self):
     def hit_email_spt(self, action, address, user='alice', should_fail=False):
         P = self.client.PxST if should_fail else self.client.POST
         data = {'action': action, 'address': address}
-        headers = {'HTTP_ACCEPT_LANGUAGE': 'en'}
+        headers = {b'HTTP_ACCEPT_LANGUAGE': b'en'}
         return P('/~alice/emails/modify.json', data, auth_as=user, **headers)
 
     def verify_email(self, email, nonce, username='alice', should_fail=False):
-        url = '/~%s/emails/verify.html?email=%s&nonce=%s' % (username, email, nonce)
+        # Email address is encoded in url.
+        url = '/~%s/emails/verify.html?email2=%s&nonce=%s'
+        url %= (username, encode_for_querystring(email), nonce)
         G = self.client.GxT if should_fail else self.client.GET
         return G(url, auth_as=username)
 
@@ -49,6 +53,13 @@ def test_adding_email_sends_verification_email(self):
         expected = "We've received a request to connect alice@gratipay.com to the alice account on Gratipay"
         assert expected in last_email['text']
 
+    def test_email_address_is_encoded_in_sent_verification_link(self):
+        address = 'alice@gratipay.com'
+        encoded = encode_for_querystring(address)
+        self.hit_email_spt('add-email', address)
+        last_email = self.get_last_email()
+        assert "~alice/emails/verify.html?email2="+encoded in last_email['text']
+
     def test_verification_email_doesnt_contain_unsubscribe(self):
         self.hit_email_spt('add-email', 'alice@gratipay.com')
         last_email = self.get_last_email()
@@ -118,6 +129,17 @@ def test_verify_email(self):
         actual = Participant.from_username('alice').email_address
         assert expected == actual
 
+    def test_email_verification_is_backwards_compatible(self):
+        """Test email verification still works with unencoded email in verification link.
+        """
+        self.hit_email_spt('add-email', 'alice@example.com')
+        nonce = self.alice.get_email('alice@example.com').nonce
+        url = '/~alice/emails/verify.html?email=alice@example.com&nonce='+nonce
+        self.client.GET(url, auth_as='alice')
+        expected = 'alice@example.com'
+        actual = Participant.from_username('alice').email_address
+        assert expected == actual
+
     def test_verified_email_is_not_changed_after_update(self):
         self.verify_and_change_email('alice@example.com', 'alice@example.net')
         expected = 'alice@example.com'
diff --git a/tests/py/test_utils.py b/tests/py/test_utils.py
--- a/tests/py/test_utils.py
+++ b/tests/py/test_utils.py
@@ -7,7 +7,7 @@
 from aspen.http.response import Response
 from gratipay import utils
 from gratipay.testing import Harness
-from gratipay.utils import i18n, markdown, pricing
+from gratipay.utils import i18n, markdown, pricing, encode_for_querystring, decode_from_querystring
 from gratipay.utils.username import safely_reserve_a_username, FailedToReserveUsername, \
                                                                            RanOutOfUsernameAttempts
 from psycopg2 import IntegrityError
@@ -216,3 +216,39 @@ def test_splh_suggests_five_and_ten_cents_on_1_dollar(self):
 
     def test_splh_rounds_to_nearest_five_cents(self):
         assert pricing.suggested_payment_low_high(D('98.33')) == (D('4.90'), D('9.85'))
+
+
+    # encoding/decoding querystring values
+    # ====================================
+
+    # efq = encode_for_querystring
+
+    def test_efq_replaces_slash_with_underscore(self):
+        # TheEnter?prise => VGhlRW50ZXI/cHJpc2U=
+        assert encode_for_querystring('TheEnter?prise') == 'VGhlRW50ZXI_cHJpc2U~'
+
+    def test_efq_replaces_equals_with_tilde(self):
+        assert encode_for_querystring('TheEnterprise') == 'VGhlRW50ZXJwcmlzZQ~~'
+
+    def test_efq_doesnt_accept_bytes(self):
+        with self.assertRaises(TypeError):
+            encode_for_querystring(b'TheEnterprise')
+
+
+    # dfq - decode_from_querystring
+
+    def test_dfq_decodes_properly(self):
+        assert decode_from_querystring('VGhlRW50ZXI_cHJpc2U~') == 'TheEnter?prise'
+        assert decode_from_querystring('VGhlRW50ZXJwcmlzZQ~~') == 'TheEnterprise'
+
+    def test_dfq_doesnt_accept_bytes(self):
+        with self.assertRaises(TypeError):
+            decode_from_querystring(b'VGhlRW50ZXI_cHJpc2U~')
+
+    def test_dfq_raises_response_on_error(self):
+        with self.assertRaises(Response) as cm:
+            decode_from_querystring('abcd')
+        assert cm.exception.code == 400
+
+    def test_dfq_returns_default_if_passed_on_error(self):
+        assert decode_from_querystring('abcd', default='error') == 'error'