Add webhook block private setting

remotes/origin/4.0rc
Álex Hermida 2018-10-10 17:07:44 +02:00 committed by Alex Hermida
parent 8b130f0361
commit e37eb65431
7 changed files with 65 additions and 43 deletions

View File

@ -547,6 +547,7 @@ EXPORTS_TTL = 60 * 60 * 24 # 24 hours
CELERY_ENABLED = False CELERY_ENABLED = False
WEBHOOKS_ENABLED = False WEBHOOKS_ENABLED = False
WEBHOOKS_BLOCK_PRIVATE_ADDRESS = False
# If is True /front/sitemap.xml show a valid sitemap of taiga-front client # If is True /front/sitemap.xml show a valid sitemap of taiga-front client

View File

@ -50,7 +50,7 @@ def reverse(viewname, *args, **kwargs):
return get_absolute_url(django_reverse(viewname, *args, **kwargs)) return get_absolute_url(django_reverse(viewname, *args, **kwargs))
class HostnameValueError(Exception): class HostnameException(Exception):
pass pass
@ -65,7 +65,7 @@ def validate_private_url(url):
try: try:
socket_args, *others = socket.getaddrinfo(host, port) socket_args, *others = socket.getaddrinfo(host, port)
except Exception: except Exception:
raise HostnameValueError(_("Host access error")) raise HostnameException(_("Host access error"))
destination_address = socket_args[4][0] destination_address = socket_args[4][0]
try: try:

View File

@ -21,6 +21,8 @@ import hashlib
import requests import requests
from requests.exceptions import RequestException from requests.exceptions import RequestException
from django.conf import settings
from taiga.base.api.renderers import UnicodeJSONRenderer from taiga.base.api.renderers import UnicodeJSONRenderer
from taiga.base.utils import json, urls from taiga.base.utils import json, urls
from taiga.base.utils.db import get_typename_for_model_instance from taiga.base.utils.db import get_typename_for_model_instance
@ -64,6 +66,15 @@ def _generate_signature(data, key):
return mac.hexdigest() return mac.hexdigest()
def _remove_leftover_webhooklogs(webhook_id):
# Only the last ten webhook logs traces are required
# so remove the leftover
ids = (WebhookLog.objects.filter(webhook_id=webhook_id)
.order_by("-id")
.values_list('id', flat=True)[10:])
WebhookLog.objects.filter(id__in=ids).delete()
def _send_request(webhook_id, url, key, data): def _send_request(webhook_id, url, key, data):
serialized_data = UnicodeJSONRenderer().render(data) serialized_data = UnicodeJSONRenderer().render(data)
signature = _generate_signature(serialized_data, key) signature = _generate_signature(serialized_data, key)
@ -73,9 +84,10 @@ def _send_request(webhook_id, url, key, data):
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if settings.WEBHOOKS_BLOCK_PRIVATE_ADDRESS:
try: try:
urls.validate_destination_address(url) urls.validate_private_url(url)
except urls.IpAddresValueError as e: except (urls.IpAddresValueError, urls.HostnameException) as e:
# Error validating url # Error validating url
webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, url=url, webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, url=url,
status=0, status=0,
@ -86,6 +98,8 @@ def _send_request(webhook_id, url, key, data):
response_headers={}, response_headers={},
duration=0) duration=0)
return webhook_log return webhook_log
finally:
_remove_leftover_webhooklogs(webhook_id)
request = requests.Request('POST', url, data=serialized_data, headers=headers) request = requests.Request('POST', url, data=serialized_data, headers=headers)
prepared_request = request.prepare() prepared_request = request.prepare()
@ -114,12 +128,7 @@ def _send_request(webhook_id, url, key, data):
response_headers=dict(response.headers), response_headers=dict(response.headers),
duration=response.elapsed.total_seconds()) duration=response.elapsed.total_seconds())
finally: finally:
# Only the last ten webhook logs traces are required _remove_leftover_webhooklogs(webhook_id)
# so remove the leftover
ids = (WebhookLog.objects.filter(webhook_id=webhook_id)
.order_by("-id")
.values_list('id', flat=True)[10:])
WebhookLog.objects.filter(id__in=ids).delete()
return webhook_log return webhook_log

View File

@ -16,13 +16,14 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import ipaddress import ipaddress
from urllib.parse import urlparse
from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from taiga.base.api import validators from taiga.base.api import validators
from urllib.parse import urlparse
from taiga.base.exceptions import ValidationError from taiga.base.exceptions import ValidationError
from .models import Webhook from .models import Webhook
@ -31,6 +32,7 @@ class WebhookValidator(validators.ModelValidator):
model = Webhook model = Webhook
def validate_url(self, attrs, source): def validate_url(self, attrs, source):
if settings.WEBHOOKS_BLOCK_PRIVATE_ADDRESS:
host = urlparse(attrs[source]).hostname host = urlparse(attrs[source]).hostname
try: try:
ipa = ipaddress.ip_address(host) ipa = ipaddress.ip_address(host)

View File

@ -55,7 +55,7 @@ def test_webhook_action_test_transform_to_json(client, data):
response.elapsed.total_seconds.return_value = 100 response.elapsed.total_seconds.return_value = 100
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response), \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response), \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
client.login(data.project_owner) client.login(data.project_owner)
response = client.json.post(url) response = client.json.post(url)
assert response.status_code == 200 assert response.status_code == 200

View File

@ -45,25 +45,25 @@ def test_new_object_with_one_webhook_signal(settings):
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test") services.take_snapshot(obj, user=obj.owner, comment="test")
assert session_send_mock.call_count == 1 assert session_send_mock.call_count == 1
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner) services.take_snapshot(obj, user=obj.owner)
assert session_send_mock.call_count == 0 assert session_send_mock.call_count == 0
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test") services.take_snapshot(obj, user=obj.owner, comment="test")
assert session_send_mock.call_count == 1 assert session_send_mock.call_count == 1
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) services.take_snapshot(obj, user=obj.owner, comment="test", delete=True)
assert session_send_mock.call_count == 1 assert session_send_mock.call_count == 1
@ -86,25 +86,25 @@ def test_new_object_with_two_webhook_signals(settings):
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test") services.take_snapshot(obj, user=obj.owner, comment="test")
assert session_send_mock.call_count == 2 assert session_send_mock.call_count == 2
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test") services.take_snapshot(obj, user=obj.owner, comment="test")
assert session_send_mock.call_count == 2 assert session_send_mock.call_count == 2
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner) services.take_snapshot(obj, user=obj.owner)
assert session_send_mock.call_count == 0 assert session_send_mock.call_count == 0
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) services.take_snapshot(obj, user=obj.owner, comment="test", delete=True)
assert session_send_mock.call_count == 2 assert session_send_mock.call_count == 2
@ -126,12 +126,12 @@ def test_send_request_one_webhook_signal(settings):
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test") services.take_snapshot(obj, user=obj.owner, comment="test")
assert session_send_mock.call_count == 1 assert session_send_mock.call_count == 1
for obj in objects: for obj in objects:
with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \ with patch("taiga.webhooks.tasks.requests.Session.send", return_value=response) as session_send_mock, \
patch("taiga.base.utils.urls.validate_destination_address", return_value=True): patch("taiga.base.utils.urls.validate_private_url", return_value=True):
services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) services.take_snapshot(obj, user=obj.owner, comment="test", delete=True)
assert session_send_mock.call_count == 1 assert session_send_mock.call_count == 1

View File

@ -23,7 +23,7 @@ import django_sites as sites
import re import re
from taiga.base.utils.urls import get_absolute_url, is_absolute_url, build_url, \ from taiga.base.utils.urls import get_absolute_url, is_absolute_url, build_url, \
validate_private_url, IpAddresValueError validate_private_url, IpAddresValueError, HostnameException
from taiga.base.utils.db import save_in_bulk, update_in_bulk, to_tsquery from taiga.base.utils.db import save_in_bulk, update_in_bulk, to_tsquery
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -103,6 +103,8 @@ TS_QUERY_TRANSFORMATIONS = [
('""', "'\"\"':*"), ('""', "'\"\"':*"),
('"""', "'\"\"':* & '\"':*"), ('"""', "'\"\"':* & '\"':*"),
] ]
def test_to_tsquery(): def test_to_tsquery():
for (input, expected) in TS_QUERY_TRANSFORMATIONS: for (input, expected) in TS_QUERY_TRANSFORMATIONS:
expected = re.sub("([0-9])", r"'\1':*", expected) expected = re.sub("([0-9])", r"'\1':*", expected)
@ -121,13 +123,21 @@ def test_to_tsquery():
"http://[::ffff:c0a8:164]/", "http://[::ffff:c0a8:164]/",
"scp://192.168.1.100/", "scp://192.168.1.100/",
"http://www.192.168.1.100.xip.io/", "http://www.192.168.1.100.xip.io/",
"http://test.local/",
]) ])
def test_validate_bad_destination_address(url): def test_validate_bad_destination_address(url):
with pytest.raises(IpAddresValueError): with pytest.raises(IpAddresValueError):
validate_private_url(url) validate_private_url(url)
@pytest.mark.parametrize("url", [
"http://test.local/",
"http://test.test/",
])
def test_validate_invalid_destination_address(url):
with pytest.raises(HostnameException):
validate_private_url(url)
@pytest.mark.parametrize("url", [ @pytest.mark.parametrize("url", [
"http://192.167.0.12", "http://192.167.0.12",
"http://11.0.0.1", "http://11.0.0.1",