Server-side handling of new apprise:// schema (#56)

This commit is contained in:
Chris Caron 2021-12-01 21:46:53 -05:00 committed by GitHub
parent 2943bce981
commit a6fe49e1e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 261 additions and 1 deletions

View File

@ -202,6 +202,7 @@ The use of environment variables allow you to provide over-rides to default sett
| `SECRET_KEY` | A Django variable acting as a *salt* for most things that require security. This API uses it for the hash sequences when writing the configuration files to disk (`hash` mode only).
| `ALLOWED_HOSTS` | A list of strings representing the host/domain names that this API can serve. This is a security measure to prevent HTTP Host header attacks, which are possible even under many seemingly-safe web server configurations. By default this is set to `*` allowing any host. Use space to delimit more than one host.
| `APPRISE_RECURSION_MAX` | This defines the number of times one Apprise API Server can (recursively) call another. This is to both support and mitigate abuse through [the `apprise://` schema](https://github.com/caronc/apprise/wiki/Notify_apprise_api) for those who choose to use it. When leveraged properly, you can increase this (recursion max) value and successfully load balance the handling of many notification requests through many additional API Servers. By default this value is set to `1` (one).
| `BASE_URL` | Those who are hosting the API behind a proxy that requires a subpath to gain access to this API should specify this path here as well. By default this is not set at all.
| `LOG_LEVEL` | Adjust the log level to the console. Possible values are `CRITICAL`, `ERROR`, `WARNING`, `INFO`, and `DEBUG`.
| `DEBUG` | This defaults to `no` and can however be set to `yes` by simply defining the global variable as such.

View File

@ -492,3 +492,99 @@ class NotifyTests(SimpleTestCase):
# nothing was changed
assert apprise.plugins.SCHEMA_MAP['mailto'].enabled is True
@override_settings(APPRISE_RECURSION_MAX=1)
@patch('apprise.Apprise.notify')
def test_stateful_notify_recursion(self, mock_notify):
"""
Test recursion an id header details as part of post
"""
# Set our return value
mock_notify.return_value = True
# our key to use
key = 'test_stateful_notify_recursion'
# Add some content
response = self.client.post(
'/add/{}'.format(key),
{'urls': 'mailto://user:pass@yahoo.ca'})
assert response.status_code == 200
# Form data
form_data = {
'body': 'test notifiction',
}
# Define our headers we plan to pass along with our request
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
'HTTP_X-APPRISE-RECURSION-COUNT': str(1),
}
# Send our notification
response = self.client.post(
'/notify/{}'.format(key), data=form_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
headers = {
# Header specified but with whitespace
'HTTP_X-APPRISE-ID': ' ',
# No Recursion value specified
}
# Reset our count
mock_notify.reset_mock()
# Recursion limit reached
response = self.client.post(
'/notify/{}'.format(key), data=form_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
# Recursion Limit hit
'HTTP_X-APPRISE-RECURSION-COUNT': str(2),
}
# Reset our count
mock_notify.reset_mock()
# Recursion limit reached
response = self.client.post(
'/notify/{}'.format(key), data=form_data, **headers)
assert response.status_code == 406
assert mock_notify.call_count == 0
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
# Negative recursion value (bad request)
'HTTP_X-APPRISE-RECURSION-COUNT': str(-1),
}
# Reset our count
mock_notify.reset_mock()
# invalid recursion specified
response = self.client.post(
'/notify/{}'.format(key), data=form_data, **headers)
assert response.status_code == 400
assert mock_notify.call_count == 0
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
# Invalid recursion value (bad request)
'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid',
}
# Reset our count
mock_notify.reset_mock()
# invalid recursion specified
response = self.client.post(
'/notify/{}'.format(key), data=form_data, **headers)
assert response.status_code == 400
assert mock_notify.call_count == 0

View File

