Browse Source

Add rate limiting for Pushover notifications

pull/474/head
Pēteris Caune 4 years ago
parent
commit
c2bb4b31b5
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
5 changed files with 85 additions and 35 deletions
  1. +1
    -0
      CHANGELOG.md
  2. +7
    -0
      hc/api/models.py
  3. +1
    -28
      hc/api/tests/test_notify.py
  4. +65
    -0
      hc/api/tests/test_notify_pushover.py
  5. +11
    -7
      hc/api/transports.py

+ 1
- 0
CHANGELOG.md View File

@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
- Change Zulip onboarding, ask for the zuliprc file (#202) - Change Zulip onboarding, ask for the zuliprc file (#202)
- Add a section in Docs about running self-hosted instances - Add a section in Docs about running self-hosted instances
- Add experimental Dockerfile and docker-compose.yml - Add experimental Dockerfile and docker-compose.yml
- Add rate limiting for Pushover notifications (6 notifications / user / minute)
## Bug Fixes ## Bug Fixes
- Fix unwanted HTML escaping in SMS and WhatsApp notifications - Fix unwanted HTML escaping in SMS and WhatsApp notifications


+ 7
- 0
hc/api/models.py View File

@ -931,6 +931,13 @@ class TokenBucket(models.Model):
# 6 messages for a single recipient per minute: # 6 messages for a single recipient per minute:
return TokenBucket.authorize(value, 6, 60) return TokenBucket.authorize(value, 6, 60)
@staticmethod
def authorize_pushover(user_key):
salted_encoded = (user_key + settings.SECRET_KEY).encode()
value = "po-%s" % hashlib.sha1(salted_encoded).hexdigest()
# 6 messages for a single user key per minute:
return TokenBucket.authorize(value, 6, 60)
@staticmethod @staticmethod
def authorize_sudo_code(user): def authorize_sudo_code(user):
value = "sudo-%d" % user.id value = "sudo-%d" % user.id


+ 1
- 28
hc/api/tests/test_notify.py View File

@ -2,7 +2,7 @@
from datetime import timedelta as td from datetime import timedelta as td
import json import json
from unittest.mock import patch, Mock
from unittest.mock import patch
from django.core import mail from django.core import mail
from django.utils.timezone import now from django.utils.timezone import now
@ -492,33 +492,6 @@ class NotifyTestCase(BaseTestCase):
n = Notification.objects.first() n = Notification.objects.first()
self.assertEqual(n.error, 'Received status code 403 with a message: "Nice try"') self.assertEqual(n.error, 'Received status code 403 with a message: "Nice try"')
@patch("hc.api.transports.requests.request")
def test_pushover(self, mock_post):
self._setup_data("po", "123|0")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertIn("DOWN", payload["title"])
@patch("hc.api.transports.requests.request")
def test_pushover_up_priority(self, mock_post):
self._setup_data("po", "123|0|2", status="up")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertIn("UP", payload["title"])
self.assertEqual(payload["priority"], 2)
self.assertIn("retry", payload)
self.assertIn("expire", payload)
@patch("hc.api.transports.requests.request") @patch("hc.api.transports.requests.request")
def test_victorops(self, mock_post): def test_victorops(self, mock_post):
self._setup_data("victorops", "123") self._setup_data("victorops", "123")


+ 65
- 0
hc/api/tests/test_notify_pushover.py View File

@ -0,0 +1,65 @@
# coding: utf-8
from datetime import timedelta as td
from unittest.mock import patch
from django.test.utils import override_settings
from django.utils.timezone import now
from hc.api.models import Channel, Check, Notification, TokenBucket
from hc.test import BaseTestCase
class NotifyTestCase(BaseTestCase):
def _setup_data(self, value, status="down", email_verified=True):
self.check = Check(project=self.project)
self.check.status = status
self.check.last_ping = now() - td(minutes=61)
self.check.save()
self.channel = Channel(project=self.project)
self.channel.kind = "po"
self.channel.value = value
self.channel.email_verified = email_verified
self.channel.save()
self.channel.checks.add(self.check)
@patch("hc.api.transports.requests.request")
def test_pushover(self, mock_post):
self._setup_data("123|0")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertIn("DOWN", payload["title"])
@patch("hc.api.transports.requests.request")
def test_pushover_up_priority(self, mock_post):
self._setup_data("123|0|2", status="up")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertIn("UP", payload["title"])
self.assertEqual(payload["priority"], 2)
self.assertIn("retry", payload)
self.assertIn("expire", payload)
@override_settings(SECRET_KEY="test-secret")
@patch("hc.api.transports.requests.request")
def test_it_obeys_rate_limit(self, mock_post):
self._setup_data("123|0")
# "c0ca..." is sha1("123test-secret")
obj = TokenBucket(value="po-c0ca2a9774952af32cabf86453f69e442c4ed0eb")
obj.tokens = 0
obj.save()
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, "Rate limit exceeded")

+ 11
- 7
hc/api/transports.py View File

@ -358,20 +358,24 @@ class Pushover(HttpTransport):
URL = "https://api.pushover.net/1/messages.json" URL = "https://api.pushover.net/1/messages.json"
def notify(self, check): def notify(self, check):
others = self.checks().filter(status="down").exclude(code=check.code)
pieces = self.channel.value.split("|")
user_key, prio = pieces[0], pieces[1]
# The third element, if present, is the priority for "up" events
if len(pieces) == 3 and check.status == "up":
prio = pieces[2]
from hc.api.models import TokenBucket
if not TokenBucket.authorize_pushover(user_key):
return "Rate limit exceeded"
others = self.checks().filter(status="down").exclude(code=check.code)
# list() executes the query, to avoid DB access while # list() executes the query, to avoid DB access while
# rendering a template # rendering a template
ctx = {"check": check, "down_checks": list(others)} ctx = {"check": check, "down_checks": list(others)}
text = tmpl("pushover_message.html", **ctx) text = tmpl("pushover_message.html", **ctx)
title = tmpl("pushover_title.html", **ctx) title = tmpl("pushover_title.html", **ctx)
pieces = self.channel.value.split("|")
user_key, prio = pieces[0], pieces[1]
# The third element, if present, is the priority for "up" events
if len(pieces) == 3 and check.status == "up":
prio = pieces[2]
payload = { payload = {
"token": settings.PUSHOVER_API_TOKEN, "token": settings.PUSHOVER_API_TOKEN,
"user": user_key, "user": user_key,


Loading…
Cancel
Save