A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
13 KiB
Security Policy
Supported Versions
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.
Reporting a Vulnerability
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
Response Timeline
| 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 Classification
| 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 |
Security Architecture
Authentication and Access Control
- 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
Input Validation
- 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
HTTP Security
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) |
Rate Limiting
- 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
Container Security
- 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
Audit Logging
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
Error Handling
- Stack traces are suppressed in production (
NODE_ENV=production) - Internal server errors return a generic message to clients
- Optional Sentry integration for error tracking
Shared Responsibility Model
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 |
Hardening Checklist
The following configurations are recommended for production deployments:
Required
- 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)
Strongly Recommended
- 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
Additional Hardening
- 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
Client IP resolution (TRUST_PROXY)
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.
Docker Desktop caveat
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.
Upgrade notes
Client IP resolution changed (TRUST_PROXY)
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.
files:own and pipelines:own are now enforced
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.
Dependency Management
- 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
Disclosure Policy
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