Last audited: 2026-03-17
- Security Architecture
- For Users
- Authentication
- Data Classification
- Encryption at Rest
- Route Protection
- Data Protection
- Input Validation
- Security Response Headers
- Threat Model
- Is There Any Telemetry Baked In?
- Username Privacy Mode
- Known Limitations
- Unmitigated Attack Vectors
- Backup Security
- Deployment Hardening
- Vulnerability Reporting
- For Contributors
- For Users
- Password hashing: Argon2 (memory-hard KDF) —
src/lib/auth.ts - Session tokens: Encrypted JWE (A256GCM) via
jose—src/lib/auth.ts - Cookie security: httpOnly, secure (when BASE_URL is HTTPS or SECURE_COOKIES=true), sameSite=strict, 7-day hard expiry
- Password policy: 8-128 characters enforced on setup and login
- Username: Optional login username (6-100 chars), case-insensitive
- TOTP 2FA: Optional TOTP via
otpauth(SHA1, 6 digits, 30s period, ±1 window). Secret encrypted at rest with AES-256-GCM. Stateless enrollment via JWE setup tokens (5min TTL). - Backup codes: 8 codes in XXXX-XXXX hex format, hashed with SHA-256 + random salt, encrypted at rest. Each code is single-use.
- Two-step login: When TOTP is enabled, login returns a pending token (60s JWE). The client sends the pending token + TOTP code to complete authentication.
- Auth model: No whitelist or bypass — every API route independently calls
authenticate(), which decrypts the JWE session.
| Data | Storage | Encrypted | Justification |
|---|---|---|---|
| Master password | app_settings.password_hash |
Argon2 hash (irreversible) | Memory-hard KDF; cannot be reversed |
| Tracker API tokens | trackers.encrypted_api_token |
AES-256-GCM | Most sensitive field; provides account access |
| Encryption key | JWE session cookie + scheduler memory | JWE (A256GCM) | In-memory copy zero-filled on destructive operations; wrapped copy persisted in DB for boot recovery |
| Scheduler key | app_settings.encrypted_scheduler_key |
AES-256-GCM (wrapped with HKDF-derived key from SESSION_SECRET) | Enables 24/7 polling; persists through logout; cleared on lockdown/nuke/password-change |
| Encryption salt | app_settings.encryption_salt |
No | Salt is not secret; useless without master password |
| Tracker names | trackers.name |
No | User-assigned label; not inherently identifying |
| Tracker base URLs | trackers.base_url |
No | Reveals which trackers the user monitors |
| Tracker usernames | tracker_snapshots.username |
No | Fetched fresh from tracker API on each poll |
| User class/group | tracker_snapshots.group_name |
No | Fetched fresh from tracker API on each poll |
| Upload/download stats | tracker_snapshots.*_bytes |
No | Numeric time-series; meaningful only with context |
| Ratio, seedbonus, H&Rs | tracker_snapshots.* |
No | Numeric time-series; meaningful only with context |
| Session cookie | Browser (tt_session) |
JWE (A256GCM) | httpOnly, secure, sameSite=strict |
| TOTP secret | app_settings.totp_secret |
AES-256-GCM | TOTP enrollment secret; compromised = account takeover |
| Proxy password | app_settings.encrypted_proxy_password |
AES-256-GCM | Proxy service credential |
| qBT credentials | download_clients.encrypted_* |
AES-256-GCM | Download client authentication |
| Backup files | Filesystem (optional) | AES-256-GCM (optional) | Contains all data including encrypted fields as ciphertext |
Disk seizure note: If an adversary accesses the raw database files, all unencrypted fields above are readable. Deploy on an encrypted filesystem (LUKS, dm-crypt, or equivalent). See Known Limitations.
All tracker API tokens are encrypted before database storage — src/lib/crypto.ts.
| Parameter | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key derivation | scrypt (N=16384, r=8, p=1) |
| IV | 12 bytes random per encryption |
| Auth tag | 16 bytes |
| Salt | 32 bytes random, stored in app_settings.encryption_salt |
The encryption key is derived from the master password on login and stored in the encrypted JWE session. It is never persisted to disk in plaintext.
The scheduler key (used to decrypt API tokens during background polling) is wrapped with a separate HKDF-derived key from SESSION_SECRET and stored in appSettings.encryptedSchedulerKey. This enables polling to run 24/7, surviving both container restarts and user logouts. The key is only cleared on destructive operations: lockdown, nuke, password change, and restore. If SESSION_SECRET is rotated, the stored key becomes undecryptable and polling will resume after the next login.
Authentication is enforced at three independent levels:
- Proxy (
src/proxy.ts): Checks fortt_sessioncookie on all non-public routes. Returns 401 for API routes; redirects to/loginfor pages. - Route handlers: Each API route calls
authenticate()fromsrc/lib/api-helpers.ts, which decrypts and validates the full JWE token. - Layout guard (
src/app/(auth)/layout.tsx): Server Component callsgetSession()before rendering any authenticated page.
Public routes are explicitly limited to: /login, /setup, /api/auth/*, /api/health.
encryptedApiTokenis never included in API responses —serializeTrackerResponse()insrc/lib/tracker-serializer.tsuses an allowlist pattern; the token is structurally unreachable from the response object.- All database queries use Drizzle ORM (parameterized queries, no SQL injection surface).
- No unsafe HTML injection methods anywhere in the codebase.
- Emergency lockdown (
POST /api/settings/lockdown): Stops scheduler, revokes all API tokens, rotates encryption salt, wipes TOTP/username, destroys session. Requires active session and three-checkbox UI acknowledgment. - Scrub & delete (
POST /api/settings/nuke): Overwrites sensitive columns with random bytes, then deletes all rows. Requires active session + master password. Disk reclamation handled by PostgreSQL autovacuum. - Backup/restore: Pure JSON format (no zip/tar). Restore validates all fields before database writes. File deletion uses
path.resolve()+ base directory prefix check. Restore requires master password re-confirmation. Encrypted fields travel as ciphertext. See Backup Security. - External HTTP requests use
AbortSignal.timeout(15_000)—src/lib/adapters/unit3d.ts:29. - Error messages sanitize hostnames and do not leak full URLs containing API tokens.
All API routes validate inputs — src/app/api/trackers/route.ts, src/app/api/trackers/[id]/route.ts.
| Field | Constraint |
|---|---|
| Tracker name | string, max 100 chars, trimmed |
| Base URL | string, max 500 chars, validated via new URL(), scheme restricted to https:// or http:// |
| API token | string, max 500 chars |
| Color | hex color format only (#[0-9a-fA-F]{3,8}), rejects arbitrary strings |
| qBittorrent tag | string, max 100 chars, trimmed |
| Poll interval | integer, clamped to 15-1440 minutes |
| Tracker ID | parsed as integer, NaN rejected |
| Platform type | allowlist: ["unit3d", "gazelle", "ggn", "nebulance", "avistaz", "mam", "custom"] |
| Password | string, 8-128 chars |
| Role name | string, max 255 chars |
| joinedAt | regex-validated YYYY-MM-DD or null |
| Notes (roles) | string, max 2000 chars |
Configured in next.config.ts for all routes:
X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 0(disables legacy XSS auditor; prevents IE/Edge quirks)X-DNS-Prefetch-Control: off(prevents DNS prefetching which can leak browsing activity)Referrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()
| Property | Description |
|---|---|
| User model | Single user, self-hosted |
| Deployment | Docker Compose (open-source) |
| Trust boundary | Network perimeter; may be internet-facing behind reverse proxy |
| Primary threats | Unauthorized access, credential leakage, supply chain |
| External calls | User-configured tracker URLs (UNIT3D, Gazelle, GGn, Nebulance APIs) + qBittorrent clients |
| Data sensitivity | Tracker API tokens (encrypted), usage statistics |
Nope 😎
Optional privacy mode (Settings → Store Usernames) controls whether tracker usernames and user classes are persisted to the database.
| Mode | Behavior |
|---|---|
| Enabled (default) | Usernames and groups are stored as-is in tracker_snapshots |
| Disabled | Usernames and groups are replaced with a length-preserving mask (▓N where N = character count) before database write. Real values are never stored. |
When disabling, you can optionally scrub historical data to retroactively replace all previously stored usernames with their masked equivalents.
The character-count mask is not strong anonymization. An investigator with database access could cross-reference the character count with ratio, upload volume, and user class to narrow down the account. For stronger protection, combine privacy mode with full-disk encryption (see Deployment Hardening).
The test-connection flow (POST /api/trackers/test) always returns the real username for confirmation. This value is ephemeral — displayed in the browser, never written to the database.
- Progressive lockout only (no IP-based rate limiting): Failed login and TOTP attempts trigger escalating lockouts (5 attempts → 30s, 10 → 2min, 15 → 15min, 20 → 1hr) via
getProgressiveLockoutMs()insrc/lib/wipe.ts. The counter is global (not per-IP), so an unauthenticated attacker can lock out the legitimate user. Deploy behind a reverse proxy with per-IP rate limiting on/api/auth/loginfor additional protection. - API token in URL parameter: UNIT3D (
?api_token=TOKEN) and GGn (?key=TOKEN) pass tokens in the query string, which may appear in the tracker's server access logs. Gazelle trackers useAuthorizationheaders (not logged by default). Upstream limitation. - No CSP header: Content Security Policy is not yet configured due to ECharts canvas rendering complexity. Basic headers (X-Frame-Options, X-Content-Type-Options) are in place.
- Optional 2FA: TOTP is available but not required. Backup codes use SHA-256 + random salt (not Argon2 — high-entropy generated codes don't need memory-hard KDF).
- DNS rebinding not mitigated at fetch time: SSRF protection validates hostnames when tracker URLs are saved, not when outbound requests are made. See Unmitigated Attack Vectors for details.
- Scheduler key persisted in DB: The encryption key is wrapped with an HKDF-derived key from
SESSION_SECRETand stored inappSettingsto enable 24/7 polling. An attacker with both database access andSESSION_SECRETcould unwrap the key and decrypt all API tokens. For Docker Compose deployments,SESSION_SECRETis in the same trust boundary as DB credentials. Deploy on an encrypted filesystem for defense against disk seizure. RotatingSESSION_SECRETinvalidates the stored key — polling resumes after the next login. - Client IP in auth logs: Failed and successful login attempts include the client IP (from
CF-Connecting-IPor the rightmostX-Forwarded-Forentry) in server log lines. IPs are never stored in the database. To suppress, configure your reverse proxy to strip these headers before forwarding to the app, or setLOG_LEVEL=errorto disable info/warn log events entirely.
These vectors are not fully mitigated at the application layer. Apply the recommended countermeasures at the infrastructure level.
Risk: Physical access to the server exposes all unencrypted database fields — tracker names, base URLs, usernames (unless privacy mode is enabled), and usage statistics. API tokens are AES-256-GCM protected, but all metadata is plaintext.
Countermeasure: Deploy the Docker volume on a LUKS- or dm-crypt-encrypted partition. See Deployment Hardening.
Risk: Root access to a running host allows process memory dumps. The scrypt-derived encryption key is held in scheduler memory for the session duration. With this key, all encrypted API tokens can be decrypted.
Countermeasure: On scheduler stop (triggered by lockdown, nuke, password change, or restore), the encryption key buffer is explicitly zero-filled (Buffer.fill(0)). Logout does not zero the key — the scheduler persists through logout for 24/7 polling. V8 may have created internal copies during GC compaction, but those are not directly inspectable. Standard server hardening also applies: patched OS, SSH key auth, non-root container.
Risk: The character-count mask (▓7 for a 7-character username) combined with ratio, upload volume, and tracker name may allow cross-referencing against a tracker's user database. Private trackers typically have small populations (hundreds to low thousands), making this feasible.
Countermeasure: Combine username privacy mode with full-disk encryption. Use the retention policy to limit historical data available for correlation.
Risk: Even over HTTPS, a network observer can see the IP addresses of tracker API endpoints, revealing which trackers the user monitors.
Countermeasure: Route outbound traffic through a VPN or Tor. This is outside the application's scope.
Risk: UNIT3D uses ?api_token=TOKEN and GGn uses ?key=TOKEN — the full URL including the token appears in the tracker's server access logs. Gazelle uses Authorization headers (not logged by default). Upstream limitation.
Countermeasure: None at the application level. Users should be aware that API tokens may appear in tracker server logs depending on the platform.
Risk: SSRF protection in src/lib/network.ts validates hostnames at configuration time, not at request time. An attacker with DNS control could register a domain that resolves to a public IP during validation, then change it to a private IP before the next poll cycle.
Countermeasure: Low risk — single user, manually entered URLs. Deploy on an isolated network segment where the container cannot reach internal services. A future enhancement could add DNS resolution validation at fetch time.
The backup/restore system (src/lib/backup.ts, src/app/api/settings/backup/) maintains the following invariants:
- Encrypted fields travel as ciphertext —
encryptedApiToken,totpSecret,encryptedProxyPassword,encryptedUsername,encryptedPasswordare never decrypted during backup generation. - Password hash excluded —
app_settings.password_hashis never included in backup files. The restoring user's current password is preserved. - Encryption salt included — required to re-derive the encryption key from the master password after restore.
- Failed login counter reset —
failedLoginAttemptsis always set to 0 on restore, regardless of the backup's value.
- All four backup routes (
export,restore,history,delete) require a valid session viaauthenticate(). - Restore requires master password re-confirmation — failed verification increments the failed login counter and triggers lockout.
When backupEncryptionEnabled is true, the entire backup JSON is wrapped in an additional AES-256-GCM layer using the session's encryption key. An encrypted backup (.ttbak) can only be restored with the same master password that created it.
- Restore executes inside a PostgreSQL transaction — any failure rolls back all changes.
- The scheduler is stopped before restore begins (encryption key zeroed).
- The session remains valid after restore. The current
encryptionSaltandpasswordHashare never overwritten — they are preserved in place. Encrypted fields are re-encrypted from the backup's salt to the current salt viareencryptField(). - BigInt values are serialized as decimal strings (not JSON numbers) to avoid 53-bit integer truncation.
- Backup files are pure JSON — no zip/tar archives, eliminating zip slip attack surface.
- Scheduled backups write to a configurable directory with
mkdir({ recursive: true }). - File deletion validates the resolved path against
backupStoragePathusingpath.resolve()+startsWith(base + path.sep). - On-demand exports are returned as a browser download and saved to the configured
backupStoragePath. If the disk write fails, the browser download still proceeds.
- Encrypted filesystem: Mount the PostgreSQL data volume on a LUKS-encrypted partition. This is the most effective defense against disk seizure.
- Non-root container: The Dockerfile runs as
nextjs(UID 1001). Verify withdocker exec <container> whoami. - Read-only filesystem: Mount the application container root as read-only (
read_only: truein docker-compose) with tmpfs for/tmp. - Network isolation: Place PostgreSQL on an internal Docker network with no published ports.
- Reverse proxy: Deploy behind Nginx, Caddy, or Traefik with TLS termination and rate limiting on
/api/auth/login. NODE_ENV=production: Required for Next.js production optimizations. Cookiesecureflag is controlled separately viaBASE_URLscheme orSECURE_COOKIES=true.SESSION_SECRET: Minimum 32 characters of cryptographically random data. Generate with:openssl rand -base64 48.
If you discover a security vulnerability:
- Do NOT open a public issue for critical/high severity findings.
- Use GitHub's private vulnerability reporting on this repository.
- Alternatively, contact the maintainer directly via the email in the git commit history.
- Include: description, reproduction steps, impact assessment, and suggested fix if possible.
- Allow reasonable time for a fix before public disclosure.
Security invariants are verified by 106 automated tests in src/lib/__tests__/security.test.ts:
| Category | Tests | What's Verified |
|---|---|---|
| Auth enforcement | 59 | Every protected route returns 401 without valid session — trackers, snapshots, roles, reorder, poll-all, settings, dashboard, quicklinks, reset-stats, logs, clients (CRUD + test + torrents + snapshots + speeds), tag-groups (CRUD + members + member CRUD), fleet (snapshots + torrents), TOTP setup/confirm/disable, change-password, lockdown, nuke, proxy-test, backup (export + restore + history + get + delete), changelog, logout |
| Token leakage | 2 | encryptedApiToken never appears in API responses (list + detail) |
| Setup protection | 1 | Setup cannot be re-triggered after initial configuration |
| Input validation | 14 | URL scheme allowlist, hex color validation, poll interval clamping, oversized input rejection, API token max length, qBT tag max length, role name max length, notes max length, date format validation, tracker ID validation |
| Crypto integrity | 5 | Encrypt/decrypt round-trip, tampered ciphertext rejected, wrong key rejected, truncated ciphertext rejected, random IV uniqueness |
| Key zeroing | 2 | Encryption key buffer is zero-filled on scheduler stop; double-stop is safe |
| Backup auth | 6 | Export, restore, history, get, and delete routes return 401 without valid session; restore validates password |
Additional security-relevant tests exist across other test files.
Run the full test suite:
pnpm test:runThe GitHub Actions workflow (.github/workflows/ci.yml) runs on every push and PR to main:
- Type check (
pnpm tsc) — catches type errors before runtime - Full test suite (
pnpm test:run) — all 2050+ tests including security invariants - Security test count guard — fails the build if the security test count drops below 78, preventing accidental removal of security tests
- Static security audit (
scripts/security-audit.ts) — runs on every PR, comments results on the PR, and fails on critical findings
The count guard ensures security coverage is monotonically non-decreasing. If a security test is removed or refactored, CI fails until the count is restored or the threshold is explicitly updated.
The security audit (scripts/security-audit.ts) performs 28 automated checks on every push and PR:
| # | Check | Severity | What's Verified |
|---|---|---|---|
| 1 | Auth enforcement | Critical | Every non-public API route calls authenticate() or getSession() |
| 2 | Dangerous functions | Critical | No code-injection-risk functions in source |
| 3 | Hardcoded secrets | Critical | No AWS keys, private keys, PATs, or API keys in source |
| 4 | Security headers | Critical | All 6 required headers present in next.config.ts |
| 5 | Cookie security | Critical | All cookie operations use httpOnly, sameSite: "strict", secure |
| 6 | Sensitive field exposure | Critical | encryptedApiToken, passwordHash, etc. not in API responses |
| 7 | Env files | Critical | No .env files tracked by git |
| 8 | Raw SQL in routes | Critical | No db.execute() in API route handlers (use Drizzle query builder) |
| 9 | Unsafe redirect/fetch | Critical | No fetch()/redirect() with user-supplied URLs in API routes (SSRF) |
| 10 | Timing-safe comparison | Critical | Secret comparisons in auth/crypto/totp use timingSafeEqual |
| 11 | No raw migrations | Critical | No SQL migration files — enforces schema-first Drizzle approach |
| 12 | Fetch timeout | Critical | All external HTTP requests in adapters/clients have AbortSignal.timeout |
| 13 | Dockerfile non-root | Critical | Docker container runs as non-root user with explicit USER directive |
| 14 | Proxy allowlist sync | Critical | Public routes in proxy allowlist match NO_AUTH_ROUTES bidirectionally |
| 15 | Console in routes | Warning | No console.log/debug/info in API route handlers |
| 16 | TODO in security files | Warning | No TODO/FIXME in security-critical source files |
| 17 | JSON.parse safety | Warning | JSON.parse() calls wrapped in try-catch |
| 18 | Bare catch blocks | Warning | No swallowed errors in API routes/lib catch blocks |
| 19 | Request body size | Warning | POST/PATCH/PUT handlers validate request body size |
| 20 | BigInt safety | Warning | BigInt fields use string serialization, not Number() |
| 21 | Path traversal defense | Critical | File delete operations use path.resolve() + startsWith(base) |
| 22 | Argon2 hashing | Critical | Password hashing in auth.ts uses Argon2, not SHA-256/bcrypt |
| 23 | Encrypted column writes | Critical | DB writes to encrypted columns use encrypt()/reencrypt() |
| 24 | TOTP flow integrity | Critical | 2FA routes enforce correct auth patterns, token flows, and single-use backup codes |
| 25 | Lockdown flow integrity | Critical | Emergency lockdown stops scheduler, revokes tokens, rotates salt, clears TOTP |
| 26 | Nuke flow integrity | Critical | Scrub & delete requires session + password, uses scrubAndDeleteAll() |
| 27 | Backup restore integrity | Critical | Restore requires session + password, resets failed attempts, uses transaction |
| 28 | Login flow integrity | Critical | Login uses Argon2, atomic failed attempts, key derivation, TOTP pending token support |
Critical failures block the build. Warnings are reported but don't block.
Suppress individual findings with an inline comment on the flagged line or the line above:
// security-audit-ignore: stream closed by client disconnect — nothing to recover
} catch { /* stream already closed */ }Block comments also work: /* security-audit-ignore: reason */
A reason is mandatory. A bare // security-audit-ignore without a colon and explanation is itself a critical failure.
Run locally: npx tsx scripts/security-audit.ts
Run this checklist when adding new features, modifying API routes, or before releases.
pnpm tsc # Type safety
pnpm test:run # Full test suite (including 78+ security tests)
npx tsx scripts/security-audit.ts # Static security audit (28 checks)- Every new API route calls
authenticate()fromsrc/lib/api-helpers.tsas its first operation - No new public routes added without updating the proxy allowlist in
src/proxy.ts(lines 12-14) -
authenticate()failure returnsNextResponse.json({ error }, { status: 401 })— no fallthrough - Destructive operations (nuke, lockdown, restore) require additional password verification beyond the session
- TOTP-protected flows use the pending token pattern (60s JWE), not direct session issuance
- New settings or admin actions do NOT bypass the three-layer defense: proxy -> layout -> route handler
- All user-supplied strings have a maximum length (
str.length > N-> 400) - URL inputs validated with
new URL()+ scheme restricted tohttp://orhttps:// - Color inputs validated against hex pattern (
/^#[0-9a-fA-F]{3,8}$/) - Numeric inputs parsed and bounds-checked (poll interval clamped to 15-1440)
- Date inputs regex-validated (
/^\d{4}-\d{2}-\d{2}$/) or null - ID parameters parsed as integers with
Number()+Number.isNaN()check - Platform type validated against allowlist, not open string
- No user input concatenated into SQL — all queries use Drizzle ORM's parameterized API
- File paths from user input validated with
path.resolve()+startsWith(basePath + path.sep)
- Zero uses of unsafe HTML injection methods in source files (innerHTML assignment, etc.)
- Zero uses of code-injection-risk functions in source files
- All user data rendered through JSX interpolation (
{value}), never as raw HTML - ECharts tooltip
formatterfunctions only render app-computed data, not raw user strings - No script tags dynamically constructed from user input
- The static security audit (
scripts/security-audit.tscheck #2) passes
- API tokens stored via
encrypt()fromsrc/lib/crypto.ts— never plaintext in the database -
encryptedApiTokenexcluded from ALL API response objects (grep for// SECURITYcomments) -
passwordHashnever included in API responses or backup exports - Encrypted credentials (
encryptedUsername,encryptedPassword,encryptedProxyPassword) excluded from responses - Password change route re-encrypts all secrets with the new derived key
-
SESSION_SECRETis at least 32 characters (checked at startup) - No secrets in
console.log,console.error, or logger calls — hostnames only, never full URLs with tokens - Error messages from adapter-fetch sanitize to hostname only (no token leakage in stack traces)
- Session cookie set with:
httpOnly: true,sameSite: "strict",secure: shouldSecureCookies(),path: "/" - Session has hard expiry (7 days) encoded in the JWE payload — not just cookie
maxAge - Destructive operations (lockdown, nuke, password change, restore) zero-fill the encryption key buffer and stop the scheduler. Logout preserves the scheduler for 24/7 polling.
- Login returns the encryption key only inside the JWE session — never in the response body
- Failed login attempts increment atomically via
recordFailedAttempt()insrc/lib/wipe.ts
- All outbound HTTP requests have a timeout (
AbortSignal.timeout(15_000)or equivalent) - Tracker URLs validated at creation time — no open redirects or protocol switching
- Proxy-required trackers (
useProxy: true) throw if proxy is unavailable — no fallback to direct connection - qBT client connections validate host/port — no SSRF via user-configured client addresses
- No user-controlled data in
Authorizationheaders beyond the stored (encrypted) API token
- All queries use Drizzle ORM — no raw SQL strings with interpolated values
- BigInt values serialized as decimal strings in JSON (not
Number()which truncates at 2^53) - Backup restore runs inside a transaction — partial failures roll back cleanly
-
scrubAndDeleteAlloverwrites sensitive columns with random bytes before deletion - Failed login counter uses atomic SQL (update + returning) — no TOCTOU race
- File read/delete operations validate resolved path against base directory +
path.sep - Backup format is pure JSON — no zip/tar archives (eliminates zip slip surface)
- No shell commands with user-supplied arguments
- Scheduled backup filenames are server-generated (timestamp-based) — not user-controlled
Verify in next.config.ts:
-
X-Content-Type-Options: nosniff -
X-Frame-Options: DENY -
Referrer-Policy: strict-origin-when-cross-origin -
Permissions-Policy: camera=(), microphone=(), geolocation=() -
X-DNS-Prefetch-Control: off
- Container runs as non-root user (UID 1001
nextjs) -
NODE_ENV=productionset in Dockerfile (cookiesecureflag derived fromBASE_URL/SECURE_COOKIES, notNODE_ENV) - PostgreSQL on internal network — no published ports
- No
.envfiles tracked by git (checked by security audit #7) -
scripts/reset-password-nuclear.mjsnot included in production Docker image
# Run static security audit (covers XSS, auth, secrets, headers)
npx tsx scripts/security-audit.ts
# Verify all API routes authenticate (files missing authenticate/getSession)
grep -rL 'authenticate\|getSession' src/app/api/**/route.ts
# Count security tests (must be >= 78)
pnpm test:run -- src/lib/__tests__/security.test.ts 2>&1 | grep 'Tests'Comparison against the 21 vulnerabilities found in Huntarr v9.4.2 (security review):
| Huntarr Vulnerability | Status | Implementation |
|---|---|---|
| Unauthenticated settings write | Mitigated | All routes call authenticate() |
| Setup flow re-arm without auth | Mitigated | Setup checks for existing config, no clear/reset endpoint |
| TOTP enrollment without auth | Mitigated | TOTP setup/confirm/disable all require active session via authenticate() |
| Recovery key without auth | Mitigated | Backup codes shown only during enrollment; disable requires valid code |
| Zip Slip file write | Mitigated | Backups are pure JSON, not archives; no file extraction |
| Path traversal in backup | Mitigated | File deletion validates resolved path against configured base directory + path.sep |
| Auth bypass whitelist | Mitigated | No whitelist — direct auth per route |
| Passwords in API responses | Mitigated | encryptedApiToken explicitly excluded from all responses |
| SHA-256 password hashing | Mitigated | Argon2 memory-hard KDF |
| Cleartext credential storage | Mitigated | AES-256-GCM encrypted at rest |
| X-Forwarded-For trust | N/A | No proxy bypass mode |
| Hardcoded API keys | Mitigated | All secrets from env vars or encrypted user input |
| XML parsing vulnerabilities | N/A | No XML handling |
| Container runs as root | Mitigated | Dockerfile uses USER nextjs (UID 1001) with explicit adduser/addgroup |
| Broad auth bypass matching | Mitigated | Explicit route-level auth, no substring/suffix matching |
| Full cross-app credential exposure | Mitigated | Responses return only safe fields, not entire config |
| World-writable file permissions | N/A | No installation scripts or service files |
| Network calls without timeouts | Mitigated | 15-second AbortSignal on all external fetches |
| Weak password hashing (salted SHA-256) | Mitigated | Argon2 with default parameters |
| No dependency scanning | Mitigated | Automated via dependency-review.yml GitHub Actions workflow on every PR |
| No security disclosure process | Mitigated | This document, plus issue tracker |