From 21617e521dfd307331af3eb499ce40a66dedbd7a Mon Sep 17 00:00:00 2001 From: Perseus Date: Sun, 5 Jul 2026 13:37:21 -0500 Subject: [PATCH] =?UTF-8?q?security:=20deploy=20hardening=20=E2=80=94=20fa?= =?UTF-8?q?il=20closed=20on=20insecure=20bind,=20secrets,=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH #1/#2: refuse to bind a non-loopback host with auth disabled (serve() guard + --allow-insecure / PLUTUS_ALLOW_INSECURE opt-in). Stops docker compose up / serve --host 0.0.0.0 from publishing an open billing console; the demo image sets --allow-insecure since its data is disposable. Covers the auth default-off AND fail-off-on-misconfig cases. - MED #9: .gitignore/.dockerignore matched only *.plutus-bak-*; the real backups are config.yaml.bak- (file-sourced secrets) -> now config.yaml* + *.bak-*. - MED #11: dependency upper bounds (stripe<14, reportlab<5, PyYAML<7, pytest<10). - LOW #15: alerts.require_tls (opt-in) fails closed if TLS can't be established. - LOW #16: Dockerfile runs as a non-root user. - LOW #17: PyYAML is a hard requirement at server start (no silent config degrade). - #10: docs/deploy-hardening.md — safe-deploy checklist for operators. 268 tests pass (+5 guard tests). Addresses the deploy-posture findings from the 2026-07-05 security review. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 5 ++++ .gitignore | 5 ++++ CHANGELOG.md | 20 ++++++++++++++ Dockerfile | 20 ++++++++++---- docker-compose.yaml | 18 ++++++++++--- docs/deploy-hardening.md | 54 ++++++++++++++++++++++++++++++++++++++ plutus_agent/alerts.py | 9 +++++++ plutus_agent/cli.py | 7 +++++ plutus_agent/server/app.py | 29 ++++++++++++++++++++ pyproject.toml | 13 +++++---- tests/test_deploy_guard.py | 37 ++++++++++++++++++++++++++ 11 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 docs/deploy-hardening.md create mode 100644 tests/test_deploy_guard.py diff --git a/.dockerignore b/.dockerignore index a152db0..722a23f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,8 @@ dist *.egg-info tests demo.cast +# Never bake config or its secret-bearing backups into the image context +config.yaml +config.yaml.* +*.bak-* +data diff --git a/.gitignore b/.gitignore index 0cecd6f..c6ef2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ plutus.html plutus.err *.plutus-bak-* config.yaml.plutus-bak-* +# Config and its timestamped backups (config.yaml.bak-) can hold file-sourced +# secrets — never commit them (the old patterns above missed the .bak- form). +config.yaml +config.yaml.* +*.bak-* # Python __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3098358..1568a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to Plutus are documented here. ## [Unreleased] ### Security +- **Fail closed instead of exposing an unauthenticated dashboard.** The server now + refuses to bind a non-loopback host (e.g. `--host 0.0.0.0`) when authentication + is disabled — previously the shipped Docker/compose default (`serve --demo + --host 0.0.0.0`) and the off-by-default / fail-off-on-misconfig auth could + publish an open billing console on the network. Override for a trusted network + with `--allow-insecure` / `PLUTUS_ALLOW_INSECURE=1`; the disposable demo image + sets it explicitly. New `docs/deploy-hardening.md` documents the safe config. +- **Config backups no longer escape the ignore rules.** `.gitignore`/`.dockerignore` + matched only `*.plutus-bak-*` but the real backup name is `config.yaml.bak-`, + which could carry file-sourced secrets into a commit or image; now `config.yaml*` + and `*.bak-*` are ignored. +- **SMTP `alerts.require_tls` (opt-in)** refuses to send alerts over an + unencrypted connection (protects alert bodies, not just credentials, from a + STARTTLS downgrade). **PyYAML** is now a hard requirement at server start + (fail fast) rather than silently degrading config parsing. + +### Changed +- Dependency floors gained upper bounds (`stripe<14`, `reportlab<5`, `PyYAML<7`, + `pytest<10`) for reproducible builds; the Docker image now runs as a non-root + user. - **Ledger atomicity: every money side effect now commits in the same transaction as the marker guarding it.** Two crash-window bugs are closed: (1) *Webhook silent credit loss / double-reverse* — `mark_stripe_event` committed diff --git a/Dockerfile b/Dockerfile index 5bae145..57a0a37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,16 +18,26 @@ COPY pyproject.toml README.md LICENSE ./ COPY plutus_agent ./plutus_agent RUN pip install --no-cache-dir ".[all]" +# Run as a non-root user; /data (config + SQLite) is owned by it. +RUN useradd --system --create-home --uid 10001 plutus \ + && mkdir -p /data && chown -R plutus:plutus /data /app +USER plutus + # State (config + SQLite) persists in a mounted volume. VOLUME ["/data"] EXPOSE 8420 -# Bind to all interfaces inside the container; map the port on the host. -# Default to the demo so `docker run -p 8420:8420 plutus-agent` shows value -# instantly. For real use, override the command: -# docker run -p 8420:8420 -v plutus:/data plutus-agent serve --host 0.0.0.0 +# Default is DEMO mode (throwaway sample data, no auth) so +# `docker run -p 8420:8420 plutus-agent` shows value instantly; it passes +# --allow-insecure because it is a disposable demo. For a REAL deploy, override +# the command AND configure auth — the server refuses to bind 0.0.0.0 with auth +# off (see docs/deploy-hardening.md): +# docker run -p 8420:8420 -v plutus:/data \ +# -e PLUTUS_AUTH_ENABLED=1 -e PLUTUS_GOOGLE_CLIENT_ID=... \ +# -e PLUTUS_GOOGLE_CLIENT_SECRET=... -e PLUTUS_BASE_URL=https://host \ +# plutus-agent serve --host 0.0.0.0 HEALTHCHECK --interval=30s --timeout=4s --start-period=5s \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8420/healthz',timeout=3).status==200 else 1)" ENTRYPOINT ["plutus"] -CMD ["serve", "--demo", "--host", "0.0.0.0"] +CMD ["serve", "--demo", "--host", "0.0.0.0", "--allow-insecure"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 1507740..e4f1458 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,15 +15,25 @@ services: - plutus-data:/data environment: PLUTUS_HOME: /data + # --- Auth (REQUIRED for a real deploy: the server refuses to bind 0.0.0.0 + # with auth disabled). Uncomment + fill these, drop --allow-insecure + # from the command below, and ideally sit behind a TLS proxy. + # See docs/deploy-hardening.md. --- + # PLUTUS_AUTH_ENABLED: "1" + # PLUTUS_GOOGLE_CLIENT_ID: ${PLUTUS_GOOGLE_CLIENT_ID:-} + # PLUTUS_GOOGLE_CLIENT_SECRET: ${PLUTUS_GOOGLE_CLIENT_SECRET:-} + # PLUTUS_BASE_URL: ${PLUTUS_BASE_URL:-} # https://... enables the Secure cookie + # PLUTUS_ALLOWED_DOMAIN: ${PLUTUS_ALLOWED_DOMAIN:-} # --- Stripe (optional; leave blank to run fully offline) --- STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PRICE_PRO: ${STRIPE_PRICE_PRO:-} - # Demo by default so the stack shows value on first `up`. Swap to the real - # server once you've run `plutus init`: - # command: ["serve", "--host", "0.0.0.0"] - command: ["serve", "--demo", "--host", "0.0.0.0"] + # DEMO by default (throwaway data, no auth) so the stack shows value on first + # `up`; --allow-insecure is required because auth is off. For a REAL deploy, + # configure the auth env vars above and switch to the production command: + # command: ["serve", "--host", "0.0.0.0"] + command: ["serve", "--demo", "--host", "0.0.0.0", "--allow-insecure"] restart: unless-stopped volumes: diff --git a/docs/deploy-hardening.md b/docs/deploy-hardening.md new file mode 100644 index 0000000..610ab02 --- /dev/null +++ b/docs/deploy-hardening.md @@ -0,0 +1,54 @@ +# Deploy hardening + +Plutus handles live billing, so a real deployment must be locked down. The engine +internals are hardened (auth, tenant isolation, CSRF, atomic ledger); the items +below are the **operator's** responsibility. This checklist came out of the +2026-07-05 security review. + +> **Fail-closed default:** the server **refuses to bind a non-loopback host +> (`0.0.0.0`, a LAN IP, …) with authentication disabled** and exits. That stops a +> plain `docker compose up` / `serve --host 0.0.0.0` from publishing an open +> billing console. Override only for a trusted network with `--allow-insecure` +> (`PLUTUS_ALLOW_INSECURE=1`); the shipped **demo** image sets this because its +> data is disposable. + +## Minimum safe production config + +1. **Enable auth, fully configured.** Set `PLUTUS_AUTH_ENABLED=1`, + `PLUTUS_GOOGLE_CLIENT_ID`, `PLUTUS_GOOGLE_CLIENT_SECRET`, and + `PLUTUS_BASE_URL=https://` (the `https://` is what turns on the `Secure` + session cookie). Confirm the startup banner shows `auth: Google OIDC (...)`, + **not** "open" or "enabled but NOT configured". +2. **Signup off, allow-list on.** Leave `allow_signup` false (default); set + `PLUTUS_ALLOWED_DOMAIN` (your corp domain) or `PLUTUS_ALLOWED_EMAILS`. Never + set `allow_unsigned_tokens`. +3. **Secrets via environment, never on disk.** Put `STRIPE_SECRET_KEY`, + `STRIPE_WEBHOOK_SECRET`, `PLUTUS_GOOGLE_CLIENT_SECRET`, `PLUTUS_SMTP_PASSWORD`, + `PLUTUS_ADMIN_TOKEN` in the environment only, so they never land in + `config.yaml` or its backups. (Config + `config.yaml.bak-*` are git/docker + ignored, but env is cleaner.) +4. **TLS in front.** Run behind a TLS-terminating reverse proxy (Caddy/nginx); + keep the app on `127.0.0.1` or an internal interface. Set + `auth.trust_forwarded_for=true` only when actually behind that proxy. +5. **Admin API.** Set `PLUTUS_ADMIN_TOKEN` to a long random value only if you need + `/v1/admin`; otherwise leave it empty (the endpoint stays 404). +6. **SMTP.** Use port 465 (implicit TLS) or an authenticated STARTTLS relay; set + `alerts.require_tls: true` to refuse sending over an unencrypted link. +7. **Reproducible artifact.** Deploy a hash-locked image/wheel; don't + `pip install --upgrade` from `curl | bash` on the production host. Dependency + upper bounds are pinned in `pyproject.toml`. +8. **Container.** The image already runs as a non-root user; mount `/data` as a + persistent, access-restricted volume. +9. **Keep the prepaid hard-stop on.** Leave `pricing.block_over_balance` true so + prepaid orgs can't overspend. + +## Quick check + +```bash +# Production: this MUST refuse to start (proves the fail-closed guard is active) +plutus serve --host 0.0.0.0 # -> exits with a "refusing to serve" error + +# Correct production invocation: +PLUTUS_AUTH_ENABLED=1 PLUTUS_GOOGLE_CLIENT_ID=... PLUTUS_GOOGLE_CLIENT_SECRET=... \ +PLUTUS_BASE_URL=https://plutus.internal plutus serve --host 127.0.0.1 +``` diff --git a/plutus_agent/alerts.py b/plutus_agent/alerts.py index b3b86e3..419830d 100644 --- a/plutus_agent/alerts.py +++ b/plutus_agent/alerts.py @@ -84,6 +84,7 @@ def send_pending(conn, cfg: dict, org_id: str, port = int(acfg.get("smtp_port", 587)) user = acfg.get("smtp_user") or "" password = acfg.get("smtp_password") or "" + require_tls = bool(acfg.get("require_tls")) # #15: fail closed if TLS unavailable sent = 0 errors = [] @@ -117,6 +118,14 @@ def send_pending(conn, cfg: dict, org_id: str, except Exception: pass + # Fix (#15): with require_tls set, refuse to send at all over an + # unencrypted link — protects alert bodies (mild PII), not just + # credentials, from a STARTTLS downgrade/MITM. + if require_tls and not tls_secured: + return {"sent": 0, "dry_run": False, "pending": len(items), + "error": "alerts.require_tls is set but STARTTLS " + "could not be established"} + # Only login if TLS is secured OR if no credentials are required if user and password: if not tls_secured: diff --git a/plutus_agent/cli.py b/plutus_agent/cli.py index a7217b6..b1c1715 100644 --- a/plutus_agent/cli.py +++ b/plutus_agent/cli.py @@ -75,6 +75,8 @@ def cmd_init(args): def cmd_serve(args, demo=False): from . import server cfg = cfgmod.load() + if getattr(args, "allow_insecure", False): + cfg.setdefault("server", {})["allow_insecure"] = True demo = demo or args.demo db_path = str(cfgmod.db_path()) if demo: @@ -419,11 +421,16 @@ def build_parser(): ps.add_argument("--host"); ps.add_argument("--port", type=int) ps.add_argument("--demo", action="store_true", help="serve realistic sample data") ps.add_argument("--open", action="store_true", help="open a browser") + ps.add_argument("--allow-insecure", action="store_true", + help="permit binding a non-loopback host with auth disabled " + "(trusted networks only; the default fails closed)") ps.set_defaults(func=cmd_serve) pd = sub.add_parser("demo", help="serve with sample data (zero setup)") pd.add_argument("--host"); pd.add_argument("--port", type=int) pd.add_argument("--open", action="store_true") + pd.add_argument("--allow-insecure", action="store_true", + help="permit binding a non-loopback host with auth disabled") pd.set_defaults(func=lambda a: cmd_serve(a, demo=True), demo=True) sub.add_parser("status", help="show orgs, balances, Stripe mode").set_defaults(func=cmd_status) diff --git a/plutus_agent/server/app.py b/plutus_agent/server/app.py index 4426f99..c7a38a5 100644 --- a/plutus_agent/server/app.py +++ b/plutus_agent/server/app.py @@ -10,6 +10,7 @@ import html import json import math +import os import secrets import sys import threading @@ -865,9 +866,36 @@ def _webhook(self, conn): return self._json(200, {"received": True, "result": result}) +def _guard_insecure_bind(host, auth_on, cfg): + """Fail closed rather than expose an UNAUTHENTICATED dashboard on a non-loopback + interface. The shipped Docker/compose default binds ``--host 0.0.0.0``, and auth + is off-by-default and falls off on misconfig, so without this a plain deploy + could publish an open billing console on the network. Bypass only with an + explicit opt-in (localhost-proxy / trusted-network deployments).""" + loopback = str(host) in ("127.0.0.1", "localhost", "::1", "") + allow_insecure = bool(cfg.get("server", {}).get("allow_insecure")) or \ + os.environ.get("PLUTUS_ALLOW_INSECURE", "").strip().lower() in ("1", "true", "yes") + if auth_on or loopback or allow_insecure: + return + raise SystemExit( + f"plutus: refusing to serve on host {host!r} with authentication disabled; " + "this would expose the billing dashboard and API to the network.\n" + " Do one of:\n" + " - enable auth (PLUTUS_AUTH_ENABLED=1 + Google client id/secret + an https base_url)\n" + " - bind 127.0.0.1 behind a TLS-terminating reverse proxy\n" + " - for a trusted network only, pass --allow-insecure (PLUTUS_ALLOW_INSECURE=1)") + + def serve(host=None, port=None, db_path=None, demo=False, cfg=None, open_browser=False): """Start the dashboard/API server. Blocks until interrupted.""" + # Fix (#17): PyYAML is a hard dependency; fail fast rather than silently + # degrading to the minimal config parser (which could reset a security config + # such as allowed_emails to defaults when run from source without deps). + import importlib.util + if importlib.util.find_spec("yaml") is None: + raise SystemExit("plutus: PyYAML is required to run the server " + "(pip install 'plutus-agent[all]').") cfg = cfg or cfgmod.load() host = host or cfg.get("server", {}).get("host", "127.0.0.1") port = int(port or cfg.get("server", {}).get("port", 8420)) @@ -879,6 +907,7 @@ def serve(host=None, port=None, db_path=None, demo=False, cfg=None, c.close() ctx = _Ctx(cfg, db_path, demo=demo) + _guard_insecure_bind(host, ctx.auth_on, cfg) httpd = _Server((host, port), Handler, ctx) url = f"http://{host}:{port}/" stripe_mode = ctx.stripe.status()["mode"] diff --git a/pyproject.toml b/pyproject.toml index 827b710..8094bd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,14 +32,17 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "PyYAML>=5.4", + "PyYAML>=5.4,<7", ] [project.optional-dependencies] -stripe = ["stripe>=7.0"] -pdf = ["reportlab>=4.0"] -all = ["stripe>=7.0", "reportlab>=4.0"] -dev = ["stripe>=7.0", "reportlab>=4.0", "pytest>=7.0"] +# Upper bounds pin against surprise major bumps of money/report-critical deps +# (reproducibility; deploy a hash-locked artifact for production — see +# docs/deploy-hardening.md). +stripe = ["stripe>=7.0,<14"] +pdf = ["reportlab>=4.0,<5"] +all = ["stripe>=7.0,<14", "reportlab>=4.0,<5"] +dev = ["stripe>=7.0,<14", "reportlab>=4.0,<5", "pytest>=7.0,<10"] [project.urls] Homepage = "https://perseus.observer/plutus/" diff --git a/tests/test_deploy_guard.py b/tests/test_deploy_guard.py new file mode 100644 index 0000000..ca6f989 --- /dev/null +++ b/tests/test_deploy_guard.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Deploy hardening (2026-07-05 security review): the server fails closed rather +than expose an unauthenticated dashboard on a non-loopback interface.""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plutus_agent.server import app + + +class TestInsecureBindGuard(unittest.TestCase): + def test_refuses_public_bind_with_auth_off(self): + with self.assertRaises(SystemExit): + app._guard_insecure_bind("0.0.0.0", auth_on=False, cfg={}) + + def test_allows_loopback_even_with_auth_off(self): + for h in ("127.0.0.1", "localhost", "::1", ""): + app._guard_insecure_bind(h, False, {}) # must not raise + + def test_allows_public_bind_when_auth_on(self): + app._guard_insecure_bind("0.0.0.0", True, {}) # must not raise + + def test_allows_public_bind_with_config_optin(self): + app._guard_insecure_bind("0.0.0.0", False, {"server": {"allow_insecure": True}}) + + def test_allows_public_bind_with_env_optin(self): + os.environ["PLUTUS_ALLOW_INSECURE"] = "1" + try: + app._guard_insecure_bind("0.0.0.0", False, {}) + finally: + del os.environ["PLUTUS_ALLOW_INSECURE"] + + +if __name__ == "__main__": + unittest.main()