Repository: mozmeao/basket

Issue:
Mask Email Addresses in User Data Requests
Unless a request comes in with a valid API key we should mask the email address in the returned user data. This would prevent anyone learning a users full email if they learned their token or email pref center URL. Since one can't update their email from the pref center displaying the full email isn't needed and it would improve privacy if it wasn't displayed unmasked. 


Discussion:

Resolving patch:
diff --git a/basket/news/urls.py b/basket/news/urls.py
--- a/basket/news/urls.py
+++ b/basket/news/urls.py
@@ -22,7 +22,7 @@
     path("common-voice-goals/", common_voice_goals),
     path("subscribe/", subscribe),
     path("unsubscribe/<uuid:token>/", unsubscribe),
-    path("user/<uuid:token>/", user),
+    path("user/<uuid:token>/", user, name="user"),
     path("user-meta/<uuid:token>/", user_meta),
     path("confirm/<uuid:token>/", confirm),
     path("debug-user/", debug_user),
diff --git a/basket/news/utils.py b/basket/news/utils.py
--- a/basket/news/utils.py
+++ b/basket/news/utils.py
@@ -247,6 +247,7 @@ def get_user_data(
     fxa_id=None,
     extra_fields=None,
     get_fxa=False,
+    masked=True,
 ):
     """
     Return a dictionary of the user's data.
@@ -264,6 +265,9 @@ def get_user_data(
     If `get_fxa` is True then a boolean field will be including indicating whether they are
     an account holder or not.
 
+    When `masked` is True, we return masked emails. We should only set
+    `masked=False` when a valid API key is being used.
+
     Review of results:
 
     None = user completely unknown, no errors talking to CTMS.
@@ -336,11 +340,14 @@ def get_user_data(
     if get_fxa:
         user["has_fxa"] = bool(user.get("fxa_id"))
 
+    if masked and user.get("email"):
+        user["email"] = mask_email(user["email"])
+
     user["status"] = "ok"
     return user
 
 
-def get_user(token=None, email=None, get_fxa=False):
+def get_user(token=None, email=None, get_fxa=False, masked=True):
     if settings.MAINTENANCE_MODE and not settings.MAINTENANCE_READ_ONLY:
         # can't return user data during maintenance
         return HttpResponseJSON(
@@ -353,7 +360,7 @@ def get_user(token=None, email=None, get_fxa=False):
         )
 
     try:
-        user_data = get_user_data(token, email, get_fxa=get_fxa)
+        user_data = get_user_data(token, email, get_fxa=get_fxa, masked=masked)
         status_code = 200
     except NewsletterException as e:
         return newsletter_exception_response(e)
@@ -486,6 +493,22 @@ def process_email(email):
     return info.ascii_email
 
 
+def masker(s):
+    """
+    Masks a string by replacing middle characters with '*'.
+    """
+    return f"{s[0]}{'*' * (len(s) - 2)}{s[-1]}"
+
+
+def mask_email(email):
+    """
+    Returns an email masking the user and domain.
+    """
+    user, domain = email.split("@")
+    domain, tld = domain.rsplit(".", 1)
+    return f"{masker(user)}@{masker(domain)}.{tld}"
+
+
 def parse_newsletters_csv(newsletters):
     """Return a list of newsletter names from a comma separated string"""
     if isinstance(newsletters, (list, tuple)):
diff --git a/basket/news/views.py b/basket/news/views.py
--- a/basket/news/views.py
+++ b/basket/news/views.py
@@ -657,7 +657,8 @@ def user(request, token):
         return update_user_task(request, SET, data)
 
     get_fxa = "fxa" in request.GET
-    return get_user(token, get_fxa=get_fxa)
+    masked = not has_valid_api_key(request)
+    return get_user(token, get_fxa=get_fxa, masked=masked)
 
 
 @require_POST
@@ -754,9 +755,9 @@ def newsletters(request):
 
 @never_cache
 def lookup_user(request):
-    """Lookup a user in Exact Target given email or token (not both).
+    """Lookup a user in CTMS given email or token (not both).
 
-    To look up by email, a valid API key are required.
+    To look up by email, a valid API key is required.
 
     If email and token are both provided, an error is returned rather
     than trying to define all the possible behaviors.
@@ -781,15 +782,14 @@ def lookup_user(request):
     or a request header ``X-api-key``. If it's provided as a query parameter,
     any request header is ignored.
 
-    For other errors, similarly
-    response status is 4xx and the json 'desc' says what's wrong.
+    For other errors, similarly response status is 4xx and the json 'desc'
+    says what's wrong.
 
     Otherwise, status is 200 and json is the return value from
     `get_user_data`. See that method for details.
 
-    Note that because this method always calls Exact Target one or
-    more times, it can be slower than some other Basket APIs, and will
-    fail if ET is down.
+    Note that because this method always calls CTMS one or more times, it can be
+    slower than some other Basket APIs, and will fail if CTMS is down.
     """
     if settings.MAINTENANCE_MODE and not settings.MAINTENANCE_READ_ONLY:
         # can't return user data during maintenance
