Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ plutus.html
plutus.err
*.plutus-bak-*
config.yaml.plutus-bak-*
# Config and its timestamped backups (config.yaml.bak-<ts>) can hold file-sourced
# secrets — never commit them (the old patterns above missed the .bak- form).
config.yaml
config.yaml.*
*.bak-*

# Python
__pycache__/
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ts>`,
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
Expand Down
20 changes: 15 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
18 changes: 14 additions & 4 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions docs/deploy-hardening.md
Original file line number Diff line number Diff line change
@@ -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://<host>` (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
```
9 changes: 9 additions & 0 deletions plutus_agent/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions plutus_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions plutus_agent/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import html
import json
import math
import os
import secrets
import sys
import threading
Expand Down Expand Up @@ -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))
Expand All @@ -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"]
Expand Down
13 changes: 8 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
37 changes: 37 additions & 0 deletions tests/test_deploy_guard.py
Original file line number Diff line number Diff line change
@@ -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()
Loading