Skip to content

Commit 94766f8

Browse files
RaduWmitsuhiko
authored andcommitted
ref(store): CORS pre-flight cleanup (#13421)
1 parent 631943c commit 94766f8

File tree

2 files changed

+98
-85
lines changed

2 files changed

+98
-85
lines changed

src/sentry/web/api.py

Lines changed: 89 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,23 @@
3232
from sentry.attachments import CachedAttachment
3333
from sentry.coreapi import (
3434
Auth, APIError, APIForbidden, APIRateLimited, ClientApiHelper, ClientAuthHelper,
35-
SecurityAuthHelper, MinidumpAuthHelper, safely_load_json_string, logger as api_logger
35+
SecurityAuthHelper, MinidumpAuthHelper, safely_load_json_string, logger as api_logger,
3636
)
3737
from sentry.event_manager import EventManager
3838
from sentry.interfaces import schemas
3939
from sentry.interfaces.base import get_interface
40-
from sentry.lang.native.unreal import process_unreal_crash, merge_apple_crash_report, \
41-
unreal_attachment_type, merge_unreal_context_event, merge_unreal_logs_event
42-
from sentry.lang.native.minidump import merge_attached_event, merge_attached_breadcrumbs, write_minidump_placeholder, \
43-
MINIDUMP_ATTACHMENT_TYPE
40+
from sentry.lang.native.unreal import (
41+
process_unreal_crash, merge_apple_crash_report,
42+
unreal_attachment_type, merge_unreal_context_event, merge_unreal_logs_event,
43+
)
44+
from sentry.lang.native.minidump import (
45+
merge_attached_event, merge_attached_breadcrumbs, write_minidump_placeholder,
46+
MINIDUMP_ATTACHMENT_TYPE,
47+
)
4448
from sentry.models import Project, OrganizationOption, Organization, File, EventAttachment, Event
4549
from sentry.signals import (
46-
event_accepted, event_dropped, event_filtered, event_received)
50+
event_accepted, event_dropped, event_filtered, event_received,
51+
)
4752
from sentry.quotas.base import RateLimit
4853
from sentry.utils import json, metrics
4954
from sentry.utils.data_filters import FilterStatKeys
@@ -52,6 +57,7 @@
5257
is_valid_origin,
5358
get_origins,
5459
is_same_domain,
60+
origin_from_request,
5561
)
5662
from sentry.utils.outcomes import Outcome, track_outcome
5763
from sentry.utils.pubsub import QueuedPublisherService, KafkaPublisher
@@ -77,6 +83,47 @@
7783
) if getattr(settings, 'KAFKA_RAW_EVENTS_PUBLISHER_ENABLED', False) else None
7884

7985

86+
def allow_cors_options(func):
87+
"""
88+
Decorator that adds automatic handling of OPTIONS requests for CORS
89+
90+
If the request is OPTIONS (i.e. pre flight CORS) construct a NO Content (204) response
91+
in which we explicitly enable the caller and add the custom headers that we support
92+
:param func: the original request handler
93+
:return: a request handler that shortcuts OPTIONS requests and just returns an OK (CORS allowed)
94+
"""
95+
96+
@wraps(func)
97+
def allow_cors_options_wrapper(self, request, *args, **kwargs):
98+
99+
if request.method == 'OPTIONS':
100+
response = HttpResponse(status=200)
101+
response['Access-Control-Max-Age'] = '3600' # don't ask for options again for 1 hour
102+
else:
103+
response = func(self, request, *args, **kwargs)
104+
105+
allow = ', '.join(self._allowed_methods())
106+
response['Allow'] = allow
107+
response['Access-Control-Allow-Methods'] = allow
108+
response['Access-Control-Allow-Headers'] = 'X-Sentry-Auth, X-Requested-With, Origin, Accept, ' \
109+
'Content-Type, Authentication'
110+
response['Access-Control-Expose-Headers'] = 'X-Sentry-Error, Retry-After'
111+
112+
if request.META.get('HTTP_ORIGIN') == 'null':
113+
origin = 'null' # if ORIGIN header is explicitly specified as 'null' leave it alone
114+
else:
115+
origin = origin_from_request(request)
116+
117+
if origin is None or origin == 'null':
118+
response['Access-Control-Allow-Origin'] = '*'
119+
else:
120+
response['Access-Control-Allow-Origin'] = origin
121+
122+
return response
123+
124+
return allow_cors_options_wrapper
125+
126+
80127
def api(func):
81128
@wraps(func)
82129
def wrapped(request, *args, **kwargs):
@@ -167,7 +214,7 @@ def process_event(event_manager, project, key, remote_addr, helper, attachments)
167214

