Skip to main content

Questarr — Threat Model & Attack Surface Analysis

Version: 1.0 Date: 2026-07-01 Last reviewed: 2026-07-01 Author: Doezer Audience: Maintainers, contributors, security reviewers

1. Purpose & Scope

This document is Questarr’s attack-surface analysis, satisfying OpenSSF Baseline OSPS-SA-03.02: it identifies critical code paths, trust boundaries, and external interactions, and records how each threat is mitigated or knowingly accepted. In scope: the Express API and its route handlers, the React client, the SQLite/Drizzle data layer, Socket.io realtime channel, and every external service Questarr talks to (indexers, download clients, IGDB, Steam, HowLongToBeat, NexusMods, xREL, PCGamingWiki). Out of scope: OS/host hardening, reverse proxy/TLS termination, and Docker deployment configuration — those are covered by docs/SECURITY.md’s deployment security guide. Maintenance rule: update this document whenever a change adds a new external integration, a new trust boundary, a new unauthenticated route, or materially changes authentication/authorization behavior. This is enforced as part of the security-relevant test policy in .github/CONTRIBUTING.md. Bump the “Last reviewed” date above whenever this document is revisited, even if no changes are needed — a stale date is the signal that a review is overdue.

2. System Overview & Trust Boundaries

Four named trust boundaries:
  1. Browser ↔ Server — JWT bearer auth (server/auth.ts). Once authenticated, a user has full access to their own resources; there is no admin/non-admin split (see Section 8, “flat trust model” — an accepted design tradeoff, not a gap).
  2. Server ↔ SQLite — fully trusted; all access is parameterized via Drizzle ORM.
  3. Server ↔ user-configured external hosts (indexers, download clients, RSS feeds) — the highest-risk boundary. A user (or a compromised/malicious indexer response) can point the server at an arbitrary host, including internal LAN services. This is the primary reason server/ssrf.ts exists.
  4. Server ↔ hardcoded external APIs (IGDB, Steam, HLTB, NexusMods, xREL, PCGamingWiki) — lower risk since hosts aren’t user-supplied, but responses are still untrusted data, and safeFetch/isSafeUrl is applied as defense in depth regardless.

3. Assets

  • Credentials: user password hashes, the JWT signing secret, indexer API keys, download-client passwords, the NexusMods API key. See docs/SECRETS.md for the authoritative inventory of where each of these lives and how it’s protected.
  • Library/download metadata: game collections, download history — low sensitivity, but cross-user leakage is a privacy concern (see Socket.io note in Section 8).
  • Server availability: self-hosted, typically on a home server/NAS — denial of service matters more here than in a scaled cloud deployment.
  • The host LAN: the top SSRF concern. Download clients are frequently on the same network as other unauthenticated home-lab services; inducing the server to make requests to arbitrary internal hosts is the most damaging class of attack against this app.

4. High-Risk Data Flows

4.1 Indexer search → download client

Search query → indexer (Torznab/Newznab/Prowlarr, user-configured URL) → parsed result (payload URLs from the indexer’s response, untrusted) → isSafeUrl() check → safeFetch() to retrieve the .torrent/.nzb payload → handed to the download client. Mitigations:
  • server/ssrf.ts isSafeUrl()/safeFetch() — DNS-rebinding-safe resolution (validates every resolved IP, not just the first), blocks cloud metadata ranges.
  • server/downloaders.ts:333,970,1893,3439,4057 — isSafeUrl() gate before fetching a release’s payload URL; :3447,4063 — safeFetch() for the actual NZB fetch.
  • server/torznab.ts:69,446 and server/newznab.ts:53,114,311,320,373,382 — indexer clients gate search-result URLs the same way before fetching.
  • server/middleware.ts:300,373,427 — sanitizeDownloaderData/sanitizeIndexerData block .. in user-supplied download paths at write time (see residual risk in Section 8).
Note: raw fetch()/RPC calls inside downloaders.ts that target the admin-configured download client itself (e.g. the Transmission/qBittorrent control API) are not SSRF-gated, and don’t need to be — that host is the trust anchor the admin explicitly configured, not attacker-influenced data. Only fetches of indexer-supplied payload URLs are the SSRF concern, and those are covered above.

