Skip to content

Commit a0285d6

Browse files
humitosstsewd
andauthored
Proxito: update CORS settings (#10751)
* Proxito: update CORS settings - Only add CORS headers on community site - Explicit host on `Access-Control-Allow-Origin` - Only query the database if the host ends with `RTD_EXTERNAL_VERSION_DOMAIN` - Add more tests Continuation of #10737 * Add `Vary: Origin` header * Use Django internals to patch `Vary` header. * Use the `Origin` header from request to check for the allowed domain * Update tests to use `origin` header * Allow cross-origin requests for public versions' docs --------- Co-authored-by: Santos Gallegos <[email protected]>
1 parent a5af4e5 commit a0285d6

File tree

4 files changed

+121
-16
lines changed

4 files changed

+121
-16
lines changed

readthedocs/embed/v3/tests/test_access.py

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import mock
33

44
import pytest
5+
from corsheaders.middleware import ACCESS_CONTROL_ALLOW_ORIGIN
56
from django.contrib.auth.models import User
67
from django.test import TestCase, override_settings
78
from django.urls import reverse
@@ -67,6 +68,7 @@ def test_get_content_public_version_anonymous_user(self, storage_mock):
6768
resp = self.get(self.url)
6869
self.assertEqual(resp.status_code, 200)
6970
self.assertIn("Content", resp.json()["content"])
71+
self.assertNotIn(ACCESS_CONTROL_ALLOW_ORIGIN, resp.headers)
7072

7173
def test_get_content_private_version_anonymous_user(self, storage_mock):
7274
self._mock_storage(storage_mock)
@@ -85,6 +87,7 @@ def test_get_content_public_version_logged_in_user(self, storage_mock):
8587
resp = self.get(self.url)
8688
self.assertEqual(resp.status_code, 200)
8789
self.assertIn("Content", resp.json()["content"])
90+
self.assertNotIn(ACCESS_CONTROL_ALLOW_ORIGIN, resp.headers)
8891

8992
def test_get_content_private_version_logged_in_user(self, storage_mock):
9093
self._mock_storage(storage_mock)
@@ -96,6 +99,7 @@ def test_get_content_private_version_logged_in_user(self, storage_mock):
9699
resp = self.get(self.url)
97100
self.assertEqual(resp.status_code, 200)
98101
self.assertIn("Content", resp.json()["content"])
102+
self.assertNotIn(ACCESS_CONTROL_ALLOW_ORIGIN, resp.headers)
99103

100104
@mock.patch.object(EmbedAPIBase, "_download_page_content")
101105
def test_get_content_allowed_external_page(
@@ -108,6 +112,7 @@ def test_get_content_allowed_external_page(
108112
)
109113
self.assertEqual(resp.status_code, 200)
110114
self.assertIn("Content", resp.json()["content"])
115+
self.assertNotIn(ACCESS_CONTROL_ALLOW_ORIGIN, resp.headers)
111116

112117
def test_get_content_not_allowed_external_page(self, storage_mock):
113118
resp = self.get(reverse("embed_api_v3") + "?url=https://example.com/en/latest/")

readthedocs/proxito/middleware.py

+24-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
from urllib.parse import urlparse
1010

1111
import structlog
12+
from corsheaders.middleware import (
13+
ACCESS_CONTROL_ALLOW_METHODS,
14+
ACCESS_CONTROL_ALLOW_ORIGIN,
15+
)
1216
from django.conf import settings
1317
from django.core.exceptions import SuspiciousOperation
1418
from django.shortcuts import redirect
@@ -308,7 +312,7 @@ def add_hosting_integrations_headers(self, request, response):
308312

309313
def add_cors_headers(self, request, response):
310314
"""
311-
Add CORS headers only on PUBLIC versions.
315+
Add CORS headers only to files from PUBLIC versions.
312316
313317
DocDiff addons requires making a request from
314318
``RTD_EXTERNAL_VERSION_DOMAIN`` to ``PUBLIC_DOMAIN`` to be able to
@@ -317,7 +321,23 @@ def add_cors_headers(self, request, response):
317321
This request needs ``Access-Control-Allow-Origin`` HTTP headers to be
318322
accepted by browsers. However, we cannot expose these headers for
319323
documentation that's not PUBLIC.
324+
325+
We set this header to `*`, since the allowed versions are public only,
326+
we don't care about the origin of the request. And we don't have the
327+
need nor want to allow passing credentials from cross-origin requests.
328+
329+
See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin.
320330
"""
331+
332+
# Disable CORS on "Read the Docs for Business" for now.
333+
# We want to be pretty sure this logic is OK before enabling it there.
334+
if settings.ALLOW_PRIVATE_REPOS:
335+
return
336+
337+
# TODO: se should add these headers to files from docs only,
338+
# proxied APIs and other endpoints should not have CORS headers.
339+
# These attributes aren't currently set for proxied APIs, but we shuold
340+
# find a better way to do this.
321341
project_slug = getattr(request, "path_project_slug", "")
322342
version_slug = getattr(request, "path_version_slug", "")
323343

@@ -328,8 +348,9 @@ def add_cors_headers(self, request, response):
328348
privacy_level=PUBLIC,
329349
).exists()
330350
if allow_cors:
331-
response.headers["Access-Control-Allow-Origin"] = "*.readthedocs.build"
332-
response.headers["Access-Control-Allow-Methods"] = "OPTIONS, GET"
351+
response.headers[ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
352+
response.headers[ACCESS_CONTROL_ALLOW_METHODS] = "HEAD, OPTIONS, GET"
353+
333354
return response
334355

335356
def _get_https_redirect(self, request):

readthedocs/proxito/tests/test_headers.py

+90-13
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import django_dynamic_fixture as fixture
2+
from corsheaders.middleware import (
3+
ACCESS_CONTROL_ALLOW_CREDENTIALS,
4+
ACCESS_CONTROL_ALLOW_METHODS,
5+
ACCESS_CONTROL_ALLOW_ORIGIN,
6+
)
27
from django.test import override_settings
38
from django.urls import reverse
9+
from django_dynamic_fixture import get
410

5-
from readthedocs.builds.constants import LATEST
11+
from readthedocs.builds.constants import EXTERNAL, LATEST
12+
from readthedocs.builds.models import Version
13+
from readthedocs.organizations.models import Organization
614
from readthedocs.projects.constants import PRIVATE, PUBLIC
715
from readthedocs.projects.models import Domain, HTTPHeader
816

@@ -12,6 +20,7 @@
1220
@override_settings(
1321
PUBLIC_DOMAIN="dev.readthedocs.io",
1422
PUBLIC_DOMAIN_USES_HTTPS=True,
23+
RTD_EXTERNAL_VERSION_DOMAIN="dev.readthedocs.build",
1524
)
1625
class ProxitoHeaderTests(BaseDocServing):
1726
def test_redirect_headers(self):
@@ -159,29 +168,97 @@ def test_hosting_integrations_header(self):
159168
self.assertIsNotNone(r.get("X-RTD-Hosting-Integrations"))
160169
self.assertEqual(r["X-RTD-Hosting-Integrations"], "true")
161170

171+
@override_settings(ALLOW_PRIVATE_REPOS=False)
172+
def test_cors_headers_external_version(self):
173+
get(
174+
Version,
175+
project=self.project,
176+
slug="111",
177+
active=True,
178+
privacy_level=PUBLIC,
179+
type=EXTERNAL,
180+
)
181+
182+
# Normal request
183+
r = self.client.get(
184+
"/en/111/",
185+
secure=True,
186+
headers={"host": "project--111.dev.readthedocs.build"},
187+
)
188+
self.assertEqual(r.status_code, 200)
189+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_ORIGIN], "*")
190+
self.assertNotIn(ACCESS_CONTROL_ALLOW_CREDENTIALS, r.headers)
191+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_METHODS], "HEAD, OPTIONS, GET")
192+
193+
# Cross-origin request
194+
r = self.client.get(
195+
"/en/111/",
196+
secure=True,
197+
headers={
198+
"host": "project--111.dev.readthedocs.build",
199+
"origin": "https://example.com",
200+
},
201+
)
202+
self.assertEqual(r.status_code, 200)
203+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_ORIGIN], "*")
204+
self.assertNotIn(ACCESS_CONTROL_ALLOW_CREDENTIALS, r.headers)
205+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_METHODS], "HEAD, OPTIONS, GET")
206+
207+
@override_settings(ALLOW_PRIVATE_REPOS=True, RTD_ALLOW_ORGANIZATIONS=True)
162208
def test_cors_headers_private_version(self):
163-
version = self.project.versions.get(slug=LATEST)
164-
version.privacy_level = PRIVATE
165-
version.save()
209+
get(Organization, owners=[self.eric], projects=[self.project])
210+
self.version.privacy_level = PRIVATE
211+
self.version.save()
212+
213+
self.client.force_login(self.eric)
166214

