From 40671b99d58fb061d183da4ebd7c5347d0b55612 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Mon, 23 Mar 2026 16:39:07 +0200 Subject: [PATCH] feat: allow to configure cors --- docs/configuration.rst | 4 ++ .../ahriman/settings/ahriman.ini.d/00-web.ini | 8 ++++ src/ahriman/core/configuration/schema.py | 32 ++++++++++++++++ src/ahriman/web/cors.py | 18 ++++++--- src/ahriman/web/web.py | 2 +- tests/ahriman/web/test_cors.py | 37 ++++++++++++++++++- 6 files changed, 94 insertions(+), 7 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 2b11d3a8..e346277c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -180,6 +180,10 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed * ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used. * ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default. +* ``cors_allow_headers`` - allowed CORS headers, space separated list of strings, optional. +* ``cors_allow_methods`` - allowed CORS methods, space separated list of strings, optional. +* ``cors_allow_origins`` - allowed CORS origins, space separated list of strings, optional, default ``*``. +* ``cors_expose_headers`` - exposed CORS headers, space separated list of strings, optional. * ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``. * ``host`` - host to bind, string, optional. * ``index_url`` - full URL of the repository index page, string, optional. diff --git a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini index d20b8da6..1b58fa31 100644 --- a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini +++ b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini @@ -30,6 +30,14 @@ allow_read_only = yes ; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh ; by default. autorefresh_intervals = 5 1 10 30 60 +; Allowed CORS headers. By default everything is allowed. +;cors_allow_headers = +; Allowed CORS methods. By default everything is allowed. +;cors_allow_methods = +; Allowed CORS origins. +;cors_allow_origins = * +; Exposed CORS headers. By default everything is exposed. +;cors_expose_headers = ; Enable file upload endpoint used by some triggers. ;enable_archive_upload = no ; Address to bind the server. diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index 13551a1d..e270ac90 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -358,6 +358,38 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "min": 0, }, }, + "cors_allow_headers": { + "type": "list", + "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, + }, + "cors_allow_methods": { + "type": "list", + "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, + }, + "cors_allow_origins": { + "type": "list", + "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, + }, + "cors_expose_headers": { + "type": "list", + "coerce": "list", + "schema": { + "type": "string", + "empty": False, + }, + }, "enable_archive_upload": { "type": "boolean", "coerce": "boolean", diff --git a/src/ahriman/web/cors.py b/src/ahriman/web/cors.py index 399efef2..332bfdea 100644 --- a/src/ahriman/web/cors.py +++ b/src/ahriman/web/cors.py @@ -21,26 +21,34 @@ import aiohttp_cors from aiohttp.web import Application +from ahriman.core.configuration import Configuration + __all__ = ["setup_cors"] -def setup_cors(application: Application) -> aiohttp_cors.CorsConfig: +def setup_cors(application: Application, configuration: Configuration) -> aiohttp_cors.CorsConfig: """ setup CORS for the web application Args: application(Application): web application instance + configuration(Configuration): configuration instance Returns: aiohttp_cors.CorsConfig: generated CORS configuration """ + allow_headers = configuration.getlist("web", "cors_allow_headers", fallback=[]) or "*" + allow_methods = configuration.getlist("web", "cors_allow_methods", fallback=[]) or "*" + expose_headers = configuration.getlist("web", "cors_expose_headers", fallback=[]) or "*" + cors = aiohttp_cors.setup(application, defaults={ - "*": aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call] - expose_headers="*", - allow_headers="*", - allow_methods="*", + origin: aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call] + expose_headers=expose_headers, + allow_headers=allow_headers, + allow_methods=allow_methods, ) + for origin in configuration.getlist("web", "cors_allow_origins", fallback=["*"]) }) for route in application.router.routes(): cors.add(route) diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index a40ea234..630b7b0e 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -187,7 +187,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis setup_routes(application, configuration) application.logger.info("setup CORS") - setup_cors(application) + setup_cors(application, configuration) application.logger.info("setup templates") loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates")) diff --git a/tests/ahriman/web/test_cors.py b/tests/ahriman/web/test_cors.py index 251c3311..219d1291 100644 --- a/tests/ahriman/web/test_cors.py +++ b/tests/ahriman/web/test_cors.py @@ -2,13 +2,17 @@ import aiohttp_cors import pytest from aiohttp.web import Application +from pytest_mock import MockerFixture + +from ahriman.web.cors import setup_cors +from ahriman.web.keys import ConfigurationKey def test_setup_cors(application: Application) -> None: """ must setup CORS """ - cors: aiohttp_cors.CorsConfig = application[aiohttp_cors.APP_CONFIG_KEY] + cors = application[aiohttp_cors.APP_CONFIG_KEY] # let's test here that it is enabled for all requests for route in application.router.routes(): # we don't want to deal with match info here though @@ -18,3 +22,34 @@ def test_setup_cors(application: Application) -> None: continue request = pytest.helpers.request(application, url, route.method, resource=route.resource) assert cors._cors_impl._router_adapter.is_cors_enabled_on_request(request) + + +def test_setup_cors_custom_origins(application: Application, mocker: MockerFixture) -> None: + """ + must setup CORS with custom origins + """ + configuration = application[ConfigurationKey] + configuration.set_option("web", "cors_allow_origins", "https://example.com https://httpbin.com") + + setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock()) + setup_cors(application, configuration) + + defaults = setup_mock.call_args.kwargs["defaults"] + assert "https://example.com" in defaults + assert "https://httpbin.com" in defaults + assert "*" not in defaults + + +def test_setup_cors_custom_methods(application: Application, mocker: MockerFixture) -> None: + """ + must setup CORS with custom methods + """ + configuration = application[ConfigurationKey] + configuration.set_option("web", "cors_allow_methods", "GET POST") + + setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock()) + setup_cors(application, configuration) + + defaults = setup_mock.call_args.kwargs["defaults"] + resource_options = next(iter(defaults.values())) + assert resource_options.allow_methods == {"GET", "POST"}