Skip to content

Proxito: add CORS headers only on public versions #10737

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

Merged
merged 5 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions dockerfiles/nginx/proxito.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ server {
add_header Access-Control-Allow-Origin $access_control_allow_origin always;
set $access_control_allow_headers $upstream_http_access_control_allow_headers;
add_header Access-Control-Allow-Headers $access_control_allow_headers always;
set $access_control_allow_methods $upstream_http_access_control_allow_methods;
add_header Access-Control-Allow-Methods $access_control_allow_methods always;
set $x_frame_options $upstream_http_x_frame_options;
add_header X-Frame-Options $x_frame_options always;
set $x_content_type_options $upstream_http_x_content_type_options;
Expand Down
28 changes: 28 additions & 0 deletions readthedocs/proxito/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
unresolver,
)
from readthedocs.core.utils import get_cache_tag
from readthedocs.projects.constants import PUBLIC
from readthedocs.proxito.cache import add_cache_tags, cache_response, private_response
from readthedocs.proxito.redirects import redirect_to_https

Expand Down Expand Up @@ -278,6 +279,32 @@ def add_hosting_integrations_headers(self, request, response):
if addons:
response["X-RTD-Hosting-Integrations"] = "true"

def add_cors_headers(self, request, response):
"""
Add CORS headers only on PUBLIC versions.

DocDiff addons requires making a request from
``RTD_EXTERNAL_VERSION_DOMAIN`` to ``PUBLIC_DOMAIN`` to be able to
compare both DOMs and show the visual differences.

This request needs ``Access-Control-Allow-Origin`` HTTP headers to be
accepted by browsers. However, we cannot expose these headers for
documentation that's not PUBLIC.
"""
project_slug = getattr(request, "path_project_slug", "")
version_slug = getattr(request, "path_version_slug", "")

if project_slug and version_slug:
allow_cors = Version.objects.filter(
project__slug=project_slug,
slug=version_slug,
privacy_level=PUBLIC,
).exists()
if allow_cors:
response.headers["Access-Control-Allow-Origin"] = "*"
Copy link
Member Author

Choose a reason for hiding this comment

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

Instead of using "*" here we could have some logic here to only support *.readthedocs.build

Limiting the possible Access-Control-Allow-Origin values to a set of allowed origins requires code on the server side to check the value of the Origin request header, compare that to a list of allowed origins, and then if the Origin value is in the list, set the Access-Control-Allow-Origin value to the same value as the Origin value.

(from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin)

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 don't think we require this since these versions are all public, but we can implement it in a following iteration if we consider.

Copy link
Member

Choose a reason for hiding this comment

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

If we are going to enable this on .com, I'd prefer if we start the most restricted we can.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed -- we definitely want to err on the side of caution here given the security considerations that have kept this disabled in the past.

response.headers["Access-Control-Allow-Methods"] = "OPTIONS, GET"
return response

def _get_https_redirect(self, request):
"""
Get a redirect response if the request should be redirected to HTTPS.
Expand Down Expand Up @@ -315,4 +342,5 @@ def process_response(self, request, response): # noqa
self.add_hsts_headers(request, response)
self.add_user_headers(request, response)
self.add_hosting_integrations_headers(request, response)
self.add_cors_headers(request, response)
return response
25 changes: 25 additions & 0 deletions readthedocs/proxito/tests/test_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.urls import reverse

from readthedocs.builds.constants import LATEST
from readthedocs.projects.constants import PRIVATE, PUBLIC
from readthedocs.projects.models import Domain, HTTPHeader

from .base import BaseDocServing
Expand Down Expand Up @@ -158,6 +159,30 @@ def test_hosting_integrations_header(self):
self.assertIsNotNone(r.get("X-RTD-Hosting-Integrations"))
self.assertEqual(r["X-RTD-Hosting-Integrations"], "true")

def test_cors_headers_private_version(self):
version = self.project.versions.get(slug=LATEST)
version.privacy_level = PRIVATE
version.save()

r = self.client.get(
"/en/latest/", secure=True, headers={"host": "project.dev.readthedocs.io"}
)
self.assertEqual(r.status_code, 200)
self.assertIsNone(r.get("Access-Control-Allow-Origin"))
self.assertIsNone(r.get("Access-Control-Allow-Methods"))

def test_cors_headers_public_version(self):
version = self.project.versions.get(slug=LATEST)
version.privacy_level = PUBLIC
version.save()

r = self.client.get(
"/en/latest/", secure=True, headers={"host": "project.dev.readthedocs.io"}
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r["Access-Control-Allow-Origin"], "*")
self.assertEqual(r["Access-Control-Allow-Methods"], "OPTIONS, GET")

@override_settings(ALLOW_PRIVATE_REPOS=False)
def test_cache_headers_public_version_with_private_projects_not_allowed(self):
r = self.client.get(
Expand Down