Repository: pypi/warehouse

Issue:
Missing Access-Control-Allow-Origin in redirect headers 
From https://sourceforge.net/p/pypi/support-requests/741/:

> Hi,
> 
> Both pypi.org and pypi.python.org do not have proper Access-Control-Allow-Origin response headers in their redirect responses. Please see https://stackoverflow.com/questions/44637138/case-sensitive-url-triggering-cors-error.
> 
> It would be very helpful to add these to the headers so that redirect requests are propertly handled.
> 
> Thanks!

---

**Good First Issue**: This issue is good for first time contributors. If there is not a corresponding pull request for this issue, it is up for grabs. For directions for getting set up, see our [Getting Started Guide](https://warehouse.pypa.io/development/getting-started/). If you are working on this issue and have questions, please feel free to ask them here, [`#pypa-dev` on Freenode](https://webchat.freenode.net/?channels=%23pypa-dev), or the [pypa-dev mailing list](https://groups.google.com/forum/#!forum/pypa-dev).


Discussion:
Per [discussion in today's bug triage meeting](https://wiki.python.org/psf/PackagingWG/2018-01-29-Warehouse), this doesn't seem critical, and we think it should be considered a "task" in the canonical URL handling code.

Resolving patch:
diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py
--- a/warehouse/legacy/api/json.py
+++ b/warehouse/legacy/api/json.py
@@ -20,6 +20,26 @@
 from warehouse.packaging.models import File, Release
 
 
+# Generate appropriate CORS headers for the JSON endpoint.
+# We want to allow Cross-Origin requests here so that users can interact
+# with these endpoints via XHR/Fetch APIs in the browser.
+_CORS_HEADERS = {
+    "Access-Control-Allow-Origin": "*",
+    "Access-Control-Allow-Headers": ", ".join([
+        "Content-Type",
+        "If-Match",
+        "If-Modified-Since",
+        "If-None-Match",
+        "If-Unmodified-Since",
+    ]),
+    "Access-Control-Allow-Methods": "GET",
+    "Access-Control-Max-Age": "86400",  # 1 day.
+    "Access-Control-Expose-Headers": ", ".join([
+        "X-PyPI-Last-Serial",
+    ]),
+}
+
+
 @view_config(
     route_name="legacy.api.json.project",
     renderer="json",
@@ -36,6 +56,7 @@ def json_project(project, request):
     if project.name != request.matchdict.get("name", project.name):
         return HTTPMovedPermanently(
             request.current_route_path(name=project.name),
+            headers=_CORS_HEADERS
         )
 
     try:
@@ -49,7 +70,7 @@ def json_project(project, request):
                       .one()
         )
     except NoResultFound:
-        return HTTPNotFound()
+        return HTTPNotFound(headers=_CORS_HEADERS)
 
     return json_release(release, request)
 
@@ -72,22 +93,11 @@ def json_release(release, request):
     if project.name != request.matchdict.get("name", project.name):
         return HTTPMovedPermanently(
             request.current_route_path(name=project.name),
+            headers=_CORS_HEADERS
         )
 
-    # We want to allow CORS here to enable anyone to fetch data from this API
-    request.response.headers["Access-Control-Allow-Origin"] = "*"
-    request.response.headers["Access-Control-Allow-Headers"] = ", ".join([
-        "Content-Type",
-        "If-Match",
-        "If-Modified-Since",
-        "If-None-Match",
-        "If-Unmodified-Since",
-    ])
-    request.response.headers["Access-Control-Allow-Methods"] = "GET"
-    request.response.headers["Access-Control-Max-Age"] = "86400"
-    request.response.headers["Access-Control-Expose-Headers"] = ", ".join([
-        "X-PyPI-Last-Serial",
-    ])
+    # Apply CORS headers.
+    request.response.headers.update(_CORS_HEADERS)
 
     # Get the latest serial number for this project.
     request.response.headers["X-PyPI-Last-Serial"] = str(project.last_serial)


Test patch:
diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py
--- a/tests/unit/legacy/api/test_json.py
+++ b/tests/unit/legacy/api/test_json.py
@@ -22,6 +22,17 @@
 )
 
 
+def _assert_has_cors_headers(headers):
+    assert headers["Access-Control-Allow-Origin"] == "*"
+    assert headers["Access-Control-Allow-Headers"] == (
+        "Content-Type, If-Match, If-Modified-Since, If-None-Match, "
+        "If-Unmodified-Since"
+    )
+    assert headers["Access-Control-Allow-Methods"] == "GET"
+    assert headers["Access-Control-Max-Age"] == "86400"
+    assert headers["Access-Control-Expose-Headers"] == "X-PyPI-Last-Serial"
+
+
 class TestJSONProject:
 
     def test_normalizing_redirects(self, db_request):
@@ -40,6 +51,7 @@ def test_normalizing_redirects(self, db_request):
 
         assert isinstance(resp, HTTPMovedPermanently)
         assert resp.headers["Location"] == "/project/the-redirect/"
+        _assert_has_cors_headers(resp.headers)
         assert db_request.current_route_path.calls == [
             pretend.call(name=project.name),
         ]
@@ -48,6 +60,7 @@ def test_missing_release(self, db_request):
         project = ProjectFactory.create()
         resp = json.json_project(project, db_request)
         assert isinstance(resp, HTTPNotFound)
+        _assert_has_cors_headers(resp.headers)
 
     def test_calls_release_detail(self, monkeypatch, db_request):
         project = ProjectFactory.create()
@@ -121,6 +134,7 @@ def test_normalizing_redirects(self, db_request):
 
         assert isinstance(resp, HTTPMovedPermanently)
         assert resp.headers["Location"] == "/project/the-redirect/3.0/"
+        _assert_has_cors_headers(resp.headers)
         assert db_request.current_route_path.calls == [
             pretend.call(name=release.project.name),
         ]
@@ -165,16 +179,8 @@ def test_detail_renders(self, pyramid_config, db_request):
             pretend.call("legacy.docs", project=project.name),
         }
 
-        headers = db_request.response.headers
-        assert headers["Access-Control-Allow-Origin"] == "*"
-        assert headers["Access-Control-Allow-Headers"] == (
-            "Content-Type, If-Match, If-Modified-Since, If-None-Match, "
-            "If-Unmodified-Since"
-        )
-        assert headers["Access-Control-Allow-Methods"] == "GET"
-        assert headers["Access-Control-Max-Age"] == "86400"
-        assert headers["Access-Control-Expose-Headers"] == "X-PyPI-Last-Serial"
-        assert headers["X-PyPI-Last-Serial"] == str(je.id)
+        _assert_has_cors_headers(db_request.response.headers)
+        assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
 
         assert result == {
             "info": {