Only the latest release of SnapOtter receives security updates. We recommend always running the most recent version.
| Version | Supported |
|---|---|
| Latest release | Yes |
| Previous releases | No |
Self-hosted deployments should subscribe to GitHub release notifications and upgrade promptly when security patches are published.
Do not open a public GitHub issue or pull request for security vulnerabilities.
To report a vulnerability, email contact@snapotter.com with:
- A description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept
- The affected version(s)
- Any suggested fix, if available
| Stage | Timeline |
|---|---|
| Acknowledgment | Within 48 hours |
| Critical severity patch | Within 7 days |
| Non-critical severity patch | Within 30 days |
After acknowledging your report, we will keep you informed of our progress toward a fix. Once a patch is released, we will credit you in the release notes unless you prefer to remain anonymous.
| Severity | Definition |
|---|---|
| Critical | Remote code execution, authentication bypass, data exfiltration without authentication |
| High | Privilege escalation, stored XSS, SQL injection, SSRF with internal network access |
| Medium | CSRF, information disclosure of non-sensitive data, denial of service |
| Low | Missing security headers on non-sensitive endpoints, verbose error messages |
- Password hashing: scrypt with 32-byte random salt and 64-byte derived key
- Timing-safe comparison: All credential verification uses
crypto.timingSafeEqualto prevent timing attacks - Password policy: Minimum 8 characters with uppercase, lowercase, and numeric requirements
- Session management: Cryptographically random UUIDs, configurable expiration (
SESSION_DURATION_HOURS), automatic cleanup of expired sessions - Credential rotation: Password changes invalidate all other sessions and revoke all API keys for the affected user
- Brute-force protection: Per-endpoint rate limiting on the login route (
LOGIN_ATTEMPT_LIMIT) - API keys: Hashed with scrypt (same parameters as passwords), SHA-256 prefix index for O(1) lookup, optional expiration, scoped permissions
- Role-based access control: Hierarchical roles (admin > editor > user) with granular permissions. Escalation prevention blocks creating or promoting users above your own role. Last-admin and self-demote protections prevent lockout
- Image uploads: Magic-byte verification against a known format table, null-byte buffer detection, configurable megapixel limit (
MAX_MEGAPIXELS), configurable upload size limit (MAX_UPLOAD_SIZE_MB) - SVG sanitization: Strips DOCTYPE declarations (XXE prevention), removes
<script>tags,<foreignObject>elements, event handlers, and blocks dangerous URI schemes (javascript:,data:text/html,file:, external URLs inhref/xlink:href) - API validation: Zod schemas on tool routes and environment config; manual validation on auth routes
- Database queries: Parameterized via Drizzle ORM (PostgreSQL) — no raw string concatenation
The following headers are set on all responses:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-XSS-Protection |
0 (modern best practice) |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Strict-Transport-Security |
max-age=31536000; includeSubDomains (production only) |
Content-Security-Policy |
Restrictive policy with default-src 'self' (production only) |
- Global rate limiting via
@fastify/rate-limit(configurable withRATE_LIMIT_PER_MIN) - Stricter per-route limits on authentication endpoints
- Static assets excluded from rate limiting
- Non-root execution: Dedicated
snapotteruser and group created at build time. The entrypoint starts as root only to fix volume permissions, then drops privileges viagosu - Root prevention: PUID/PGID of 0 are explicitly rejected with a warning
- PID 1:
tinihandles zombie reaping and signal forwarding - Multi-stage build: Production image contains only runtime dependencies
- No baked credentials: Auth defaults (
AUTH_ENABLED,DEFAULT_USERNAME,DEFAULT_PASSWORD) are set at container runtime, never in image layers - Health check: Built-in
HEALTHCHECKinstruction with 30-second intervals - PUID/PGID support: Bind mount permission conflicts are resolved by remapping the runtime user to match host UID/GID
Security-relevant events are dual-written to structured stdout (for log aggregators) and to the PostgreSQL database:
LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, PASSWORD_CHANGED, PASSWORD_RESET, USER_CREATED, USER_UPDATED, USER_DELETED, API_KEY_CREATED, API_KEY_DELETED, ROLE_CREATED, ROLE_UPDATED, ROLE_DELETED, SETTINGS_UPDATED, FILE_UPLOADED, FILE_DELETED
- Stack traces are suppressed in production (
NODE_ENV=production) - Internal server errors return a generic message to clients
- Optional Sentry integration for error tracking
SnapOtter is a self-hosted application. Security is a shared responsibility between the SnapOtter maintainers and the deployer.
| Area | SnapOtter maintainers | Deployer |
|---|---|---|
| Application code | Patch vulnerabilities, follow secure coding practices | Keep SnapOtter updated to the latest release |
| Docker image | Publish hardened images with non-root user, minimal attack surface | Pull updates regularly, scan images with your own tooling |
| Dependencies | Monitor and update npm/pip dependencies | N/A |
| Authentication | Provide secure auth implementation (scrypt, RBAC, brute-force protection) | Change default credentials before production use, enforce strong passwords |
| TLS/HTTPS | Support TRUST_PROXY for termination at a reverse proxy, defaulting to private-network peers only |
Configure and maintain TLS certificates and reverse proxy; set TRUST_PROXY to match your actual topology |
| Network security | Bind to 0.0.0.0 for container flexibility |
Restrict network exposure with firewalls, do not expose port 1349 directly to the internet |
| Host OS | N/A | Patch and harden the host operating system |
| Secrets management | Never bake credentials into image layers | Manage env vars securely (Docker secrets, Vault, etc.), rotate the default admin password |
| Data backups | Store data in /data for easy volume mounting |
Implement backup and disaster recovery for the /data volume |
| Monitoring | Emit structured audit logs and health check endpoints | Collect logs, set up alerting, monitor /api/v1/health |
The following configurations are recommended for production deployments:
- Change the default admin password immediately after first login (enforced by
mustChangePasswordunlessSKIP_MUST_CHANGE_PASSWORD=true) - Place SnapOtter behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik)
- Set
CORS_ORIGINto your specific domain(s) if cross-origin access is needed (default in production is same-origin only)
- Set
RATE_LIMIT_PER_MINto an appropriate value for your workload (e.g.,60) - Set
MAX_UPLOAD_SIZE_MBto limit upload sizes (e.g.,50) - Set
MAX_MEGAPIXELSto prevent memory exhaustion from oversized images (e.g.,100) - Set
MAX_USERSto limit account creation - Set
SESSION_DURATION_HOURSto a value appropriate for your environment (default:168/ 7 days) - Set
LOGIN_ATTEMPT_LIMITto a low value (default:10) - Set
TRUST_PROXYto match your topology (see "Client IP resolution" below) - Use named Docker volumes instead of bind mounts for the
/datadirectory - Run with explicit
PUID/PGIDmatching your host user
- Set
MAX_BATCH_SIZEto limit batch processing resource consumption - Set
MAX_PIPELINE_STEPSto limit pipeline complexity - Set
PROCESSING_TIMEOUT_Sto prevent long-running operations from monopolizing resources - Set
MAX_SVG_SIZE_MBto limit SVG upload sizes - Set
MAX_PDF_PAGESto limit PDF processing scope - Forward structured logs to a centralized log aggregator (audit events emit at
infolevel — do not setLOG_LEVELaboveinfoor audit stdout output will be suppressed) - Monitor the
/api/v1/healthendpoint with your infrastructure monitoring - Restrict Docker socket access if running alongside other containers
request.ip is the key for every IP-scoped control: the global rate limiter, the login brute-force limiter, the enterprise IP allowlist, and IP attribution in the audit log. TRUST_PROXY decides which peers are allowed to set that value with an X-Forwarded-For header.
The default is loopback,linklocal,uniquelocal, so only a peer on a private network is believed:
| Value | Who may set the client IP | Use it when |
|---|---|---|
loopback,linklocal,uniquelocal (default) |
Peers on 127.0.0.0/8, 169.254.0.0/16, 10/8, 172.16/12, 192.168/16 and the IPv6 equivalents |
Anything normal: a direct docker run, or a reverse proxy on the same host, a Docker network, or your LAN |
false |
Nobody. request.ip is always the socket peer |
You want header handling off entirely and accept that a proxied deployment shares one rate-limit bucket |
true |
Anyone who connects | Only when a proxy you control sits in front on a public address. Never on a directly exposed instance |
10.0.0.5,192.0.2.7 or a CIDR list |
Exactly the peers you name | You want to name your proxies rather than trust a whole range |
A number, e.g. 2 |
The client is taken that many hops from the right of the chain | A known, fixed depth of proxies |
Setting true on an instance that is reachable directly makes request.ip attacker-controlled: a caller who rotates the header gets a fresh rate-limit bucket on every request, which removes brute-force protection from the login route and makes the IP allowlist bypassable.
On Docker Desktop (macOS and Windows) a published port is served through a userland proxy that rewrites the source address to the VM gateway, 192.168.65.1. Every client arrives as that one private address, so:
- the default trust list believes it, and a forged
X-Forwarded-Forstill movesrequest.ip - setting
TRUST_PROXY=falsedoes not help either: it just collapses every client into a single bucket keyed on the gateway, so one abusive caller rate-limits everyone
No value of TRUST_PROXY recovers the real client address on that platform, because it is discarded before the application sees it. Docker Desktop is a development target. Deploy on Linux, where published ports use NAT and the real source address survives, and put a reverse proxy in front for anything internet-facing.
The shipped default moved from true to loopback,linklocal,uniquelocal. Most deployments need no action: a reverse proxy on the same host, a Docker network, or a LAN holds a private address and is still believed.
Set TRUST_PROXY=true explicitly if your proxy reaches SnapOtter from a public address, for example a cloud load balancer on a different network. If you do not, X-Forwarded-For from that proxy is ignored and every request is attributed to the proxy's address, which puts all your users in one rate-limit bucket and breaks an IP allowlist keyed on real client addresses.
Both permissions appeared in the roles editor but no route checked them, so unticking either had no effect. They are now enforced on the file-library routes (/api/v1/files*) and the saved-pipeline routes (/api/v1/pipeline/save, /list, DELETE /:id).
All three built-in roles (admin, editor, user) include both permissions, so they are unaffected. Only a custom role that deliberately omitted one changes behaviour, which is what unticking the box was meant to do in the first place. Holding the broader files:all or pipelines:all is sufficient on its own, so no role is locked out for lacking the :own half.
If a custom role loses access it did not expect to lose, add the missing permission back in Settings, Roles.
Ad-hoc pipeline execution (/api/v1/pipeline/execute and /batch) is unchanged: it stores nothing and remains governed by tools:use.
- npm dependencies are locked via
pnpm-lock.yamlwith--frozen-lockfilein CI and Docker builds - Python dependencies are pinned to exact versions in the Dockerfile
- GitHub Dependabot or similar tooling is recommended for automated dependency update PRs
We follow coordinated disclosure. After a fix is released:
- The vulnerability is documented in the GitHub release notes
- A CVE is requested for critical and high severity issues
- The reporter is credited unless they request anonymity