diff --git a/bugsink/views.py b/bugsink/views.py index a9aa7a9..aff3dd2 100644 --- a/bugsink/views.py +++ b/bugsink/views.py @@ -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" diff --git a/ingest/views.py b/ingest/views.py index d8ab9fa..e4e6938 100644 --- a/ingest/views.py +++ b/ingest/views.py @@ -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):