4.2 IGDB metadata fetch

Server → id.twitch.tv (OAuth token) → api.igdb.com (game metadata). Both hosts are hardcoded; credentials come from system_config/env (server/config.ts). Mitigations: igdbRateLimiter (per-user configurable); sanitizeSearchQuery/sanitizeIgdbId in server/middleware.ts; as of this document’s revision, both calls in server/igdb.ts route through safeFetch() (previously used raw fetch(), inconsistent with every other integration — fixed alongside this document, see Section 8).

4.3 User-configured RSS / indexer / downloader URLs

A user enters an arbitrary host (RSS feed URL, custom indexer, custom downloader address) at setup/settings time → stored in SQLite → later fetched by server/cron.ts/server/rss.ts/the relevant client. Mitigations: sanitizeIndexerData/sanitizeDownloaderData validate at write time; every fetch re-validates via isSafeUrl()/safeFetch() at use time too, not just at save time — this matters because DNS can change between when a URL is saved and when it’s next fetched (classic TOCTOU/rebinding window).

5. External Integration Trust Table


6. Existing Mitigations Index

This section cross-references controls by category rather than duplicating them — treat the linked file as the source of truth.
  • SSRF / DNS rebinding: server/ssrf.ts (isSafeUrl, safeFetch)
  • Input sanitization: server/middleware.ts (sanitizeSearchQuery, sanitizeGameId, sanitizeDownloadId, sanitizeIgdbId, sanitizeGameData, sanitizeIndexerData, sanitizeDownloaderData, sanitizeIndexerSearchQuery)
  • Rate limiting: server/middleware.ts (igdbRateLimiter, authRateLimiter, sensitiveEndpointLimiter, generalApiLimiter, scanRateLimiter); server/index.ts:34 (global mount)
  • Authentication/session: server/auth.ts (JWT issuance/verification); global gate at server/routes.ts:845
  • SQL injection: not applicable by construction — Drizzle ORM parameterizes all application queries; the only raw SQL (sql.raw/sql template literals in server/migrate.ts) is hardcoded migration DDL with no user input.
  • Secrets encryption at rest: server/credential-crypto.ts (AES-256-GCM) — indexer API keys and downloader username/passwords; see docs/SECRETS.md §4 for the full mechanism (key resolution, legacy-plaintext-row handling, masked-sentinel rotation)
  • Secrets scanning / dependency hygiene: .github/workflows/ci.yml (secretlint), .github/dependabot.yml
  • SAST: CodeQL (GitHub default setup, javascript-typescript + actions queries — no workflow file needed, configured at the repo level)
  • Supply-chain scoring: .github/workflows/scorecard.yml (OpenSSF Scorecard)
  • SBOM: docs/SBOM.md — Syft-generated, attached to Docker releases
  • Disclosure process / access governance: docs/SECURITY.md, MAINTAINERS.md
  • Test coverage: server/__tests__/ssrf.test.ts, ssrf_routes.test.ts, rss-ssrf.test.ts, downloaders_ssrf.test.ts, security.test.ts, security_error_handling.test.ts, auth-setup-ratelimit.test.ts, scan-ratelimit.test.ts

7. Unauthenticated Surface

server/routes.ts:845 registers app.use("/api", authenticateToken), which gates every route registered after that line. The routes below are registered before it and are the actual unauthenticated surface: No route here exposes user data or performs a state-changing action without either explicit authentication or a narrowly-scoped, low-sensitivity response.

8. Residual & Accepted Risks


9. Review Cadence

Re-review triggers, rather than a calendar chore that tends to get skipped:
  • A new external integration is added.
  • A new unauthenticated route is introduced (i.e., anything registered before the routes.ts:845 gate).
  • Authentication/session/authorization behavior changes.
  • A major version bump.
Any maintainer or contributor should update this document as part of the PR that triggers one of the above — see the testing policy in .github/CONTRIBUTING.md.