Repository: getsentry/sentry

Issue:
Prevent Referer leaking on password reset page
Right now when you get a password reset link, the page you end up on contains the reset token in the url.

Doing this causes a problem in which any page redirected to after this, and _especially_ any third party assets being loaded leak this reset token via the `Referer` header on the request.

While this isn't specifically a problem since the only things we embed are trusted vendors, in the case of SaaS, it's better to just avoid it.

My proposal is to keep the same url, but this endpoint does the following:

* Accepts the request, and puts the token either into the browser session or browser's cookie.
* Redirect away from this url, to a generic password reset form page.
* This new password reset form page then takes the values from your session or cookie.

Doing this mitigates any ability for leaking data through any Referer header in the future.

I am open to other proposals, but this is the best that comes to my mind.


Discussion:
Can I work on this?
@Kacppian Sure!
Hey, I have some doubts. Couldn't find a place to ask questions. I tried irc #sentry but couldn't find anyone to talk to or maybe I'm bad at using my irc client. Can you please help me? 

On `lostpasswordhash.py`, `lno - 68`, I see the template of the email is for both set_password and recover_account. But grep reveals no service actually is using template as `'set_password'`. May I know why? If this is the wrong way to reach out, I'm extremely sorry. 
My idea of solving this was to use `recover_account` view to set the token in the browser session and redirect to `set_password` view to be able to set the password without the hash in the URL. That led me to see if `set_password` template is being used  
> I see the template of the email is for both set_password and recover_account. But grep reveals no service actually is using template as 'set_password'. May I know why?

The `set_password` email is unused. It was added in e962e56a912e27f79d6acc3b77cf17fe1f34a313 but doesn't appear to ever have been used. There is a `set_password` flow for when SSO is disabled on an organization but that is a separate thing.

Your plan of setting into the session and redirecting sounds like a good plan to me.
Hello.

I think to resolve this problem sentry server just need provide header `Referrer-Policy` with value `strict-origin-when-cross-origin` : https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
I'd be happy to take a crack at this for Hacktoberfest! 

Resolving patch:
diff --git a/src/sentry/web/decorators.py b/src/sentry/web/decorators.py
--- a/src/sentry/web/decorators.py
+++ b/src/sentry/web/decorators.py
@@ -40,6 +40,19 @@ def wrapped(request, *args, **kwargs):
     return wrapped
 
 
+def set_referrer_policy(policy):
+    def real_decorator(func):
+        @wraps(func)
+        def wrapped(request, *args, **kwargs):
+            response = func(request, *args, **kwargs)
+            response["Referrer-Policy"] = policy
+            return response
+
+        return wrapped
+
+    return real_decorator
+
+
 def transaction_start(endpoint):
     def decorator(func):
         @wraps(func)
diff --git a/src/sentry/web/frontend/accounts.py b/src/sentry/web/frontend/accounts.py
--- a/src/sentry/web/frontend/accounts.py
+++ b/src/sentry/web/frontend/accounts.py
@@ -17,7 +17,7 @@
 from sentry.models import UserEmail, LostPasswordHash, Project, UserOption, Authenticator
 from sentry.security import capture_security_activity
 from sentry.signals import email_verified
-from sentry.web.decorators import login_required, signed_auth_required
+from sentry.web.decorators import login_required, signed_auth_required, set_referrer_policy
 from sentry.web.forms.accounts import RecoverPasswordForm, ChangePasswordRecoverForm
 from sentry.web.helpers import render_to_response
 from sentry.utils import auth
@@ -25,6 +25,10 @@
 logger = logging.getLogger("sentry.accounts")
 
 
+def get_template(mode, name):
+    return u"sentry/account/{}/{}.html".format(mode, name)
+
+
 @login_required
 def login_redirect(request):
     login_url = auth.get_login_redirect(request)
@@ -36,7 +40,7 @@ def expired(request, user):
     password_hash.send_email(request)
 
     context = {"email": password_hash.user.email}
-    return render_to_response("sentry/account/recover/expired.html", context, request)
+    return render_to_response(get_template("recover", "expired"), context, request)
 
 
 def recover(request):
@@ -76,20 +80,19 @@ def recover(request):
 
             logger.info("recover.sent", extra=extra)
 
-        tpl = "sentry/account/recover/sent.html"
         context = {"email": email}
 
-        return render_to_response(tpl, context, request)
+        return render_to_response(get_template("recover", "sent"), context, request)
 
     if form._errors:
         logger.warning("recover.error", extra=extra)
 
-    tpl = "sentry/account/recover/index.html"
     context = {"form": form}
 
-    return render_to_response(tpl, context, request)
+    return render_to_response(get_template("recover", "index"), context, request)
 
 
+@set_referrer_policy("strict-origin-when-cross-origin")
 def recover_confirm(request, user_id, hash, mode="recover"):
     try:
         password_hash = LostPasswordHash.objects.get(user=user_id, hash=hash)
@@ -99,7 +102,7 @@ def recover_confirm(request, user_id, hash, mode="recover"):
         user = password_hash.user
 
     except LostPasswordHash.DoesNotExist:
-        return render_to_response(u"sentry/account/{}/{}.html".format(mode, "failure"), {}, request)
+        return render_to_response(get_template(mode, "failure"), {}, request)
 
     if request.method == "POST":
         form = ChangePasswordRecoverForm(request.POST)
@@ -131,9 +134,7 @@ def recover_confirm(request, user_id, hash, mode="recover"):
     else:
         form = ChangePasswordRecoverForm()
 
-    return render_to_response(
-        u"sentry/account/{}/{}.html".format(mode, "confirm"), {"form": form}, request
-    )
+    return render_to_response(get_template(mode, "confirm"), {"form": form}, request)
 
 
 # Set password variation of password recovery
@@ -189,6 +190,7 @@ def start_confirm_email(request):
     return HttpResponseRedirect(reverse("sentry-account-settings-emails"))
 
 
+@set_referrer_policy("strict-origin-when-cross-origin")
 def confirm_email(request, user_id, hash):
     msg = _("Thanks for confirming your email")
     level = messages.SUCCESS


Test patch:
diff --git a/tests/sentry/web/frontend/test_accounts.py b/tests/sentry/web/frontend/test_accounts.py
--- a/tests/sentry/web/frontend/test_accounts.py
+++ b/tests/sentry/web/frontend/test_accounts.py
@@ -12,6 +12,9 @@ class TestAccounts(TestCase):
     def path(self):
         return reverse("sentry-account-recover")
 
+    def password_recover_path(self, user_id, hash_):
+        return reverse("sentry-account-recover-confirm", kwargs={"user_id": user_id, "hash": hash_})
+
     def test_get_renders_form(self):
         resp = self.client.get(self.path)
         assert resp.status_code == 200
@@ -55,3 +58,21 @@ def test_post_multiple_users(self):
         assert resp.status_code == 200
         self.assertTemplateUsed("sentry/account/recover/index.html")
         assert 0 == len(LostPasswordHash.objects.all())
+
+    def test_leaking_recovery_hash(self):
+        user = self.create_user()
+
+        resp = self.client.post(self.path, {"user": user.email})
+        assert resp.status_code == 200
+
+        lost_password = LostPasswordHash.objects.get(user=user)
+
+        resp = self.client.post(
+            self.password_recover_path(lost_password.user_id, lost_password.hash),
+            {"password": "test_password"},
+        )
+
+        header_name = "Referrer-Policy"
+
+        assert resp.has_header(header_name)
+        assert resp[header_name] == "strict-origin-when-cross-origin"