fix: fail startup when AUTH_ENABLED=true and JWT_SECRET is unset (closes silent auth bypass) - #14
Merged
Merged
Conversation
…ses silent auth bypass) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RttwcxBgsnXH2TwQGQsrp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes the silent auth-bypass gap documented in
tests/test_auth.py(PR #13): whenAUTH_ENABLED=truebutJWT_SECRETis unset/empty,require_auth()used to validate JWTs against an empty-string HMAC secret, which PyJWT accepts — anyone could forgejwt.encode({"sub": "attacker"}, "", algorithm="HS256")and get in.The fix makes a misconfigured deployment refuse to start instead of coming up looking secured while actually being open.
Before / after
Before this PR —
AUTH_ENABLED=true,JWT_SECRETunset:secret=""got a200with a real actor identity — full bypass, silently.After this PR — same misconfiguration:
uvicornexits with a non-zero code before binding a port or serving a single request.Correctly configured —
AUTH_ENABLED=true,JWT_SECRETset — starts and works exactly as before:Implementation
api/auth.py: newvalidate_auth_config()— raisesRuntimeError("AUTH_ENABLED is true but JWT_SECRET is not set -- refusing to start with auth silently disabled.")iff_auth_enabled()andnot _jwt_secret().require_auth()itself is unchanged.api/main.py:startup_event()now callsvalidate_auth_config()beforeinit_control_plane(), so a misconfigured deployment fails at FastAPI's ASGI startup lifecycle — before serving anything — not as a warning/log line.AUTH_ENABLED=falseis completely unaffected:validate_auth_config()'s check short-circuits on_auth_enabled()first, soJWT_SECRETstays fully irrelevant when auth is off. Covered bytest_validate_auth_config_ignores_missing_secret_when_auth_disabled.Test changes (
tests/test_auth.py)Per instructions, the test that previously documented the bypass (
test_require_auth_unset_secret_accepts_token_forged_with_empty_secret— asserted a forged token got a200) is replaced, not weakened, with:test_validate_auth_config_raises_when_enabled_without_secret— unsetJWT_SECRETtest_validate_auth_config_raises_when_enabled_with_blank_secret— whitespace-onlyJWT_SECRET(strips to"")test_validate_auth_config_passes_when_enabled_with_secret_set— must not raisetest_validate_auth_config_ignores_missing_secret_when_auth_disabled— confirms requirement 4 (AUTH_ENABLED=false unaffected)test_startup_refuses_when_auth_enabled_without_secret— end-to-end: calls the realapi.main.startup_event()(not just the isolated guard function), confirms it raisestest_require_auth_unset_secret_internal_header_path_is_unreachableis kept as-is — it documentsrequire_auth()'s own internal-header short-circuit behavior when the secret is empty, which is unrelated to this fix and still holds.Test results
tests/test_auth.py: 24 passed (was 20 in PR test: add auth unit tests and wire frontend into CI #13; net +4 from the 1-removed/5-added swap above)Related but separate: Dockerfile's
${JWT_SECRET:-change-me}fallbackDockerfile:115bakes${JWT_SECRET:-change-me}into nginx's generateddevhub.confas theX-Devhub-Internalheader value used for the UI's same-origin proxy requests. This PR does not touch it — flagging explicitly per instructions rather than silently fixing or silently ignoring it.Analysis: in the current single-container architecture, this fix already neutralizes practical exploitability of that fallback: nginx and FastAPI share the same container env, so (a) if
AUTH_ENABLED=trueandJWT_SECRETis genuinely unset, FastAPI now refuses to start and nginx's proxy target never comes up (502s, not a bypass); (b) ifAUTH_ENABLED=false, no auth is enforced regardless of the header, so there's nothing to bypass; (c) ifJWT_SECRETis set, nginx bakes the real value, not the fallback.Recommendation: file as its own follow-up PR, not bundled here. Reasons:
${VAR:-default}fallback + nginx config generation vs. this PR's Python-levelAUTH_ENABLED/JWT_SECRETvalidation) — deserves its own review and its own Docker build+run verification, not folded into a security-critical Python behavior-change PR."change-me"is still bad practice on its own and worth removing outright (e.g. failing the container's CMD script ifJWT_SECRETis unset, mirroring this PR's spirit, rather than silently falling back).Scope
3 files:
api/auth.py,api/main.py,tests/test_auth.py.This is a security-critical behavior change. Per instructions, not merged automatically — results reported above; merge only after explicit confirmation.