168215
# TODO(dcramer): ideally we'd only validate this if the event_id was
169216
# supplied by the user
170-
cache_key = 'ev:%s:%s' % (project.id, event_id, )
217+
cache_key = 'ev:%s:%s' % (project.id, event_id,)
171218

172219
if cache.get(cache_key) is not None:
173220
track_outcome(
@@ -179,7 +226,7 @@ def process_event(event_manager, project, key, remote_addr, helper, attachments)
179226
event_id=event_id
180227
)
181228
raise APIForbidden(
182-
'An event with the same ID already exists (%s)' % (event_id, ))
229+
'An event with the same ID already exists (%s)' % (event_id,))
183230

184231
scrub_ip_address = (org_options.get('sentry:require_scrub_ip_address', False) or
185232
project.get_option('sentry:scrub_ip_address', False))
@@ -301,13 +348,13 @@ def _publish_to_kafka(self, request):
301348

302349
@csrf_exempt
303350
@never_cache
351+
@allow_cors_options
304352
def dispatch(self, request, project_id=None, *args, **kwargs):
305353
helper = ClientApiHelper(
306354
agent=request.META.get('HTTP_USER_AGENT'),
307355
project_id=project_id,
308356
ip_address=request.META['REMOTE_ADDR'],
309357
)
310-
origin = None
311358

312359
if kafka_publisher is not None:
313360
self._publish_to_kafka(request)
@@ -349,15 +396,15 @@ def dispatch(self, request, project_id=None, *args, **kwargs):
349396
# tsdb could optimize this
350397
metrics.incr('client-api.all-versions.requests', skip_internal=False)
351398
metrics.incr('client-api.all-versions.responses.%s' %
352-
(response.status_code, ), skip_internal=False)
399+
(response.status_code,), skip_internal=False)
353400
metrics.incr(
354401
'client-api.all-versions.responses.%sxx' % (six.text_type(response.status_code)[0],),
355402
skip_internal=False,
356403
)
357404

358405
if helper.context.version:
359406
metrics.incr(
360-
'client-api.v%s.requests' % (helper.context.version, ),
407+
'client-api.v%s.requests' % (helper.context.version,),
361408
skip_internal=False,
362409
)
363410
metrics.incr(
@@ -370,19 +417,6 @@ def dispatch(self, request, project_id=None, *args, **kwargs):
370417
skip_internal=False,
371418
)
372419

373-
if response.status_code != 200 and origin:
374-
# We allow all origins on errors
375-
response['Access-Control-Allow-Origin'] = '*'
376-
377-
if origin:
378-
response['Access-Control-Allow-Headers'] = \
379-
'X-Sentry-Auth, X-Requested-With, Origin, Accept, ' \
380-
'Content-Type, Authentication'
381-
response['Access-Control-Allow-Methods'] = \
382-
', '.join(self._allowed_methods())
383-
response['Access-Control-Expose-Headers'] = \
384-
'X-Sentry-Error, Retry-After'
385-
386420
return response
387421

