From 91beb8c2dc9b0cf128a92f05bfc5957a7790fc86 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Tue, 1 Sep 2026 16:28:04 +0700 Subject: [PATCH] fix: fail fast on missing/short SECRET_KEY; hide ADMIN_PASSWORD from argv Production correct-credential login returned auth_unavailable 503 because Flask's SECRET_KEY was unset: wrong-password probes stopped at 401 before CSRF token issuance, while valid credentials reached _csrf_serializer() and crashed. App factory now rejects absent/short (<32 char) SECRET_KEY at startup, and docker_entrypoint.sh fails fast before migration/services. Bootstrap no longer passes ADMIN_PASSWORD in process arguments; env-only. Tests: app-factory + entrypoint regression (5 focused passed), full backend suite 204 passed. Independent review PASS. --- backend/app/__init__.py | 2 + backend/docker_entrypoint.sh | 14 +++++-- backend/tests/test_cors_config.py | 18 ++++++++- backend/tests/test_deploy_entrypoint.py | 10 +++++ docs/HANDOFF.md | 1 + docs/engineering-log.md | 2 +- .../2026-09-01-auth-secret-key.md | 39 +++++++++++++++++++ 7 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_deploy_entrypoint.py create mode 100644 docs/engineering-log/2026-09-01-auth-secret-key.md diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 9410ca5..48a6da3 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -24,6 +24,8 @@ def create_app(config_class=Config): """Flask应用工厂函数""" app = Flask(__name__) app.config.from_object(config_class) + if not app.config.get("SECRET_KEY") or len(str(app.config.get("SECRET_KEY"))) < 32: + raise RuntimeError("secret_key_required") if "*" in app.config.get("CORS_ALLOWED_ORIGINS", []): raise RuntimeError("wildcard_cors_not_allowed") diff --git a/backend/docker_entrypoint.sh b/backend/docker_entrypoint.sh index dfff104..1e56897 100644 --- a/backend/docker_entrypoint.sh +++ b/backend/docker_entrypoint.sh @@ -18,6 +18,16 @@ if [[ -z "${DATABASE_URL:-}" ]]; then exit 1 fi +if [[ -z "${SECRET_KEY:-}" ]]; then + echo "[entrypoint] FATAL: SECRET_KEY is not set. Authentication sessions and CSRF tokens cannot be signed." >&2 + exit 1 +fi + +if [[ ${#SECRET_KEY} -lt 32 ]]; then + echo "[entrypoint] FATAL: SECRET_KEY must be at least 32 characters." >&2 + exit 1 +fi + # If DATABASE_URL points at Postgres, fail fast with a clear message if the # psycopg driver is missing (instead of a confusing SQLAlchemy # NoSuchModuleError inside alembic). @@ -38,9 +48,7 @@ echo "[entrypoint] Migrations complete." # container can self-provision its first super_admin on startup. if [[ -n "${ADMIN_EMAIL:-}" && -n "${ADMIN_PASSWORD:-}" ]]; then echo "[entrypoint] Bootstrapping first super_admin (${ADMIN_EMAIL})..." - uv run --frozen python scripts/bootstrap_super_admin.py \ - --email "${ADMIN_EMAIL}" --password "${ADMIN_PASSWORD}" \ - --org "${ADMIN_ORG_NAME:-Acme}" --slug "${ADMIN_ORG_SLUG:-}" + uv run --frozen python scripts/bootstrap_super_admin.py echo "[entrypoint] super_admin bootstrap complete." else echo "[entrypoint] ADMIN_EMAIL/ADMIN_PASSWORD not set; skipping auto bootstrap." diff --git a/backend/tests/test_cors_config.py b/backend/tests/test_cors_config.py index 6684a7b..2841f28 100644 --- a/backend/tests/test_cors_config.py +++ b/backend/tests/test_cors_config.py @@ -8,7 +8,7 @@ from app.models.task import TaskManager class TestConfig(Config): - SECRET_KEY = "test-secret" + SECRET_KEY = "test-secret-key-test-secret-key-0123456789-abcdef" MEMORY_BACKEND = "local" CORS_ALLOWED_ORIGINS = ["https://allowed.example"] TESTING = True @@ -41,3 +41,19 @@ def test_cors_wildcard_is_rejected_with_cookie_auth(): with pytest.raises(RuntimeError, match="wildcard_cors_not_allowed"): create_app(WildcardConfig) + + +def test_missing_secret_key_is_rejected_at_startup(): + class MissingSecretConfig(TestConfig): + SECRET_KEY = None + + with pytest.raises(RuntimeError, match="secret_key_required"): + create_app(MissingSecretConfig) + + +def test_short_secret_key_is_rejected_at_startup(): + class ShortSecretConfig(TestConfig): + SECRET_KEY = "short" + + with pytest.raises(RuntimeError, match="secret_key_required"): + create_app(ShortSecretConfig) diff --git a/backend/tests/test_deploy_entrypoint.py b/backend/tests/test_deploy_entrypoint.py new file mode 100644 index 0000000..991f8a3 --- /dev/null +++ b/backend/tests/test_deploy_entrypoint.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +ENTRYPOINT = Path(__file__).resolve().parents[1] / "docker_entrypoint.sh" + + +def test_entrypoint_never_exposes_admin_password_in_process_arguments(): + source = ENTRYPOINT.read_text(encoding="utf-8") + assert '--password "${ADMIN_PASSWORD}"' not in source + assert 'uv run --frozen python scripts/bootstrap_super_admin.py' in source diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3db3502..05e89f6 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -20,6 +20,7 @@ The repository now has a tested identity/authentication foundation, tenant/owner - Explicit credentialed CORS allowlist. - Three roles only: `super_admin`, `admin`, `user`. - `/api/auth/login`, `/api/auth/me`, `/api/auth/logout`. +- Production correct-credential login 503 (`auth_unavailable`) was traced to a missing `SECRET_KEY`: wrong-password probes returned 401 before CSRF issuance, while successful credentials reached `_csrf_serializer()` and failed. App factory + Docker entrypoint now fail fast when `SECRET_KEY` is absent or shorter than 32 chars; bootstrap no longer exposes `ADMIN_PASSWORD` in process arguments. Backend **204 passed**; EasyPanel must set a persistent random `SECRET_KEY` and `SESSION_COOKIE_SECURE=true` before restart. - `/api/admin/users` list/create with role escalation prevention and response redaction. - Project `organization_id`/`owner_user_id` metadata and scoped project reads/lists. - Blueprint-wide auth and CSRF checks for simulation/report routes. diff --git a/docs/engineering-log.md b/docs/engineering-log.md index d691b97..f1e1a3a 100644 --- a/docs/engineering-log.md +++ b/docs/engineering-log.md @@ -7,7 +7,7 @@ | Baseline architecture study | complete | 2026-08-23 | `npm run build` passed; `compileall` passed; `git diff --check` passed; source inventory completed | Review MiroFish SaaS plan and lock M0 decisions | | Thai/English frontend hardening | production login root cause fixed; redeploy pending | 2026-09-01 | Root cause of `/login` white screen proved with a RED vue-i18n compiler test: `auth.emailPlaceholder = "name@company.com"` is invalid linked-message syntax and throws compiler code 10 (`Invalid linked format`) while LoginView renders. Escaped as `name{'@'}company.com` in th/en. Recursive compiler regression covers every translation; frontend tests **11 passed**, production build passed (`index-B4oVHpLg.js`), Chrome rendered DOM contains `login-card`, Thai heading, and rendered `name@company.com`; screenshot analysis unavailable because vision provider returned 401 | Complete fresh reviewer gate, then commit/push/redeploy and verify live `/login` | | Zep replacement | bounded local E2E slice | 2026-08-24 | Local graph → profile → simulation config → report tools → persisted report regression passed; default remains Zep; no full consumer cutover or semantic parity claim | Cut over remaining consumers and close semantic/E2E gaps | -| Auth/tenant/roles | bounded foundation | 2026-08-24 | Identity/session/roles/CSRF/CORS/idempotency/resource guards covered by focused tests; durable task app-state leak fixed; task query filters now push tenant predicates into SQL | Complete broader tenant matrix, admin UI, rate limits, audit/usage policy | +| Auth/tenant/roles | bounded foundation; production secret fail-fast added | 2026-09-01 | Identity/session/roles/CSRF/CORS/idempotency/resource guards covered; production correct-credential login 503 was proved to be missing `SECRET_KEY` at CSRF token issuance. App factory now rejects missing signing secret; Docker entrypoint requires a persistent key ≥32 chars. Focused tests 5 passed; backend full suite **204 passed**; independent review PASS | Set persistent EasyPanel `SECRET_KEY` + `SESSION_COOKIE_SECURE=true`, restart, then verify correct-credential live login | | SaaS foundation batch | in progress | 2026-08-24 | Backend full suite **193 passed** after app/factory isolation, SQLite-FK, auxiliary API auth/CSRF/idempotency, cross-route/multipart idempotency, local consumer-boundary fixes, durable product-resource schema/repository, tenant-scoped `ArtifactStore`, durable `JobQueue`+`worker.py`, versioned redacted `PlatformSettings`, durable `RateLimiter` (wired to login), durable LLM `UsageService`, durable redacted `AuditService`, and durable single-use `PasswordResetService` + endpoints (also covers invite-pending setup); schema/TaskManager regression **16 passed**; auxiliary security **8 passed**; idempotency API **5 passed**; local import-boundary regression **9 fresh-import tests**; product-resource persistence **21 tests**; artifact store **12 tests**; job queue/worker **10 tests**; settings service **4 tests**; rate limiter **6 tests**; usage service **4 tests**; audit service **3 tests**; password reset **6 tests**; frontend gates passed; bounded reviewers passed their exact slices; hardened bases ready; remaining: resource authz matrix completion, admin/bootstrap UI, and deploy topology; `ruff` unavailable; no commit/push/deploy | Complete admin UI, authz matrix, then deploy topology; do not claim full-system approval | | Admin/super-admin UI | bounded foundation | 2026-08-24 | Backend: `GET/POST/PATCH /api/admin/users` + `GET/PUT /api/admin/settings` (super-admin only, masked/encrypted secret via `SettingsService`); Frontend: `AdminView.vue` (user mgmt) + `SettingsView.vue` (LLM settings form) routed at `/admin` + `/admin/settings` with admin/super-admin role guards, th/en i18n identical; build + 10 frontend tests pass; backend 197 passed | Add invite self-setup UX, connection-test endpoint, then full i18n/mobile review | | Production worker/deployment | production topology drafted, locally smoke-tested | 2026-08-24 | Dockerfile rebuilt as multi-stage production (frontend build + python-gunicorn + nginx-SPA-proxy + supervisord worker); `backend/wsgi.py` gunicorn entry + `gunicorn>=21` added; local smoke test: gunicorn `wsgi:app` started, `/health` OK, `/api/auth/login` 401, built SPA assets served 200; backend 197 passed | Build in EasyPanel container to verify nginx SPA-fallback + `/api` proxy + worker poll; choose broker (Redis vs durable-poll) + object storage for full readiness | diff --git a/docs/engineering-log/2026-09-01-auth-secret-key.md b/docs/engineering-log/2026-09-01-auth-secret-key.md new file mode 100644 index 0000000..c665d87 --- /dev/null +++ b/docs/engineering-log/2026-09-01-auth-secret-key.md @@ -0,0 +1,39 @@ +# 2026-09-01 — Login 503 from Missing SECRET_KEY + +## Status + +Root cause proved; fail-fast fix implemented and verified locally; fresh review pending. + +## Incident + +Production login returned: + +```json +{"error_code":"auth_unavailable","success":false} +``` + +Wrong-password probes returned 401, while the browser's correct credentials returned 503. The distinction was critical: invalid credentials exit before session/CSRF issuance, but successful credential verification continues to `issue_csrf_token()`. + +## Verified root cause + +`Config.SECRET_KEY` reads the `SECRET_KEY` environment variable. `issue_csrf_token()` calls `_csrf_serializer()`, which raises `ApiError("auth_unavailable", 503, ...)` when `current_app.secret_key` is absent. Therefore the browser's 503 indicates that credentials passed but the deployment had no signing secret. + +## Fix + +- `create_app()` now raises `RuntimeError("secret_key_required")` when the signing secret is absent. +- `docker_entrypoint.sh` now fails before migration/services if `SECRET_KEY` is absent or shorter than 32 characters, with a clear error message. +- Bootstrap now reads `ADMIN_PASSWORD` only from inherited environment variables; it is no longer exposed in process arguments. +- Added RED/GREEN app-factory and entrypoint security regression tests. + +## Verification + +- RED: the missing-secret test failed because `create_app()` previously started normally. +- GREEN: focused app-factory/entrypoint security tests **5 passed**. +- Backend full suite **204 passed**, 24 warnings. +- Entry-point Bash syntax passed. +- `git diff --check` passed. +- Independent reviewer (CEO profile): `{"passed":true,"security_concerns":[...],"logic_errors":[],"suggestions":[...]}`. + +## Operator action + +Generate a persistent random signing secret (for example `python3 -c "import secrets; print(secrets.token_urlsafe(48))"`), save it as the EasyPanel `SECRET_KEY` environment variable, set `SESSION_COOKIE_SECURE=true`, and restart/redeploy. Do not rotate `SECRET_KEY` casually because existing sessions and encrypted platform settings depend on it.