diff --git a/openpype/modules/webserver/cors_middleware.py b/openpype/modules/webserver/cors_middleware.py
new file mode 100644
index 0000000000..f1cd7b04b3
--- /dev/null
+++ b/openpype/modules/webserver/cors_middleware.py
@@ -0,0 +1,283 @@
+r"""
+===============
+CORS Middleware
+===============
+.. versionadded:: 0.2.0
+Dealing with CORS headers for aiohttp applications.
+**IMPORTANT:** There is a `aiohttp-cors
+`_ library, which handles CORS
+headers by attaching additional handlers to aiohttp application for
+OPTIONS (preflight) requests. In same time this CORS middleware mimics the
+logic of `django-cors-headers `_,
+where all handling done in the middleware without any additional handlers. This
+approach allows aiohttp application to respond with CORS headers for OPTIONS or
+wildcard handlers, which is not possible with ``aiohttp-cors`` due to
+https://github.com/aio-libs/aiohttp-cors/issues/241 issue.
+For detailed information about CORS (Cross Origin Resource Sharing) please
+visit:
+- `Wikipedia `_
+- Or `MDN `_
+Configuration
+=============
+**IMPORTANT:** By default, CORS middleware do not allow any origins to access
+content from your aiohttp appliction. Which means, you need carefully check
+possible options and provide custom values for your needs.
+Usage
+=====
+.. code-block:: python
+ import re
+ from aiohttp import web
+ from aiohttp_middlewares import cors_middleware
+ from aiohttp_middlewares.cors import DEFAULT_ALLOW_HEADERS
+ # Unsecure configuration to allow all CORS requests
+ app = web.Application(
+ middlewares=[cors_middleware(allow_all=True)]
+ )
+ # Allow CORS requests from URL http://localhost:3000
+ app = web.Application(
+ middlewares=[
+ cors_middleware(origins=["http://localhost:3000"])
+ ]
+ )
+ # Allow CORS requests from all localhost urls
+ app = web.Application(
+ middlewares=[
+ cors_middleware(
+ origins=[re.compile(r"^https?\:\/\/localhost")]
+ )
+ ]
+ )
+ # Allow CORS requests from https://frontend.myapp.com as well
+ # as allow credentials
+ CORS_ALLOW_ORIGINS = ["https://frontend.myapp.com"]
+ app = web.Application(
+ middlewares=[
+ cors_middleware(
+ origins=CORS_ALLOW_ORIGINS,
+ allow_credentials=True,
+ )
+ ]
+ )
+ # Allow CORS requests only for API urls
+ app = web.Application(
+ middelwares=[
+ cors_middleware(
+ origins=CORS_ALLOW_ORIGINS,
+ urls=[re.compile(r"^\/api")],
+ )
+ ]
+ )
+ # Allow CORS requests for POST & PATCH methods, and for all
+ # default headers and `X-Client-UID`
+ app = web.Application(
+ middlewares=[
+ cors_middleware(
+ origings=CORS_ALLOW_ORIGINS,
+ allow_methods=("POST", "PATCH"),
+ allow_headers=DEFAULT_ALLOW_HEADERS
+ + ("X-Client-UID",),
+ )
+ ]
+ )
+"""
+
+import logging
+import re
+from typing import Pattern, Tuple
+
+from aiohttp import web
+
+from aiohttp_middlewares.annotations import (
+ Handler,
+ Middleware,
+ StrCollection,
+ UrlCollection,
+)
+from aiohttp_middlewares.utils import match_path
+
+
+ACCESS_CONTROL = "Access-Control"
+ACCESS_CONTROL_ALLOW = f"{ACCESS_CONTROL}-Allow"
+ACCESS_CONTROL_ALLOW_CREDENTIALS = f"{ACCESS_CONTROL_ALLOW}-Credentials"
+ACCESS_CONTROL_ALLOW_HEADERS = f"{ACCESS_CONTROL_ALLOW}-Headers"
+ACCESS_CONTROL_ALLOW_METHODS = f"{ACCESS_CONTROL_ALLOW}-Methods"
+ACCESS_CONTROL_ALLOW_ORIGIN = f"{ACCESS_CONTROL_ALLOW}-Origin"
+ACCESS_CONTROL_EXPOSE_HEADERS = f"{ACCESS_CONTROL}-Expose-Headers"
+ACCESS_CONTROL_MAX_AGE = f"{ACCESS_CONTROL}-Max-Age"
+ACCESS_CONTROL_REQUEST_METHOD = f"{ACCESS_CONTROL}-Request-Method"
+
+DEFAULT_ALLOW_HEADERS = (
+ "accept",
+ "accept-encoding",
+ "authorization",
+ "content-type",
+ "dnt",
+ "origin",
+ "user-agent",
+ "x-csrftoken",
+ "x-requested-with",
+)
+DEFAULT_ALLOW_METHODS = ("DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT")
+DEFAULT_URLS: Tuple[Pattern[str]] = (re.compile(r".*"),)
+
+logger = logging.getLogger(__name__)
+
+
+def cors_middleware(
+ *,
+ allow_all: bool = False,
+ origins: UrlCollection = None,
+ urls: UrlCollection = None,
+ expose_headers: StrCollection = None,
+ allow_headers: StrCollection = DEFAULT_ALLOW_HEADERS,
+ allow_methods: StrCollection = DEFAULT_ALLOW_METHODS,
+ allow_credentials: bool = False,
+ max_age: int = None,
+) -> Middleware:
+ """Middleware to provide CORS headers for aiohttp applications.
+ :param allow_all:
+ When enabled, allow any Origin to access content from your aiohttp web
+ application. **Please be careful with enabling this option as it may
+ result in security issues for your application.** By default: ``False``
+ :param origins:
+ Allow content access for given list of origins. Support supplying
+ strings for exact origin match or regex instances. By default: ``None``
+ :param urls:
+ Allow contect access for given list of URLs in aiohttp application.
+ By default: *apply CORS headers for all URLs*
+ :param expose_headers:
+ List of headers to be exposed with every CORS request. By default:
+ ``None``
+ :param allow_headers:
+ List of allowed headers. By default:
+ .. code-block:: python
+ (
+ "accept",
+ "accept-encoding",
+ "authorization",
+ "content-type",
+ "dnt",
+ "origin",
+ "user-agent",
+ "x-csrftoken",
+ "x-requested-with",
+ )
+ :param allow_methods:
+ List of allowed methods. By default:
+ .. code-block:: python
+ ("DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT")
+ :param allow_credentials:
+ When enabled apply allow credentials header in response, which results
+ in sharing cookies on shared resources. **Please be careful with
+ allowing credentials for CORS requests.** By default: ``False``
+ :param max_age: Access control max age in seconds. By default: ``None``
+ """
+ check_urls: UrlCollection = DEFAULT_URLS if urls is None else urls
+
+ @web.middleware
+ async def middleware(
+ request: web.Request, handler: Handler
+ ) -> web.StreamResponse:
+ # Initial vars
+ request_method = request.method
+ request_path = request.rel_url.path
+
+ # Is this an OPTIONS request
+ is_options_request = request_method == "OPTIONS"
+
+ # Is this a preflight request
+ is_preflight_request = (
+ is_options_request
+ and ACCESS_CONTROL_REQUEST_METHOD in request.headers
+ )
+
+ # Log extra data
+ log_extra = {
+ "is_preflight_request": is_preflight_request,
+ "method": request_method.lower(),
+ "path": request_path,
+ }
+
+ # Check whether CORS should be enabled for given URL or not. By default
+ # CORS enabled for all URLs
+ if not match_items(check_urls, request_path):
+ logger.debug(
+ "Request should not be processed via CORS middleware",
+ extra=log_extra,
+ )
+ return await handler(request)
+
+ # If this is a preflight request - generate empty response
+ if is_preflight_request:
+ response = web.StreamResponse()
+ # Otherwise - call actual handler
+ else:
+ response = await handler(request)
+
+ # Now check origin heaer
+ origin = request.headers.get("Origin")
+ # Empty origin - do nothing
+ if not origin:
+ logger.debug(
+ "Request does not have Origin header. CORS headers not "
+ "available for given requests",
+ extra=log_extra,
+ )
+ return response
+
+ # Set allow credentials header if necessary
+ if allow_credentials:
+ response.headers[ACCESS_CONTROL_ALLOW_CREDENTIALS] = "true"
+
+ # Check whether current origin satisfies CORS policy
+ if not allow_all and not (origins and match_items(origins, origin)):
+ logger.debug(
+ "CORS headers not allowed for given Origin", extra=log_extra
+ )
+ return response
+
+ # Now start supplying CORS headers
+ # First one is Access-Control-Allow-Origin
+ if allow_all and not allow_credentials:
+ cors_origin = "*"
+ else:
+ cors_origin = origin
+ response.headers[ACCESS_CONTROL_ALLOW_ORIGIN] = cors_origin
+
+ # Then Access-Control-Expose-Headers
+ if expose_headers:
+ response.headers[ACCESS_CONTROL_EXPOSE_HEADERS] = ", ".join(
+ expose_headers
+ )
+
+ # Now, if this is an options request, respond with extra Allow headers
+ if is_options_request:
+ response.headers[ACCESS_CONTROL_ALLOW_HEADERS] = ", ".join(
+ allow_headers
+ )
+ response.headers[ACCESS_CONTROL_ALLOW_METHODS] = ", ".join(
+ allow_methods
+ )
+ if max_age is not None:
+ response.headers[ACCESS_CONTROL_MAX_AGE] = str(max_age)
+
+ # If this is preflight request - do not allow other middlewares to
+ # process this request
+ if is_preflight_request:
+ logger.debug(
+ "Provide CORS headers with empty response for preflight "
+ "request",
+ extra=log_extra,
+ )
+ raise web.HTTPOk(text="", headers=response.headers)
+
+ # Otherwise return normal response
+ logger.debug("Provide CORS headers for request", extra=log_extra)
+ return response
+
+ return middleware
+
+
+def match_items(items: UrlCollection, value: str) -> bool:
+ """Go through all items and try to match item with given value."""
+ return any(match_path(item, value) for item in items)
diff --git a/openpype/modules/webserver/server.py b/openpype/modules/webserver/server.py
index 83a29e074e..82b681f406 100644
--- a/openpype/modules/webserver/server.py
+++ b/openpype/modules/webserver/server.py
@@ -1,15 +1,18 @@
+import re
import threading
import asyncio
from aiohttp import web
from openpype.lib import PypeLogger
+from .cors_middleware import cors_middleware
log = PypeLogger.get_logger("WebServer")
class WebServerManager:
"""Manger that care about web server thread."""
+
def __init__(self, port=None, host=None):
self.port = port or 8079
self.host = host or "localhost"
@@ -18,7 +21,13 @@ class WebServerManager:
self.handlers = {}
self.on_stop_callbacks = []
- self.app = web.Application()
+ self.app = web.Application(
+ middlewares=[
+ cors_middleware(
+ origins=[re.compile(r"^https?\:\/\/localhost")]
+ )
+ ]
+ )
# add route with multiple methods for single "external app"
diff --git a/poetry.lock b/poetry.lock
index 47509f334e..7221e191ff 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -46,6 +46,19 @@ python-versions = ">=3.5"
[package.dependencies]
aiohttp = ">=3,<4"
+[[package]]
+name = "aiohttp-middlewares"
+version = "2.0.0"
+description = "Collection of useful middlewares for aiohttp applications."
+category = "main"
+optional = false
+python-versions = ">=3.7,<4.0"
+
+[package.dependencies]
+aiohttp = ">=3.8.1,<4.0.0"
+async-timeout = ">=4.0.2,<5.0.0"
+yarl = ">=1.5.1,<2.0.0"
+
[[package]]
name = "aiosignal"
version = "1.2.0"
@@ -1783,6 +1796,10 @@ aiohttp-json-rpc = [
{file = "aiohttp-json-rpc-0.13.3.tar.gz", hash = "sha256:6237a104478c22c6ef96c7227a01d6832597b414e4b79a52d85593356a169e99"},
{file = "aiohttp_json_rpc-0.13.3-py3-none-any.whl", hash = "sha256:4fbd197aced61bd2df7ae3237ead7d3e08833c2ccf48b8581e1828c95ebee680"},
]
+aiohttp-middlewares = [
+ {file = "aiohttp-middlewares-2.0.0.tar.gz", hash = "sha256:e08ba04dc0e8fe379aa5e9444a68485c275677ee1e18c55cbb855de0c3629502"},
+ {file = "aiohttp_middlewares-2.0.0-py3-none-any.whl", hash = "sha256:29cf1513176b4013844711975ff520e26a8a5d8f9fefbbddb5e91224a86b043e"},
+]
aiosignal = [
{file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
{file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
diff --git a/pyproject.toml b/pyproject.toml
index a159559763..09dfdf45cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -68,6 +68,7 @@ slack-sdk = "^3.6.0"
requests = "^2.25.1"
pysftp = "^0.2.9"
dropbox = "^11.20.0"
+aiohttp-middlewares = "^2.0.0"
[tool.poetry.dev-dependencies]
@@ -154,4 +155,4 @@ exclude = [
ignore = ["website", "docs", ".git"]
reportMissingImports = true
-reportMissingTypeStubs = false
\ No newline at end of file
+reportMissingTypeStubs = false