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.
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
backend/tests/test_deploy_entrypoint.py
Normal file
10
backend/tests/test_deploy_entrypoint.py
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
39
docs/engineering-log/2026-09-01-auth-secret-key.md
Normal file
39
docs/engineering-log/2026-09-01-auth-secret-key.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user