Skip to content

Commit ca3ffaa

Browse files
authored
fix(api): Add missing CORS headers on external OPTIONS requests (#252)
We previously did not return the `Access-Control-Allow-Headers`, `Access-Control-Allow-Methods`, and `Access-Control-Max-Age`, headers on `OPTIONS` requests coming from an external (non-whitelisted domain). With this change, we properly allow for 3rd parties to use our API with CORS-enabled requests.
1 parent 93a3ac0 commit ca3ffaa

File tree

3 files changed

+90
-19
lines changed

3 files changed

+90
-19
lines changed

apps/codecov-api/codecov/settings_base.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,10 @@
368368
# This is read by our custom CORS middleware and will set the allowed origins header to
369369
# "*" for all origins other than the ones explicitly allowed in CORS_ALLOWED_ORIGINS
370370
# and CORS_ALLOWED_ORIGIN_REGEXES, in which case it will rely on the default CORS
371-
# middleware behavior. This ensures that CORS_ALLOW_CREDENTIALS can never be enabled
372-
# on a request that does not come from an explicitly allowed origin.
373-
EXTERNAL_CORS_ALLOW_ALL_ORIGINS = True
371+
# middleware (which also reads this value) behavior. This ensures that
372+
# CORS_ALLOW_CREDENTIALS can never be enabled on a request that does not come from an
373+
# explicitly allowed origin.
374+
CORS_ALLOW_ALL_ORIGINS = True
374375

375376
CORS_ALLOW_CREDENTIALS = True
376377
CORS_ALLOW_HEADERS = (

apps/codecov-api/codecov_auth/middleware.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import logging
2+
from urllib.parse import urlsplit
23

34
import jwt
45
from asgiref.sync import sync_to_async
6+
from corsheaders.conf import conf
57
from corsheaders.middleware import (
8+
ACCESS_CONTROL_ALLOW_CREDENTIALS,
69
ACCESS_CONTROL_ALLOW_ORIGIN,
710
)
811
from corsheaders.middleware import CorsMiddleware as BaseCorsMiddleware
9-
from django.conf import settings
1012
from django.http import HttpRequest, HttpResponseForbidden, HttpResponseNotFound
1113
from django.urls import resolve
1214
from rest_framework import exceptions
@@ -150,19 +152,21 @@ def middleware(request: HttpRequest):
150152
# Generate base CORS response
151153
response = base_cors(request)
152154

153-
# Perform some of the same checks as the base CORS middleware
154-
if not base_cors.is_enabled(request) or not request.headers.get("origin"):
155+
# If not present, it means CORS is disabled for this request.
156+
if ACCESS_CONTROL_ALLOW_ORIGIN not in response:
155157
return response
156158

157159
# We rely on the CORS middleware to verify that the origin is
158-
# valid and allowed for credentialed requests. Otherwise, we
159-
# set the access control headers to allow all origins based on
160-
# our custom setting.
161-
if (
162-
settings.EXTERNAL_CORS_ALLOW_ALL_ORIGINS
163-
and ACCESS_CONTROL_ALLOW_ORIGIN not in response.headers
164-
):
165-
response.headers[ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
160+
# valid and to add necessary headers. We manage overriding the
161+
# allow origin header and removing the allow credentials header
162+
# if the origin is not whitelisted.
163+
if conf.CORS_ALLOW_ALL_ORIGINS and conf.CORS_ALLOW_CREDENTIALS:
164+
origin = response[ACCESS_CONTROL_ALLOW_ORIGIN]
165+
url = urlsplit(origin)
166+
167+
if not base_cors.origin_found_in_white_lists(origin, url):
168+
response[ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
169+
del response[ACCESS_CONTROL_ALLOW_CREDENTIALS]
166170

167171
return response
168172

apps/codecov-api/codecov_auth/tests/unit/test_middleware.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,93 @@
66
@override_settings(
77
CORS_ALLOWED_ORIGINS=["http://localhost:3000"],
88
CORS_ALLOWED_ORIGIN_REGEXES=[],
9-
EXTERNAL_CORS_ALLOW_ALL_ORIGINS=True,
9+
CORS_ALLOW_ALL_ORIGINS=True,
10+
CORS_ALLOW_CREDENTIALS=True,
11+
CORS_EXPOSE_HEADERS=["Test-Expose"],
1012
)
1113
class MiddlewareTest(TestCase):
14+
options_cors_headers = (
15+
"Access-Control-Allow-Headers",
16+
"Access-Control-Allow-Methods",
17+
"Access-Control-Max-Age",
18+
)
19+
1220
def setUp(self):
1321
self.client = Client()
1422

15-
def test_whitelisted_origin(self):
23+
def test_get_whitelisted_origin(self):
1624
res = self.client.get("/health", headers={"Origin": "http://localhost:3000"})
1725

1826
assert res.headers["Access-Control-Allow-Origin"] == "http://localhost:3000"
1927
assert res.headers["Access-Control-Allow-Credentials"] == "true"
28+
assert res.headers["Access-Control-Expose-Headers"] == "Test-Expose"
29+
30+
for header in self.options_cors_headers:
31+
assert header not in res.headers
32+
33+
def test_options_whitelisted_origin(self):
34+
res = self.client.options(
35+
"/health", headers={"Origin": "http://localhost:3000"}
36+
)
37+
38+
assert res.headers["Access-Control-Allow-Origin"] == "http://localhost:3000"
39+
assert res.headers["Access-Control-Allow-Credentials"] == "true"
40+
assert res.headers["Access-Control-Expose-Headers"] == "Test-Expose"
2041

21-
def test_non_whitelisted_origin(self):
42+
for header in self.options_cors_headers:
43+
assert header in res.headers
44+
45+
def test_get_non_whitelisted_origin(self):
2246
res = self.client.get("/health", headers={"Origin": "http://example.com"})
2347

2448
assert res.headers["Access-Control-Allow-Origin"] == "*"
2549
assert "Access-Control-Allow-Credentials" not in res.headers
50+
assert res.headers["Access-Control-Expose-Headers"] == "Test-Expose"
51+
52+
for header in self.options_cors_headers:
53+
assert header not in res.headers
54+
55+
def test_options_non_whitelisted_origin(self):
56+
res = self.client.options("/health", headers={"Origin": "http://example.com"})
57+
58+
assert res.headers["Access-Control-Allow-Origin"] == "*"
59+
assert "Access-Control-Allow-Credentials" not in res.headers
60+
assert res.headers["Access-Control-Expose-Headers"] == "Test-Expose"
2661

27-
@override_settings(EXTERNAL_CORS_ALLOW_ALL_ORIGINS=False)
28-
def test_external_cors_allow_all_origins_false(self):
62+
for header in self.options_cors_headers:
63+
assert header in res.headers
64+
65+
@override_settings(CORS_ALLOW_ALL_ORIGINS=False)
66+
def test_options_cors_allow_all_origins_false(self):
67+
res = self.client.options("/health", headers={"Origin": "http://example.com"})
68+
69+
assert "Access-Control-Allow-Origin" not in res.headers
70+
assert "Access-Control-Allow-Credentials" not in res.headers
71+
assert "Access-Control-Expose-Headers" not in res.headers
72+
73+
for header in self.options_cors_headers:
74+
assert header not in res.headers
75+
76+
@override_settings(CORS_ALLOW_ALL_ORIGINS=False, CORS_ALLOW_CREDENTIALS=False)
77+
def test_get_cors_allow_all_origins_credentials_false(self):
2978
res = self.client.get("/health", headers={"Origin": "http://example.com"})
3079

3180
assert "Access-Control-Allow-Origin" not in res.headers
3281
assert "Access-Control-Allow-Credentials" not in res.headers
82+
assert "Access-Control-Expose-Headers" not in res.headers
83+
84+
for header in self.options_cors_headers:
85+
assert header not in res.headers
86+
87+
@override_settings(CORS_ALLOW_CREDENTIALS=False)
88+
def test_options_cors_allow_credentials_false(self):
89+
res = self.client.options(
90+
"/health", headers={"Origin": "http://localhost:3000"}
91+
)
92+
93+
assert res.headers["Access-Control-Allow-Origin"] == "*"
94+
assert "Access-Control-Allow-Credentials" not in res.headers
95+
assert res.headers["Access-Control-Expose-Headers"] == "Test-Expose"
96+
97+
for header in self.options_cors_headers:
98+
assert header in res.headers

0 commit comments

Comments
 (0)