Repository: edgedb/edgedb

Issue:
Allow configuring CORS `Access-Control-Allow-Origin`
Currently if the environment variable `EDGEDB_DEBUG_HTTP_INJECT_CORS` is set to `1`, we will respond with `*`.

We should make this configurable, and ideally (though optionally) have UI for it as well, and then get rid of the environment variable ideally.
Not sure if there are other related fields that should be configurable also.

See https://github.com/edgedb/edgedb/issues/5755, https://github.com/edgedb/edgedb/issues/6569


Discussion:

Resolving patch:
diff --git a/edb/common/debug.py b/edb/common/debug.py
--- a/edb/common/debug.py
+++ b/edb/common/debug.py
@@ -142,9 +142,6 @@ class flags(metaclass=FlagsMeta):
     server_clobber_pg_conns = Flag(
         doc="Discard Postgres connections when releasing them to the pool.")
 
-    http_inject_cors = Flag(
-        doc="Inject 'Access-Control-Allow-Origin: *' header in HTTP ports.")
-
     print_locals = Flag(
         doc="Include values of local variables in tracebacks.")
 
diff --git a/edb/testbase/http.py b/edb/testbase/http.py
--- a/edb/testbase/http.py
+++ b/edb/testbase/http.py
@@ -56,6 +56,34 @@ def tearDownClass(cls):
             )
         super().tearDownClass()
 
+    @classmethod
+    async def _wait_for_db_config(cls, config_key, *, instance_config=False):
+        dbname = cls.get_database_name()
+        # Wait for the database config changes to propagate to the
+        # server by watching a debug endpoint
+        async for tr in cls.try_until_succeeds(ignore=AssertionError):
+            async with tr:
+                with cls.http_con() as http_con:
+                    (
+                        rdata,
+                        _headers,
+                        status,
+                    ) = cls.http_con_request(
+                        http_con,
+                        prefix="",
+                        path="server-info",
+                    )
+                    data = json.loads(rdata)
+                    if instance_config:
+                        config = data['instance_config']
+                    else:
+                        if 'databases' not in data:
+                            # multi-tenant instance - use the first tenant
+                            data = next(iter(data['tenants'].values()))
+                        config = data['databases'][dbname]['config']
+                    if config_key not in config:
+                        raise AssertionError('database config not ready')
+
 
 class ExtAuthTestCase(BaseHttpExtensionTest):
 
@@ -75,7 +103,14 @@ def get_extension_path(cls):
         return 'edgeql'
 
     def edgeql_query(
-            self, query, *, use_http_post=True, variables=None, globals=None):
+            self,
+            query,
+            *,
+            use_http_post=True,
+            variables=None,
+            globals=None,
+            origin=None,
+    ):
         req_data = {
             'query': query
         }
