From bc2bc115b2692f1c4f3d878b1d5bbefcabb81a43 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 26 Jul 2026 15:40:45 -0500 Subject: [PATCH] Allow Capacitor WebView origins in CORS/CSRF (#22) Merge https://localhost and capacitor://localhost into CORS_ALLOWED_ORIGINS and CSRF_TRUSTED_ORIGINS so JWT API calls from native shells pass origin checks without cookie credentials. --- .env.prod.example | 1 + .../chat_backend/tests/test_settings_cors.py | 61 +++++++++++++++++++ llm_be/llm_be/settings.py | 39 +++++++++--- 3 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 llm_be/chat_backend/tests/test_settings_cors.py diff --git a/.env.prod.example b/.env.prod.example index 2ef674a..d7fa685 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -18,6 +18,7 @@ DJANGO_DEBUG=false DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_ALLOWED_HOSTS=chatbackend.aimloperations.com # Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS. +# Capacitor WebView origins (https://localhost, capacitor://localhost) are always merged in code. # DJANGO_CSRF_TRUSTED_ORIGINS=https://chatbackend.aimloperations.com,https://chat.aimloperations.com CORS_ALLOWED_ORIGINS=https://chat.aimloperations.com CORS_ORIGIN_ALLOW_ALL=false diff --git a/llm_be/chat_backend/tests/test_settings_cors.py b/llm_be/chat_backend/tests/test_settings_cors.py new file mode 100644 index 0000000..1bde2cb --- /dev/null +++ b/llm_be/chat_backend/tests/test_settings_cors.py @@ -0,0 +1,61 @@ +from django.test import SimpleTestCase + +from llm_be.settings import ( + CAPACITOR_WEBVIEW_ORIGINS, + build_csrf_trusted_origins, + with_capacitor_webview_origins, +) + + +class BuildCsrfTrustedOriginsTests(SimpleTestCase): + def test_derives_https_for_public_hosts(self): + origins = build_csrf_trusted_origins( + ["chat.aimloperations.com", "chatbackend.aimloperations.com"] + ) + self.assertEqual( + origins, + [ + "https://chat.aimloperations.com", + "https://chatbackend.aimloperations.com", + ], + ) + + def test_derives_http_for_localhost(self): + origins = build_csrf_trusted_origins(["localhost:8003", "127.0.0.1"]) + self.assertEqual( + origins, + ["http://localhost:8003", "http://127.0.0.1"], + ) + + def test_explicit_list_wins(self): + origins = build_csrf_trusted_origins( + ["ignored.example"], + ["https://chat.aimloperations.com"], + ) + self.assertEqual(origins, ["https://chat.aimloperations.com"]) + + +class CapacitorWebviewOriginTests(SimpleTestCase): + def test_appends_capacitor_origins(self): + merged = with_capacitor_webview_origins(["https://chat.aimloperations.com"]) + self.assertEqual(merged[0], "https://chat.aimloperations.com") + for origin in CAPACITOR_WEBVIEW_ORIGINS: + self.assertIn(origin, merged) + + def test_does_not_duplicate_existing(self): + seed = ["https://localhost", "https://chat.aimloperations.com"] + merged = with_capacitor_webview_origins(seed) + self.assertEqual(merged.count("https://localhost"), 1) + self.assertIn("capacitor://localhost", merged) + + def test_settings_include_capacitor_origins(self): + from django.conf import settings + + for origin in CAPACITOR_WEBVIEW_ORIGINS: + self.assertIn(origin, settings.CORS_ALLOWED_ORIGINS) + self.assertIn(origin, settings.CSRF_TRUSTED_ORIGINS) + + def test_cors_credentials_disabled_for_jwt_only(self): + from django.conf import settings + + self.assertFalse(settings.CORS_ALLOW_CREDENTIALS) diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index 619c82c..97370f0 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -74,11 +74,20 @@ def database_config() -> dict: } +# Capacitor WebView origins (Android https://localhost, iOS capacitor://localhost). +# Required so JWT API calls from native shells pass CORS/CSRF origin checks (#22). +CAPACITOR_WEBVIEW_ORIGINS = ( + "https://localhost", + "capacitor://localhost", + "http://localhost", +) + + def build_csrf_trusted_origins( allowed_hosts: list[str], explicit: list[str] | None = None ) -> list[str]: if explicit: - return explicit + return list(explicit) local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"} origins: list[str] = [] @@ -91,6 +100,17 @@ def build_csrf_trusted_origins( return origins +def with_capacitor_webview_origins(origins: list[str]) -> list[str]: + """Append Capacitor WebView origins without duplicates (order preserved).""" + seen = set(origins) + merged = list(origins) + for origin in CAPACITOR_WEBVIEW_ORIGINS: + if origin not in seen: + merged.append(origin) + seen.add(origin) + return merged + + DJANGO_ENV = (env("DJANGO_ENV", "dev") or "dev").lower() SECRET_KEY = env( @@ -106,16 +126,21 @@ allowed_hosts = env_list( ) ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] -CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins( - ALLOWED_HOSTS, - env_list("DJANGO_CSRF_TRUSTED_ORIGINS"), +CSRF_TRUSTED_ORIGINS = with_capacitor_webview_origins( + build_csrf_trusted_origins( + ALLOWED_HOSTS, + env_list("DJANGO_CSRF_TRUSTED_ORIGINS"), + ) ) +# JWT Authorization header only — no cookie credentials from the SPA/Capacitor shell. CORS_ALLOW_CREDENTIALS = False CORS_ORIGIN_ALLOW_ALL = env_bool("CORS_ORIGIN_ALLOW_ALL", True) -CORS_ALLOWED_ORIGINS = env_list( - "CORS_ALLOWED_ORIGINS", - "http://localhost:3000,http://127.0.0.1:3000,https://chat.aimloperations.com", +CORS_ALLOWED_ORIGINS = with_capacitor_webview_origins( + env_list( + "CORS_ALLOWED_ORIGINS", + "http://localhost:3000,http://127.0.0.1:3000,https://chat.aimloperations.com", + ) ) # Ollama — GPU host on LAN for deployed envs; loopback for local Ollama. -- 2.54.0