From 0c7d0576548f65f3841cd0c3bc85ce33238d8f56 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 15:38:54 +0800 Subject: [PATCH 1/8] Auto-derive COOKIE_SECURE from ENVIRONMENT setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COOKIE_SECURE now defaults to None and auto-derives from ENVIRONMENT (production → true, else false) via a Pydantic model_validator. Explicit env var values are still respected as an override escape hatch. Adds a startup log line showing the resolved value. Restructures .env.example with clear sections and inline docs, removes redundant production checklist block. Co-Authored-By: Claude Opus 4.6 --- .env.example | 34 ++++++++++++++++++---------------- README.md | 8 ++------ backend/app/config.py | 15 ++++++++++++++- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index ce2fbff..1dff5c1 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,34 @@ +# ────────────────────────────────────── # Database +# ────────────────────────────────────── POSTGRES_USER=umbra POSTGRES_PASSWORD=changeme_in_production POSTGRES_DB=umbra - -# Backend DATABASE_URL=postgresql+asyncpg://umbra:changeme_in_production@db:5432/umbra + +# ────────────────────────────────────── +# Application +# ────────────────────────────────────── +# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=change-this-to-a-random-secret-key-in-production -# Environment (development|production — controls Swagger/ReDoc visibility) -# ENVIRONMENT=development +# development | production — controls Swagger/ReDoc visibility and cookie defaults +ENVIRONMENT=development # CORS allowed origins (comma-separated, default: http://localhost:5173) -# CORS_ORIGINS=http://localhost:5173 +# CORS_ORIGINS=https://umbra.example.com # Timezone (applied to backend + db containers via env_file) TZ=Australia/Perth -# Session cookie security -# Set to true when serving over HTTPS. Required before any TLS deployment. -# COOKIE_SECURE=true - +# ────────────────────────────────────── # Integrations +# ────────────────────────────────────── OPENWEATHERMAP_API_KEY=your-openweathermap-api-key -# Production security checklist (enable all before any non-internal deployment): -# 1. Set SECRET_KEY to output of: openssl rand -hex 32 -# 2. Set POSTGRES_PASSWORD to a strong unique value -# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000) -# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream) -# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; -# 6. Complete user_id migration (migration 026) before enabling multi-user accounts +# ────────────────────────────────────── +# Overrides (rarely needed) +# ────────────────────────────────────── +# COOKIE_SECURE auto-derives from ENVIRONMENT (production → true). +# Only set explicitly to override, e.g. false for a non-TLS prod behind a proxy. +# COOKIE_SECURE=false diff --git a/README.md b/README.md index 01d27ac..db33f82 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,13 @@ python3 -c "import secrets; print(secrets.token_hex(32))" python3 -c "import secrets; print(secrets.token_urlsafe(24))" # or: openssl rand -base64 24 -# Set ENVIRONMENT to disable Swagger/ReDoc +# Set ENVIRONMENT to disable Swagger/ReDoc and auto-enable secure cookies ENVIRONMENT=production - -# Enable secure cookies (requires HTTPS) -COOKIE_SECURE=true ``` Additionally for production: - Place behind a reverse proxy with TLS termination (e.g., Caddy, Traefik, or nginx with Let's Encrypt) -- Set `COOKIE_SECURE=true` to enforce HTTPS-only session cookies -- Set `ENVIRONMENT=production` to disable API documentation endpoints +- Set `ENVIRONMENT=production` — this disables API docs and auto-enables HTTPS-only session cookies (`COOKIE_SECURE` derives from `ENVIRONMENT`; override with `COOKIE_SECURE=false` if running non-TLS prod behind a proxy) - Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`) - Consider adding HSTS headers at the TLS-terminating proxy layer diff --git a/backend/app/config.py b/backend/app/config.py index ca01e9c..73846f9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ import sys +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -6,7 +7,7 @@ class Settings(BaseSettings): DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra" SECRET_KEY: str = "your-secret-key-change-in-production" ENVIRONMENT: str = "development" - COOKIE_SECURE: bool = False + COOKIE_SECURE: bool | None = None # Auto-derives from ENVIRONMENT if not explicitly set OPENWEATHERMAP_API_KEY: str = "" # Session config — sliding window @@ -28,8 +29,20 @@ class Settings(BaseSettings): case_sensitive=True ) + @model_validator(mode="after") + def derive_cookie_secure(self) -> "Settings": + if self.COOKIE_SECURE is None: + self.COOKIE_SECURE = self.ENVIRONMENT == "production" + assert self.COOKIE_SECURE is not None # type narrowing + return self + settings = Settings() +print( + f"INFO: COOKIE_SECURE={settings.COOKIE_SECURE} " + f"(ENVIRONMENT={settings.ENVIRONMENT})", + file=sys.stderr, +) if settings.SECRET_KEY == "your-secret-key-change-in-production": if settings.ENVIRONMENT != "development": From fee454fc3347ee7252e5ef443713e8038ce871c3 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:17:39 +0800 Subject: [PATCH 2/8] Fix 503s behind reverse proxy: add uvicorn --proxy-headers FastAPI trailing-slash redirects (307) were using http:// instead of https:// because uvicorn wasn't reading X-Forwarded-Proto from the reverse proxy. When Pangolin (TLS-terminating proxy) received the http:// redirect it returned 503, breaking all list endpoints (/events, /calendars, /settings, /projects, /people, /locations). Adding --proxy-headers makes uvicorn honour X-Forwarded-Proto so redirects use the correct scheme. --forwarded-allow-ips '*' trusts headers from any IP since nginx sits on the Docker bridge network. Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index a278604..60f2692 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,5 +22,8 @@ USER appuser # Expose port EXPOSE 8000 -# Run migrations and start server (--no-server-header suppresses uvicorn version disclosure) -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header"] +# Run migrations and start server +# --no-server-header: suppresses uvicorn version disclosure +# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme +# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network) +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"] From ccfc5e151a531c40dbfbbc8f9fc68bc2f61468fc Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:21:32 +0800 Subject: [PATCH 3/8] Fix SECRET_KEY sentinel mismatch in .env.example (W-01) The .env.example value didn't match the sentinel checked in config.py, so copying .env.example verbatim to production would bypass the fatal safety exit. Aligned to use the same default string. Co-Authored-By: Claude Opus 4.6 --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 1dff5c1..88cb09c 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ DATABASE_URL=postgresql+asyncpg://umbra:changeme_in_production@db:5432/umbra # Application # ────────────────────────────────────── # Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" -SECRET_KEY=change-this-to-a-random-secret-key-in-production +SECRET_KEY=your-secret-key-change-in-production # development | production — controls Swagger/ReDoc visibility and cookie defaults ENVIRONMENT=development From 7721bf5cecf6fd10cf5029112b54c4bc14eba875 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:43:12 +0800 Subject: [PATCH 4/8] Harden nginx: real client IP, HSTS, custom dotfile 404 (PT-01/02/04) PT-01: Add set_real_ip_from/real_ip_header/real_ip_recursive to restore real client IP from X-Forwarded-For. Rate limiting now keys on actual client IP instead of the Pangolin proxy IP. PT-02: Add Strict-Transport-Security header (max-age 1 year) to both the server block and static assets block. PT-04: Replace bare 404 on dotfile requests with JSON response to suppress nginx server identity disclosure in error pages. Co-Authored-By: Claude Opus 4.6 --- frontend/nginx.conf | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 1e8bbd8..3a39185 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -21,15 +21,26 @@ server { # Suppress nginx version in Server header server_tokens off; + # ── Real client IP restoration (PT-01) ──────────────────────────── + # Pangolin (TLS-terminating reverse proxy) connects via Docker bridge. + # Restore the real client IP from X-Forwarded-For so that limit_req_zone + # (which keys on $binary_remote_addr) throttles per-client, not per-proxy. + # Safe to trust all sources: nginx is only reachable via Docker networking, + # never directly internet-facing. Tighten if deployment model changes. + set_real_ip_from 0.0.0.0/0; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + # Gzip compression gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; - # Block dotfiles (except .well-known for ACME/Let's Encrypt) + # Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04) location ~ /\.(?!well-known) { - return 404; + default_type application/json; + return 404 '{"detail":"Not Found"}'; } # Rate-limited auth endpoints (keep in sync with proxy-params.conf) @@ -104,6 +115,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; } # Security headers @@ -111,4 +123,5 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; } From ab7e4a7c7ed0f89e3e6bbd7942942ba8381a57ce Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:43:27 +0800 Subject: [PATCH 5/8] Backend pentest remediation (PT-03/05/06/07) PT-03: Make UMBRA_URL configurable via env var (default http://localhost). Replaces hardcoded http://10.0.69.35 in notification dispatch job and ntfy test endpoint. Add UMBRA_URL to .env.example. PT-05: Add explicit path="/" to session cookie for clarity. PT-06: Add concurrent session limit (MAX_SESSIONS_PER_USER, default 10). When exceeded, oldest sessions are revoked. New login always succeeds. PT-07: Escape LIKE metacharacters (%, _) in admin audit log action filter to prevent wildcard abuse. Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 +++ backend/app/config.py | 6 ++++++ backend/app/jobs/notifications.py | 10 +++++----- backend/app/routers/admin.py | 4 +++- backend/app/routers/auth.py | 21 +++++++++++++++++++++ backend/app/routers/settings.py | 3 ++- 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 88cb09c..68add17 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ ENVIRONMENT=development # CORS allowed origins (comma-separated, default: http://localhost:5173) # CORS_ORIGINS=https://umbra.example.com +# Public URL for ntfy notification click links (default: http://localhost) +# UMBRA_URL=https://umbra.example.com + # Timezone (applied to backend + db containers via env_file) TZ=Australia/Perth diff --git a/backend/app/config.py b/backend/app/config.py index 73846f9..fd72db5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,6 +23,12 @@ class Settings(BaseSettings): # CORS allowed origins (comma-separated) CORS_ORIGINS: str = "http://localhost:5173" + # Public-facing URL used in ntfy notification click links + UMBRA_URL: str = "http://localhost" + + # Concurrent session limit per user (oldest evicted when exceeded) + MAX_SESSIONS_PER_USER: int = 10 + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index cc97c35..be463f4 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -35,7 +35,7 @@ from app.services.ntfy_templates import ( logger = logging.getLogger(__name__) -UMBRA_URL = "http://10.0.69.35" +from app.config import settings as app_settings # ── Dedup helpers ───────────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: @@ -144,7 +144,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: @@ -184,7 +184,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: @@ -223,7 +223,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_k ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 78f1906..b355344 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -770,7 +770,9 @@ async def get_audit_log( ) if action: - base_q = base_q.where(AuditLog.action.like(f"{action}%")) + # Escape LIKE metacharacters so user input is treated literally + safe_action = action.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + base_q = base_q.where(AuditLog.action.like(f"{safe_action}%", escape="\\")) if target_user_id is not None: base_q = base_q.where(AuditLog.target_user_id == target_user_id) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 72cdbbc..b982e48 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -66,6 +66,7 @@ def _set_session_cookie(response: Response, token: str) -> None: secure=app_settings.COOKIE_SECURE, max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400, samesite="lax", + path="/", ) @@ -219,6 +220,26 @@ async def _create_db_session( ) db.add(db_session) await db.flush() + + # Enforce concurrent session limit: revoke oldest sessions beyond the cap + active_sessions = ( + await db.execute( + select(UserSession) + .where( + UserSession.user_id == user.id, + UserSession.revoked == False, # noqa: E712 + UserSession.expires_at > datetime.now(), + ) + .order_by(UserSession.created_at.asc()) + ) + ).scalars().all() + + max_sessions = app_settings.MAX_SESSIONS_PER_USER + if len(active_sessions) > max_sessions: + for old_session in active_sessions[: len(active_sessions) - max_sessions]: + old_session.revoked = True + await db.flush() + token = create_session_token(user.id, session_id) return session_id, token diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 6ac79a3..afba67d 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from app.config import settings as app_settings from app.models.settings import Settings from app.models.user import User from app.schemas.settings import SettingsUpdate, SettingsResponse @@ -108,7 +109,7 @@ async def test_ntfy( message="If you see this, your ntfy integration is working correctly.", tags=["white_check_mark"], priority=3, - click_url="http://10.0.69.35", + click_url=app_settings.UMBRA_URL, ) if not success: From c986028f51326c1f24bc8603e886f0f1c4536d34 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:43:43 +0800 Subject: [PATCH 6/8] Multi-stage Dockerfile: remove gcc/psql from runtime image (PT-11) Convert to two-stage build: builder stage installs gcc and compiles Python C extensions, runtime stage copies only the installed packages. Removes gcc and postgresql-client from the production image, reducing attack surface. postgresql-client was unused (healthchecks use urllib). Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 60f2692..c80a749 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,16 +1,22 @@ +# ── Build stage: compile C extensions ────────────────────────────────── +FROM python:3.12-slim AS builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ── Runtime stage: lean production image ─────────────────────────────── FROM python:3.12-slim WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Copy pre-built Python packages from builder +COPY --from=builder /install /usr/local # Copy application code COPY . . @@ -19,7 +25,6 @@ COPY . . RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser -# Expose port EXPOSE 8000 # Run migrations and start server From cad1ca00c7cf0aaaf158e7ca8c780abfc40b5ebe Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:46:39 +0800 Subject: [PATCH 7/8] Fix SECRET_KEY sentinel in backend/.env.example Align with config.py check so the fatal safety exit triggers correctly if this file is used verbatim in production. Co-Authored-By: Claude Opus 4.6 --- backend/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index 0f23dea..396bfaa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,2 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra -SECRET_KEY=your-secret-key-change-in-production-use-a-long-random-string +SECRET_KEY=your-secret-key-change-in-production From dadd19bc3058d7b3c08640498cd2b067a9923d28 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 17:53:26 +0800 Subject: [PATCH 8/8] Auto-derive CORS_ORIGINS from UMBRA_URL in production In production, CORS_ORIGINS now defaults to UMBRA_URL so deployers only need to set the external URL once. In development it defaults to http://localhost:5173 (Vite dev server). Explicit CORS_ORIGINS env var is still respected as an override for multi-origin or custom setups. This means a production .env only needs: ENVIRONMENT, SECRET_KEY, UMBRA_URL, and DB credentials. COOKIE_SECURE and CORS_ORIGINS both auto-derive. Co-Authored-By: Claude Opus 4.6 --- .env.example | 9 +++++---- backend/app/config.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 68add17..9ac77b4 100644 --- a/.env.example +++ b/.env.example @@ -15,10 +15,7 @@ SECRET_KEY=your-secret-key-change-in-production # development | production — controls Swagger/ReDoc visibility and cookie defaults ENVIRONMENT=development -# CORS allowed origins (comma-separated, default: http://localhost:5173) -# CORS_ORIGINS=https://umbra.example.com - -# Public URL for ntfy notification click links (default: http://localhost) +# Public URL — used for ntfy click links and auto-derives CORS_ORIGINS in production # UMBRA_URL=https://umbra.example.com # Timezone (applied to backend + db containers via env_file) @@ -35,3 +32,7 @@ OPENWEATHERMAP_API_KEY=your-openweathermap-api-key # COOKIE_SECURE auto-derives from ENVIRONMENT (production → true). # Only set explicitly to override, e.g. false for a non-TLS prod behind a proxy. # COOKIE_SECURE=false + +# CORS_ORIGINS auto-derives from UMBRA_URL in production, http://localhost:5173 in dev. +# Only set explicitly if you need a different origin or multiple origins. +# CORS_ORIGINS=https://custom-domain.example.com diff --git a/backend/app/config.py b/backend/app/config.py index fd72db5..592af82 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -20,10 +20,11 @@ class Settings(BaseSettings): # TOTP issuer name shown in authenticator apps TOTP_ISSUER: str = "UMBRA" - # CORS allowed origins (comma-separated) - CORS_ORIGINS: str = "http://localhost:5173" + # CORS allowed origins (comma-separated). Auto-derives from UMBRA_URL in + # production or http://localhost:5173 in development. Set explicitly to override. + CORS_ORIGINS: str | None = None - # Public-facing URL used in ntfy notification click links + # Public-facing URL used in ntfy notification click links and CORS derivation UMBRA_URL: str = "http://localhost" # Concurrent session limit per user (oldest evicted when exceeded) @@ -36,10 +37,16 @@ class Settings(BaseSettings): ) @model_validator(mode="after") - def derive_cookie_secure(self) -> "Settings": + def derive_defaults(self) -> "Settings": if self.COOKIE_SECURE is None: self.COOKIE_SECURE = self.ENVIRONMENT == "production" + if self.CORS_ORIGINS is None: + if self.ENVIRONMENT == "production": + self.CORS_ORIGINS = self.UMBRA_URL + else: + self.CORS_ORIGINS = "http://localhost:5173" assert self.COOKIE_SECURE is not None # type narrowing + assert self.CORS_ORIGINS is not None return self