Version: 1.0 Date: 2026-07-01 Last reviewed: 2026-07-01 Author: Doezer Audience: Maintainers, contributors, security reviewers
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.
flowchart LR
Browser["Browser (React SPA)"]
API["Express API"]
Gateauth gate\napp.use('/api', authenticateToken)\nroutes.ts:845
Handlers["Route handlers"]
DB[("SQLite\n(Drizzle ORM)")]
Socket["Socket.io\n(unauthenticated broadcast)"]
SSRFsafeFetch / isSafeUrl\nssrf.ts
Browser -- "JWT bearer" --> API
API --> Gate --> Handlers
Handlers --> DB
Handlers -.-> Socket
Socket -.-> Browser
Handlers --> SSRF
SSRF --> Indexers["Indexers\n(Torznab/Newznab/Prowlarr)\nuser-configured"]
SSRF --> Downloaders["Download clients\n(qBittorrent/Transmission/rTorrent/\nSABnzbd/NZBGet)\nuser-configured, typically LAN"]
SSRF --> RSS["RSS feeds\nuser-configured"]
SSRF --> IGDB["IGDB / Twitch OAuth\nhardcoded host"]
SSRF --> Steam["Steam / HLTB / NexusMods /\nxREL / PCGamingWiki\nhardcoded hosts"]
Four named trust boundaries:
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).server/ssrf.ts exists.safeFetch/isSafeUrl is applied as defense in depth regardless.docs/SECRETS.md for the authoritative inventory of where each of these
lives and how it’s protected.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.
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).
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).
| Integration | Host source | Auth surface | SSRF-checked? | Notes |
|---|---|---|---|---|
| Indexers (Torznab/Newznab/Prowlarr) | user-configured | API key (AES-256-GCM encrypted at rest) | Yes | highest risk — user-supplied host |
| Download clients (qBittorrent/Transmission/rTorrent/SABnzbd/NZBGet) | user-configured, typically LAN | username/password (AES-256-GCM encrypted at rest) | Payload-URL fetches: yes. Control-plane calls to the configured client: N/A (trust anchor) | see 4.1 |
| RSS feeds | user-configured | none | Yes (server/rss.ts) |
|
| IGDB / Twitch | hardcoded | client ID/secret (system_config) |
Yes (fixed in this revision) | see 4.2 |
| Steam | hardcoded | none (public endpoint) | Yes (server/steam.ts) |
wishlist import only |
| HowLongToBeat | hardcoded | none | Yes (server/hltb.ts) |
|
| NexusMods | hardcoded | API key (system_config) |
Yes (server/nexusmods.ts) |
|
| xREL | hardcoded allowlist | none | Yes (server/xrel.ts) |
only api.xrel.to/xrel-api.nfos.to permitted |
| PCGamingWiki | hardcoded | none | Yes (server/pcgamingwiki-router.ts) |
This section cross-references controls by category rather than duplicating them — treat the linked file as the source of truth.
server/ssrf.ts (isSafeUrl, safeFetch)server/middleware.ts (sanitizeSearchQuery, sanitizeGameId,
sanitizeDownloadId, sanitizeIgdbId, sanitizeGameData, sanitizeIndexerData,
sanitizeDownloaderData, sanitizeIndexerSearchQuery)server/middleware.ts (igdbRateLimiter, authRateLimiter,
sensitiveEndpointLimiter, generalApiLimiter, scanRateLimiter); server/index.ts:34 (global mount)server/auth.ts (JWT issuance/verification); global gate at
server/routes.ts:845sql.raw/sql template literals in
server/migrate.ts) is hardcoded migration DDL with no user input.server/services/ArchiveService.ts — preflight listing
refuses archives over ARCHIVE_MAX_ENTRIES / ARCHIVE_MAX_EXPANDED_BYTES, with unsafe paths
(absolute, drive letter, .., too deep), or with symlinks/hard links; see
docs/SECRETS.md §1server/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).github/workflows/ci.yml (secretlint),
.github/dependabot.ymljavascript-typescript + actions queries — no
workflow file needed, configured at the repo level).github/workflows/scorecard.yml (OpenSSF Scorecard)docs/SBOM.md — Syft-generated, attached to Docker releasesdocs/SECURITY.md,
MAINTAINERS.mdserver/__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.tsserver/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:
| Route | Rate-limited? | Justification |
|---|---|---|
GET /api/auth/status |
no | Returns whether initial setup is complete; no sensitive data. |
POST /api/auth/setup |
yes (authRateLimiter, added in this revision) |
Creates the first admin account; short-circuits with 403 once a user exists. |
POST /api/auth/login |
yes (authRateLimiter) |
Standard login brute-force protection. |
GET /api/auth/me |
n/a | Has explicit authenticateToken despite being registered before the gate. |
GET /api/health |
no | Intentionally public liveness check. |
GET/PATCH /api/settings/ssl, SSL generate/upload |
n/a | Each has explicit authenticateToken. |
GET /api/system/filesystem |
n/a (sensitiveEndpointLimiter) |
Has explicit authenticateToken; scoped to FILE_BROWSER_ROOT with path-resolution + prefix checks. |
GET /api/config |
yes (sensitiveEndpointLimiter) |
Intentionally public — returns only an IGDB-configured boolean and the xREL API base URL. |
No route here exposes user data or performs a state-changing action without either explicit authentication or a narrowly-scoped, low-sensitivity response.
| Risk | Status | Rationale |
|---|---|---|
Socket.io (server/socket.ts) has no per-connection authentication; notifyUser() broadcasts via plain io.emit() to every connected client regardless of which user owns the event |
Accepted risk / documented assumption | Connections are restricted to configured origins (CORS), so this is a same-install, cross-account metadata leak (e.g. one user seeing another’s download progress), not an externally exposed one. Questarr is not a multi-user application (see docs/ARCHITECTURE.md §9) — the supported deployment has one trusted operator, so this is low severity by design, not a gap awaiting a fix. |
Path-traversal checks in sanitizeDownloaderData/sanitizeIndexerData use a !value.includes("..") blocklist rather than the resolve-and-prefix-check pattern server/routes.ts’s file-browser endpoint uses |
Recommended follow-up | Downstream consumers don’t currently perform direct filesystem access with these values, so risk is low; migrating to the stronger pattern is still worthwhile for consistency. File a follow-up issue. |
No container image scanning (Trivy/Grype) in CI — docker-build builds the image but doesn’t scan it |
Recommended follow-up | CodeQL SAST is now enabled (GitHub default setup) and OpenSSF Scorecard runs via .github/workflows/scorecard.yml, closing the SAST half of this gap; image scanning is a separate CI infrastructure change deserving its own review (tool choice, false-positive tuning, gating vs. advisory). File a follow-up issue against .github/workflows/ci.yml. |
| Flat, single-tier trust model — an authenticated user has full access with no RBAC/admin split | Accepted risk / documented assumption | Deliberate simplicity tradeoff for a small, self-hosted app with one trusted operator per instance — Questarr is not a multi-user application (see docs/ARCHITECTURE.md §9). |
Re-review triggers, rather than a calendar chore that tends to get skipped:
routes.ts:845 gate).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.