388422
def _dispatch(self, request, helper, project_id=None, origin=None, *args, **kwargs):
@@ -403,47 +437,31 @@ def _dispatch(self, request, helper, project_id=None, origin=None, *args, **kwar
403437
None,
404438
Outcome.INVALID,
405439
FilterStatKeys.CORS)
406-
raise APIForbidden('Invalid origin: %s' % (origin, ))
440+
raise APIForbidden('Invalid origin: %s' % (origin,))
407441

408-
# XXX: It seems that the OPTIONS call does not always include custom headers
409-
if request.method == 'OPTIONS':
410-
response = self.options(request, project)
411-
else:
412-
auth = self._parse_header(request, helper, project)
442+
auth = self._parse_header(request, helper, project)
413443

414-
key = helper.project_key_from_auth(auth)
444+
key = helper.project_key_from_auth(auth)
415445

416-
# Legacy API was /api/store/ and the project ID was only available elsewhere
417-
if not project:
418-
project = Project.objects.get_from_cache(id=key.project_id)
419-
helper.context.bind_project(project)
420-
elif key.project_id != project.id:
421-
raise APIError('Two different projects were specified')
422-
423-
helper.context.bind_auth(auth)
424-
425-
# Explicitly bind Organization so we don't implicitly query it later
426-
# this just allows us to comfortably assure that `project.organization` is safe.
427-
# This also allows us to pull the object from cache, instead of being
428-
# implicitly fetched from database.
429-
project.organization = Organization.objects.get_from_cache(
430-
id=project.organization_id)
431-
432-
response = super(APIView, self).dispatch(
433-
request=request, project=project, auth=auth, helper=helper, key=key, **kwargs
434-
)
446+
# Legacy API was /api/store/ and the project ID was only available elsewhere
447+
if not project:
448+
project = Project.objects.get_from_cache(id=key.project_id)
449+
helper.context.bind_project(project)
450+
elif key.project_id != project.id:
451+
raise APIError('Two different projects were specified')
435452

436-
if origin:
437-
if origin == 'null':
438-
# If an Origin is `null`, but we got this far, that means
439-
# we've gotten past our CORS check for some reason. But the
440-
# problem is that we can't return "null" as a valid response
441-
# to `Access-Control-Allow-Origin` and we don't have another
442-
# value to work with, so just allow '*' since they've gotten
443-
# this far.
444-
response['Access-Control-Allow-Origin'] = '*'
445-
else:
446-
response['Access-Control-Allow-Origin'] = origin
453+
helper.context.bind_auth(auth)
454+
455+
# Explicitly bind Organization so we don't implicitly query it later
456+
# this just allows us to comfortably assure that `project.organization` is safe.
457+
# This also allows us to pull the object from cache, instead of being
458+
# implicitly fetched from database.
459+
project.organization = Organization.objects.get_from_cache(
460+
id=project.organization_id)
461+
462+
response = super(APIView, self).dispatch(
463+
request=request, project=project, auth=auth, helper=helper, key=key, **kwargs
464+
)
447465

448466
return response
449467

@@ -452,10 +470,15 @@ def _allowed_methods(self):
452470
return [m.upper() for m in self.http_method_names if hasattr(self, m)]
453471

454472
def options(self, request, *args, **kwargs):
455-
response = HttpResponse()
456-
response['Allow'] = ', '.join(self._allowed_methods())
457-
response['Content-Length'] = '0'
458-
return response
473+
"""
474+
Serves requests for OPTIONS
475+
476+
NOTE: This function is not called since it is shortcut by the @allow_cors_options descriptor.
477+
It is nevertheless used to construct the allowed http methods and it should not be removed.
478+
"""
479+
raise NotImplementedError("Options request should have been handled by @allow_cors_options.\n"
480+
"If dispatch was overridden either decorate it with @allow_cors_options or provide "
481+
"a valid implementation for options.")
459482

460483

461484
class StoreView(APIView):
@@ -832,7 +855,7 @@ def post(self, request, project, **kwargs):
832855

