Browse Source

Fix minor API inconsistencies

1. Drop API support for GET, DELETE requests with a request body.
Healthchecks had an undocumented quirk where you could authenticate a
GET or DELETE request by putting a '{"api_key":"..."}' in request body.
This commit removes this feature.

Note: POST requests can still authenticate either by sending
a X-Api-Key header, or by putting a "api_key" key in request body.
GET and DELETE requests can now only authenticate with the
request header.

2. Add missing @csrf_exempt annotations in API views
When client sends a HTTP POST request to a GET-only endpoint,
the server is supposed to respond with "405 Method Not Allowed".
Due to CSRF checking, a couple endpoints were responding with
"403 Forbidden" instead. Adding @csrf_exempt annotations fixes
the problem.
pull/563/head
Pēteris Caune 3 years ago
parent
commit
f8131741ef
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
10 changed files with 37 additions and 34 deletions
  1. +2
    -0
      CHANGELOG.md
  2. +1
    -1
      hc/api/decorators.py
  3. +7
    -4
      hc/api/tests/test_delete_check.py
  4. +1
    -1
      hc/api/tests/test_get_badges.py
  5. +5
    -1
      hc/api/tests/test_get_flips.py
  6. +5
    -1
      hc/api/tests/test_get_pings.py
  7. +10
    -9
      hc/api/tests/test_list_channels.py
  8. +0
    -9
      hc/api/tests/test_list_checks.py
  9. +3
    -7
      hc/api/views.py
  10. +3
    -1
      hc/test.py

+ 2
- 0
CHANGELOG.md View File