@ -113,6 +113,93 @@ class StatelessNotifyTests(SimpleTestCase):
assert response.status_code == 424
assert mock_notify.call_count == 2
@override_settings(APPRISE_RECURSION_MAX=1)
@patch('apprise.Apprise.notify')
def test_stateless_notify_recursion(self, mock_notify):
"""
Test recursion an id header details as part of post
"""
# Set our return value
mock_notify.return_value = True
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
'HTTP_X-APPRISE-RECURSION-COUNT': str(1),
}
# Preare our form data (without url specified)
# content will fall back to default configuration
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
}
# At a minimum 'body' is requred
form = NotifyByUrlForm(data=form_data)
assert form.is_valid()
# recursion value is within correct limits
response = self.client.post('/notify', form.cleaned_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
headers = {
# Header specified but with whitespace
'HTTP_X-APPRISE-ID': ' ',
# No Recursion value specified
}
# Reset our count
mock_notify.reset_mock()
# Recursion limit reached
response = self.client.post('/notify', form.cleaned_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
# Recursion Limit hit
'HTTP_X-APPRISE-RECURSION-COUNT': str(2),
}
# Reset our count
mock_notify.reset_mock()
# Recursion limit reached
response = self.client.post('/notify', form.cleaned_data, **headers)
assert response.status_code == 406
assert mock_notify.call_count == 0
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
# Negative recursion value (bad request)
'HTTP_X-APPRISE-RECURSION-COUNT': str(-1),
}
# Reset our count
mock_notify.reset_mock()
# invalid recursion specified
response = self.client.post('/notify', form.cleaned_data, **headers)
assert response.status_code == 400
assert mock_notify.call_count == 0
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
# Invalid recursion value (bad request)
'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid',
}
# Reset our count
mock_notify.reset_mock()
# invalid recursion specified
response = self.client.post('/notify', form.cleaned_data, **headers)
assert response.status_code == 400
assert mock_notify.call_count == 0
@override_settings(APPRISE_STATELESS_URLS="mailto://user:pass@localhost")
@patch('apprise.Apprise.notify')
def test_notify_default_urls(self, mock_notify):

View File

@ -89,6 +89,7 @@ class ResponseCode(object):
no_access = 403
not_found = 404
method_not_allowed = 405
method_not_accepted = 406
failed_dependency = 424
internal_server_error = 500
@ -623,6 +624,41 @@ class NotifyView(View):
'APPRISE_DENY_SERVICES plugin %s:// was not found -'
' ignoring.', name)
# Prepare our keyword arguments (to be passed into an AppriseAsset
# object)
kwargs = {}
if body_format:
# Store our defined body format
kwargs['body_format'] = body_format
# Acquire our recursion count (if defined)
try:
recursion = \
int(request.headers.get('X-Apprise-Recursion-Count', 0))
if recursion < 0:
# We do not accept negative numbers
raise TypeError("Invalid Recursion Value")
if recursion > settings.APPRISE_RECURSION_MAX:
return HttpResponse(
_('The recursion limit has been reached.'),
status=ResponseCode.method_not_accepted)
# Store our recursion value for our AppriseAsset() initialization
kwargs['_recursion'] = recursion
except (TypeError, ValueError):
return HttpResponse(
_('An invalid recursion value was specified.'),
status=ResponseCode.bad_request)
# Acquire our unique identifier (if defined)
uid = request.headers.get('X-Apprise-ID', '').strip()
if uid:
kwargs['_uid'] = uid
# Prepare ourselves a default Asset
asset = None if not body_format else \
apprise.AppriseAsset(body_format=body_format)
@ -785,6 +821,41 @@ class StatelessNotifyView(View):
_('An invalid (body) format was specified.'),
status=ResponseCode.bad_request)
# Prepare our keyword arguments (to be passed into an AppriseAsset
# object)
kwargs = {}
if body_format:
# Store our defined body format
kwargs['body_format'] = body_format
# Acquire our recursion count (if defined)
try:
recursion = \
int(request.headers.get('X-Apprise-Recursion-Count', 0))
if recursion < 0:
# We do not accept negative numbers
raise TypeError("Invalid Recursion Value")
if recursion > settings.APPRISE_RECURSION_MAX:
return HttpResponse(
_('The recursion limit has been reached.'),
status=ResponseCode.method_not_accepted)
# Store our recursion value for our AppriseAsset() initialization
kwargs['_recursion'] = recursion
except (TypeError, ValueError):
return HttpResponse(
_('An invalid recursion value was specified.'),
status=ResponseCode.bad_request)
# Acquire our unique identifier (if defined)
uid = request.headers.get('X-Apprise-ID', '').strip()
if uid:
kwargs['_uid'] = uid
# Prepare ourselves a default Asset
asset = None if not body_format else \
apprise.AppriseAsset(body_format=body_format)

View File

@ -163,3 +163,9 @@ APPRISE_DENY_SERVICES = os.environ.get('APPRISE_DENY_SERVICES', ','.join((
# - anything not identified here is denied/disabled)
# - this list trumps the APPRISE_DENY_SERVICES identified above
APPRISE_ALLOW_SERVICES = os.environ.get('APPRISE_ALLOW_SERVICES', '')
# Define the number of recursive calls your system will allow users to make
# The idea here is to prevent people from defining apprise:// URL's triggering
# a call to the same server again, and again and again. By default we allow
# 1 level of recursion
APPRISE_RECURSION_MAX = int(os.environ.get('APPRISE_RECURSION_MAX', 1))

View File

@ -1,5 +1,4 @@
django
cryptography == 3.3.2
apprise == 0.9.6
# 3rd party service support