215+
# Normal request
167216
r = self.client.get(
168-
"/en/latest/", secure=True, headers={"host": "project.dev.readthedocs.io"}
217+
"/en/latest/",
218+
secure=True,
219+
headers={"host": "project.dev.readthedocs.io"},
169220
)
170221
self.assertEqual(r.status_code, 200)
171-
self.assertIsNone(r.get("Access-Control-Allow-Origin"))
172-
self.assertIsNone(r.get("Access-Control-Allow-Methods"))
222+
self.assertNotIn(ACCESS_CONTROL_ALLOW_ORIGIN, r.headers)
173223

224+
# Cross-origin request
225+
r = self.client.get(
226+
"/en/latest/",
227+
secure=True,
228+
headers={
229+
"host": "project.dev.readthedocs.io",
230+
"origin": "https://example.com",
231+
},
232+
)
233+
self.assertEqual(r.status_code, 200)
234+
self.assertNotIn(ACCESS_CONTROL_ALLOW_ORIGIN, r.headers)
235+
236+
@override_settings(ALLOW_PRIVATE_REPOS=False)
174237
def test_cors_headers_public_version(self):
175-
version = self.project.versions.get(slug=LATEST)
176-
version.privacy_level = PUBLIC
177-
version.save()
238+
# Normal request
239+
r = self.client.get(
240+
"/en/latest/",
241+
secure=True,
242+
headers={"host": "project.dev.readthedocs.io"},
243+
)
244+
self.assertEqual(r.status_code, 200)
245+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_ORIGIN], "*")
246+
self.assertNotIn(ACCESS_CONTROL_ALLOW_CREDENTIALS, r.headers)
247+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_METHODS], "HEAD, OPTIONS, GET")
178248

