Repository: rucio/rucio

Issue:
allow CORS headers for preflight requests
Motivation
----------
For requests that are [`unsafe`](https://javascript.info/fetch-crossorigin#safe-requests) OR `cross-origin`, browsers make a `preflight` request to the Rucio Server. A `preflight` request is essentially an [`OPTIONS` ( HTTP method)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS) to the same endpoint where the actual request was intended. 

The old WebUI never had the requirement of handling pre-flight requests, since the requests always went to the WebUI flask server, which was available on the same apache host as the rucio server:
https://github.com/rucio/rucio/blob/763454f971b8d9a47939e3f8183c2adba54f0d03/lib/rucio/web/ui/static/rucio.js#L27-L29

Besides the auth server, no other endpoints had to handle the `HTTP OPTIONS` request.

https://github.com/rucio/rucio/blob/763454f971b8d9a47939e3f8183c2adba54f0d03/lib/rucio/web/rest/flaskapi/v1/auth.py#L992-L998
 
However, the new UI is a React application running in the browser and any request that is made to the rucio server invokes the CORS `preflight` request. All of these preflight requests are blocked as the the rucio server does not handle the `OPTIONS` HTTP methods. (anywhere besides auth endpoints)

As a result, while the new WebUI can successfully obtain the rucio token, any subsequent request made to other endpoints of the rucio server fail. 


Modification
------------
Add a flask middleware to filter for OPTIONS requests and respond to the them with a `200 OK` response if they originate from the WebUI URLs specified in the appropriate `rucio.cfg` section




Discussion:

Resolving patch:
diff --git a/lib/rucio/web/rest/flaskapi/v1/common.py b/lib/rucio/web/rest/flaskapi/v1/common.py
--- a/lib/rucio/web/rest/flaskapi/v1/common.py
+++ b/lib/rucio/web/rest/flaskapi/v1/common.py
@@ -22,15 +22,20 @@
 from typing import TYPE_CHECKING
 
 import flask
+import typing
 from flask.views import MethodView
 from werkzeug.datastructures import Headers
 from werkzeug.exceptions import HTTPException
+from werkzeug.wrappers import Request, Response
 
 from rucio.api.authentication import validate_auth_token
 from rucio.common.exception import RucioException, CannotAuthenticate, UnsupportedRequestedContentType
 from rucio.common.schema import get_schema_value
 from rucio.common.utils import generate_uuid, render_json
 from rucio.core.vo import map_vo
+from rucio.common import config
+from configparser import NoOptionError, NoSectionError
+
 
 if TYPE_CHECKING:
     from typing import Optional, Union, Dict, Sequence, Tuple, Callable, Any, List
@@ -38,6 +43,38 @@
     HeadersType = Union[Headers, Dict[str, str], Sequence[Tuple[str, str]]]
 
 
+class CORSMiddleware(object):
+    """
+    WebUI 2.0 makes preflight requests to the API, which are not handled by the API.
+    This middleware intercepts the preflight OPTIONS requests and returns a 200 OK response.
+    """
+
+    def __init__(self, app: flask.Flask) -> typing.NoReturn:
+        self.app = app
+
+    def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Union[Response, typing.Iterable[bytes]]:
+        request: Request = Request(environ)
+
+        if request.environ.get('REQUEST_METHOD') == 'OPTIONS':
+            try:
+                webui_urls = config.config_get_list('webui', 'urls')
+            except (NoOptionError, NoSectionError, RuntimeError) as error:
+                logging.exception('Could not get webui urls from config file')
+                return str(error), 500
+            if request.origin in webui_urls:
+                response: Response = Response(status=200)
+                response.headers['Access-Control-Allow-Origin'] = request.origin
+                response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
+                response.headers['Access-Control-Allow-Headers'] = '*'
+                response.headers['Access-Control-Allow-Credentials'] = 'true'
+                return response(environ, start_response)
+            response: Response = Response(status=403)
+            return response(environ, start_response)
+
+        # bypass this middleware for non-OPTIONS requests
+        return self.app(environ, start_response)
+
+
 class ErrorHandlingMethodView(MethodView):
     """
     Special MethodView that handles generic RucioExceptions and more generic
diff --git a/lib/rucio/web/rest/flaskapi/v1/main.py b/lib/rucio/web/rest/flaskapi/v1/main.py
--- a/lib/rucio/web/rest/flaskapi/v1/main.py
+++ b/lib/rucio/web/rest/flaskapi/v1/main.py
@@ -17,7 +17,7 @@
 import importlib
 
 from flask import Flask
-
+from rucio.web.rest.flaskapi.v1.common import CORSMiddleware
 from rucio.common.config import config_get
 from rucio.common.exception import ConfigurationError
 from rucio.common.logging import setup_logging
@@ -72,6 +72,7 @@ def apply_endpoints(app, modules):
     endpoints = DEFAULT_ENDPOINTS
 
 application = Flask(__name__)
+application.wsgi_app = CORSMiddleware(application.wsgi_app)
 apply_endpoints(application, endpoints)
 setup_logging(application)
 


Test patch: