Skip to content

Improve CORS configuration #6168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 6 additions & 51 deletions readthedocs/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,6 @@

log = logging.getLogger(__name__)

WHITELIST_URLS = [
'/api/v2/footer_html',
'/api/v2/search',
'/api/v2/docsearch',
'/api/v2/sustainability',
]

webhook_github = Signal(providing_args=['project', 'data', 'event'])
webhook_gitlab = Signal(providing_args=['project', 'data', 'event'])
webhook_bitbucket = Signal(providing_args=['project', 'data', 'event'])
Expand All @@ -34,52 +27,14 @@ def decide_if_cors(sender, request, **kwargs): # pylint: disable=unused-argumen
"""
Decide whether a request should be given CORS access.

This checks that:
* The URL is whitelisted against our CORS-allowed domains
* The Domain exists in our database, and belongs to the project being queried.
This checks that the URL is under ``/api/`` and it's a safe method.

Returns True when a request should be given CORS access.
Returns ``True`` when a request should be given CORS access.
"""
if 'HTTP_ORIGIN' not in request.META:
return False
host = urlparse(request.META['HTTP_ORIGIN']).netloc.split(':')[0]

# Don't do domain checking for this API for now
if request.path_info.startswith('/api/v2/sustainability'):
return True

# Don't do domain checking for APIv2 when the Domain is known
if request.path_info.startswith('/api/v2/') and request.method in SAFE_METHODS:
domain = Domain.objects.filter(domain__icontains=host)
if domain.exists():
return True

valid_url = False
for url in WHITELIST_URLS:
if request.path_info.startswith(url):
valid_url = True
break

if valid_url:
project_slug = request.GET.get('project', None)
try:
project = Project.objects.get(slug=project_slug)
except Project.DoesNotExist:
log.warning(
'Invalid project passed to domain. [%s:%s]',
project_slug,
host,
)
return False

domain = Domain.objects.filter(
Q(domain__icontains=host),
Q(project=project) | Q(project__subprojects__child=project),
)
if domain.exists():
return True

return False
return all([
request.method in SAFE_METHODS,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We define this setting CORS_ALLOW_METHODS, do we really need to check this again here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these settings are confusing to me as well.

I understand that CORS_ALLOW_METHODS is used for the pre-flight request (OPTIONS) and it's to add the header Access-Control-Allow-Methods so after the browser can decide if will execute the final request or block it.

The signal is checked independently of these settings. If it returns True the origin is allowed.

In the end, CORS_ALLOW_METHODS is not considered when allowing it or denying it, it's just to "inform" the browser on the pre-flight request.

This chunk of code is useful to understand this behavior: https://github.com/adamchainz/django-cors-headers/blob/master/corsheaders/middleware.py#L123-L146

request.path_info.startswith('/api/'),
])


@receiver(pre_delete, sender=settings.AUTH_USER_MODEL)
Expand Down
23 changes: 18 additions & 5 deletions readthedocs/rtd_tests/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def setUp(self):
)
self.domain = get(Domain, domain='my.valid.domain', project=self.project)

def test_proper_domain(self):
def test_known_domain(self):
request = self.factory.get(
self.url,
{'project': self.project.slug},
Expand All @@ -194,11 +194,24 @@ def test_proper_domain(self):
resp = self.middleware.process_response(request, {})
self.assertIn('Access-Control-Allow-Origin', resp)

def test_invalid_domain(self):
def test_unknown_domain(self):
"""
Test accessing the API from a domain that it's not registered in our DB.

Only the endpoints under ``/api/`` should work.
"""
request = self.factory.get(
self.url,
{'project': self.project.slug},
HTTP_ORIGIN='http://invalid.domain',
HTTP_ORIGIN='http://unknown.domain',
)
resp = self.middleware.process_response(request, {})
self.assertIn('Access-Control-Allow-Origin', resp)

request = self.factory.get(
'/dashboard/',
{'project': self.project.slug},
HTTP_ORIGIN='http://unknown.domain',
)
resp = self.middleware.process_response(request, {})
self.assertNotIn('Access-Control-Allow-Origin', resp)
Expand Down Expand Up @@ -227,15 +240,15 @@ def test_apiv2_endpoint_allowed(self):
resp = self.middleware.process_response(request, {})
self.assertIn('Access-Control-Allow-Origin', resp)

def test_apiv2_endpoint_not_allowed(self):
request = self.factory.get(
'/api/v2/version/',
{'project__slug': self.project.slug, 'active': True},
HTTP_ORIGIN='http://invalid.domain',
)
resp = self.middleware.process_response(request, {})
self.assertNotIn('Access-Control-Allow-Origin', resp)
self.assertIn('Access-Control-Allow-Origin', resp)

def test_apiv2_method_not_allowed(self):
# POST is not allowed
request = self.factory.post(
'/api/v2/version/',
Expand Down
21 changes: 12 additions & 9 deletions readthedocs/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,16 +403,19 @@ def USE_PROMOS(self): # noqa
r'^http://(.+)\.readthedocs\.io$',
r'^https://(.+)\.readthedocs\.io$',
)
# So people can post to their accounts

# We need to allow credentials (cookies) to identify the user and remove ads
# if it's Gold member. Note: in Django 2.1 the ``SESSION_COOKIE_SAMESITE``
# setting was added, set to 'Lax' by default, which will prevent Django's
# session cookie being sent cross-domain. Change it to None to bypass this
# security restriction.
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = (
'x-requested-with',
'content-type',
'accept',
'origin',
'authorization',
'x-csrftoken'
)

CORS_ALLOW_METHODS = [
'GET',
'OPTIONS',
'HEAD',
]

# RTD Settings
REPO_LOCK_SECONDS = 30
Expand Down