From b6f0636ab7173e540ffeae6e6e55e6f0e735e4c0 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 18 Sep 2026 08:41:03 +0000 Subject: [PATCH] Configure CORS headers from environment variables --- deploy/k8s/tavolo.yaml | 10 +++ docker-compose.yml | 9 +++ server/.env.example | 22 ++++++ server/README.md | 7 ++ server/pyproject.toml | 1 + server/requirements.txt | 3 + server/src/tavolo/app.py | 47 +++++++++++-- server/src/tavolo/config.py | 41 +++++++++++- server/tests/test_config.py | 49 ++++++++++++++ server/tests/test_cors.py | 129 ++++++++++++++++++++++++++++++++++++ 10 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 server/tests/test_cors.py diff --git a/deploy/k8s/tavolo.yaml b/deploy/k8s/tavolo.yaml index 5c67fca..f28557f 100644 --- a/deploy/k8s/tavolo.yaml +++ b/deploy/k8s/tavolo.yaml @@ -61,6 +61,16 @@ data: GAME_TTL_SECONDS: "86400" HAND_ACK_TIMEOUT_SECONDS: "30" TURN_TIMEOUT_SECONDS: "30" + # CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or + # CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are + # served from the same origin. See server/.env.example for details. + # CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*" + # CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app' + # CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all + # CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request + # CORS_ALLOW_CREDENTIALS: "false" + # CORS_EXPOSE_HEADERS: "" + # CORS_MAX_AGE: "600" # OIDC (provider lives in another namespace). OIDC_CLIENT_ID: tavolo OIDC_POST_LOGIN_REDIRECT: / diff --git a/docker-compose.yml b/docker-compose.yml index 129f12e..1af2814 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,6 +113,15 @@ services: REDIS_URL: redis://redis:6379/0 HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30} + # CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX + # is set (see server/.env.example for the full list of options). + CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-} + CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-} + CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-} + CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-} + CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-} + CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-} + CORS_MAX_AGE: ${CORS_MAX_AGE:-} ports: - "127.0.0.1:${APP_PORT:-8080}:8080" diff --git a/server/.env.example b/server/.env.example index 00ce720..641f488 100644 --- a/server/.env.example +++ b/server/.env.example @@ -39,6 +39,28 @@ TURN_TIMEOUT_SECONDS=30 # schema). Unset logs DEBUG to the console. #LOGGING_CONFIG=/path/to/logging.yaml +# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's +# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or +# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from +# the same origin, so no CORS headers are needed by default. +# Comma-separated list of origins allowed to make cross-origin requests, +# or "*" for any origin: +#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com +# Optional regex (fullmatch) allowed origins are additionally checked +# against — handy for dynamic preview URLs: +#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app +# Comma-separated allowed methods, or "*" for all (default GET): +#CORS_ALLOW_METHODS=GET,POST +# Comma-separated allowed request headers, or "*" to mirror back whatever +# the browser requests (default: only the CORS-safelisted headers): +#CORS_ALLOW_HEADERS=Authorization,Content-Type +# Allow cookies/credentials on cross-origin requests (1/true/yes/on): +#CORS_ALLOW_CREDENTIALS=false +# Comma-separated response headers exposed to the browser: +#CORS_EXPOSE_HEADERS= +# Seconds browsers may cache the preflight response (default 600): +#CORS_MAX_AGE=600 + # App server APP_HOST=0.0.0.0 APP_PORT=8080 diff --git a/server/README.md b/server/README.md index 80fb845..39c783c 100644 --- a/server/README.md +++ b/server/README.md @@ -62,6 +62,13 @@ All configuration comes from environment variables (see `.env.example`): | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them | | `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) | | `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console | +| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set | +| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` | +| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all | +| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed | +| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests | +| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser | +| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | ## Logging diff --git a/server/pyproject.toml b/server/pyproject.toml index d2b8b8a..3cc7445 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "kaya-core", + "kaya-cors", "kaya-session", "kaya-session-redis", "kaya-oidc", diff --git a/server/requirements.txt b/server/requirements.txt index 3df862f..21cdd78 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -52,11 +52,14 @@ iso8601==2.1.0 # via tortoise-orm kaya-core==0.0.3 # via + # kaya-cors # kaya-oidc # kaya-openapi # kaya-rsgi # kaya-session # tavolo (pyproject.toml) +kaya-cors==0.0.3 + # via tavolo (pyproject.toml) kaya-oidc==0.0.3 # via tavolo (pyproject.toml) kaya-openapi==0.0.3 diff --git a/server/src/tavolo/app.py b/server/src/tavolo/app.py index c2fd73c..f191215 100644 --- a/server/src/tavolo/app.py +++ b/server/src/tavolo/app.py @@ -11,6 +11,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins: - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at ``/api/openapi.json`` and a Swagger UI at ``/api/docs``) +A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the +``CORS_*`` environment variables (see :mod:`tavolo.config`). + Live games are kept in :data:`game_store` (Redis when configured, in-memory otherwise). Routes and the websocket handlers are registered by importing their modules at the bottom; imports must happen after ``app`` is built. @@ -19,15 +22,17 @@ from __future__ import annotations from importlib.metadata import version as _pkg_version from logging import getLogger +from typing import Optional -from kaya.core import KayaApp +from kaya.core import KayaApp, KayaMixin +from kaya.cors import CorsMixin from kaya.oidc import OIDCConfig, OIDCMixin from kaya.openapi import OpenAPIMixin from kaya.session import InMemorySessionStore, SessionMixin, SessionStore from kaya.session.redis import RedisSessionStore from redis.asyncio import Redis -from .config import settings +from .config import Settings, settings from .deadlines import DeadlineSchedulerMixin from .logging_config import configure_logging from .store import GameStore, InMemoryGameStore, RedisGameStore @@ -36,6 +41,27 @@ from .tortoise_mixin import TortoiseMixin configure_logging(settings.logging_config) log = getLogger(__name__) + +def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]: + """Build a :class:`~kaya.cors.CorsMixin` from the CORS settings. + + Returns ``None`` — CORS disabled — unless at least one of + ``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured. + Settings left unset fall back to the mixin's own defaults. + """ + if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None: + return None + return CorsMixin( + allow_origins=settings.cors_allow_origins or (), + allow_origin_regex=settings.cors_allow_origin_regex, + allow_methods=settings.cors_allow_methods or ("GET",), + allow_headers=settings.cors_allow_headers or (), + allow_credentials=settings.cors_allow_credentials, + expose_headers=settings.cors_expose_headers or (), + max_age=settings.cors_max_age, + ) + + session_store: SessionStore if settings.redis_url is not None: # Lazy client: no connection is opened until a session is actually @@ -77,8 +103,21 @@ tortoise_mixin = TortoiseMixin( skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), ) -app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, - DeadlineSchedulerMixin(game_store)]) +mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, + DeadlineSchedulerMixin(game_store)] +cors_mixin = cors_mixin_from_settings(settings) +if cors_mixin is not None: + # First in the list: preflight requests are answered before the session + # and OIDC hooks run. + mixins.insert(0, cors_mixin) + log.info( + "CORS enabled: origins=%s origin_regex=%s credentials=%s", + settings.cors_allow_origins, + settings.cors_allow_origin_regex, + settings.cors_allow_credentials, + ) + +app = KayaApp(mixins=mixins) log.debug( "timeouts: hand_ack=%ds turn=%ds", settings.hand_ack_timeout_seconds, diff --git a/server/src/tavolo/config.py b/server/src/tavolo/config.py index c456dfc..53c0483 100644 --- a/server/src/tavolo/config.py +++ b/server/src/tavolo/config.py @@ -7,7 +7,7 @@ from __future__ import annotations import os from dataclasses import dataclass -from typing import Optional +from typing import Optional, Tuple from urllib.parse import quote @@ -20,6 +20,25 @@ def _env(name: str, default: Optional[str] = None) -> str: return value +def _env_list(name: str) -> Optional[Tuple[str, ...]]: + """Parse a comma-separated environment variable into a tuple of values. + + Items are stripped and empty items dropped. Unset or empty variables + yield ``None``. + """ + value = os.environ.get(name) + if value is None or value.strip() == "": + return None + return tuple(part.strip() for part in value.split(",") if part.strip()) + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None or value == "": + return default + return value.strip().lower() in ("1", "true", "yes", "on") + + def _database_url_from_parts(engine: str, user: str, password: Optional[str], @@ -84,6 +103,16 @@ class Settings: # Path to a YAML logging configuration file (logging.config.dictConfig # schema). Unset uses the built-in default: DEBUG to the console. logging_config: Optional[str] + # CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or + # CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API + # from the same origin, so no CORS headers are needed by default. + cors_allow_origins: Optional[Tuple[str, ...]] + cors_allow_origin_regex: Optional[str] + cors_allow_methods: Optional[Tuple[str, ...]] + cors_allow_headers: Optional[Tuple[str, ...]] + cors_allow_credentials: bool + cors_expose_headers: Optional[Tuple[str, ...]] + cors_max_age: int @staticmethod def from_env() -> "Settings": @@ -120,6 +149,16 @@ class Settings: turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")), deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")), logging_config=os.environ.get("LOGGING_CONFIG"), + # CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated + # list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX + # is set. + cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"), + cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None, + cors_allow_methods=_env_list("CORS_ALLOW_METHODS"), + cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"), + cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"), + cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"), + cors_max_age=int(_env("CORS_MAX_AGE", "600")), ) diff --git a/server/tests/test_config.py b/server/tests/test_config.py index f6947e0..d7c7012 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -81,5 +81,54 @@ class DatabaseUrlTests(unittest.TestCase): ) +class CorsSettingsTests(unittest.TestCase): + def test_cors_disabled_by_default(self): + settings = _settings({}) + self.assertIsNone(settings.cors_allow_origins) + self.assertIsNone(settings.cors_allow_origin_regex) + self.assertIsNone(settings.cors_allow_methods) + self.assertIsNone(settings.cors_allow_headers) + self.assertFalse(settings.cors_allow_credentials) + self.assertIsNone(settings.cors_expose_headers) + self.assertEqual(600, settings.cors_max_age) + + def test_allow_origins_parses_comma_separated_list(self): + settings = _settings({ + "CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example", + }) + self.assertEqual( + ("https://a.example", "https://b.example", "https://c.example"), + settings.cors_allow_origins, + ) + + def test_allow_origins_star_is_passed_through(self): + settings = _settings({"CORS_ALLOW_ORIGINS": "*"}) + self.assertEqual(("*",), settings.cors_allow_origins) + + def test_allow_origin_regex_is_passed_through(self): + settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"}) + self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex) + + def test_allow_methods_and_headers_parse_as_lists(self): + settings = _settings({ + "CORS_ALLOW_METHODS": "GET,POST", + "CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header", + "CORS_EXPOSE_HEADERS": "X-Total-Count", + }) + self.assertEqual(("GET", "POST"), settings.cors_allow_methods) + self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers) + self.assertEqual(("X-Total-Count",), settings.cors_expose_headers) + + def test_allow_credentials_parses_boolean(self): + for value in ("1", "true", "TRUE", "yes", "on"): + self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials) + for value in ("0", "false", "no", "off", "anything-else"): + self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials) + + def test_max_age_parses_int(self): + settings = _settings({"CORS_MAX_AGE": "3600"}) + self.assertEqual(3600, settings.cors_max_age) + + if __name__ == "__main__": unittest.main() diff --git a/server/tests/test_cors.py b/server/tests/test_cors.py new file mode 100644 index 0000000..c5618f2 --- /dev/null +++ b/server/tests/test_cors.py @@ -0,0 +1,129 @@ +"""Integration tests for the CORS configuration in :mod:`tavolo.app`. + +The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these +tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the +environment-driven :class:`~tavolo.config.Settings` onto it correctly. A +minimal ``KayaApp`` is used instead of the global ``app`` so the tests do +not depend on the environment the suite was imported with. +""" +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from httpx import ASGITransport, AsyncClient +from kaya.core import HttpContext, KayaApp +from pwo import async_test + +from tavolo.app import cors_mixin_from_settings +from tavolo.config import Settings + +ORIGIN = "https://cards.example" + + +def _settings(env: dict) -> Settings: + with patch.dict(os.environ, env, clear=True): + return Settings.from_env() + + +def _app(settings: Settings) -> KayaApp: + mixin = cors_mixin_from_settings(settings) + assert mixin is not None + app = KayaApp(mixins=[mixin]) + + @app.GET("/api/health") + async def health(ctx: HttpContext) -> None: + await ctx.send_str(200, "ok") + + return app + + +class CorsMixinFromSettingsTests(unittest.TestCase): + def test_disabled_when_unconfigured(self): + self.assertIsNone(cors_mixin_from_settings(_settings({}))) + + def test_enabled_by_allow_origins(self): + self.assertIsNotNone(cors_mixin_from_settings( + _settings({"CORS_ALLOW_ORIGINS": ORIGIN}))) + + def test_enabled_by_allow_origin_regex_alone(self): + self.assertIsNotNone(cors_mixin_from_settings( + _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"}))) + + +class CorsBehaviorTests(unittest.TestCase): + @async_test + async def test_request_without_origin_is_untouched(self) -> None: + app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://127.0.0.1") as client: + response = await client.get("/api/health") + self.assertEqual(200, response.status_code) + self.assertNotIn("access-control-allow-origin", response.headers) + + @async_test + async def test_simple_request_with_allowed_origin(self) -> None: + app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://127.0.0.1") as client: + response = await client.get("/api/health", headers={"Origin": ORIGIN}) + self.assertEqual(200, response.status_code) + self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"]) + + @async_test + async def test_simple_request_with_disallowed_origin(self) -> None: + app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://127.0.0.1") as client: + response = await client.get( + "/api/health", headers={"Origin": "https://mallory.example"}) + self.assertEqual(200, response.status_code) + self.assertNotIn("access-control-allow-origin", response.headers) + + @async_test + async def test_preflight_allowed(self) -> None: + app = _app(_settings({ + "CORS_ALLOW_ORIGINS": ORIGIN, + "CORS_ALLOW_METHODS": "GET,POST", + "CORS_MAX_AGE": "3600", + })) + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://127.0.0.1") as client: + response = await client.options("/api/health", headers={ + "Origin": ORIGIN, + "Access-Control-Request-Method": "POST", + }) + self.assertEqual(200, response.status_code) + self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"]) + self.assertEqual("GET, POST", response.headers["access-control-allow-methods"]) + self.assertEqual("3600", response.headers["access-control-max-age"]) + + @async_test + async def test_preflight_disallowed_origin(self) -> None: + app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://127.0.0.1") as client: + response = await client.options("/api/health", headers={ + "Origin": "https://mallory.example", + "Access-Control-Request-Method": "GET", + }) + self.assertEqual(400, response.status_code) + self.assertIn("Disallowed CORS", response.text) + + @async_test + async def test_credentials_echo_origin_and_set_flag(self) -> None: + app = _app(_settings({ + "CORS_ALLOW_ORIGINS": "*", + "CORS_ALLOW_CREDENTIALS": "true", + })) + async with AsyncClient(transport=ASGITransport(app=app), + base_url="http://127.0.0.1") as client: + response = await client.get("/api/health", headers={"Origin": ORIGIN}) + self.assertEqual(200, response.status_code) + self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"]) + self.assertEqual("true", response.headers["access-control-allow-credentials"]) + + +if __name__ == "__main__": + unittest.main()