diff --git a/.env.example b/.env.example index 6e342d5..66225d5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # Copy to `.env` and fill in values. `.env` is gitignored. +# Generate secrets with: openssl rand -hex 32 +# Validate before deploying: uv run python scripts/check_prod.py --env ../.env # Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas). # Prod overrides via this .env file (e.g. APP_ENV=prod). # Paths resolve from the repo root in code. @@ -12,6 +14,8 @@ FORGEJO_TOKEN= LANGFLOW_VERSION=1.12.0 LANGFLOW_URL=http://langflow:7860 LANGFLOW_SUPERUSER=langflow +# Long random values (check_prod.py enforces length). Never leave the +# placeholders: the authoring UI is internet-facing. LANGFLOW_SUPERUSER_PASSWORD= LANGFLOW_SECRET_KEY= # Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3795ad8..8eab383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Security +- Production ops: `scripts/check_prod.py` validates `.env` secrets and + modes before deploy (with tests); README production checklist + (proxy gates for Langflow, secret rotation, upgrade runbook). +### Security - API authorization: flow management is admin-only, runs and run/approval reads require login with strict session-tenant scoping, approval decisions record the session user, and flow callbacks authenticate with diff --git a/README.md b/README.md index 9152cea..30fde11 100644 --- a/README.md +++ b/README.md @@ -126,15 +126,32 @@ Flow workflow: design in Langflow → export into `backend/flows/.json` (`--pull`, or UI download) → commit → the watcher deploys it automatically. Deleting a file removes the flow including its runs (git keeps the JSON). -## Production (self-hosted, internal use — no TLS/reverse proxy included) +## Production (self-hosted — TLS/reverse proxy required, not included) 1. Check out the repo on the host, create `.env` from `.env.example` with - real secrets and `FRONTEND_MODE=preview`. -2. `docker compose up -d --build`. -3. Code updates via `git pull` + `docker compose restart api frontend` + real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`. +2. Validate it: `uv run python scripts/check_prod.py --env ../.env` + (run from `backend/`; blocks on weak secrets or dev modes). +3. `docker compose up -d --build`. +4. Code updates via `git pull` + `docker compose restart api frontend` (the checkout is bind-mounted); image rebuilds only for dependency changes (`docker compose up -d --build`). +Internet exposure checklist (both forwarded ports sit behind your HTTPS +proxy — nothing here substitutes for it): + +- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or + OIDC) in front of Langflow `:7860`. Langflow has a history of + critical unauthenticated RCEs — treat its UI as privileged tooling, + never share flows publicly ("Shareable Playground" = RCE risk). +- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate + after any incident or staff change; re-run `check_prod.py`. +- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION` + (`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then + `lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on + `backend/flows/*.json`. Check Langflow security advisories monthly. +- Backups: `backend/data/` (SQLite) and the `langflow-data` volume. + Container updates: registry images (`langflowai/langflow:latest`) auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`. diff --git a/backend/scripts/check_prod.py b/backend/scripts/check_prod.py new file mode 100644 index 0000000..05182e4 --- /dev/null +++ b/backend/scripts/check_prod.py @@ -0,0 +1,111 @@ +"""Validate a prod `.env` file before (re)deploying. + +Usage (from backend/ or repo root): + uv run python scripts/check_prod.py --env ../.env + uv run python scripts/check_prod.py # defaults to repo-root .env + +Fails (exit 1) on missing/weak secrets and insecure modes; warns on +soft misconfigurations. Safe to run anywhere: reads one file, no imports +from app (stdlib only), prints no secret values. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +BACKEND = Path(__file__).resolve().parent.parent +REPO_ROOT = BACKEND.parent + +MIN_TOKEN = 32 +MIN_PASSWORD = 16 +MIN_API_KEY = 16 + + +def parse_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + if key.startswith("export "): + key = key[len("export ") :].strip() + values[key] = value + return values + + +def check(values: dict[str, str]) -> tuple[list[str], list[str]]: + """(failures, warnings) for a parsed env file.""" + failures: list[str] = [] + warnings: list[str] = [] + + def secret(name: str, minimum: int, what: str) -> None: + value = values.get(name, "") + if not value: + failures.append(f"{name} is empty — generate one: openssl rand -hex 32") + elif len(value) < minimum: + failures.append(f"{name} too short ({len(value)} < {minimum} chars)") + + def placeholder(name: str, prefixes: tuple[str, ...]) -> None: + value = values.get(name, "") + if value and any(value == p or value.startswith(p) for p in prefixes): + failures.append(f"{name} still holds an example placeholder") + + # Shared + service API keys. + secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token") + secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key") + placeholder("LANGFLOW_API_KEY", ("sk-",)) + secret("LFX_API_KEY", MIN_API_KEY, "lfx api key") + placeholder("LFX_API_KEY", ("lfx-",)) + # Langflow's own auth (internet-facing authoring UI). + secret("LANGFLOW_SUPERUSER_PASSWORD", MIN_PASSWORD, "langflow superuser password") + secret("LANGFLOW_SECRET_KEY", MIN_TOKEN, "langflow secret key") + + # Insecure modes. + if values.get("FRONTEND_MODE", "preview") == "dev": + failures.append("FRONTEND_MODE=dev serves the Vite dev server — use preview in prod") + if values.get("APP_ENV", "prod") == "dev": + failures.append("APP_ENV=dev enables autoreload — unset it in prod") + if values.get("LANGFLOW_AUTO_LOGIN", "false").lower() == "true": + failures.append("LANGFLOW_AUTO_LOGIN=true allows passwordless superuser access") + + # Soft misconfigurations. + if not values.get("ADMIN_EMAILS", ""): + warnings.append("ADMIN_EMAILS empty — nobody can bootstrap an admin account") + if values.get("SMTP_HOST", "") and not values.get("SMTP_PASSWORD", ""): + warnings.append("SMTP_HOST without SMTP_PASSWORD — magic-link mail will fail") + if not values.get("CONTACT_TO", ""): + warnings.append("CONTACT_TO empty — access-request notifications go nowhere") + frontend_url = values.get("FRONTEND_URL", "") + if frontend_url and not frontend_url.startswith("https://"): + warnings.append("FRONTEND_URL is not https — cookies and tokens travel in cleartext") + + return failures, warnings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env", default=str(REPO_ROOT / ".env")) + args = parser.parse_args() + path = Path(args.env) + if not path.is_file(): + print(f"no env file at {path}") + return 1 + failures, warnings = check(parse_env(path)) + for message in warnings: + print(f"WARN: {message}") + for message in failures: + print(f"FAIL: {message}") + if failures: + print(f"{len(failures)} blocking issue(s) — fix before deploying") + return 1 + print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else "")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/test_check_prod.py b/backend/tests/test_check_prod.py new file mode 100644 index 0000000..e695f08 --- /dev/null +++ b/backend/tests/test_check_prod.py @@ -0,0 +1,79 @@ +"""Prod env checker: failures block deploys, warnings don't.""" + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts")) + +from check_prod import check + + +def _good() -> dict: + return { + "CALLBACK_TOKEN": "t" * 40, + "LANGFLOW_API_KEY": "k" * 24, + "LFX_API_KEY": "x" * 24, + "LANGFLOW_SUPERUSER_PASSWORD": "p" * 20, + "LANGFLOW_SECRET_KEY": "s" * 40, + "FRONTEND_MODE": "preview", + "ADMIN_EMAILS": "admin@example.com", + "SMTP_HOST": "smtp.example.com", + "SMTP_PASSWORD": "secret", + "CONTACT_TO": "team@example.com", + "FRONTEND_URL": "https://example.com", + } + + +def test_good_env_passes_without_warnings(): + failures, warnings = check(_good()) + assert failures == [] + assert warnings == [] + + +def test_empty_and_short_secrets_fail(): + values = _good() + values["CALLBACK_TOKEN"] = "" + values["LANGFLOW_SUPERUSER_PASSWORD"] = "short" + failures, _ = check(values) + assert any("CALLBACK_TOKEN" in f for f in failures) + assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures) + + +def test_placeholders_fail(): + values = _good() + values["LANGFLOW_API_KEY"] = "sk-" + values["LFX_API_KEY"] = "lfx-abc" + failures, _ = check(values) + assert any("LANGFLOW_API_KEY" in f for f in failures) + assert any("LFX_API_KEY" in f for f in failures) + + +def test_insecure_modes_fail(): + values = _good() + values["FRONTEND_MODE"] = "dev" + values["APP_ENV"] = "dev" + values["LANGFLOW_AUTO_LOGIN"] = "true" + failures, _ = check(values) + assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1 + assert len([f for f in failures if "APP_ENV" in f]) == 1 + assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1 + + +def test_soft_misconfigurations_warn_only(): + values = _good() + values["ADMIN_EMAILS"] = "" + values["SMTP_PASSWORD"] = "" + values["CONTACT_TO"] = "" + values["FRONTEND_URL"] = "http://example.com" + failures, warnings = check(values) + assert failures == [] + assert len(warnings) == 4 + + +def test_example_file_would_not_pass(): + from check_prod import parse_env + + values = parse_env(REPO_ROOT / ".env.example") + failures, _ = check(values) + assert failures, "placeholders must be detectable"