From 47cddcbeaf705be15352dc215b77433b75df2f2e Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:06:31 +0000 Subject: [PATCH] feat(security): add JWT startup guard, configurable CORS, and externalize DB credentials - Add check_jwt_secret() that refuses default JWT secret when APP_ENV != development - Make CORS origins configurable via CORS_ORIGINS env var (comma-separated) - Replace hardcoded postgres credentials in docker-compose.yml with env var references - Add APP_ENV and cors_origins to config.py - Update .env.example with all required variables and documentation - Add tests for JWT startup guard and CORS configuration Closes leeworks-agents/SPARC#4 Closes leeworks-agents/SPARC#5 Closes leeworks-agents/SPARC#6 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 39 ++++++++++---- SPARC/api.py | 4 +- SPARC/auth.py | 16 +++++- SPARC/config.py | 13 +++++ docker-compose.yml | 14 ++--- tests/test_security.py | 116 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 tests/test_security.py diff --git a/.env.example b/.env.example index acf4901..4e78c43 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,42 @@ # SPARC Configuration +# ---- Application Environment ---- +# Set to "production" or "staging" in deployed environments. +# The API will refuse to start with the default JWT secret unless APP_ENV=development. +APP_ENV=development + +# ---- API Keys ---- + # SerpAPI key for patent search API_KEY=your_serpapi_key_here # OpenRouter API key for LLM analysis OPENROUTER_API_KEY=your_openrouter_key_here -# Database configuration -# All messages are stored in the database for persistence and caching -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/sparc +# ---- Database ---- -# Cache configuration -# When USE_CACHE=true: check database for cached responses before making API calls -# When USE_CACHE=false: always make fresh API calls (still stores results in database) -# Default: true -USE_CACHE=true +# PostgreSQL credentials (used by docker-compose) +POSTGRES_USER=postgres +POSTGRES_PASSWORD=change-me-to-a-secure-password +POSTGRES_DB=sparc -# JWT Secret for authentication +# Full database URL (must match the credentials above) +DATABASE_URL=postgresql://postgres:change-me-to-a-secure-password@localhost:5432/sparc + +# ---- Authentication ---- + +# JWT Secret for signing tokens # IMPORTANT: Change this to a secure random string in production JWT_SECRET=your-secure-jwt-secret-change-in-production + +# ---- CORS ---- + +# Comma-separated list of allowed origins for CORS +# Defaults to http://localhost:3000,http://localhost:5173 when unset +# CORS_ORIGINS=https://sparc.example.com,https://app.example.com + +# ---- Cache ---- + +# When USE_CACHE=true: check database for cached responses before making API calls +# When USE_CACHE=false: always make fresh API calls (still stores results in database) +USE_CACHE=true diff --git a/SPARC/api.py b/SPARC/api.py index 482caab..23d2f2b 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -16,6 +16,7 @@ from SPARC.analyzer import CompanyAnalyzer from SPARC.auth import ( TokenResponse, UserResponse, + check_jwt_secret, create_tokens, decode_token, get_current_admin, @@ -150,6 +151,7 @@ _analyzer: CompanyAnalyzer | None = None async def lifespan(app: FastAPI): """Initialize resources on startup.""" global _analyzer + check_jwt_secret() _analyzer = CompanyAnalyzer() yield # Cleanup if needed @@ -167,7 +169,7 @@ app = FastAPI( # Add CORS middleware for React frontend app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://localhost:5173"], + allow_origins=config.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/SPARC/auth.py b/SPARC/auth.py index 4a5a28f..d134ad8 100644 --- a/SPARC/auth.py +++ b/SPARC/auth.py @@ -13,11 +13,25 @@ from SPARC import config from SPARC.database import DatabaseClient # JWT Configuration -JWT_SECRET = os.getenv("JWT_SECRET", "sparc-secret-key-change-in-production") +_DEFAULT_JWT_SECRET = "sparc-secret-key-change-in-production" +JWT_SECRET = os.getenv("JWT_SECRET", _DEFAULT_JWT_SECRET) JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_DAYS = 7 + +def check_jwt_secret() -> None: + """Refuse to start with the default JWT secret in non-development environments. + + Raises: + RuntimeError: If JWT_SECRET is the default value and APP_ENV is not 'development'. + """ + if JWT_SECRET == _DEFAULT_JWT_SECRET and config.app_env != "development": + raise RuntimeError( + f"FATAL: JWT_SECRET is set to the default value and APP_ENV={config.app_env!r}. " + "Set a secure JWT_SECRET environment variable before running in non-development environments." + ) + security = HTTPBearer() diff --git a/SPARC/config.py b/SPARC/config.py index 31bee7a..2bedc63 100644 --- a/SPARC/config.py +++ b/SPARC/config.py @@ -33,3 +33,16 @@ patent_thread_workers = int(os.getenv("PATENT_THREAD_WORKERS", "5")) # Root path for running behind a reverse proxy (e.g., "/api" when served at /api/) # This ensures OpenAPI docs work correctly when accessed via the proxy root_path = os.getenv("ROOT_PATH", "") + +# Application environment: "development", "staging", or "production" +# Used for safety checks (e.g., refusing default JWT secret in production) +app_env = os.getenv("APP_ENV", "development") + +# CORS allowed origins (comma-separated) +# Defaults to localhost dev origins when unset +_cors_origins_raw = os.getenv("CORS_ORIGINS", "") +cors_origins: list[str] = ( + [o.strip() for o in _cors_origins_raw.split(",") if o.strip()] + if _cors_origins_raw + else ["http://localhost:3000", "http://localhost:5173"] +) diff --git a/docker-compose.yml b/docker-compose.yml index 7bbdbe2..fa42f8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,15 +3,15 @@ services: image: postgres:16-alpine container_name: sparc-postgres environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: sparc + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: 5s timeout: 5s retries: 5 @@ -22,7 +22,7 @@ services: container_name: sparc-init-db command: python scripts/init_database.py environment: - DATABASE_URL: postgresql://postgres:postgres@postgres:5432/sparc + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} depends_on: postgres: condition: service_healthy @@ -35,9 +35,11 @@ services: environment: API_KEY: ${API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} - DATABASE_URL: postgresql://postgres:postgres@postgres:5432/sparc + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} USE_CACHE: "true" JWT_SECRET: ${JWT_SECRET:-sparc-secret-key-change-in-production} + CORS_ORIGINS: ${CORS_ORIGINS:-} + APP_ENV: ${APP_ENV:-development} ROOT_PATH: /api ports: - "8000:8000" diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..b6e4be1 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,116 @@ +"""Tests for security hardening: JWT secret startup check, CORS config, credential handling.""" + +import os +from unittest.mock import patch + +import pytest + + +class TestJWTSecretStartupCheck: + """Test the startup guard that refuses default JWT secret in non-dev environments.""" + + def test_default_secret_in_production_raises(self): + """Starting with default secret and APP_ENV=production must raise RuntimeError.""" + with patch.dict(os.environ, {"APP_ENV": "production"}): + # Reload config to pick up the new APP_ENV + import importlib + import SPARC.config + importlib.reload(SPARC.config) + + from SPARC.auth import _DEFAULT_JWT_SECRET, check_jwt_secret + # Patch JWT_SECRET to the default + with patch("SPARC.auth.JWT_SECRET", _DEFAULT_JWT_SECRET): + with pytest.raises(RuntimeError, match="FATAL.*JWT_SECRET"): + check_jwt_secret() + + # Restore config + with patch.dict(os.environ, {"APP_ENV": "development"}): + importlib.reload(SPARC.config) + + def test_default_secret_in_development_succeeds(self): + """Starting with default secret and APP_ENV=development must not raise.""" + with patch.dict(os.environ, {"APP_ENV": "development"}): + import importlib + import SPARC.config + importlib.reload(SPARC.config) + + from SPARC.auth import _DEFAULT_JWT_SECRET, check_jwt_secret + with patch("SPARC.auth.JWT_SECRET", _DEFAULT_JWT_SECRET): + # Should not raise + check_jwt_secret() + + # Restore + importlib.reload(SPARC.config) + + def test_custom_secret_in_production_succeeds(self): + """Starting with a custom secret in production must not raise.""" + with patch.dict(os.environ, {"APP_ENV": "production"}): + import importlib + import SPARC.config + importlib.reload(SPARC.config) + + from SPARC.auth import check_jwt_secret + with patch("SPARC.auth.JWT_SECRET", "my-secure-random-secret-abc123"): + # Should not raise + check_jwt_secret() + + with patch.dict(os.environ, {"APP_ENV": "development"}): + importlib.reload(SPARC.config) + + def test_default_secret_unset_env_succeeds(self): + """When APP_ENV is unset (defaults to development), default secret is allowed.""" + with patch.dict(os.environ, {}, clear=False): + # Remove APP_ENV if present + env = os.environ.copy() + env.pop("APP_ENV", None) + with patch.dict(os.environ, env, clear=True): + import importlib + import SPARC.config + importlib.reload(SPARC.config) + + from SPARC.auth import _DEFAULT_JWT_SECRET, check_jwt_secret + with patch("SPARC.auth.JWT_SECRET", _DEFAULT_JWT_SECRET): + # Should not raise (defaults to development) + check_jwt_secret() + + with patch.dict(os.environ, {"APP_ENV": "development"}): + importlib.reload(SPARC.config) + + +class TestCORSConfig: + """Test that CORS origins are configurable via environment variable.""" + + def test_default_cors_origins(self): + """When CORS_ORIGINS is unset, defaults to localhost origins.""" + with patch.dict(os.environ, {"CORS_ORIGINS": ""}): + import importlib + import SPARC.config + importlib.reload(SPARC.config) + assert SPARC.config.cors_origins == [ + "http://localhost:3000", + "http://localhost:5173", + ] + + def test_custom_cors_origins(self): + """Setting CORS_ORIGINS configures allowed origins.""" + with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}): + import importlib + import SPARC.config + importlib.reload(SPARC.config) + assert SPARC.config.cors_origins == [ + "https://sparc.example.com", + "https://app.example.com", + ] + # Restore + with patch.dict(os.environ, {"CORS_ORIGINS": ""}): + importlib.reload(SPARC.config) + + def test_single_cors_origin(self): + """A single origin without comma works correctly.""" + with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}): + import importlib + import SPARC.config + importlib.reload(SPARC.config) + assert SPARC.config.cors_origins == ["https://sparc.example.com"] + with patch.dict(os.environ, {"CORS_ORIGINS": ""}): + importlib.reload(SPARC.config)