249+
# Cross-origin request
179250
r = self.client.get(
180-
"/en/latest/", secure=True, headers={"host": "project.dev.readthedocs.io"}
251+
"/en/latest/",
252+
secure=True,
253+
headers={
254+
"host": "project.dev.readthedocs.io",
255+
"origin": "https://example.com",
256+
},
181257
)
182258
self.assertEqual(r.status_code, 200)
183-
self.assertEqual(r["Access-Control-Allow-Origin"], "*.readthedocs.build")
184-
self.assertEqual(r["Access-Control-Allow-Methods"], "OPTIONS, GET")
259+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_ORIGIN], "*")
260+
self.assertNotIn(ACCESS_CONTROL_ALLOW_CREDENTIALS, r.headers)
261+
self.assertEqual(r[ACCESS_CONTROL_ALLOW_METHODS], "HEAD, OPTIONS, GET")
185262

186263
@override_settings(ALLOW_PRIVATE_REPOS=False)
187264
def test_cache_headers_public_version_with_private_projects_not_allowed(self):

readthedocs/search/tests/test_proxied_api.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from corsheaders.middleware import ACCESS_CONTROL_ALLOW_ORIGIN
23

34
from readthedocs.search.tests.test_api import BaseTestDocumentSearch
45

@@ -25,3 +26,4 @@ def test_headers(self, api_client, project):
2526
f"{project.slug},{project.slug}:{version.slug},{project.slug}:rtd-search"
2627
)
2728
assert resp["Cache-Tag"] == cache_tags
29+
assert ACCESS_CONTROL_ALLOW_ORIGIN not in resp.headers

0 commit comments

Comments
 (0)