From 9c62549b507590f909c699572fe582834ad28a9b Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 6 Jul 2026 17:53:49 +0200 Subject: [PATCH] fix(rmi-backend,audit): jwt_secret is required, fail-fast in prod (P1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously: jwt_secret defaulted to dev-secret-CHANGE-ME so a missing env var in production would silently boot with a known-public dev HMAC key — an instant auth bypass for any service verifying JWTs. Now: - Field(...) with no default → missing env raises ValidationError at boot - min_length=32 enforces minimum entropy (rejects dev placeholder too) - field_validator rejects the literal dev-secret-CHANGE-ME in ENVIRONMENT=prod as belt-and-suspenders in case min_length is loosened later Verified behavior: - no env, no .env -> ValidationError: Field required - ENVIRONMENT=prod, no JWT_SECRET -> ValidationError: Field required - prod, JWT_SECRET=dev-secret-... -> ValidationError: string_too_short - prod, JWT_SECRET=short -> ValidationError: string_too_short - prod, JWT_SECRET=<64 hex> -> OK, len=64 .env.example updated to show the placeholder + generation hint. Refs AUDIT-2026-Q3.md P1.5. --- .env.example | 2 +- app/config.py | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 37c3274..0187a28 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ DATABASE_URL=postgresql+asyncpg://rmi:rmi@localhost/rmi REDIS_URL=redis://localhost:6379/0 # ── Auth ──────────────────────────────────────────────────────── -JWT_SECRET=dev-secret-CHANGE-ME +JWT_SECRET= # MANDATORY in production — generate with: openssl rand -hex 32 JWT_ALGORITHM=HS256 JWT_EXPIRE_MINUTES=1440 diff --git a/app/config.py b/app/config.py index f14fde7..d3cf8df 100644 --- a/app/config.py +++ b/app/config.py @@ -6,10 +6,11 @@ Import: `from app.config import settings` from __future__ import annotations +import os from functools import lru_cache from typing import Literal -from pydantic import Field +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -33,7 +34,14 @@ class Settings(BaseSettings): redis_url: str = "redis://localhost:6379/0" # ── Auth ──────────────────────────────────────────────────────── - jwt_secret: str = Field(default="dev-secret-CHANGE-ME") + jwt_secret: str = Field( # required, no default, fail-fast in prod + min_length=32, + description=( + "HMAC secret for JWT. Required. Must be >= 32 chars. " + "Generate with: openssl rand -hex 32. " + "Set via gopass (rmi/prod/jwt_secret) or JWT_SECRET env var." + ), + ) jwt_algorithm: str = "HS256" jwt_expire_minutes: int = 60 * 24 @@ -68,6 +76,21 @@ class Settings(BaseSettings): "phishing_db", ] + @field_validator("jwt_secret") + @classmethod + def _jwt_secret_must_be_set_in_prod(cls, v: str) -> str: + """Reject the dev default in production. + + Pydantic-settings binds v from env (or rejects via min_length above). + Here we only enforce the dev placeholder cannot leak into a prod boot. + """ + if v == "dev-secret-CHANGE-ME" and os.getenv("ENVIRONMENT", "dev") == "prod": + raise ValueError( + "jwt_secret must be set to a non-default value in production. " + "Set via gopass (rmi/prod/jwt_secret) or JWT_SECRET env var." + ) + return v + @lru_cache(maxsize=1) def get_settings() -> Settings: