Support for CORS

Tested in-browser with:

```
function main() {
    $.ajax({
        type: "POST",
        url: "http://bugsink:8000/api/1/store/",
        headers: {
            "Content-Type": "application/json",
            "X-Sentry-Auth": "Sentry sentry_key=a2df4cd647dc4b7a8a81b78a3601eba1, sentry_version=7, sentry_client=bugsink/0.0.1",
        },
        data: JSON.stringify({foo: "Bar"}),
        success: function(data) {
            console.log(data);
        }
    });
}
```
This commit is contained in:
Klaas van Schelven
2025-02-20 14:43:01 +01:00
parent 2354241e2c
commit 757ee31bed
2 changed files with 61 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ from django.views.defaults import permission_denied as django_permission_denied,
from django.http import FileResponse, HttpRequest, HttpResponse
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render
from django.views import debug
from snappea.settings import get_settings as get_snappea_settings
@@ -28,6 +29,24 @@ from bugsink.decorators import atomic_for_request_method
from phonehome.tasks import send_if_due
from phonehome.models import Installation
from ingest.views import BaseIngestAPIView
def cors_for_api_view(view):
def inner(request, *args, **kwargs):
response = view(request, *args, **kwargs)
if request.path.startswith("/api/"):
return BaseIngestAPIView._set_cors_headers(response)
return response
return inner
# The below lines monkey-patch the debug views to add CORS headers for API views in DEBUG=True mode; a small convenience
# to not get distracted by CORS errors in the browser console when there's a bug in the API view. Though I generally
# avoid monkey-patching, this will only affect me (DEBUG=True) so it should be OK.
debug.technical_404_response = cors_for_api_view(debug.technical_404_response)
debug.technical_500_response = cors_for_api_view(debug.technical_500_response)
def _phone_home():
# I need a way to cron-like run tasks that works for the setup with and without snappea. With snappea it's straight-
@@ -137,6 +156,7 @@ def silence_email_system_warning(request):
@requires_csrf_token
@cors_for_api_view
def bad_request(request, exception, template_name=ERROR_400_TEMPLATE_NAME):
# verbatim copy of Django's default bad_request view, but with "exception" in the context
# doing this for any-old-Django-site is probably a bad idea, but here the security/convenience tradeoff is fine,
@@ -158,6 +178,7 @@ def bad_request(request, exception, template_name=ERROR_400_TEMPLATE_NAME):
@requires_csrf_token
@cors_for_api_view
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
# verbatim copy of Django's default server_error view, but with "exception" in the context
# doing this for any-old-Django-site is probably a bad idea, but here the security/convenience tradeoff is fine,
@@ -181,6 +202,7 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
@requires_csrf_token
@cors_for_api_view
def permission_denied(request, exception, template_name=ERROR_403_TEMPLATE_NAME):
# (this remark applies 4 times, for 400, 403, 404 and 500):
# the check for /api/ is a UX improvement such that we get slightly easier-to-read errors on screen in our own
@@ -193,6 +215,7 @@ def permission_denied(request, exception, template_name=ERROR_403_TEMPLATE_NAME)
@requires_csrf_token
@cors_for_api_view
def page_not_found(request, exception, template_name=ERROR_404_TEMPLATE_NAME):
if request.path.startswith("/api/"):
template_name = "4xx_5xx_api.txt"

View File

@@ -55,13 +55,48 @@ performance_logger = logging.getLogger("bugsink.performance.ingest")
@method_decorator(csrf_exempt, name='dispatch')
class BaseIngestAPIView(View):
@staticmethod
def _set_cors_headers(response):
# For in-browser SDKs, we need to set the CORS headers, because if we don't, the browser will block the response
# from being read and print an error in the console; the longer version is:
#
# CORS protects the user of a browser from some random website "A" sending requests to the API of some site "B";
# implemented by the the browser enforcing the "Access-Control-Allow-Origin" response header as set by server B.
# In this case, the "other website B" is the Bugsink server, and the "random website A" is the application that
# is being monitored. The user has no relationship with the Bugsink API (there's nothing to protect), so we need
# to tell the browser to not protect against Bugsink data from reaching the monitored application, i.e.
# Access-Control-Allow-Origin: *.
response["Access-Control-Allow-Origin"] = "*"
response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
response["Access-Control-Allow-Headers"] = (
# The following 2 headers are actually understood by us:
"Content-Type, X-Sentry-Auth, "
# The following list of headers may be sent by Sentry SDKs. Even if we don't use them, we list them, because
# any not-listed header is not allowed by the browser and would trip the CORS protection:
"X-Requested-With, Origin, Accept, Authentication, Authorization, Content-Encoding, sentry-trace, "
"baggage, X-CSRFToken"
)
return response
def options(self, request, project_pk=None):
# This is a CORS preflight request; we just return the headers that the browser expects. (we _could_ check for
# the Origin, Request-Method, etc. headers, but we don't need to)
result = HttpResponse()
self._set_cors_headers(result)
result["Access-Control-Max-Age"] = "3600" # tell browser to cache to avoid repeated preflight requests
return result
def post(self, request, project_pk=None):
try:
return self._post(request, project_pk)
return self._set_cors_headers(self._post(request, project_pk))
except MaxLengthExceeded as e:
return JsonResponse({"message": str(e)}, status=HTTP_400_BAD_REQUEST)
return self._set_cors_headers(JsonResponse({"message": str(e)}, status=HTTP_400_BAD_REQUEST))
except exceptions.ValidationError as e:
return JsonResponse({"message": str(e)}, status=HTTP_400_BAD_REQUEST)
return self._set_cors_headers(JsonResponse({"message": str(e)}, status=HTTP_400_BAD_REQUEST))
@classmethod
def get_sentry_key_for_request(cls, request):