Files
SnapOtter/SECURITY.md
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
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.
2026-07-27 15:37:30 +08:00

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.timingSafeEqual to 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 in href/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 with RATE_LIMIT_PER_MIN)
  • Stricter per-route limits on authentication endpoints
  • Static assets excluded from rate limiting

Container Security

  • Non-root execution: Dedicated snapotter user and group created at build time. The entrypoint starts as root only to fix volume permissions, then drops privileges via gosu
  • Root prevention: PUID/PGID of 0 are explicitly rejected with a warning
  • PID 1: tini handles 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 HEALTHCHECK instruction 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 mustChangePassword unless SKIP_MUST_CHANGE_PASSWORD=true)
  • Place SnapOtter behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik)
  • Set CORS_ORIGIN to your specific domain(s) if cross-origin access is needed (default in production is same-origin only)
  • Set RATE_LIMIT_PER_MIN to an appropriate value for your workload (e.g., 60)
  • Set MAX_UPLOAD_SIZE_MB to limit upload sizes (e.g., 50)
  • Set MAX_MEGAPIXELS to prevent memory exhaustion from oversized images (e.g., 100)
  • Set MAX_USERS to limit account creation
  • Set SESSION_DURATION_HOURS to a value appropriate for your environment (default: 168 / 7 days)
  • Set LOGIN_ATTEMPT_LIMIT to a low value (default: 10)
  • Set TRUST_PROXY to match your topology (see "Client IP resolution" below)
  • Use named Docker volumes instead of bind mounts for the /data directory
  • Run with explicit PUID/PGID matching your host user

Additional Hardening

  • Set MAX_BATCH_SIZE to limit batch processing resource consumption
  • Set MAX_PIPELINE_STEPS to limit pipeline complexity
  • Set PROCESSING_TIMEOUT_S to prevent long-running operations from monopolizing resources
  • Set MAX_SVG_SIZE_MB to limit SVG upload sizes
  • Set MAX_PDF_PAGES to limit PDF processing scope
  • Forward structured logs to a centralized log aggregator (audit events emit at info level — do not set LOG_LEVEL above info or audit stdout output will be suppressed)
  • Monitor the /api/v1/health endpoint 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-For still moves request.ip
  • setting TRUST_PROXY=false does 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.yaml with --frozen-lockfile in 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:

  1. The vulnerability is documented in the GitHub release notes
  2. A CVE is requested for critical and high severity issues
  3. The reporter is credited unless they request anonymity