As shipped in docker-compose.yml. The five core services run with
no-new-privileges and a read-only root filesystem (writable paths are tmpfs
or volumes). The optional tailscale service has neither.
| Container | Runs as | Capabilities | Notes |
|---|---|---|---|
| backend (Go) | root (the image sets no USER) |
none (cap_drop: ALL) |
No Docker socket. Changes reach the proxy and dnsmasq as files in /config, which their watchdogs apply |
| waf (Go) | waf (UID 1000) |
none (cap_drop: ALL) |
ICAP on 1344 and the management API on 8080, internal network only |
| web (nginx) | master as root, workers as nginx |
CHOWN, SETUID, SETGID, NET_BIND_SERVICE |
TLS termination, static files, /api reverse proxy |
| proxy (Squid) | supervisord and the watchdog as root; Squid as proxy |
Docker's default set plus NET_ADMIN (no cap_drop) |
NET_ADMIN is used for iptables REDIRECT rules; transparent interception is not implemented (no intercept port) |
| dns (dnsmasq) | root (--user=root) |
NET_BIND_SERVICE, DAC_OVERRIDE |
Squid's resolver, not published on the host |
No container mounts the Docker socket. ./data (the database, .jwt_secret,
.enc_key) is mounted in the backend only.
- Admin password stored as a bcrypt hash (Go's default cost, 10). The
BASIC_AUTH_PASSWORDvalue is compared only while no hash is stored yet. - JWT access tokens, 8 hours by default, and refresh tokens that end 7 days after the login. Revoked tokens are kept in the database and survive a restart; a password change invalidates every earlier token.
- Failed Basic logins limited to 5 per client address in 5 minutes.
- Every request limited to 20 per second per client address (burst 60) by the
backend, and
/api/to 20 per second (burst 40) by nginx. - WebSocket: a single-use token offered as a subprotocol, and an
Origincheck againstCORS_ALLOWED_ORIGINSand the page's own host. - HTTPS for the UI by default: a self-signed certificate valid for 365 days,
or one mounted at
/etc/nginx/ssl/.
- Alert channel secrets (webhook URL and bearer token, Gotify URL and token, Teams webhook URL, Telegram bot token and chat ID, ntfy URL and topic) are stored with AES-256-GCM and are write-only through the API.
- The encryption key is
ENCRYPTION_KEY, or one generated and kept indata/.enc_key(mode 0600). The JWT secret isSECRET_KEY, or one generated and kept indata/.jwt_secret(mode 0600). - UI TLS: TLS 1.2 and 1.3, ECDHE key exchange with AES-GCM ciphers for TLS 1.2
(
ssl_ciphersinui/nginx.conf.template), HSTS for one year.
The UI is served by nginx with:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self'
The backend adds its own policy to API responses
(backend-go/internal/middleware/middleware.go), which also allows ws: and
wss: in connect-src.
- 172 request rules in 21 categories and 5 response rules in 2, plus 6 heuristics and per-request feature logging.
- Anomaly scoring. The URL is matched both as sent and normalised; headers
and bodies are matched normalised. Request bodies are
inspected up to 1 MB; a larger body adds the
WAF-BODY-OVERSIZEfinding and the rest is not inspected. Compressed response bodies are not inspected and are counted inwaf_respmod_uninspectable_total. - REQMOD fails closed: an unreachable WAF blocks requests unless
WAF_FAIL_OPEN=1, and a WAF handler that panics blocks the request.
- Circuit breaker: the backend's calls to the WAF management API stop for 30 seconds after 3 failures.
- Alert retry: a delivery is attempted 3 times, with 1 s and then 2 s between attempts.
- Graceful shutdown: the HTTP server drains first, then the background workers stop and are waited for (up to 5 seconds).
- Atomic file exports: each list is written to a temporary file, synced and renamed, so Squid and dnsmasq never read a partial file.
- gosec on both Go modules gates every pull request; govulncheck reports reachable known vulnerabilities as warnings.
- npm audit: HIGH and CRITICAL advisories fail the build unless listed with
a reason in
scripts/npm-audit-allowlist.json. - Trivy: fixable HIGH and CRITICAL findings in the dependencies and in the third-party images the compose files pin fail the build.
- Tests: Go tests with the race detector, with coverage floors of 60% (backend) and 70% (WAF).
- Images: CI builds the
web,backend,waf,proxyanddnsimages.
Every published release image is keyless-signed with cosign by the release
workflow, for a vX.Y.Z tag of this repository, after Trivy has scanned it
for both architectures. Each GHCR image also carries a BuildKit SBOM and SLSA
build provenance attestation. The identity is anchored, so a signature made by
any other workflow or on any other ref does not match:
cosign verify \
--certificate-identity-regexp '^https://github\.com/fabriziosalmi/secure-proxy-manager/\.github/workflows/multi-arch\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/fabriziosalmi/secure-proxy-manager-proxy:v3.29.0
# The SBOM and provenance are BuildKit attestations (not cosign artefacts):
docker buildx imagetools inspect ghcr.io/fabriziosalmi/secure-proxy-manager-proxy:v3.29.0 --format '{{ json .SBOM }}'
docker buildx imagetools inspect ghcr.io/fabriziosalmi/secure-proxy-manager-proxy:v3.29.0 --format '{{ json .Provenance }}'This needs cosign 3 or later; deploy/install.sh skips the check with a
warning when an older cosign is installed. With cosign 3 or later installed,
the installer runs the same cosign verify on every image and stops if one
fails.
Component advisories and how SPM mitigates them, such as the Squid "Squidbleed" FTP-gateway leak (CVE-2026-47729), are in docs/guide/security-advisories.md.
- Do not open a public issue.
- Use GitHub Security Advisories.
- We respond within 48 hours.
| Version | Status |
|---|---|
| 3.18.x | Current: security fixes are released here |
| < 3.18 | Not supported: upgrade to the latest 3.18.x |