Skip to content

fix(security): startup config validation + fail-closed unsigned-session fallback (#174)#228

Merged
Jose-Gael-Cruz-Lopez merged 1 commit into
mainfrom
security/174-config-validation-failclosed
Jun 14, 2026
Merged

fix(security): startup config validation + fail-closed unsigned-session fallback (#174)#228
Jose-Gael-Cruz-Lopez merged 1 commit into
mainfrom
security/174-config-validation-failclosed

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez commented Jun 13, 2026

Copy link
Copy Markdown
Member

Closes #174. Labeled P2 but load-bearing: the whole security model rests on session-validated route code, so a forgeable/unsigned session undermines all of it (underscored by the realtime RLS finding).

Problem

config.py read every required secret with an empty-string default and validated none. The app booted with everything unset and failed opaquely later:

  • "" SUPABASE_URL → malformed REST URL on the first DB call;
  • missing GEMINI_API_KEY → surfaces only mid-request;
  • empty SESSION_SECRET → routes/auth.py silently switched OAuth state to an unsigned in-memory fallback — an insecure degradation rather than a hard failure.

Fix

  • validate_config(), called from the FastAPI lifespan: raises one clear error naming every missing key. SUPABASE_URL/SUPABASE_SERVICE_KEY/GEMINI_API_KEY always required; SESSION_SECRET required outside local.
  • APP_ENV (defaults to production) / IS_LOCAL — fail-closed by default: a deployment that sets nothing gets the strict checks. Set APP_ENV=local to relax SESSION_SECRET for local dev.
  • Unsigned OAuth-state fallback now raises outside local (defense in depth at the use site) → unreachable in production independent of startup validation.

Tests

tests/test_config_validation_failclosed.py: validate_config names all missing keys, requires SESSION_SECRET outside local, relaxes it in local; the unsigned fallback raises in production (fails pre-fix, which silently used it). Updated the two pre-existing fallback round-trip tests in test_auth_state.py to declare local mode (the fallback is now local-only). Verified the lifespan storage-bucket test still passes (test env supplies all secrets).

⚠️ Auth-boundary / startup change — do not merge, opened for your review.

Summary by CodeRabbit

  • New Features

    • Added startup-time configuration validation with a single, clear error that lists all missing required settings before the service starts.
    • Introduced environment-mode switching to relax local-only requirements while keeping stricter checks elsewhere.
    • Improved OAuth state handling to fail closed (no unsigned fallback cookies) outside local dev.
  • Tests

    • Expanded automated coverage for configuration validation, environment-specific secret rules, and OAuth cookie fail-closed behavior.

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ee88e5a-a06b-4c75-8efb-2a2620c02472

📥 Commits

Reviewing files that changed from the base of the PR and between 7d02a68 and 9a456e5.

📒 Files selected for processing (6)
  • backend/config.py
  • backend/main.py
  • backend/routes/auth.py
  • backend/tests/conftest.py
  • backend/tests/test_auth_state.py
  • backend/tests/test_config_validation_failclosed.py
✅ Files skipped from review due to trivial changes (1)
  • backend/tests/conftest.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/main.py
  • backend/config.py

📝 Walkthrough

Walkthrough

This PR hardens application startup by adding environment validation and fail-closed OAuth security. It introduces APP_ENV and IS_LOCAL flags, implements validate_config() to check required secrets before requests are served, calls this validation in FastAPI's lifespan hook, and guards OAuth cookie encoding and decoding to prevent unsigned token fallback in production.

Changes

Startup Configuration Validation and Auth Security Hardening

Layer / File(s) Summary
Configuration validation foundation
backend/config.py
Defines APP_ENV (from environment, normalized to lowercase, default "production") and derives IS_LOCAL boolean. Implements validate_config() that collects missing required keys (SUPABASE_URL, SUPABASE_SERVICE_KEY, GEMINI_API_KEY, and SESSION_SECRET except in local mode) and raises a single RuntimeError listing all missing keys with an APP_ENV hint.
Startup integration
backend/main.py
Imports validate_config and calls it at the start of the FastAPI lifespan, adding upfront configuration validation before external resources are bootstrapped.
Fail-closed OAuth guards
backend/routes/auth.py
Imports IS_LOCAL and adds runtime checks in both _encode_oauth_cookie() and _decode_oauth_cookie() that raise or reject unsigned tokens when SESSION_SECRET is missing and the environment is not local, preventing silent fallback to unsigned tokens in production.
Test coverage
backend/tests/conftest.py, backend/tests/test_auth_state.py, backend/tests/test_config_validation_failclosed.py
Sets APP_ENV to "test" in test configuration. Updates existing auth state tests to explicitly set IS_LOCAL = True when testing in-memory fallback scenarios. Adds comprehensive regression test suite with TestValidateConfig (success/failure/conditional requirement and strength validation cases) and TestUnsignedOAuthFallbackFailsClosed (fail-closed enforcement and signed-cookie validation for both encoding and decoding).

Sequence Diagram: Startup Validation Flow

sequenceDiagram
  participant FastAPI
  participant validate_config
  participant ConfigVars as CONFIG VARS
  FastAPI->>validate_config: Call at lifespan start
  validate_config->>ConfigVars: Read SUPABASE_URL
  validate_config->>ConfigVars: Read SUPABASE_SERVICE_KEY
  validate_config->>ConfigVars: Read GEMINI_API_KEY
  validate_config->>ConfigVars: Read SESSION_SECRET (if not IS_LOCAL)
  alt All required vars present
    validate_config-->>FastAPI: Success
    FastAPI->>FastAPI: Continue initialization
  else Missing required vars
    validate_config-->>FastAPI: RuntimeError (list all missing)
    FastAPI->>FastAPI: Abort startup
  end
Loading

Sequence Diagram: OAuth Cookie Encoding and Decoding Guard

sequenceDiagram
  participant OAuth as OAuth Handler
  participant encode as _encode_oauth_cookie
  participant decode as _decode_oauth_cookie
  participant check as Secret Check
  OAuth->>encode: Encode state cookie
  encode->>check: SESSION_SECRET and IS_LOCAL?
  alt SESSION_SECRET present
    check-->>encode: Use HMAC signing
    encode-->>OAuth: Signed cookie (contains .)
  else IS_LOCAL = True
    check-->>encode: Use in-memory fallback
    encode-->>OAuth: Unsigned cookie
  else Production mode, no secret
    check-->>encode: Raise RuntimeError
    encode-->>OAuth: Fail-closed
  end
  OAuth->>decode: Decode received cookie
  decode->>check: Verify cookie signature
  alt Cookie is signed or IS_LOCAL
    check-->>decode: Accept cookie
    decode-->>OAuth: State nonce
  else Unsigned in production
    check-->>decode: Reject
    decode-->>OAuth: Return None
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops through startup code,
Checking secrets on the road—
If SESSION_SECRET goes missing today,
In production, we fail-closed the OAuth way!
Local dev gets a fallback pass,
But prod won't silently degrade its class. 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the two main changes: startup config validation and fail-closed unsigned-session fallback, matching the core security fixes in the changeset.
Description check ✅ Passed The description is comprehensive, clearly explaining the problem, fix, tests, and security rationale. It covers all key aspects of the changes across multiple files.
Linked Issues check ✅ Passed All coding requirements from issue #174 are met: startup validation catches missing config keys, unsigned-session fallback fails in production, and fail-closed defaults are implemented via APP_ENV/IS_LOCAL.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing issue #174: config validation, environment flags, and test coverage. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/174-config-validation-failclosed

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 13, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
frontend 9a456e5 Commit Preview URL

Branch Preview URL
Jun 14 2026, 02:16 AM

@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez force-pushed the security/174-config-validation-failclosed branch from 7d02a68 to 3a2b66d Compare June 13, 2026 06:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_config_validation_failclosed.py (1)

16-27: ⚡ Quick win

Exercise APP_ENV in this suite instead of patching IS_LOCAL directly.

The new contract here is APP_ENV -> IS_LOCAL, but _set_required() bypasses it by monkeypatching config.IS_LOCAL itself. A regression in the mode parsing would still leave these tests green. I'd add at least one monkeypatch.setenv("APP_ENV", ...) + importlib.reload(config) path for local and production.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_config_validation_failclosed.py` around lines 16 - 27, The
helper _set_required currently monkeypatches config.IS_LOCAL directly which
bypasses the APP_ENV -> IS_LOCAL conversion; change it to exercise APP_ENV
instead by calling monkeypatch.setenv("APP_ENV", overrides.pop("APP_ENV",
"production" if not overrides.get("IS_LOCAL") else "local")) (or set explicit
values in tests), then call importlib.reload(config) to pick up the env change
before monkeypatching the remaining config attributes; remove direct
monkeypatch.setattr(config, "IS_LOCAL", ...) usage and instead assert
config.IS_LOCAL is derived from APP_ENV in the tests (add explicit test cases
that set monkeypatch.setenv("APP_ENV","local") and
monkeypatch.setenv("APP_ENV","production") followed by importlib.reload(config)
to verify behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/config.py`:
- Around line 22-23: The module-level reads of APP_ENV and IS_LOCAL happen
before the project's .env is loaded, so move the dotenv load into config.py and
call load_dotenv(Path(__file__).with_name(".env")) (or equivalent dotenv load)
at the top of the file before evaluating APP_ENV, IS_LOCAL and any secret reads;
this ensures the globals (APP_ENV, IS_LOCAL) used by validate_config() and
routes.auth reflect values from .env. Alternatively, replace the module-level
snapshots with accessor functions (e.g., get_app_env(), is_local()) that read
os.getenv at call time, but the simplest fix is to load the same .env source at
the top of config.py before assigning APP_ENV and IS_LOCAL.
- Around line 57-65: The environment-var presence checks currently allow
whitespace-only values; update the checks that build the missing list to reject
strings that are blank after trimming: for SUPABASE_URL, SUPABASE_SERVICE_KEY,
GEMINI_API_KEY, and SESSION_SECRET (when not IS_LOCAL) use a test like "if not
(VAR and VAR.strip()): missing.append(...)" (or call .strip() before checking
truthiness) so purely-whitespace values are treated as absent; keep the existing
IS_LOCAL exemption for SESSION_SECRET.

---

Nitpick comments:
In `@backend/tests/test_config_validation_failclosed.py`:
- Around line 16-27: The helper _set_required currently monkeypatches
config.IS_LOCAL directly which bypasses the APP_ENV -> IS_LOCAL conversion;
change it to exercise APP_ENV instead by calling monkeypatch.setenv("APP_ENV",
overrides.pop("APP_ENV", "production" if not overrides.get("IS_LOCAL") else
"local")) (or set explicit values in tests), then call importlib.reload(config)
to pick up the env change before monkeypatching the remaining config attributes;
remove direct monkeypatch.setattr(config, "IS_LOCAL", ...) usage and instead
assert config.IS_LOCAL is derived from APP_ENV in the tests (add explicit test
cases that set monkeypatch.setenv("APP_ENV","local") and
monkeypatch.setenv("APP_ENV","production") followed by importlib.reload(config)
to verify behavior).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 64554247-dd9f-47be-839e-93bb62adfa32

📥 Commits

Reviewing files that changed from the base of the PR and between a27b688 and 7d02a68.

📒 Files selected for processing (5)
  • backend/config.py
  • backend/main.py
  • backend/routes/auth.py
  • backend/tests/test_auth_state.py
  • backend/tests/test_config_validation_failclosed.py

Comment thread backend/config.py
Comment thread backend/config.py Outdated
Comment on lines +57 to +65
missing = []
if not SUPABASE_URL:
missing.append("SUPABASE_URL")
if not SUPABASE_SERVICE_KEY:
missing.append("SUPABASE_SERVICE_KEY")
if not GEMINI_API_KEY:
missing.append("GEMINI_API_KEY")
if not SESSION_SECRET and not IS_LOCAL:
missing.append("SESSION_SECRET")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject whitespace-only config values here.

These checks only fail on empty strings. Values like " " still pass for SUPABASE_URL, SUPABASE_SERVICE_KEY, GEMINI_API_KEY, and SESSION_SECRET, so production can boot with unusable config and a trivially weak signing key.

Suggested fix
+def _is_blank(value: str) -> bool:
+    return not value or not value.strip()
+
 def validate_config() -> None:
@@
-    if not SUPABASE_URL:
+    if _is_blank(SUPABASE_URL):
         missing.append("SUPABASE_URL")
-    if not SUPABASE_SERVICE_KEY:
+    if _is_blank(SUPABASE_SERVICE_KEY):
         missing.append("SUPABASE_SERVICE_KEY")
-    if not GEMINI_API_KEY:
+    if _is_blank(GEMINI_API_KEY):
         missing.append("GEMINI_API_KEY")
-    if not SESSION_SECRET and not IS_LOCAL:
+    if _is_blank(SESSION_SECRET) and not IS_LOCAL:
         missing.append("SESSION_SECRET")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
missing = []
if not SUPABASE_URL:
missing.append("SUPABASE_URL")
if not SUPABASE_SERVICE_KEY:
missing.append("SUPABASE_SERVICE_KEY")
if not GEMINI_API_KEY:
missing.append("GEMINI_API_KEY")
if not SESSION_SECRET and not IS_LOCAL:
missing.append("SESSION_SECRET")
def _is_blank(value: str) -> bool:
return not value or not value.strip()
def validate_config() -> None:
missing = []
if _is_blank(SUPABASE_URL):
missing.append("SUPABASE_URL")
if _is_blank(SUPABASE_SERVICE_KEY):
missing.append("SUPABASE_SERVICE_KEY")
if _is_blank(GEMINI_API_KEY):
missing.append("GEMINI_API_KEY")
if _is_blank(SESSION_SECRET) and not IS_LOCAL:
missing.append("SESSION_SECRET")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/config.py` around lines 57 - 65, The environment-var presence checks
currently allow whitespace-only values; update the checks that build the missing
list to reject strings that are blank after trimming: for SUPABASE_URL,
SUPABASE_SERVICE_KEY, GEMINI_API_KEY, and SESSION_SECRET (when not IS_LOCAL) use
a test like "if not (VAR and VAR.strip()): missing.append(...)" (or call
.strip() before checking truthiness) so purely-whitespace values are treated as
absent; keep the existing IS_LOCAL exemption for SESSION_SECRET.

…ned-session fallback (#174)

config.py read every required secret with an empty-string default and validated
none, so the app booted with all secrets unset and failed opaquely later —
malformed REST URL on first DB call, mid-request Gemini errors, and worst, an
empty SESSION_SECRET silently switched OAuth state to an unsigned in-memory
fallback (insecure degradation).

- Add validate_config(), called from the FastAPI lifespan: raises one clear
  error naming every missing key (SUPABASE_URL, SUPABASE_SERVICE_KEY,
  GEMINI_API_KEY always; SESSION_SECRET outside local).
- Add APP_ENV (defaults to production → fail-closed) / IS_LOCAL. The unsigned
  OAuth-state fallback now raises outside local dev (defense in depth at the
  use site), so it is unreachable in production.

Tests: validate_config names all missing keys and relaxes SESSION_SECRET only
in local; the unsigned fallback raises in production (fails pre-fix, which
silently used it). Updated the two pre-existing fallback round-trip tests to
declare local mode.
@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez force-pushed the security/174-config-validation-failclosed branch from 3a2b66d to 9a456e5 Compare June 14, 2026 02:14
@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez merged commit a7182bd into main Jun 14, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez deleted the security/174-config-validation-failclosed branch June 14, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No startup env validation + silent HMAC-less SESSION_SECRET fallback

1 participant