Browse Source

Fix CSRF in Slack, Pushbullet and Discord callbacks

pull/109/head
Pēteris Caune 8 years ago
parent
commit
17bf0d109e
6 changed files with 113 additions and 18 deletions
  1. +22
    -1
      hc/front/tests/test_add_discord.py
  2. +22
    -1
      hc/front/tests/test_add_pushbullet.py
  3. +27
    -2
      hc/front/tests/test_add_slack_btn.py
  4. +37
    -11
      hc/front/views.py
  5. +4
    -2
      templates/front/welcome.html
  6. +1
    -1
      templates/integrations/add_slack.html

+ 22
- 1
hc/front/tests/test_add_discord.py View File

@ -16,6 +16,9 @@ class AddDiscordTestCase(BaseTestCase):
self.assertContains(r, "Connect Discord", status_code=200) self.assertContains(r, "Connect Discord", status_code=200)
self.assertContains(r, "discordapp.com/api/oauth2/authorize") self.assertContains(r, "discordapp.com/api/oauth2/authorize")
# There should now be a key in session
self.assertTrue("discord" in self.client.session)
@override_settings(DISCORD_CLIENT_ID=None) @override_settings(DISCORD_CLIENT_ID=None)
def test_it_requires_client_id(self): def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
@ -24,6 +27,10 @@ class AddDiscordTestCase(BaseTestCase):
@patch("hc.front.views.requests.post") @patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post): def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["discord"] = "foo"
session.save()
oauth_response = { oauth_response = {
"access_token": "test-token", "access_token": "test-token",
"webhook": { "webhook": {
@ -35,7 +42,7 @@ class AddDiscordTestCase(BaseTestCase):
mock_post.return_value.text = json.dumps(oauth_response) mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response mock_post.return_value.json.return_value = oauth_response
url = self.url + "?code=12345678"
url = self.url + "?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True) r = self.client.get(url, follow=True)
@ -44,3 +51,17 @@ class AddDiscordTestCase(BaseTestCase):
ch = Channel.objects.get() ch = Channel.objects.get()
self.assertEqual(ch.discord_webhook_url, "foo") self.assertEqual(ch.discord_webhook_url, "foo")
# Session should now be clean
self.assertFalse("discord" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["discord"] = "foo"
session.save()
url = self.url + "?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)

+ 22
- 1
hc/front/tests/test_add_pushbullet.py View File

@ -16,6 +16,9 @@ class AddPushbulletTestCase(BaseTestCase):
self.assertContains(r, "www.pushbullet.com/authorize", status_code=200) self.assertContains(r, "www.pushbullet.com/authorize", status_code=200)
self.assertContains(r, "Connect Pushbullet") self.assertContains(r, "Connect Pushbullet")
# There should now be a key in session
self.assertTrue("pushbullet" in self.client.session)
@override_settings(PUSHBULLET_CLIENT_ID=None) @override_settings(PUSHBULLET_CLIENT_ID=None)
def test_it_requires_client_id(self): def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
@ -24,12 +27,16 @@ class AddPushbulletTestCase(BaseTestCase):
@patch("hc.front.views.requests.post") @patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post): def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["pushbullet"] = "foo"
session.save()
oauth_response = {"access_token": "test-token"} oauth_response = {"access_token": "test-token"}
mock_post.return_value.text = json.dumps(oauth_response) mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response mock_post.return_value.json.return_value = oauth_response
url = self.url + "?code=12345678"
url = self.url + "?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True) r = self.client.get(url, follow=True)
@ -38,3 +45,17 @@ class AddPushbulletTestCase(BaseTestCase):
ch = Channel.objects.get() ch = Channel.objects.get()
self.assertEqual(ch.value, "test-token") self.assertEqual(ch.value, "test-token")
# Session should now be clean
self.assertFalse("pushbullet" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["pushbullet"] = "foo"
session.save()
url = self.url + "?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)

+ 27
- 2
hc/front/tests/test_add_slack_btn.py View File

@ -14,6 +14,9 @@ class AddSlackBtnTestCase(BaseTestCase):
self.assertContains(r, "Before adding Slack integration", self.assertContains(r, "Before adding Slack integration",
status_code=200) status_code=200)
# There should now be a key in session
self.assertTrue("slack" in self.client.session)
@override_settings(SLACK_CLIENT_ID="foo") @override_settings(SLACK_CLIENT_ID="foo")
def test_slack_button(self): def test_slack_button(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
@ -22,6 +25,10 @@ class AddSlackBtnTestCase(BaseTestCase):
@patch("hc.front.views.requests.post") @patch("hc.front.views.requests.post")
def test_it_handles_oauth_response(self, mock_post): def test_it_handles_oauth_response(self, mock_post):
session = self.client.session
session["slack"] = "foo"
session.save()
oauth_response = { oauth_response = {
"ok": True, "ok": True,
"team_name": "foo", "team_name": "foo",
@ -34,7 +41,7 @@ class AddSlackBtnTestCase(BaseTestCase):
mock_post.return_value.text = json.dumps(oauth_response) mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response mock_post.return_value.json.return_value = oauth_response
url = "/integrations/add_slack_btn/?code=12345678"
url = "/integrations/add_slack_btn/?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True) r = self.client.get(url, follow=True)
@ -46,8 +53,26 @@ class AddSlackBtnTestCase(BaseTestCase):
self.assertEqual(ch.slack_channel, "bar") self.assertEqual(ch.slack_channel, "bar")
self.assertEqual(ch.slack_webhook_url, "http://example.org") self.assertEqual(ch.slack_webhook_url, "http://example.org")
# Session should now be clean
self.assertFalse("slack" in self.client.session)
def test_it_avoids_csrf(self):
session = self.client.session
session["slack"] = "foo"
session.save()
url = "/integrations/add_slack_btn/?code=12345678&state=bar"
self.client.login(username="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)
@patch("hc.front.views.requests.post") @patch("hc.front.views.requests.post")
def test_it_handles_oauth_error(self, mock_post): def test_it_handles_oauth_error(self, mock_post):
session = self.client.session
session["slack"] = "foo"
session.save()
oauth_response = { oauth_response = {
"ok": False, "ok": False,
"error": "something went wrong" "error": "something went wrong"
@ -56,7 +81,7 @@ class AddSlackBtnTestCase(BaseTestCase):
mock_post.return_value.text = json.dumps(oauth_response) mock_post.return_value.text = json.dumps(oauth_response)
mock_post.return_value.json.return_value = oauth_response mock_post.return_value.json.return_value = oauth_response
url = "/integrations/add_slack_btn/?code=12345678"
url = "/integrations/add_slack_btn/?code=12345678&state=foo"
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.get(url, follow=True) r = self.client.get(url, follow=True)


+ 37
- 11
hc/front/views.py View File

@ -91,7 +91,9 @@ def index(request):
"page": "welcome", "page": "welcome",
"check": check, "check": check,
"ping_url": check.url(), "ping_url": check.url(),
"enable_pushover": settings.PUSHOVER_API_TOKEN is not None
"enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None,
"enable_pushover": settings.PUSHOVER_API_TOKEN is not None,
"enable_discord": settings.DISCORD_CLIENT_ID is not None
} }
return render(request, "front/welcome.html", ctx) return render(request, "front/welcome.html", ctx)
@ -430,6 +432,24 @@ def add_pd(request):
return render(request, "integrations/add_pd.html", ctx) return render(request, "integrations/add_pd.html", ctx)
def _prepare_state(request, session_key):
state = get_random_string()
request.session[session_key] = state
return state
def _get_validated_code(request, session_key):
if session_key not in request.session:
return None
session_state = request.session.pop(session_key)
request_state = request.GET.get("state")
if session_state is None or session_state != request_state:
return None
return request.GET.get("code")
def add_slack(request): def add_slack(request):
if not settings.SLACK_CLIENT_ID and not request.user.is_authenticated: if not settings.SLACK_CLIENT_ID and not request.user.is_authenticated:
return redirect("hc-login") return redirect("hc-login")
@ -452,13 +472,16 @@ def add_slack(request):
"slack_client_id": settings.SLACK_CLIENT_ID "slack_client_id": settings.SLACK_CLIENT_ID
} }
if settings.SLACK_CLIENT_ID:
ctx["state"] = _prepare_state(request, "slack")
return render(request, "integrations/add_slack.html", ctx) return render(request, "integrations/add_slack.html", ctx)
@login_required @login_required
def add_slack_btn(request): def add_slack_btn(request):
code = request.GET.get("code", "")
if len(code) < 8:
code = _get_validated_code(request, "slack")
if code is None:
return HttpResponseBadRequest() return HttpResponseBadRequest()
result = requests.post("https://slack.com/api/oauth.access", { result = requests.post("https://slack.com/api/oauth.access", {
@ -507,8 +530,8 @@ def add_pushbullet(request):
raise Http404("pushbullet integration is not available") raise Http404("pushbullet integration is not available")
if "code" in request.GET: if "code" in request.GET:
code = request.GET.get("code", "")
if len(code) < 8:
code = _get_validated_code(request, "pushbullet")
if code is None:
return HttpResponseBadRequest() return HttpResponseBadRequest()
result = requests.post("https://api.pushbullet.com/oauth2/token", { result = requests.post("https://api.pushbullet.com/oauth2/token", {
@ -536,7 +559,8 @@ def add_pushbullet(request):
authorize_url = "https://www.pushbullet.com/authorize?" + urlencode({ authorize_url = "https://www.pushbullet.com/authorize?" + urlencode({
"client_id": settings.PUSHBULLET_CLIENT_ID, "client_id": settings.PUSHBULLET_CLIENT_ID,
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"response_type": "code"
"response_type": "code",
"state": _prepare_state(request, "pushbullet")
}) })
ctx = { ctx = {
@ -553,8 +577,8 @@ def add_discord(request):
redirect_uri = settings.SITE_ROOT + reverse("hc-add-discord") redirect_uri = settings.SITE_ROOT + reverse("hc-add-discord")
if "code" in request.GET: if "code" in request.GET:
code = request.GET.get("code", "")
if len(code) < 8:
code = _get_validated_code(request, "discord")
if code is None:
return HttpResponseBadRequest() return HttpResponseBadRequest()
result = requests.post("https://discordapp.com/api/oauth2/token", { result = requests.post("https://discordapp.com/api/oauth2/token", {
@ -579,17 +603,19 @@ def add_discord(request):
return redirect("hc-channels") return redirect("hc-channels")
authorize_url = "https://discordapp.com/api/oauth2/authorize?" + urlencode({
auth_url = "https://discordapp.com/api/oauth2/authorize?" + urlencode({
"client_id": settings.DISCORD_CLIENT_ID, "client_id": settings.DISCORD_CLIENT_ID,
"scope": "webhook.incoming", "scope": "webhook.incoming",
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"response_type": "code"
"response_type": "code",
"state": _prepare_state(request, "discord")
}) })
ctx = { ctx = {
"page": "channels", "page": "channels",
"authorize_url": authorize_url
"authorize_url": auth_url
} }
return render(request, "integrations/add_discord.html", ctx) return render(request, "integrations/add_discord.html", ctx)


+ 4
- 2
templates/front/welcome.html View File

@ -248,12 +248,14 @@
<td>Notifications in <a href="https://slack.com/">Slack</a> channel.</td> <td>Notifications in <a href="https://slack.com/">Slack</a> channel.</td>
</tr> </tr>
{% if enable_discord %}
<tr> <tr>
<td> <td>
<img width="22" height="22" alt="Discord icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsAQMAAAAkSshCAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAGUExURUxpcXKJ2kFuBesAAAABdFJOUwBA5thmAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAb0lEQVQY053QzQmAMAwF4CceRBAcoaPoSG5gQQfrKAUXKF68FGOf8feogfDd8pKgEJEI1AkBzAP7g+gnMqMkASD+or9xJ21DQnhg34BtRmVARZzv9mG932Nl+bruepCm8PYUxE8wLFWtFEquZArsBlghj/fhSNdMAAAAAElFTkSuQmCC" /> <img width="22" height="22" alt="Discord icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsAQMAAAAkSshCAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAGUExURUxpcXKJ2kFuBesAAAABdFJOUwBA5thmAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAb0lEQVQY053QzQmAMAwF4CceRBAcoaPoSG5gQQfrKAUXKF68FGOf8feogfDd8pKgEJEI1AkBzAP7g+gnMqMkASD+or9xJ21DQnhg34BtRmVARZzv9mG932Nl+bruepCm8PYUxE8wLFWtFEquZArsBlghj/fhSNdMAAAAAElFTkSuQmCC" />
</td> </td>
<td>Notifications in <a href="https://discordapp.com/">Discord</a> channel.</td> <td>Notifications in <a href="https://discordapp.com/">Discord</a> channel.</td>
</tr> </tr>
{% endif %}
<tr> <tr>
<td> <td>
@ -283,14 +285,14 @@
<td>Open and resolve incidents in <a href="https://victorops.com/">VictorOps</a>.</td> <td>Open and resolve incidents in <a href="https://victorops.com/">VictorOps</a>.</td>
</tr> </tr>
{% if enable_pushbullet %}
<tr> <tr>
<td> <td>
<img width="22" height="22" alt="Pushbullet icon" src="data:image/png;base64,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" /> <img width="22" height="22" alt="Pushbullet icon" src="data:image/png;base64,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" />
</td> </td>
<td>Instant push notifications with <a href="https://www.pushbullet.com/">Pushbullet</a>.</td> <td>Instant push notifications with <a href="https://www.pushbullet.com/">Pushbullet</a>.</td>
</tr> </tr>
{% endif %}
{% if enable_pushover %} {% if enable_pushover %}
<tr> <tr>


+ 1
- 1
templates/integrations/add_slack.html View File

@ -16,7 +16,7 @@
Slack channel.</p> Slack channel.</p>
<div class="text-center"> <div class="text-center">
<a href="https://slack.com/oauth/authorize?scope=incoming-webhook&client_id={{ slack_client_id }}">
<a href="https://slack.com/oauth/authorize?scope=incoming-webhook&client_id={{ slack_client_id }}&state={{ state }}">
<img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/[email protected] 2x" /> <img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/[email protected] 2x" />
</a> </a>
</div> </div>


Loading…
Cancel
Save