Configure CORS headers from environment variables
CI / Build and push docker image (push) Successful in 3m24s

This commit is contained in:
2026-09-18 19:18:27 +08:00
parent e2091ee4df
commit e0896b4a95
10 changed files with 313 additions and 5 deletions
+10
View File
@@ -61,6 +61,16 @@ data:
GAME_TTL_SECONDS: "86400" GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30" HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_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 (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: / OIDC_POST_LOGIN_REDIRECT: /
+9
View File
@@ -113,6 +113,15 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_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: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
+22
View File
@@ -39,6 +39,28 @@ TURN_TIMEOUT_SECONDS=30
# schema). Unset logs DEBUG to the console. # schema). Unset logs DEBUG to the console.
#LOGGING_CONFIG=/path/to/logging.yaml #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 server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+7
View File
@@ -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 | | `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) | | `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 | | `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 | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Logging ## Logging
+1
View File
@@ -10,6 +10,7 @@ readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"kaya-core", "kaya-core",
"kaya-cors",
"kaya-session", "kaya-session",
"kaya-session-redis", "kaya-session-redis",
"kaya-oidc", "kaya-oidc",
+3
View File
@@ -52,11 +52,14 @@ iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.3 kaya-core==0.0.3
# via # via
# kaya-cors
# kaya-oidc # kaya-oidc
# kaya-openapi # kaya-openapi
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
kaya-cors==0.0.3
# via tavolo (pyproject.toml)
kaya-oidc==0.0.3 kaya-oidc==0.0.3
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-openapi==0.0.3 kaya-openapi==0.0.3
+43 -4
View File
@@ -11,6 +11,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``) ``/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 Live games are kept in :data:`game_store` (Redis when configured, in-memory
otherwise). Routes and the websocket handlers are registered by importing otherwise). Routes and the websocket handlers are registered by importing
their modules at the bottom; imports must happen after ``app`` is built. 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 importlib.metadata import version as _pkg_version
from logging import getLogger 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.oidc import OIDCConfig, OIDCMixin
from kaya.openapi import OpenAPIMixin from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
from kaya.session.redis import RedisSessionStore from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis from redis.asyncio import Redis
from .config import settings from .config import Settings, settings
from .deadlines import DeadlineSchedulerMixin from .deadlines import DeadlineSchedulerMixin
from .logging_config import configure_logging from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore from .store import GameStore, InMemoryGameStore, RedisGameStore
@@ -36,6 +41,27 @@ from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config) configure_logging(settings.logging_config)
log = getLogger(__name__) 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 session_store: SessionStore
if settings.redis_url is not None: if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually # 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"}), skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
) )
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
DeadlineSchedulerMixin(game_store)]) 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( log.debug(
"timeouts: hand_ack=%ds turn=%ds", "timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds, settings.hand_ack_timeout_seconds,
+40 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
@@ -20,6 +20,25 @@ def _env(name: str, default: Optional[str] = None) -> str:
return value 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, def _database_url_from_parts(engine: str,
user: str, user: str,
password: Optional[str], password: Optional[str],
@@ -84,6 +103,16 @@ class Settings:
# Path to a YAML logging configuration file (logging.config.dictConfig # Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console. # schema). Unset uses the built-in default: DEBUG to the console.
logging_config: Optional[str] 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 @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -120,6 +149,16 @@ class Settings:
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")), turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")), deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
logging_config=os.environ.get("LOGGING_CONFIG"), 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")),
) )
+49
View File
@@ -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__": if __name__ == "__main__":
unittest.main() unittest.main()
+129
View File
@@ -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()