mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix(api): prevent a crash when an over-limit upload stream has no consumer yet busboy's "limit" handler destroyed the file stream with an error but never attached its own error listener, relying entirely on whatever consumes part.file downstream to do so. On a fast enough connection (or a fully buffered body, e.g. Fastify inject()), busboy can process enough bytes to hit the size limit before the route handler's receiveUpload() call has attached its own stream listener, leaving the resulting "error" event with zero listeners -- which crashes the whole process by default in Node. Surfaced by tonight's FULL_MATRIX+FUZZ integration run (880 uncaught exceptions, all the same root cause). Reproduces deterministically in isolation; unrelated to this release's actual code delta (file untouched since PR #413, well before the baseline QA pass). Fix: attach a baseline no-op error listener the moment the stream is created, guaranteeing at least one listener always exists. EventEmitter delivers "error" to every registered listener, so the real consumer's own error handling is unaffected. * fix(ai-bundles): rebuild upscale-enhance and photo-restoration to reconcile scipy ABI upscale-enhance and photo-restoration both depend on codeformer-pip, whose transitive closure (basicsr -> realesrgan -> gfpgan) pulls in an unpinned scipy. Both bundles were last built ~June 18-19, before PR #437 added the manifest's `constraints` array (numpy==1.26.4, scipy==1.12.0, etc.) to pin exactly this kind of dependency during bundle builds. Only the ocr bundle was rebuilt after that fix landed. install_feature.py has no pip install step -- it's a raw tarfile extraction with no cross-bundle conflict resolution, so installing OCR alongside either stale bundle left three incompatible scipy versions' files mixed in the same site-packages directory (a compiled _rotation.*.so from one release next to Python files expecting a different release's API), breaking the `upscale` tool and OCR's higher-quality tiers with an ImportError. Rebuilt both bundles for amd64-gpu and arm64-cpu from the current manifest, verified scipy/scikit-learn/scikit-image/pandas all resolve to the pinned versions in the tarballs themselves, then verified end-to-end on real hardware (Mac arm64 CPU and ubuntu_gpu .248 RTX 4070): installing all affected bundles together now yields exactly one version of each constrained package, `upscale` produces correct output, and OCR's balanced/best tiers correctly use PaddleOCR-GPU instead of erroring out. Published the rebuilt tarballs to the public deepsafe/feature-bundles HuggingFace repo and updated this manifest's sha256/sizes to match. Also adds verify-bundle-compatibility.sh: verify-bundle.sh checks each bundle in isolation (a fresh venv per bundle), which is exactly why this shipped twice -- nothing ever checked that bundles built at different times agree once layered into the one shared venv real installs use. The new script installs every bundle for an arch into one venv and asserts each constrained package has exactly one, correct version. Known follow-up (not fixed here, needs separate discussion): uninstalling a bundle only removes its downloaded model weights, never the site-packages it added, so existing installations that already hit this bug have no clean self-service fix via uninstall+reinstall -- they need a full AI-venv wipe. * fix(docker): bake a real rate limit default for the all-in-one one-liner The documented single-container `docker run` install had RATE_LIMIT_PER_MIN=0 (effectively unlimited, ~50k/min) baked in, since only docker-compose.yml carried a hardened override. A self-hoster following the one-liner path got no meaningful throttling anywhere, including auth-adjacent routes with no dedicated per-route limit. Bakes a generous-but-real 1000/min default into the Dockerfile, raises both compose files' fallback to match so the two documented install paths converge on the same posture, and updates the Zod schema default plus docs that quoted the old value. * fix(api): boot log undercounted tool routes by the conversion-preset total The "Tool routes: N active" line logged before registerConversionPresets(app) ran, so it only ever reported the base 158 tools, 83 short of the real 241-tool total. Presets have to register after the base loop (they delegate to each base tool's own processV2), so the fix moves the log line to after that call and has registerConversionPresets return its count instead of reordering the dependency. * fix(ai): forward {info}/{warning} stderr JSON instead of dropping it The dispatcher stderr parser only recognized {ready} and {progress,stage} shaped JSON lines; anything else that parsed as valid JSON (like ocr.py's GPU-to-tesseract downgrade notice, an {"info": ...} line) matched neither branch and fell through silently, never reaching docker logs. Adds explicit {info}/{warning} handling that forwards to console.log/console.warn, same as the existing [prefix]-tagged non-JSON path. * fix(api): fall back to a lower OCR tier when PaddleOCR itself is unusable ocr.ts already retries lower quality tiers on a crashed dispatcher, but the condition only matched crash-style messages (segfault, exited unexpectedly). ocr.py's own ImportError/exception handlers already produce messages telling the caller to use a lower tier (e.g. on the scipy ABI conflict class of bug), but nothing ever acted on them, so a broken PaddleOCR hard-failed with 422 instead of degrading to Tesseract like ocr-pdf effectively does. Broadens the retry condition to also catch PaddleOCR-engine-unusable messages. Note: ocr-pdf's tesseract-only behavior turned out to be an unrelated, pre-existing, deliberate design choice (PaddleOCR segfaults on rasterized PDF pages on arm64), not a graceful-fallback mechanism to copy -- the two tools weren't actually solving the same problem, so this fixes ocr.ts's own gap rather than trying to mirror ocr-pdf.
335 lines
12 KiB
Markdown
335 lines
12 KiB
Markdown
---
|
|
description: Security hardening guide for SnapOtter. Container security, network isolation, Docker secrets, Kubernetes deployment, and compliance artifacts.
|
|
---
|
|
|
|
# Security & Hardening
|
|
|
|
SnapOtter processes files entirely on your infrastructure. It sends anonymous, content-free product analytics and crash reports by default to help improve the project. It never sends your files, file names, file contents, OCR output, image metadata, or document text. Optional feedback is sent only after a user submits it, only when analytics is enabled, and contact fields are included only with explicit contact consent. An administrator can turn analytics and feedback capture off in one click under Settings > System > Privacy, no rebuild required. File processing always stays inside your container.
|
|
|
|
The container runs as a dedicated non-root user (`snapotter`) with all Linux capabilities dropped except the minimum required set. For the full vulnerability disclosure policy and security architecture, see [SECURITY.md](https://github.com/snapotter-hq/SnapOtter/blob/main/SECURITY.md) on GitHub.
|
|
|
|
## Container Hardening
|
|
|
|
The [default docker-compose.yml](https://github.com/snapotter-hq/SnapOtter/blob/main/docker/docker-compose.yml) includes production security hardening. Here is a breakdown of each option and why it matters:
|
|
|
|
```yaml
|
|
services:
|
|
SnapOtter:
|
|
image: snapotter/snapotter:latest
|
|
ports:
|
|
# Bind to localhost only for internet-facing deployments:
|
|
- "127.0.0.1:1349:1349"
|
|
volumes:
|
|
- SnapOtter-data:/data
|
|
- SnapOtter-workspace:/tmp/workspace
|
|
environment:
|
|
- AUTH_ENABLED=true
|
|
- DEFAULT_PASSWORD=change-me-immediately
|
|
- RATE_LIMIT_PER_MIN=1000
|
|
- DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter
|
|
- REDIS_URL=redis://redis:6379
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
|
|
# --- Resource limits ---
|
|
mem_limit: 6g # Prevents runaway memory from crashing the host
|
|
memswap_limit: 6g # No swap - fail fast instead of degrading the host
|
|
cpus: 4 # Cap CPU usage to 4 cores
|
|
pids_limit: 512 # Prevents fork bombs
|
|
|
|
# --- Capability restrictions ---
|
|
cap_drop:
|
|
- ALL # Drop ALL Linux capabilities first
|
|
cap_add:
|
|
- CHOWN # Needed for volume permission setup
|
|
- SETUID # Needed for gosu privilege drop (root -> snapotter)
|
|
- SETGID # Needed for gosu privilege drop
|
|
- DAC_OVERRIDE # Needed for volume permission setup
|
|
- FOWNER # Needed for volume permission setup
|
|
|
|
# --- Logging ---
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "50m" # Rotate logs at 50 MB
|
|
max-file: "5" # Keep 5 rotated log files
|
|
|
|
# --- Health check ---
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-sf", "--max-time", "5", "http://localhost:1349/api/v1/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 60s
|
|
retries: 3
|
|
|
|
shm_size: "2gb" # Required for Python ML shared memory
|
|
restart: unless-stopped
|
|
|
|
postgres:
|
|
image: postgres:17-alpine
|
|
environment:
|
|
POSTGRES_USER: snapotter
|
|
POSTGRES_PASSWORD: snapotter
|
|
POSTGRES_DB: snapotter
|
|
volumes:
|
|
- SnapOtter-pgdata:/var/lib/postgresql/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U snapotter"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
redis:
|
|
image: redis:8-alpine
|
|
command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"]
|
|
volumes:
|
|
- SnapOtter-redisdata:/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 10s
|
|
|
|
volumes:
|
|
SnapOtter-data:
|
|
SnapOtter-workspace:
|
|
SnapOtter-pgdata:
|
|
SnapOtter-redisdata:
|
|
```
|
|
|
|
### Why `no-new-privileges` Is Not Set
|
|
|
|
`security_opt: [no-new-privileges:true]` is intentionally omitted. The entrypoint starts as root to fix volume ownership, then drops to the `snapotter` user via [gosu](https://github.com/tianon/gosu), which requires setuid. Once the privilege drop completes, the process runs as `snapotter` with all capabilities except the five listed above removed.
|
|
|
|
If you use Kubernetes or Docker's `--user` flag to run as non-root directly (bypassing gosu), `no-new-privileges` is safe to enable.
|
|
|
|
### Why `read_only` Is Not Set
|
|
|
|
`read_only: true` is not set because PUID/PGID remapping writes to `/etc/passwd` and `/etc/group` at startup. If you use Docker's `--user` flag or Kubernetes `runAsUser` instead of PUID/PGID, you can safely enable a read-only root filesystem.
|
|
|
|
## Network Isolation
|
|
|
|
During normal operation, the container makes **zero outbound network connections**. All file processing happens locally using bundled libraries.
|
|
|
|
```
|
|
Browser --> Reverse Proxy (TLS) --> SnapOtter container --> (nothing)
|
|
```
|
|
|
|
The only exception is **AI model downloads**: when a user installs an AI feature bundle through the UI, the container downloads model files from GitHub Releases and PyPI. These downloads happen once per bundle and are stored in the `/data` volume.
|
|
|
|
**Firewall recommendations:**
|
|
|
|
| Scenario | Outbound rule |
|
|
|---|---|
|
|
| Air-gapped (no AI) | Block all outbound traffic from the container |
|
|
| AI bundles needed | Allow HTTPS to `github.com`, `objects.githubusercontent.com`, `pypi.org`, `files.pythonhosted.org` during install, then block |
|
|
| After AI install | Block all outbound traffic - models are cached locally |
|
|
|
|
For reverse proxy configuration (Nginx, Traefik, Caddy, Cloudflare Tunnels), see the [Deployment guide](/guide/deployment#reverse-proxy).
|
|
|
|
## Docker Secrets
|
|
|
|
For production deployments, avoid passing secrets as plain-text environment variables. The entrypoint supports Docker's `_FILE` convention: mount a secret as a file and set the corresponding `_FILE` variable to its path.
|
|
|
|
**Supported secrets:**
|
|
|
|
| Variable | `_FILE` equivalent |
|
|
|---|---|
|
|
| `DEFAULT_PASSWORD` | `DEFAULT_PASSWORD_FILE` |
|
|
| `COOKIE_SECRET` | `COOKIE_SECRET_FILE` |
|
|
| `OIDC_CLIENT_SECRET` | `OIDC_CLIENT_SECRET_FILE` |
|
|
| `S3_ACCESS_KEY_ID` | `S3_ACCESS_KEY_ID_FILE` |
|
|
| `S3_SECRET_ACCESS_KEY` | `S3_SECRET_ACCESS_KEY_FILE` |
|
|
| `SNAPOTTER_LICENSE_KEY` | `SNAPOTTER_LICENSE_KEY_FILE` |
|
|
|
|
**Example with Docker Compose secrets:**
|
|
|
|
```yaml
|
|
services:
|
|
SnapOtter:
|
|
image: snapotter/snapotter:latest
|
|
environment:
|
|
- AUTH_ENABLED=true
|
|
- DEFAULT_USERNAME=admin
|
|
- DEFAULT_PASSWORD_FILE=/run/secrets/snapotter_password
|
|
- COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
|
secrets:
|
|
- snapotter_password
|
|
- cookie_secret
|
|
|
|
secrets:
|
|
snapotter_password:
|
|
file: ./secrets/snapotter_password.txt
|
|
cookie_secret:
|
|
file: ./secrets/cookie_secret.txt
|
|
```
|
|
|
|
::: tip
|
|
Docker Compose secrets (without Swarm) require Compose v2.23 or later.
|
|
:::
|
|
|
|
## Kubernetes Deployment
|
|
|
|
The entrypoint detects when the container is already running as non-root (e.g., via Kubernetes `runAsUser`) and skips the gosu privilege drop automatically. In that case it cannot chown the mounted volumes itself, so it verifies they are writable and exits early with actionable guidance if they are not — see [Storage permissions](/guide/deployment#storage-permissions) for `fsGroup` and foreign-UID setups (TrueNAS, OpenShift).
|
|
|
|
**Recommended Pod SecurityContext:**
|
|
|
|
```yaml
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: snapotter
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: snapotter
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: snapotter
|
|
spec:
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 999
|
|
runAsGroup: 999
|
|
fsGroup: 999
|
|
containers:
|
|
- name: snapotter
|
|
image: snapotter/snapotter:latest
|
|
ports:
|
|
- containerPort: 1349
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
capabilities:
|
|
drop: [ALL]
|
|
resources:
|
|
requests:
|
|
cpu: "1"
|
|
memory: 2Gi
|
|
limits:
|
|
cpu: "4"
|
|
memory: 6Gi
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /api/v1/health
|
|
port: 1349
|
|
initialDelaySeconds: 60
|
|
periodSeconds: 30
|
|
timeoutSeconds: 5
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /api/v1/health
|
|
port: 1349
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /data
|
|
- name: workspace
|
|
mountPath: /tmp/workspace
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim:
|
|
claimName: snapotter-data
|
|
- name: workspace
|
|
emptyDir:
|
|
medium: Memory
|
|
sizeLimit: 2Gi
|
|
```
|
|
|
|
Since `runAsUser: 999` is set at the pod level, the entrypoint skips gosu entirely. This allows `allowPrivilegeEscalation: false` and `drop: [ALL]` capabilities without conflict.
|
|
|
|
For resource sizing, see [Hardware Requirements](/guide/deployment#hardware-requirements).
|
|
|
|
## Backup and Recovery
|
|
|
|
Persistent state is split across two volumes:
|
|
|
|
| Volume | Contents | Critical? |
|
|
|---|---|---|
|
|
| `SnapOtter-pgdata` | PostgreSQL database (users, settings, pipelines, jobs, audit log) | Yes |
|
|
| `/data` (app volume) | User-uploaded files, AI models, Python venv | Partially (see below) |
|
|
|
|
Within the `/data` volume:
|
|
|
|
| Path | Contents | Critical? |
|
|
|---|---|---|
|
|
| `/data/uploads/`, `/data/outputs/` | User files and processing results | Yes |
|
|
| `/data/ai/` | Downloaded AI model files | No (re-downloadable) |
|
|
| `/data/venv/` | Python virtual environment | No (rebuilt on start) |
|
|
|
|
### Database backup
|
|
|
|
Use `pg_dump` to back up the database while the stack is running:
|
|
|
|
```bash
|
|
# Dump the database
|
|
docker exec SnapOtter-postgres pg_dump -U snapotter snapotter > backup.sql
|
|
|
|
# Restore into a fresh database
|
|
cat backup.sql | docker exec -i SnapOtter-postgres psql -U snapotter snapotter
|
|
```
|
|
|
|
Alternatively, stop the stack and snapshot the `SnapOtter-pgdata` volume:
|
|
|
|
```bash
|
|
docker compose down
|
|
docker run --rm -v SnapOtter-pgdata:/data -v $(pwd)/backup:/backup \
|
|
alpine tar czf /backup/snapotter-pgdata.tar.gz -C /data .
|
|
```
|
|
|
|
### User files backup
|
|
|
|
```bash
|
|
# Snapshot the app data volume (excluding re-downloadable AI models)
|
|
docker run --rm -v SnapOtter-data:/data -v $(pwd)/backup:/backup \
|
|
alpine tar czf /backup/snapotter-files.tar.gz \
|
|
--exclude='ai' --exclude='venv' -C /data .
|
|
```
|
|
|
|
AI models total up to about 24 GB across all bundles. Since they are re-downloadable, exclude `/data/ai/` and `/data/venv/` from backups to save space. Only the database and user files are critical.
|
|
|
|
## Compliance Artifacts
|
|
|
|
Each SnapOtter release includes the following security artifacts:
|
|
|
|
| Artifact | Format | Where to find it |
|
|
|---|---|---|
|
|
| SBOM (CycloneDX) | JSON | [GitHub Release](https://github.com/snapotter-hq/SnapOtter/releases) asset: `snapotter-v{version}-sbom.cdx.json` |
|
|
| SBOM (SPDX) | JSON | [GitHub Release](https://github.com/snapotter-hq/SnapOtter/releases) asset: `snapotter-v{version}-sbom.spdx.json` |
|
|
| Vulnerability scan | Trivy JSON | [GitHub Release](https://github.com/snapotter-hq/SnapOtter/releases) asset: `snapotter-v{version}-trivy.json` |
|
|
| Vulnerability scan | SARIF | [GitHub Security](https://github.com/snapotter-hq/SnapOtter/security) tab |
|
|
| Static analysis | CodeQL (JS/TS + Python) | [GitHub Security](https://github.com/snapotter-hq/SnapOtter/security) tab, runs weekly + per PR |
|
|
| Dependency review | GitHub native | Per-PR check, fails on high-severity additions |
|
|
| Python dependency audit | pip-audit | CI run log on every push |
|
|
| Security policy | Markdown | [SECURITY.md](https://github.com/snapotter-hq/SnapOtter/blob/main/SECURITY.md) in the repository |
|
|
| Dependency updates | Dependabot | Automated weekly PRs for npm, pip, Docker, Actions |
|
|
|
|
**Running your own scan:**
|
|
|
|
Download the SBOM from the release and scan it with your preferred tool:
|
|
|
|
```bash
|
|
# Scan with Grype using the CycloneDX SBOM
|
|
grype sbom:snapotter-v1.17.2-sbom.cdx.json
|
|
|
|
# Scan with Trivy using the SPDX SBOM
|
|
trivy sbom snapotter-v1.17.2-sbom.spdx.json
|
|
|
|
# Scan the Docker image directly
|
|
trivy image snapotter/snapotter:1.17.2
|
|
```
|
|
|
|
::: info
|
|
The SBOM and vulnerability scan reflect the exact image published for that release. AI model bundles installed after deployment are not included in the SBOM since they are downloaded at runtime.
|
|
:::
|