From f597906c572f07af5d3314e0dd0d81f3f6dc72f1 Mon Sep 17 00:00:00 2001
From: Ameen Vazayil <7609895+vazra@users.noreply.github.com>
Date: Wed, 29 Apr 2026 13:39:06 -0700
Subject: [PATCH] docs: document security-related config + behavior changes
from PRs 28-67
- reference/configuration.md: management_addr (default 127.0.0.1),
http_listen_addr now defaulted to :80 when tls.mode auto/local.
- reference/env-vars.md: SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK; updated
webhook SSRF note for DNS-rebind protection.
- reference/ports.md: dashboard local-only default; loopback rewrite for
app ports, with opt-out via env var or explicit interface bindings.
- reference/compose-labels.md: new section listing all dangerous compose
fields rejected by the validator (security_opt, devices, userns_mode,
expanded caps, host-bind shim via driver_opts, etc.).
- guides/api-keys.md: expires_at on create + last_used_at in list.
- guides/users-roles.md: session invalidation behavior (token_version
bumped on logout / password change / role change), per-(user,IP)
lockout, dedicated login rate limit.
- operations/security-hardening.md: HKDF subkey for JWT signing, iss/aud
binding, token_version, login limiter, lockout enumeration fix.
- operations/disaster-recovery.md: tar archive validation rejection
rules + concurrency cap on upload-restore.
---
docs/guides/api-keys.md | 6 ++++-
docs/guides/users-roles.md | 17 ++++++++++++-
docs/operations/disaster-recovery.md | 4 +++
docs/operations/security-hardening.md | 31 ++++++++++++++++++-----
docs/reference/compose-labels.md | 36 +++++++++++++++++++++++++++
docs/reference/configuration.md | 8 +++++-
docs/reference/env-vars.md | 3 ++-
docs/reference/ports.md | 8 +++---
8 files changed, 100 insertions(+), 13 deletions(-)
diff --git a/docs/guides/api-keys.md b/docs/guides/api-keys.md
index b822ff0..13e4f90 100644
--- a/docs/guides/api-keys.md
+++ b/docs/guides/api-keys.md
@@ -31,9 +31,13 @@ sudo simpledeploy apikey create --name ci-deploy --user-id 1
curl -X POST https://manage.example.com/api/apikeys \
-H "Authorization: Bearer $SD_API_KEY" \
-H "Content-Type: application/json" \
- -d '{"name":"ci-deploy"}'
+ -d '{"name":"ci-deploy","expires_at":"2026-12-31T23:59:59Z"}'
# {"id":3,"name":"ci-deploy","key":"sd_a1b2c3..."}
```
+
+`expires_at` is optional (omit for keys that never expire). Past dates are
+rejected. `GET /api/apikeys` returns `id`, `name`, `created_at`,
+`expires_at`, and `last_used_at` so operators can spot stale keys.
diff --git a/docs/guides/users-roles.md b/docs/guides/users-roles.md
index 56dbb25..a965651 100644
--- a/docs/guides/users-roles.md
+++ b/docs/guides/users-roles.md
@@ -132,8 +132,23 @@ curl -X DELETE https://manage.example.com/api/users/2 \
This also revokes all their API keys.
+## Session invalidation
+
+JWT cookies carry a `tv` (token version) claim that is bumped server-side on:
+
+- **Logout** — the cookie is invalidated immediately, not just cleared.
+- **Password change** — every other session for that user is signed out.
+- **Role change** — promotions and demotions take effect right away,
+ not 24 hours later when the cookie expires.
+
+If a session is suspected compromised, force-rotating the user's password
+or role kills the existing JWT cookie even if the attacker holds it.
+
See also: [API keys](/guides/api-keys/), [Audit log](/guides/audit-log/).
diff --git a/docs/operations/disaster-recovery.md b/docs/operations/disaster-recovery.md
index 9ce906a..475eb49 100644
--- a/docs/operations/disaster-recovery.md
+++ b/docs/operations/disaster-recovery.md
@@ -117,6 +117,10 @@ docker run --rm -v myapp_data:/restore -v $(pwd):/backup alpine \
tar xzf /backup/myapp-2026-04-15.tar.gz -C /restore
```
+
+
### Step 8: Verify
- Dashboard loads
diff --git a/docs/operations/security-hardening.md b/docs/operations/security-hardening.md
index a082993..c3fcd45 100644
--- a/docs/operations/security-hardening.md
+++ b/docs/operations/security-hardening.md
@@ -26,22 +26,41 @@ Before going to production:
### JWT Sessions
-- Session tokens are signed JWTs with 24-hour expiry
-- The signing key is derived from `master_secret` in your config
-- If `master_secret` is not set, a random secret is generated per process (sessions won't survive restarts)
-- Cookies are set with `HttpOnly`, `Secure`, `SameSite=Strict`, and `MaxAge=86400`
+- Session tokens are signed JWTs (HS256) with 24-hour expiry. The signing
+ key is HKDF-SHA256-derived from `master_secret` with purpose label
+ `simpledeploy-jwt-v1` so it is domain-separated from credential
+ encryption and API-key HMAC.
+- Tokens carry `iss=simpledeploy`, `aud=simpledeploy-dashboard`, and a
+ per-user `tv` (token version) claim. Bumping `tv` server-side
+ invalidates all outstanding JWTs for that user.
+- `tv` is bumped on logout, password change, and role change — a stolen
+ cookie cannot outlive any of those events.
+- Cookies are `HttpOnly`, `Secure` (when TLS), `SameSite=Strict`, `MaxAge=86400`.
### API Keys
- API keys use the `sd_` prefix followed by 64 hex characters (32 bytes of entropy)
- Keys are hashed with **HMAC-SHA256** using your `master_secret` before storage
- Even if the database is stolen, keys cannot be recovered without the master secret
-- Keys support optional expiry dates; expired keys are rejected at the middleware level
+- Keys support optional `expires_at` (set on create); expired keys are rejected at the middleware level
+- `last_used_at` is updated lazily on every successful auth so operators can spot stale keys
- The plaintext key is shown exactly once at creation and never stored
+### Login Rate Limit
+
+The login endpoint has its own rate limiter, capped at **10 requests per
+minute per client IP**. Login abuse cannot deplete the global request
+budget and vice versa. `trusted_proxies` accepts CIDR ranges
+(e.g. `10.0.0.0/8`); without a matching entry the proxy IP is treated as
+the client.
+
### Account Lockout
-After 10 failed login attempts, the account is temporarily locked with progressive backoff:
+After 10 failed login attempts per (username, IP) tuple, the account is
+temporarily locked with progressive backoff. Locking is per-IP-per-user
+so an attacker on one IP cannot DoS a victim's login from a different
+IP. A locked-out attempt returns the same `401 invalid credentials` as
+a wrong password, eliminating an enumeration tell.
| Failures over threshold | Lockout duration |
|------------------------|-----------------|
diff --git a/docs/reference/compose-labels.md b/docs/reference/compose-labels.md
index 0a638e0..5f081c5 100644
--- a/docs/reference/compose-labels.md
+++ b/docs/reference/compose-labels.md
@@ -46,6 +46,42 @@ If `simpledeploy.port` is not set, SimpleDeploy uses the first port mapping it f
Endpoint services no longer need to publish host ports to be reachable. SimpleDeploy auto-attaches them to a shared `simpledeploy-public` Docker network and reverse-proxies over that. `ports:` still works and, when present, takes precedence over the shared-network path.
+### Published port loopback rewrite
+
+`ports: "8080:80"` style mappings are rewritten at deploy time to
+`127.0.0.1:8080:80` so the published port is reachable only from the host
+itself. Caddy still proxies external traffic to the same upstream, but
+this prevents an unauthenticated attacker from reaching the app raw on
+:8080 and bypassing per-app `simpledeploy.access.allow` and
+`simpledeploy.ratelimit.*` controls.
+
+Operator-explicit interface bindings (`"0.0.0.0:8080:80"`,
+`"127.0.0.1:9090:90"`, `"[::1]:5432:5432"`) are preserved verbatim. To
+disable the rewrite globally, set `SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK=true`.
+
+### Compose security validation
+
+Compose files are rejected at deploy time (and by the reconciler watcher
+on disk) if they declare any of the following container-escape vectors:
+
+- `privileged: true`
+- `network_mode: host`, `pid: host`, `pid: container:*`, `pid: service:*`
+- `ipc: host`, `userns_mode: host`, `cgroup: host`
+- Dangerous capabilities via `cap_add`: `ALL`, `SYS_ADMIN`, `SYS_PTRACE`,
+ `SYS_MODULE`, `SYS_RAWIO`, `SYS_BOOT`, `SYS_TIME`, `NET_ADMIN`,
+ `NET_RAW`, `DAC_READ_SEARCH`, `DAC_OVERRIDE`, `BPF`, `PERFMON`, `MKNOD`
+ (with or without the `CAP_` prefix).
+- `security_opt`: `apparmor=unconfined`, `seccomp=unconfined`,
+ `label=disable`, `systempaths=unconfined`, `no-new-privileges=false`.
+- `devices` (any non-empty list).
+- `volumes_from` (any non-empty list).
+- Bind mounts of `/etc`, `/proc`, `/sys`, `/dev`, `/var/run/docker.sock`,
+ `/root`, `/var/lib/docker`, `/boot`, `/lib/modules`, `/run`, `/`,
+ or any path containing `..`.
+- Top-level volumes with `driver_opts` of the form
+ `type: none, o: bind, device: /host/path` (the host-bind shim) when the
+ device path falls in the bind-source deny list.
+
## Access Control Labels
See [IP access control](/guides/access-control/) for the full guide.
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index dbaa72f..9a80620 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -29,6 +29,11 @@ http_listen_addr: ":80"
# Management API + dashboard port
management_port: 8443
+# Management bind address. Default "127.0.0.1" keeps the dashboard local-only;
+# front it with Caddy on a manage. route, or set to "" (or "0.0.0.0")
+# to bind every interface (legacy behavior, exposes plain HTTP).
+management_addr: "127.0.0.1"
+
# Management domain (for TLS cert)
domain: manage.example.com
@@ -98,8 +103,9 @@ git_sync:
| `data_dir` | string | `/var/lib/simpledeploy` | Database and backup storage |
| `apps_dir` | string | `/etc/simpledeploy/apps` | Watched directory for compose files |
| `listen_addr` | string | `:443` | Reverse proxy listen address (HTTPS) |
-| `http_listen_addr` | string | `""` | Optional plain-HTTP listener that 308-redirects to HTTPS. Ignored when `tls.mode: off`. |
+| `http_listen_addr` | string | `:80` (when `tls.mode` is `auto` or `local`) | Plain-HTTP listener that 308-redirects to HTTPS. Set explicitly to `""` to disable. Ignored when `tls.mode: off`. |
| `management_port` | int | `8443` | Management API port |
+| `management_addr` | string | `127.0.0.1` | Management API bind address. Default keeps the dashboard local-only. Set to `""` (or `0.0.0.0`) to expose on every interface. |
| `domain` | string | - | Management UI domain |
| `public_host` | string | `""` | Server hostname/IP used for sslip.io auto-domains in template Quick test mode. Editable at runtime. |
| `tls.mode` | string | `auto` | TLS mode: `auto`, `custom`, `off`, `local` |
diff --git a/docs/reference/env-vars.md b/docs/reference/env-vars.md
index 91e9248..ea56dc7 100644
--- a/docs/reference/env-vars.md
+++ b/docs/reference/env-vars.md
@@ -10,7 +10,8 @@ SimpleDeploy reads a small number of environment variables. The CLI prefers expl
| Name | Default | Scope | Effect |
|------|---------|-------|--------|
| `SD_PASSWORD` | (none) | client | Password used by `users create`, `apikey create`, and `registry add` when the `--password` flag is omitted. Avoids interactive stdin prompt. |
-| `SIMPLEDEPLOY_ALLOW_PRIVATE_WEBHOOKS` | `0` | server | When set to `1`, the alert webhook dispatcher allows posting to private/loopback IP ranges (RFC 1918, 127.0.0.0/8). Off by default to prevent SSRF. |
+| `SIMPLEDEPLOY_ALLOW_PRIVATE_WEBHOOKS` | `0` | server | When set to `1`, the alert webhook dispatcher allows posting to private/loopback IP ranges (RFC 1918, 127.0.0.0/8, CGNAT, multicast, etc.). Off by default. The dispatcher also re-validates the resolved IP at connect time to close DNS-rebinding bypass. |
+| `SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK` | `false` | server | When `true`, compose `ports:` mappings are NOT rewritten to `127.0.0.1:`. Default behavior pins published ports to loopback so Caddy's `simpledeploy.access.allow` and `simpledeploy.ratelimit.*` controls cannot be bypassed by direct connection. |
| `SIMPLEDEPLOY_UPSTREAM_HOST` | `localhost` | server | Overrides the host used for `localhost:` upstreams. Set to `host.docker.internal` when running SimpleDeploy inside a Docker container (non-host network) so Caddy can reach app host-published ports. The Docker install docs enable this automatically. |
| `SIMPLEDEPLOY_HEALTH_PORT` | `8443` | container (healthcheck only) | Port used by the official Docker image's `HEALTHCHECK` to probe `http://localhost:$SIMPLEDEPLOY_HEALTH_PORT/api/health`. Override when your `management_port` differs from the default (e.g. `make dev-docker` sets `8500`). Not read by the simpledeploy binary itself. |
diff --git a/docs/reference/ports.md b/docs/reference/ports.md
index 9f28837..13eaf00 100644
--- a/docs/reference/ports.md
+++ b/docs/reference/ports.md
@@ -13,11 +13,13 @@ SimpleDeploy listens on three TCP ports by default. App container ports are boun
| `443` | Caddy | HTTPS reverse proxy for all apps with `simpledeploy.domain`. | Yes |
| `8443` | management API | Dashboard, REST API, WebSockets. | Optional |
-The proxy listen address comes from `listen_addr` in `config.yaml` (default `:443`). Caddy automatically opens `:80` when `tls.mode` is `auto` so it can solve ACME challenges and redirect plaintext traffic.
+The proxy listen address comes from `listen_addr` in `config.yaml` (default `:443`). Caddy automatically opens `:80` when `tls.mode` is `auto` or `local` (via `http_listen_addr`, defaulted to `:80`) so it can solve ACME challenges and 308-redirect plaintext traffic to HTTPS.
-The management port comes from `management_port` (default `8443`). It serves the Svelte UI, REST API (`/api/*`), and WebSocket streams. Bind it to a private interface or front it with the same Caddy proxy under a `manage.example.com` domain if you do not want it directly reachable.
+The management dashboard listens on `management_port` (default `8443`) bound to `management_addr` (default `127.0.0.1`). With the default bind, it is reachable only from the host itself; route external traffic to it through Caddy under a `manage.` route, or set `management_addr: ""` to expose every interface (legacy behavior, plain HTTP).
-App containers bind whatever ports the compose file declares. SimpleDeploy never auto-publishes ports; if you do not write `ports:` in your compose, the container is reachable only on the Docker bridge network and via the Caddy reverse proxy.
+App containers bind whatever ports the compose file declares — but any `ports: "8080:80"` mapping is rewritten at deploy time to `127.0.0.1:8080:80` so the published port is reachable only from the host. Caddy still proxies external traffic to the same upstream, but raw connections from outside the host cannot bypass the per-app `simpledeploy.access.allow` IP allowlist or `simpledeploy.ratelimit.*` controls. Operators who explicitly want the legacy 0.0.0.0 binding can set `SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK=true`, or write the bind explicitly (`"0.0.0.0:8080:80"`) which is preserved verbatim.
+
+If you do not write `ports:` at all, the container is reachable only on the Docker bridge network and via the Caddy reverse proxy.
## Firewall examples