Browse Source

Auto-submit the unsubscribe confirmation form only if signature is more than 5 minutes old. Idea from https://stackoverflow.com/questions/59281750/strategies-to-prevent-email-scanners-from-activating-unsubscribe-links/59381066#59381066

pull/320/head
Pēteris Caune 5 years ago
parent
commit
0d2c6217d3
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
8 changed files with 113 additions and 25 deletions
  1. +15
    -0
      hc/accounts/tests/test_unsubscribe_reports.py
  2. +1
    -1
      hc/accounts/urls.py
  3. +16
    -5
      hc/accounts/views.py
  4. +4
    -1
      hc/api/models.py
  5. +46
    -9
      hc/front/tests/test_unsubscribe_email.py
  6. +1
    -1
      hc/front/urls.py
  7. +25
    -8
      hc/front/views.py
  8. +5
    -0
      templates/accounts/unsubscribe_submit.html

+ 15
- 0
hc/accounts/tests/test_unsubscribe_reports.py View File

@ -1,8 +1,10 @@
from datetime import timedelta as td from datetime import timedelta as td
import time
from django.core import signing from django.core import signing
from django.utils.timezone import now from django.utils.timezone import now
from hc.test import BaseTestCase from hc.test import BaseTestCase
from mock import patch
class UnsubscribeReportsTestCase(BaseTestCase): class UnsubscribeReportsTestCase(BaseTestCase):
@ -36,3 +38,16 @@ class UnsubscribeReportsTestCase(BaseTestCase):
r = self.client.get(url) r = self.client.get(url)
self.assertContains(r, "Please press the button below") self.assertContains(r, "Please press the button below")
self.assertNotContains(r, "submit()")
def test_aged_signature_autosubmits(self):
with patch("django.core.signing.time") as mock_time:
mock_time.time.return_value = time.time() - 301
signer = signing.TimestampSigner(salt="reports")
sig = signer.sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertContains(r, "submit()")

+ 1
- 1
hc/accounts/urls.py View File

@ -16,7 +16,7 @@ urlpatterns = [
path("profile/notifications/", views.notifications, name="hc-notifications"), path("profile/notifications/", views.notifications, name="hc-notifications"),
path("close/", views.close, name="hc-close"), path("close/", views.close, name="hc-close"),
path( path(
"unsubscribe_reports/<str:username>/",
"unsubscribe_reports/<str:signed_username>/",
views.unsubscribe_reports, views.unsubscribe_reports,
name="hc-unsubscribe-reports", name="hc-unsubscribe-reports",
), ),


+ 16
- 5
hc/accounts/views.py View File

@ -433,17 +433,28 @@ def change_email_done(request):
@csrf_exempt @csrf_exempt
def unsubscribe_reports(request, username):
def unsubscribe_reports(request, signed_username):
# Some email servers open links in emails to check for malicious content.
# To work around this, for GET requests we serve a confirmation form.
# If the signature is more than 5 minutes old, we also include JS code to
# auto-submit the form.
ctx = {}
signer = signing.TimestampSigner(salt="reports") signer = signing.TimestampSigner(salt="reports")
# First, check the signature without looking at the timestamp:
try: try:
username = signer.unsign(username)
username = signer.unsign(signed_username)
except signing.BadSignature: except signing.BadSignature:
return render(request, "bad_link.html") return render(request, "bad_link.html")
# Some email servers open links in emails to check for malicious content.
# To work around this, we serve a form that auto-submits with JS.
# Check if timestamp is older than 5 minutes:
try:
username = signer.unsign(signed_username, max_age=300)
except signing.SignatureExpired:
ctx["autosubmit"] = True
if request.method != "POST": if request.method != "POST":
return render(request, "accounts/unsubscribe_submit.html")
return render(request, "accounts/unsubscribe_submit.html", ctx)
user = User.objects.get(username=username) user = User.objects.get(username=username)
profile = Profile.objects.for_user(user) profile = Profile.objects.for_user(user)


+ 4
- 1
hc/api/models.py View File

@ -7,6 +7,7 @@ from datetime import datetime, timedelta as td
from croniter import croniter from croniter import croniter
from django.conf import settings from django.conf import settings
from django.core.signing import TimestampSigner
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -372,7 +373,9 @@ class Channel(models.Model):
emails.verify_email(self.email_value, {"verify_link": verify_link}) emails.verify_email(self.email_value, {"verify_link": verify_link})
def get_unsub_link(self): def get_unsub_link(self):
args = [self.code, self.make_token()]
signer = TimestampSigner(salt="alerts")
signed_token = signer.sign(self.make_token())
args = [self.code, signed_token]
verify_link = reverse("hc-unsubscribe-alerts", args=args) verify_link = reverse("hc-unsubscribe-alerts", args=args)
return settings.SITE_ROOT + verify_link return settings.SITE_ROOT + verify_link


+ 46
- 9
hc/front/tests/test_unsubscribe_email.py View File

@ -1,5 +1,8 @@
import time
from django.core.signing import TimestampSigner
from hc.api.models import Channel from hc.api.models import Channel
from hc.test import BaseTestCase from hc.test import BaseTestCase
from mock import patch
class UnsubscribeEmailTestCase(BaseTestCase): class UnsubscribeEmailTestCase(BaseTestCase):
@ -9,7 +12,15 @@ class UnsubscribeEmailTestCase(BaseTestCase):
self.channel.value = "[email protected]" self.channel.value = "[email protected]"
self.channel.save() self.channel.save()
def test_it_works(self):
def test_it_serves_confirmation_form(self):
token = self.channel.make_token()
url = "/integrations/%s/unsub/%s/" % (self.channel.code, token)
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertNotContains(r, "submit()")
def test_post_unsubscribes(self):
token = self.channel.make_token() token = self.channel.make_token()
url = "/integrations/%s/unsub/%s/" % (self.channel.code, token) url = "/integrations/%s/unsub/%s/" % (self.channel.code, token)
@ -19,6 +30,39 @@ class UnsubscribeEmailTestCase(BaseTestCase):
q = Channel.objects.filter(code=self.channel.code) q = Channel.objects.filter(code=self.channel.code)
self.assertEqual(q.count(), 0) self.assertEqual(q.count(), 0)
def test_fresh_signature_does_not_autosubmit(self):
signer = TimestampSigner(salt="alerts")
signed_token = signer.sign(self.channel.make_token())
url = "/integrations/%s/unsub/%s/" % (self.channel.code, signed_token)
r = self.client.get(url)
self.assertContains(
r, "Please press the button below to unsubscribe", status_code=200
)
self.assertNotContains(r, "submit()", status_code=200)
def test_aged_signature_does_autosubmit(self):
with patch("django.core.signing.time") as mock_time:
mock_time.time.return_value = time.time() - 301
signer = TimestampSigner(salt="alerts")
signed_token = signer.sign(self.channel.make_token())
url = "/integrations/%s/unsub/%s/" % (self.channel.code, signed_token)
r = self.client.get(url)
self.assertContains(
r, "Please press the button below to unsubscribe", status_code=200
)
self.assertContains(r, "submit()", status_code=200)
def test_it_checks_signature(self):
signed_token = self.channel.make_token() + ":bad:signature"
url = "/integrations/%s/unsub/%s/" % (self.channel.code, signed_token)
r = self.client.get(url)
self.assertContains(r, "link you just used is incorrect", status_code=200)
def test_it_checks_token(self): def test_it_checks_token(self):
url = "/integrations/%s/unsub/faketoken/" % self.channel.code url = "/integrations/%s/unsub/faketoken/" % self.channel.code
@ -33,11 +77,4 @@ class UnsubscribeEmailTestCase(BaseTestCase):
url = "/integrations/%s/unsub/%s/" % (self.channel.code, token) url = "/integrations/%s/unsub/%s/" % (self.channel.code, token)
r = self.client.get(url) r = self.client.get(url)
self.assertEqual(r.status_code, 400)
def test_it_serves_confirmation_form(self):
token = self.channel.make_token()
url = "/integrations/%s/unsub/%s/" % (self.channel.code, token)
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertEqual(r.status_code, 404)

+ 1
- 1
hc/front/urls.py View File

@ -57,7 +57,7 @@ channel_urls = [
"<uuid:code>/verify/<slug:token>/", views.verify_email, name="hc-verify-email" "<uuid:code>/verify/<slug:token>/", views.verify_email, name="hc-verify-email"
), ),
path( path(
"<uuid:code>/unsub/<slug:token>/",
"<uuid:code>/unsub/<str:signed_token>/",
views.unsubscribe_email, views.unsubscribe_email,
name="hc-unsubscribe-alerts", name="hc-unsubscribe-alerts",
), ),