@@ -88,6 +123,8 @@ def edgeql_query(
             req = urllib.request.Request(self.http_addr, method='POST')
             req.add_header('Content-Type', 'application/json')
             req.add_header('Authorization', self.make_auth_header())
+            if origin:
+                req.add_header('Origin', origin)
             response = urllib.request.urlopen(
                 req, json.dumps(req_data).encode(), context=self.tls_context
             )
@@ -108,7 +145,7 @@ def edgeql_query(
             resp_data = json.loads(response.read())
 
         if 'data' in resp_data:
-            return resp_data['data']
+            return (resp_data['data'], response)
 
         err = resp_data['error']
 
@@ -122,7 +159,7 @@ def assert_edgeql_query_result(self, query, result, *,
                                    use_http_post=True,
                                    variables=None,
                                    globals=None):
-        res = self.edgeql_query(
+        res, _ = self.edgeql_query(
             query,
             use_http_post=use_http_post,
             variables=variables,


Test patch:
diff --git a/tests/test_http_auth.py b/tests/test_http_auth.py
--- a/tests/test_http_auth.py
+++ b/tests/test_http_auth.py
@@ -19,6 +19,7 @@
 
 import base64
 import struct
+import urllib
 
 import edgedb
 from edgedb import scram
@@ -26,9 +27,15 @@
 from edb import protocol
 from edb.server import defines as edbdef
 from edb.testbase import server as tb_server
+from edb.testbase import http as tb_http
 
 
-class BaseTestHttpAuth(tb_server.ConnectedTestCase):
+class BaseTestHttpAuth(
+    tb_http.BaseHttpExtensionTest,
+    tb_server.ConnectedTestCase,
+):
+    TRANSACTION_ISOLATION = False
+
     @classmethod
     def get_api_prefix(cls) -> str:
         return "/auth"
@@ -195,6 +202,63 @@ def test_http_auth_scram_invalid_password(self):
     def test_http_auth_scram_no_user(self):
         self._scram_auth_expect_failure("scram_no_user", "bad-password")
 
+    async def test_http_auth_scram_cors(self):
+        conn_args = self.get_connect_args()
+        url = f'https://{conn_args["host"]}:{conn_args["port"]}/auth/token'
+
+        req = urllib.request.Request(url, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        await self.con.execute(
+            'configure instance '
+            'set cors_allow_origins := {"https://example.edgedb.com"}')
+        await self._wait_for_db_config(
+            'cors_allow_origins', instance_config=True)
+
+        req = urllib.request.Request(url, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        headers = response.headers
+
+        self.assertIn('Access-Control-Allow-Origin', headers)
+        self.assertEqual(
+            headers['Access-Control-Allow-Origin'],
+            'https://example.edgedb.com'
+        )
+        self.assertIn('GET', headers['Access-Control-Allow-Methods'])
+        self.assertIn('Authorization', headers['Access-Control-Allow-Headers'])
+        self.assertIn(
+            'WWW-Authenticate',
+            headers['Access-Control-Expose-Headers']
+        )
+        self.assertIn(
+            'Authentication-Info',
+            headers['Access-Control-Expose-Headers']
+        )
+
+        with self.http_con() as con:
+            _, headers, status = self.http_con_request(
+                con, {}, path="token",
+                headers={'Origin': 'https://example.edgedb.com'}
+            )
+            self.assertEqual(status, 401)
+            self.assertIn('access-control-allow-origin', headers)
+            self.assertIn('access-control-expose-headers', headers)
+            self.assertIn(
+                'WWW-Authenticate',
+                headers['access-control-expose-headers']
+            )
+            self.assertIn(
+                'Authentication-Info',
+                headers['access-control-expose-headers']
+            )
+
 
 class TestHttpAuthSystem(BaseTestHttpAuth):
 
diff --git a/tests/test_http_edgeql.py b/tests/test_http_edgeql.py
--- a/tests/test_http_edgeql.py
+++ b/tests/test_http_edgeql.py
@@ -408,3 +408,89 @@ def test_http_edgeql_decimal_params_01(self):
                 [decimal.Decimal('1234567890123456789.01234567890123456789')]
             ]
         })
+
+    async def test_http_edgeql_cors(self):
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        _, response = self.edgeql_query(
+            r"""
+                SELECT User {
+                    name,
+                    age,
+                    groups: { name }
+                }
+                FILTER .name = <str>$name AND .age = <int64>$age;
+            """,
+            variables=dict(name='Bob', age=21),
+            origin='https://example.edgedb.com'
+        )
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        await self.con.execute(
+            'configure instance '
+            'set cors_allow_origins := {"https://example.edgedb.com"}')
+        await self._wait_for_db_config(
+            'cors_allow_origins', instance_config=True)
+
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://other.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        headers = response.headers
+        self.assertIn('Access-Control-Allow-Origin', headers)
+        self.assertEqual(
+            headers['Access-Control-Allow-Origin'],
+            'https://example.edgedb.com'
+        )
+        self.assertIn('POST', headers['Access-Control-Allow-Methods'])
+        self.assertIn('GET', headers['Access-Control-Allow-Methods'])
+        self.assertIn('Authorization', headers['Access-Control-Allow-Headers'])
+        self.assertIn('X-EdgeDB-User', headers['Access-Control-Allow-Headers'])
+        self.assertEqual(headers['Access-Control-Allow-Credentials'], 'true')
+
+        await self.con.execute(
+            'configure current database '
+            'set cors_allow_origins := {"https://other.edgedb.com"}')
+        await self._wait_for_db_config(
+            'cors_allow_origins')
+
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://other.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+        self.assertIn('Access-Control-Allow-Origin', response.headers)
+
+        _, response = self.edgeql_query(
+            r"""
+                SELECT User {
+                    name,
+                    age,
+                    groups: { name }
+                }
+                FILTER .name = <str>$name AND .age = <int64>$age;
+            """,
+            variables=dict(name='Bob', age=21),
+            origin='https://other.edgedb.com'
+        )
+        self.assertIn('Access-Control-Allow-Origin', response.headers)
diff --git a/tests/test_http_ext_auth.py b/tests/test_http_ext_auth.py
--- a/tests/test_http_ext_auth.py
+++ b/tests/test_http_ext_auth.py
@@ -439,35 +439,12 @@ class TestHttpExtAuth(tb.ExtAuthTestCase):
         """,
     ]
 
-    @classmethod
-    async def _wait_for_db_config(cls):
-        dbname = cls.get_database_name()
-        # Wait for the database config changes to propagate to the
-        # server by watching a debug endpoint
-        async for tr in cls.try_until_succeeds(ignore=AssertionError):
-            async with tr:
-                with cls.http_con() as http_con:
-                    (
-                        rdata,
-                        _headers,
-                        status,
-                    ) = tb.ExtAuthTestCase.http_con_request(
-                        http_con,
-                        prefix="",
-                        path="server-info",
-                    )
-                    data = json.loads(rdata)
-                    if 'databases' not in data:
-                        # multi-tenant instance - use the first tenant
-                        data = next(iter(data['tenants'].values()))
-                    config = data['databases'][dbname]['config']
-                    if 'ext::auth::AuthConfig::providers' not in config:
-                        raise AssertionError('database config not ready')
-
     @classmethod
     def setUpClass(cls):
         super().setUpClass()
-        cls.loop.run_until_complete(cls._wait_for_db_config())
+        cls.loop.run_until_complete(
+            cls._wait_for_db_config('ext::auth::AuthConfig::providers')
+        )
 
     @classmethod
     def get_setup_script(cls):
diff --git a/tests/test_http_graphql_query.py b/tests/test_http_graphql_query.py
--- a/tests/test_http_graphql_query.py
+++ b/tests/test_http_graphql_query.py
@@ -4615,6 +4615,39 @@ def test_graphql_func_01(self):
                 use_http_post=use_http_post,
             )
 
+    async def test_graphql_cors(self):
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        await self.con.execute(
+            'configure current database '
+            'set cors_allow_origins := {"https://example.edgedb.com"}')
+        await self._wait_for_db_config('cors_allow_origins')
+
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+
+        headers = response.headers
+
+        self.assertIn('Access-Control-Allow-Origin', headers)
+        self.assertEqual(
+            headers['Access-Control-Allow-Origin'],
+            'https://example.edgedb.com'
+        )
+        self.assertIn('POST', headers['Access-Control-Allow-Methods'])
+        self.assertIn('GET', headers['Access-Control-Allow-Methods'])
+        self.assertIn('Authorization', headers['Access-Control-Allow-Headers'])
+        self.assertIn('X-EdgeDB-User', headers['Access-Control-Allow-Headers'])
+        self.assertEqual(headers['Access-Control-Allow-Credentials'], 'true')
+
 
 class TestGraphQLInit(tb.GraphQLTestCase):
     """Test GraphQL initialization on an empty database."""
diff --git a/tests/test_http_notebook.py b/tests/test_http_notebook.py
--- a/tests/test_http_notebook.py
+++ b/tests/test_http_notebook.py
@@ -282,3 +282,54 @@ def test_http_notebook_09(self):
         ])
 
         self.assertNotIn('error', results['results'][0])
+
+    async def test_http_notebook_cors(self):
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+
+        self.assertNotIn('Access-Control-Allow-Origin', response.headers)
+
+        await self.con.execute(
+            'configure current database '
+            'set cors_allow_origins := {"https://example.edgedb.com"}')
+        await self._wait_for_db_config('cors_allow_origins')
+
+        req = urllib.request.Request(self.http_addr, method='OPTIONS')
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req, context=self.tls_context
+        )
+
+        headers = response.headers
+
+        self.assertIn('Access-Control-Allow-Origin', headers)
+        self.assertEqual(
+            headers['Access-Control-Allow-Origin'],
+            'https://example.edgedb.com'
+        )
+        self.assertIn('POST', headers['Access-Control-Allow-Methods'])
+        self.assertIn('Authorization', headers['Access-Control-Allow-Headers'])
+        self.assertIn(
+            'EdgeDB-Protocol-Version',
+            headers['Access-Control-Expose-Headers']
+        )
+
+        req = urllib.request.Request(self.http_addr, method='POST')
+        req.add_header('Content-Type', 'application/json')
+        req.add_header('Authorization', self.make_auth_header())
+        req.add_header('Origin', 'https://example.edgedb.com')
+        response = urllib.request.urlopen(
+            req,
+            json.dumps({'queries': ['select 123']}).encode(),
+            context=self.tls_context
+        )
+        headers = response.headers
+        self.assertIn('Access-Control-Allow-Origin', headers)
+        self.assertIn('Access-Control-Expose-Headers', headers)
+        self.assertIn(
+            'EdgeDB-Protocol-Version',
+            headers['Access-Control-Expose-Headers']
+        )