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:
Kunthawat Greethong
2026-09-01 16:28:04 +07:00
parent 9b0c0da9cb
commit 91beb8c2dc
7 changed files with 81 additions and 5 deletions

View File

@@ -24,6 +24,8 @@ def create_app(config_class=Config):
"""Flask应用工厂函数""" """Flask应用工厂函数"""
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class) 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", []): if "*" in app.config.get("CORS_ALLOWED_ORIGINS", []):
raise RuntimeError("wildcard_cors_not_allowed") raise RuntimeError("wildcard_cors_not_allowed")

View File

@@ -18,6 +18,16 @@ if [[ -z "${DATABASE_URL:-}" ]]; then
exit 1 exit 1
fi 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 # If DATABASE_URL points at Postgres, fail fast with a clear message if the
# psycopg driver is missing (instead of a confusing SQLAlchemy # psycopg driver is missing (instead of a confusing SQLAlchemy
# NoSuchModuleError inside alembic). # NoSuchModuleError inside alembic).
@@ -38,9 +48,7 @@ echo "[entrypoint] Migrations complete."
# container can self-provision its first super_admin on startup. # container can self-provision its first super_admin on startup.
if [[ -n "${ADMIN_EMAIL:-}" && -n "${ADMIN_PASSWORD:-}" ]]; then if [[ -n "${ADMIN_EMAIL:-}" && -n "${ADMIN_PASSWORD:-}" ]]; then
echo "[entrypoint] Bootstrapping first super_admin (${ADMIN_EMAIL})..." echo "[entrypoint] Bootstrapping first super_admin (${ADMIN_EMAIL})..."
uv run --frozen python scripts/bootstrap_super_admin.py \ uv run --frozen python scripts/bootstrap_super_admin.py
--email "${ADMIN_EMAIL}" --password "${ADMIN_PASSWORD}" \
--org "${ADMIN_ORG_NAME:-Acme}" --slug "${ADMIN_ORG_SLUG:-}"
echo "[entrypoint] super_admin bootstrap complete." echo "[entrypoint] super_admin bootstrap complete."
else else
echo "[entrypoint] ADMIN_EMAIL/ADMIN_PASSWORD not set; skipping auto bootstrap." echo "[entrypoint] ADMIN_EMAIL/ADMIN_PASSWORD not set; skipping auto bootstrap."

View File

@@ -8,7 +8,7 @@ from app.models.task import TaskManager
class TestConfig(Config): class TestConfig(Config):
SECRET_KEY = "test-secret" SECRET_KEY = "test-secret-key-test-secret-key-0123456789-abcdef"
MEMORY_BACKEND = "local" MEMORY_BACKEND = "local"
CORS_ALLOWED_ORIGINS = ["https://allowed.example"] CORS_ALLOWED_ORIGINS = ["https://allowed.example"]
TESTING = True TESTING = True
@@ -41,3 +41,19 @@ def test_cors_wildcard_is_rejected_with_cookie_auth():
with pytest.raises(RuntimeError, match="wildcard_cors_not_allowed"): with pytest.raises(RuntimeError, match="wildcard_cors_not_allowed"):
create_app(WildcardConfig) 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)

View 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

View File

@@ -20,6 +20,7 @@ The repository now has a tested identity/authentication foundation, tenant/owner
- Explicit credentialed CORS allowlist. - Explicit credentialed CORS allowlist.
- Three roles only: `super_admin`, `admin`, `user`. - Three roles only: `super_admin`, `admin`, `user`.
- `/api/auth/login`, `/api/auth/me`, `/api/auth/logout`. - `/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. - `/api/admin/users` list/create with role escalation prevention and response redaction.
- Project `organization_id`/`owner_user_id` metadata and scoped project reads/lists. - Project `organization_id`/`owner_user_id` metadata and scoped project reads/lists.
- Blueprint-wide auth and CSRF checks for simulation/report routes. - Blueprint-wide auth and CSRF checks for simulation/report routes.

View File

@@ -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 | | 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` | | 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 | | 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 | | 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 | | 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 | | 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 |

View 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.