833856
# Endpoint used by the Unreal Engine 4 (UE4) Crash Reporter.
834857
class UnrealView(StoreView):
835-
content_types = ('application/octet-stream', )
858+
content_types = ('application/octet-stream',)
836859

837860
def _dispatch(self, request, helper, sentry_key, project_id=None, origin=None, *args, **kwargs):
838861
if request.method != 'POST':

tests/sentry/web/api/tests.py

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -192,40 +192,30 @@ def test_options_response(self, parse_header):
192192
self.assertIn('Content-Length', resp)
193193
self.assertEquals(resp['Content-Length'], '0')
194194

195-
@mock.patch('sentry.web.api.is_valid_origin', mock.Mock(return_value=False))
196-
def test_options_response_with_invalid_origin(self):
197-
resp = self.client.options(self.path, HTTP_ORIGIN='http://foo.com')
198-
assert resp.status_code == 403, (resp.status_code, resp.content)
199-
self.assertIn('Access-Control-Allow-Origin', resp)
200-
self.assertEquals(resp['Access-Control-Allow-Origin'], '*')
201-
self.assertIn('X-Sentry-Error', resp)
202-
assert resp['X-Sentry-Error'] == "Invalid origin: http://foo.com"
203-
assert json.loads(resp.content)['error'] == resp['X-Sentry-Error']
204-
205-
@mock.patch('sentry.web.api.is_valid_origin', mock.Mock(return_value=False))
206-
def test_options_response_with_invalid_referrer(self):
207-
resp = self.client.options(self.path, HTTP_REFERER='http://foo.com')
208-
assert resp.status_code == 403, (resp.status_code, resp.content)
195+
def test_options_with_no_origin_or_referrer(self):
196+
resp = self.client.options(self.path)
197+
assert resp.status_code == 200, (resp.status_code, resp.content)
209198
self.assertIn('Access-Control-Allow-Origin', resp)
210199
self.assertEquals(resp['Access-Control-Allow-Origin'], '*')
211-
self.assertIn('X-Sentry-Error', resp)
212-
assert resp['X-Sentry-Error'] == "Invalid origin: http://foo.com"
213-
assert json.loads(resp.content)['error'] == resp['X-Sentry-Error']
214200

215-
@mock.patch('sentry.web.api.is_valid_origin', mock.Mock(return_value=True))
216201
def test_options_response_with_valid_origin(self):
217202
resp = self.client.options(self.path, HTTP_ORIGIN='http://foo.com')
218203
assert resp.status_code == 200, (resp.status_code, resp.content)
219204
self.assertIn('Access-Control-Allow-Origin', resp)
220205
self.assertEquals(resp['Access-Control-Allow-Origin'], 'http://foo.com')
221206

222-
@mock.patch('sentry.web.api.is_valid_origin', mock.Mock(return_value=True))
223207
def test_options_response_with_valid_referrer(self):
224208
resp = self.client.options(self.path, HTTP_REFERER='http://foo.com')
225209
assert resp.status_code == 200, (resp.status_code, resp.content)
226210
self.assertIn('Access-Control-Allow-Origin', resp)
227211
self.assertEquals(resp['Access-Control-Allow-Origin'], 'http://foo.com')
228212

213+
def test_options_response_origin_preferred_over_referrer(self):
214+
resp = self.client.options(self.path, HTTP_REFERER='http://foo.com', HTTP_ORIGIN='http://bar.com')
215+
assert resp.status_code == 200, (resp.status_code, resp.content)
216+
self.assertIn('Access-Control-Allow-Origin', resp)
217+
self.assertEquals(resp['Access-Control-Allow-Origin'], 'http://bar.com')
218+
229219
@mock.patch('sentry.event_manager.is_valid_ip', mock.Mock(return_value=False))
230220
def test_request_with_blacklisted_ip(self):
231221
resp = self._postWithHeader({})

0 commit comments

Comments
 (0)