@@ -816,7 +816,8 @@ def lookup_user(request):
             400,
         )
 
-    if email and not is_authorized(request, email):
+    authorized = is_authorized(request, email)
+    if email and not authorized:
         return HttpResponseJSON(
             {
                 "status": "error",
@@ -833,7 +834,9 @@ def lookup_user(request):
             return invalid_email_response()
 
     try:
-        user_data = get_user_data(token=token, email=email, get_fxa=get_fxa)
+        user_data = get_user_data(
+            token=token, email=email, get_fxa=get_fxa, masked=not authorized
+        )
     except NewsletterException as e:
         return newsletter_exception_response(e)
 


Test patch:
diff --git a/basket/news/tests/test__utils.py b/basket/news/tests/test__utils.py
--- a/basket/news/tests/test__utils.py
+++ b/basket/news/tests/test__utils.py
@@ -18,6 +18,7 @@
     has_valid_fxa_oauth,
     is_authorized,
     language_code_is_valid,
+    mask_email,
     parse_newsletters_csv,
     process_email,
 )
@@ -383,3 +384,10 @@ def test_invalid_email(self):
         self.assertIsNone(process_email("dude@home@example.com"))
         self.assertIsNone(process_email(""))
         self.assertIsNone(process_email(None))
+
+
+class TestMaskEmail(TestCase):
+    def test_mask(self):
+        self.assertEqual(mask_email("dude@example.com"), "d**e@e*****e.com")
+        self.assertEqual(mask_email("the.dude@example.com"), "t******e@e*****e.com")
+        self.assertEqual(mask_email("dude@sub.example.com"), "d**e@s*********e.com")
diff --git a/basket/news/tests/test_users.py b/basket/news/tests/test_users.py
--- a/basket/news/tests/test_users.py
+++ b/basket/news/tests/test_users.py
@@ -1,15 +1,14 @@
 import json
+import uuid
 from unittest.mock import patch
 
 from django.http import HttpResponse
 from django.test import TestCase
-from django.test.client import RequestFactory
 from django.urls import reverse
 from requests import Response
 from requests.exceptions import HTTPError
 
 from basket import errors
-from basket.news import views
 from basket.news.backends.ctms import CTMSMultipleContactsError, CTMSNotConfigured
 from basket.news.models import APIUser
 from basket.news.utils import SET
@@ -17,21 +16,59 @@
 
 class UserTest(TestCase):
     def setUp(self):
-        self.factory = RequestFactory()
-
-    def test_user_set(self):
-        """If the user view is sent a POST request, it should attempt to update
-        the user's info.
-        """
-        request = self.factory.post("/news/user/asdf/", {"fake": "data"})
-        with patch.object(views, "update_user_task") as update_user_task:
-            update_user_task.return_value = HttpResponse()
-            views.user(request, "asdf")
-            update_user_task.assert_called_with(
-                request,
-                SET,
-                {"fake": "data", "token": "asdf"},
-            )
+        self.auth = APIUser.objects.create(name="test")
+        self.token = str(uuid.uuid4())
+        self.url = reverse("user", kwargs={"token": self.token})
+
+    @patch("basket.news.views.update_user_task")
+    def test_user_set(self, update_user_task):
+        """If request is POST, it should attempt to update the user's info."""
+        update_user_task.return_value = HttpResponse()
+        resp = self.client.post(self.url, data={"fake": "data"})
+        update_user_task.assert_called_with(
+            resp.wsgi_request,
+            SET,
+            {"fake": "data", "token": self.token},
+        )
+
+    @patch("basket.news.utils.ctms", spec_set=["get"])
+    def test_user(self, ctms_mock):
+        ctms_mock.get.return_value = {
+            "email": "hisdudeness@example.com",
+        }
+        resp = self.client.get(self.url)
+        assert resp.status_code == 200
+        assert resp.json() == {
+            "email": "h*********s@e*****e.com",
+            "status": "ok",
+        }
+
+    @patch("basket.news.utils.ctms", spec_set=["get"])
+    def test_user_with_api_key(self, ctms_mock):
+        ctms_mock.get.return_value = {
+            "email": "hisdudeness@example.com",
+        }
+        resp = self.client.get(self.url, data={"api-key": self.auth.api_key})
+        assert resp.status_code == 200
+        assert resp.json() == {
+            "email": "hisdudeness@example.com",
+            "status": "ok",
+        }
+
+    @patch("basket.news.utils.ctms", spec_set=["get"])
+    def test_user_with_fxa(self, ctms_mock):
+        ctms_mock.get.return_value = {
+            "email": "hisdudeness@example.com",
+            "fxa_id": "the-dude-abides",
+        }
+        resp = self.client.get(self.url, data={"fxa": "1"})
+        assert resp.status_code == 200
+        assert resp.json() == {
+            "email": "h*********s@e*****e.com",
+            "fxa_id": "the-dude-abides",
+            "has_fxa": True,
+            "status": "ok",
+        }
 
 
 class TestLookupUser(TestCase):
@@ -78,10 +115,37 @@ def test_both_parms(self):
     @patch("basket.news.utils.ctms", spec_set=["get"])
     def test_with_token(self, ctms_mock):
         """Passing a token gets back that user's data"""
-        ctms_mock.get.return_value = {"token": "dummy"}
+        ctms_mock.get.return_value = {
+            "token": "dummy",
+            "email": "hisdudeness@example.com",
+        }
         rsp = self.get(params={"token": "dummy"})
         assert rsp.status_code == 200
-        assert rsp.json() == {"status": "ok", "token": "dummy"}
+        assert rsp.json() == {
+            "email": "h*********s@e*****e.com",
+            "status": "ok",
+            "token": "dummy",
+        }
+        ctms_mock.get.assert_called_once_with(
+            email=None,
+            fxa_id=None,
+            token="dummy",
+        )
+
+    @patch("basket.news.utils.ctms", spec_set=["get"])
+    def test_with_token_authorized(self, ctms_mock):
+        """Passing a token gets back that user's data"""
+        ctms_mock.get.return_value = {
+            "token": "dummy",
+            "email": "hisdudeness@example.com",
+        }
+        rsp = self.get(params={"token": "dummy", "api-key": self.auth.api_key})
+        assert rsp.status_code == 200
+        assert rsp.json() == {
+            "email": "hisdudeness@example.com",
+            "status": "ok",
+            "token": "dummy",
+        }
         ctms_mock.get.assert_called_once_with(
             email=None,
             fxa_id=None,
@@ -98,7 +162,7 @@ def test_get_fxa_status(self, ctms_mock):
         rsp = self.get(params={"token": "dummy", "fxa": "1"})
         assert rsp.status_code == 200
         assert rsp.json() == {
-            "email": "hisdudeness@example.com",
+            "email": "h*********s@e*****e.com",
             "fxa_id": "the-dude-abides",
             "has_fxa": True,
             "status": "ok",
@@ -111,11 +175,35 @@ def test_get_fxa_status_false(self, ctms_mock):
         rsp = self.get(params={"token": "dummy", "fxa": "1"})
         assert rsp.status_code == 200
         assert rsp.json() == {
-            "email": "hisdudeness@example.com",
+            "email": "h*********s@e*****e.com",
             "has_fxa": False,
             "status": "ok",
         }
 
+    @patch("basket.news.utils.ctms", spec_set=["get"])
+    def test_get_fxa_status_with_api_key(self, ctms_mock):
+        """Passing email and valid api key param gets user's data"""
+        ctms_mock.get.return_value = {
+            "email": "hisdudeness@example.com",
+            "fxa_id": "the-dude-abides",
+        }
+        params = {
+            "email": "hisdudeness@example.com",
+            "api-key": self.auth.api_key,
+            "fxa": "1",
+        }
+        rsp = self.get(params)
+        self.assertEqual(200, rsp.status_code, rsp.content)
+        self.assertEqual(
+            rsp.json(),
+            {
+                "email": "hisdudeness@example.com",
+                "fxa_id": "the-dude-abides",
+                "has_fxa": True,
+                "status": "ok",
+            },
+        )
+
     @patch("basket.news.utils.ctms", spec_set=["get"])
     def test_ctms_user_found(self, ctms_mock):
         """If CTMS knows a contact, it adds the missing email_id"""