+ 25
- 8
hc/front/views.py View File

@ -702,18 +702,35 @@ def verify_email(request, code, token):
@csrf_exempt @csrf_exempt
def unsubscribe_email(request, code, token):
channel = get_object_or_404(Channel, code=code)
def unsubscribe_email(request, code, signed_token):
# Some email servers open links in emails to check for malicious content.
# To work around this, on GET requests we serve a confirmation form.
# If the signature is at least 5 minutes old, we also include JS code to
# auto-submit the form.
ctx = {}
if ":" in signed_token:
signer = signing.TimestampSigner(salt="alerts")
# First, check the signature without looking at the timestamp:
try:
token = signer.unsign(signed_token)
except signing.BadSignature:
return render(request, "bad_link.html")
# Check if timestamp is older than 5 minutes:
try:
signer.unsign(signed_token, max_age=300)
except signing.SignatureExpired:
ctx["autosubmit"] = True
else:
token = signed_token
channel = get_object_or_404(Channel, code=code, kind="email")
if channel.make_token() != token: if channel.make_token() != token:
return render(request, "bad_link.html") return render(request, "bad_link.html")
if channel.kind != "email":
return HttpResponseBadRequest()
# Some email servers open links in emails to check for malicious content.
# To work around this, we serve a form that auto-submits with JS.
if request.method != "POST": if request.method != "POST":
return render(request, "accounts/unsubscribe_submit.html")
return render(request, "accounts/unsubscribe_submit.html", ctx)
channel.delete() channel.delete()
return render(request, "front/unsubscribe_success.html") return render(request, "front/unsubscribe_success.html")


+ 5
- 0
templates/accounts/unsubscribe_submit.html View File

@ -12,4 +12,9 @@
class="btn btn-lg btn-primary" class="btn btn-lg btn-primary"
value="Unsubscribe"> value="Unsubscribe">
</form> </form>
{% if autosubmit %}
<script>document.getElementById("form").submit();</script>
{% endif %}
{% endblock %} {% endblock %}

Loading…
Cancel
Save