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