@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
- Add handling for non-latin-1 characters in webhook headers - Add handling for non-latin-1 characters in webhook headers
- Fix dark mode bug in selectpicker widgets - Fix dark mode bug in selectpicker widgets
- Fix a crash during login when user's profile does not exist (#77) - Fix a crash during login when user's profile does not exist (#77)
- Drop API support for GET, DELETE requests with a request body
- Add missing @csrf_exempt annotations in API views
## v1.22.0 - 2020-08-06 ## v1.22.0 - 2020-08-06


+ 1
- 1
hc/api/decorators.py View File

@ -73,7 +73,7 @@ def validate_json(schema=None):
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
def wrapper(request, *args, **kwds): def wrapper(request, *args, **kwds):
if request.body:
if request.method == "POST" and request.body:
try: try:
request.json = json.loads(request.body.decode()) request.json = json.loads(request.body.decode())
except ValueError: except ValueError:


+ 7
- 4
hc/api/tests/test_delete_check.py View File

@ -6,11 +6,10 @@ class DeleteCheckTestCase(BaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.check = Check.objects.create(project=self.project) self.check = Check.objects.create(project=self.project)
self.url = f"/api/v1/checks/{self.check.code}"
def test_it_works(self): def test_it_works(self):
r = self.client.delete(
"/api/v1/checks/%s" % self.check.code, HTTP_X_API_KEY="X" * 32
)
r = self.client.delete(self.url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(r["Access-Control-Allow-Origin"], "*") self.assertEqual(r["Access-Control-Allow-Origin"], "*")
@ -23,6 +22,10 @@ class DeleteCheckTestCase(BaseTestCase):
self.assertEqual(r.status_code, 404) self.assertEqual(r.status_code, 404)
def test_it_handles_options(self): def test_it_handles_options(self):
r = self.client.options("/api/v1/checks/%s" % self.check.code)
r = self.client.options(self.url)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertIn("DELETE", r["Access-Control-Allow-Methods"]) self.assertIn("DELETE", r["Access-Control-Allow-Methods"])
def test_it_handles_missing_api_key(self):
r = self.client.delete(self.url)
self.assertContains(r, "missing api key", status_code=401)

+ 1
- 1
hc/api/tests/test_get_badges.py View File

@ -39,7 +39,7 @@ class GetBadgesTestCase(BaseTestCase):
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
def test_it_rejects_post(self): def test_it_rejects_post(self):
r = self.client.post(self.url, HTTP_X_API_KEY="X" * 32)
r = self.csrf_client.post(self.url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
def test_it_handles_missing_api_key(self): def test_it_handles_missing_api_key(self):


+ 5
- 1
hc/api/tests/test_get_flips.py View File

@ -53,7 +53,7 @@ class GetFlipsTestCase(BaseTestCase):
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
def test_it_rejects_post(self): def test_it_rejects_post(self):
r = self.client.post(self.url, HTTP_X_API_KEY="X" * 32)
r = self.csrf_client.post(self.url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
def test_it_rejects_non_integer_start(self): def test_it_rejects_non_integer_start(self):
@ -82,3 +82,7 @@ class GetFlipsTestCase(BaseTestCase):
def test_it_rejects_huge_seconds(self): def test_it_rejects_huge_seconds(self):
r = self.get(qs="?seconds=12345678901234567890") r = self.get(qs="?seconds=12345678901234567890")
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 400)
def test_it_handles_missing_api_key(self):
r = self.client.get(self.url)
self.assertContains(r, "missing api key", status_code=401)

+ 5
- 1
hc/api/tests/test_get_pings.py View File

@ -54,5 +54,9 @@ class GetPingsTestCase(BaseTestCase):
self.assertEqual(r.status_code, 401) self.assertEqual(r.status_code, 401)
def test_it_rejects_post(self): def test_it_rejects_post(self):
r = self.client.post(self.url, HTTP_X_API_KEY="X" * 32)
r = self.csrf_client.post(self.url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
def test_it_handles_missing_api_key(self):
r = self.client.get(self.url)
self.assertContains(r, "missing api key", status_code=401)

+ 10
- 9
hc/api/tests/test_list_channels.py View File

@ -13,8 +13,10 @@ class ListChannelsTestCase(BaseTestCase):
self.c1.name = "Email to Alice" self.c1.name = "Email to Alice"
self.c1.save() self.c1.save()
self.url = "/api/v1/channels/"
def get(self): def get(self):
return self.client.get("/api/v1/channels/", HTTP_X_API_KEY="X" * 32)
return self.client.get(self.url, HTTP_X_API_KEY="X" * 32)
def test_it_works(self): def test_it_works(self):
r = self.get() r = self.get()
@ -30,7 +32,7 @@ class ListChannelsTestCase(BaseTestCase):
self.assertEqual(c["name"], "Email to Alice") self.assertEqual(c["name"], "Email to Alice")
def test_it_handles_options(self): def test_it_handles_options(self):
r = self.client.options("/api/v1/channels/")
r = self.client.options(self.url)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertIn("GET", r["Access-Control-Allow-Methods"]) self.assertIn("GET", r["Access-Control-Allow-Methods"])
@ -43,11 +45,10 @@ class ListChannelsTestCase(BaseTestCase):
for c in data["channels"]: for c in data["channels"]:
self.assertNotEqual(c["name"], "Bob") self.assertNotEqual(c["name"], "Bob")
def test_it_accepts_api_key_from_request_body(self):
payload = json.dumps({"api_key": "X" * 32})
r = self.client.generic(
"GET", "/api/v1/channels/", payload, content_type="application/json"
)
def test_it_handles_missing_api_key(self):
r = self.client.get(self.url)
self.assertContains(r, "missing api key", status_code=401)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Email to Alice")
def test_it_rejects_post(self):
r = self.csrf_client.post(self.url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 405)

+ 0
- 9
hc/api/tests/test_list_checks.py View File

@ -94,15 +94,6 @@ class ListChecksTestCase(BaseTestCase):
for check in data["checks"]: for check in data["checks"]:
self.assertNotEqual(check["name"], "Bob 1") self.assertNotEqual(check["name"], "Bob 1")
def test_it_accepts_api_key_from_request_body(self):
payload = json.dumps({"api_key": "X" * 32})
r = self.client.generic(
"GET", "/api/v1/checks/", payload, content_type="application/json"
)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Alice")
def test_it_works_with_tags_param(self): def test_it_works_with_tags_param(self):
r = self.client.get("/api/v1/checks/?tag=a2-tag", HTTP_X_API_KEY="X" * 32) r = self.client.get("/api/v1/checks/?tag=a2-tag", HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)


+ 3
- 7
hc/api/views.py View File

@ -171,7 +171,6 @@ def _update(check, spec):
check.channel_set.set(new_channels) check.channel_set.set(new_channels)
@validate_json()
@authorize_read @authorize_read
def get_checks(request): def get_checks(request):
q = Check.objects.filter(project=request.project) q = Check.objects.filter(project=request.project)
@ -222,7 +221,7 @@ def checks(request):
@cors("GET") @cors("GET")
@validate_json()
@csrf_exempt
@authorize @authorize
def channels(request): def channels(request):
q = Channel.objects.filter(project=request.project) q = Channel.objects.filter(project=request.project)
@ -230,7 +229,6 @@ def channels(request):
return JsonResponse({"channels": channels}) return JsonResponse({"channels": channels})
@validate_json()
@authorize_read @authorize_read
def get_check(request, code): def get_check(request, code):
check = get_object_or_404(Check, code=code) check = get_object_or_404(Check, code=code)
@ -241,7 +239,6 @@ def get_check(request, code):
@cors("GET") @cors("GET")
@csrf_exempt @csrf_exempt
@validate_json()
@authorize_read @authorize_read
def get_check_by_unique_key(request, unique_key): def get_check_by_unique_key(request, unique_key):
checks = Check.objects.filter(project=request.project.id) checks = Check.objects.filter(project=request.project.id)
@ -266,7 +263,6 @@ def update_check(request, code):
return JsonResponse(check.to_dict()) return JsonResponse(check.to_dict())
@validate_json()
@authorize @authorize
def delete_check(request, code): def delete_check(request, code):
check = get_object_or_404(Check, code=code) check = get_object_or_404(Check, code=code)
@ -312,6 +308,7 @@ def pause(request, code):
@cors("GET") @cors("GET")
@csrf_exempt
@validate_json() @validate_json()
@authorize @authorize
def pings(request, code): def pings(request, code):
@ -369,7 +366,6 @@ def flips(request, check):
@cors("GET") @cors("GET")
@csrf_exempt @csrf_exempt
@validate_json()
@authorize_read @authorize_read
def flips_by_uuid(request, code): def flips_by_uuid(request, code):
check = get_object_or_404(Check, code=code) check = get_object_or_404(Check, code=code)
@ -378,7 +374,6 @@ def flips_by_uuid(request, code):
@cors("GET") @cors("GET")
@csrf_exempt @csrf_exempt
@validate_json()
@authorize_read @authorize_read
def flips_by_unique_key(request, unique_key): def flips_by_unique_key(request, unique_key):
checks = Check.objects.filter(project=request.project.id) checks = Check.objects.filter(project=request.project.id)
@ -389,6 +384,7 @@ def flips_by_unique_key(request, unique_key):
@cors("GET") @cors("GET")
@csrf_exempt
@authorize_read @authorize_read
def badges(request): def badges(request):
tags = set(["*"]) tags = set(["*"])


+ 3
- 1
hc/test.py View File

@ -1,6 +1,6 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.signing import TimestampSigner from django.core.signing import TimestampSigner
from django.test import TestCase
from django.test import Client, TestCase
from hc.accounts.models import Member, Profile, Project from hc.accounts.models import Member, Profile, Project
@ -9,6 +9,8 @@ class BaseTestCase(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.csrf_client = Client(enforce_csrf_checks=True)
# Alice is a normal user for tests. Alice has team access enabled. # Alice is a normal user for tests. Alice has team access enabled.
self.alice = User(username="alice", email="[email protected]") self.alice = User(username="alice", email="[email protected]")
self.alice.set_password("password") self.alice.set_password("password")


Loading…
Cancel
Save