Security Assessment

July 16, 2026 · View on GitHub

This is a technical risk assessment of Questarr's most likely and impactful potential security problems. It is distinct from two other documents that cover adjacent ground and are linked rather than duplicated here:

  • .github/SECURITY.md — vulnerability reporting process, deployment hardening checklist, and the collaborator access/ escalation policy.
  • docs/SECRETS.md — a full inventory of every secret/ credential in the system, how each is stored, and how it's rotated.
  • docs/VEX.md — exploitability assessments for known vulnerabilities (CVE/GHSA) reported against third-party components Questarr ships. This document covers first-party design risk; VEX covers third-party component vulnerabilities.

Read those first for the complete credential, access-control, and component-vulnerability picture; this document analyzes risk, not inventory.

Update policy: revisit this register whenever a PR touches authentication, SSRF protections, credential storage, rate limiting, or adds a new external integration/actor (see docs/ARCHITECTURE.md for the actor list).

Methodology

Each row below is a specific, source-verified risk area rated by likelihood and impact given Questarr's threat model — a self-hosted, typically single-or-few-user application, often run behind a home network or reverse proxy rather than exposed as a multi-tenant public service. Full STRIDE-style modeling was judged to be more process than a small project can keep current; this lighter table format matches the pragmatic tone of the rest of docs/.

Risk register

Risk areaDescriptionLikelihoodImpactMitigation / statusReference
Session revocationJWTs are valid for 7 days (server/auth.ts:79-82) with no server-side revocation list or logout endpoint. A stolen token remains usable for up to a week.Low–MediumMediumAccepted risk for the self-hosted, single-tenant use case; no fix planned. Operators concerned about this can shorten the effective window by rotating JWT_SECRET, which invalidates all sessions immediately.server/auth.ts:77-82
JWT secret storageThe signing secret resolves env var → DB → auto-generated via crypto.randomBytes(64) (server/auth.ts:23-67). When auto-generated, it is persisted in the same SQLite database as application data — a DB compromise yields both data and the means to forge sessions.LowMediumMitigated by operator action: set JWT_SECRET explicitly in production (documented in docs/SECRETS.md §2 and .github/SECURITY.md).server/auth.ts:23-67, server/config.ts:18-24
SSRF / DNS rebinding windowsafeFetch resolves a hostname once and validates every returned IP, then pins HTTP requests to that IP. HTTPS requests cannot be IP-pinned (TLS SNI/certificate validation requires the original hostname), leaving a narrow window where a DNS record could change between the safety check and the actual connection.LowMediumPartially mitigated — the window is narrow (single resolution immediately before the request) and the attack requires the operator to point an indexer/downloader/service URL at an attacker-controlled domain in the first place. Documented as a known limitation, not fixed further.server/ssrf.ts:172-249
Private-network access allowed by designisSafeUrl/isSafeIp/safeFetch default allowPrivate: true, so RFC1918 and loopback targets are reachable.N/A (by design)N/AAccepted, intentional: indexers and download clients commonly run on the same LAN or even the same host as Questarr in a self-hosted deployment. Cloud-metadata and link-local ranges remain blocked unconditionally regardless of this setting.server/ssrf.ts:4-18,19-22,86-170
CodeQL js/request-forgery findings in server/downloaders/*.tsCodeQL flags every downloader call site where a fetch URL traces back to a user/indexer-supplied value (e.g. deluge.ts:284, qbittorrent.ts:91, rtorrent.ts:89, nzbget.ts:277, sabnzbd.ts:106,243, synology.ts:214,715, transmission.ts:178, utils.ts:62).LowLowFalse positive — every listed call site already gates the URL through isSafeUrl() and/or routes the request through safeFetch() (same mechanism as the SSRF/DNS-rebinding row above) before any network call is made. CodeQL's default js/request-forgery query does not model this project-specific validator as a taint sanitizer, so it can't see the guard and keeps reporting the path as live. Dismissed in Code Scanning as "false positive" per docs/VULNERABILITY_MANAGEMENT.md §2.2 item 3.server/ssrf.ts, server/downloaders/*.ts
Inconsistent input validation on auth endpoints/api/auth/setup and /api/auth/login validate username/password with manual typeof checks instead of the express-validator/Zod pattern used consistently elsewhere in routes.ts.LowLow–MediumFunctionally adequate today (rejects non-string input, and login sits behind authRateLimiter), but inconsistent with the rest of the codebase and easier to get wrong on future edits. Tracked as a follow-up to migrate to the standard validator pattern.server/routes.ts:282-299,361-368
Legacy plaintext credentialsIndexer apiKey and downloader username/password values written before AES-256-GCM encryption was introduced remain plaintext indefinitely — decryptCredential() detects the missing enc:v1: prefix and returns them unchanged, with no forced migration.LowMediumRe-saving a credential (e.g. via PATCH /api/indexers/:id) re-encrypts it going forward. No background migration exists; operators with credentials configured before this feature shipped should re-save them once to force encryption.server/credential-crypto.ts:98-108
Rate-limiting gapsRSS feed creation has no limiter beyond the general 100 req/min-per-IP fallback. (POST /api/auth/setup was previously unlimited too — fixed: it now sits behind authRateLimiter, the same limiter as /api/auth/login.)LowLowRSS creation is a low-value target for abuse; monitor for abuse reports rather than pre-emptively hardening further. /api/auth/setup no longer needs a mitigation note — it's rate-limited like /api/auth/login.server/routes.ts:282-359, server/middleware.ts:33-57
Download path handlingdownloadPath fields reject values containing .. (server/middleware.ts:293-301,366-374,420-428) but do not otherwise normalize or allow-list paths — absolute paths, symlink traversal, null bytes, and Windows-style separators are not explicitly handled.Low–MediumMediumPartial mitigation via the .. substring check. Full path normalization/allow-listing is tracked as a hardening follow-up.server/middleware.ts:293-301,366-374,420-428
Supply-chain patch lagpackage-lock.json is committed and Dependabot runs weekly for both npm and GitHub Actions. Major-version bumps were previously excluded from auto-PRs, which meant a security patch shipping only in a new major version wouldn't be proposed automatically. Fixed: the exclusion has been removed — Dependabot now opens PRs for major-version bumps too (as individual, ungrouped PRs so they still get dedicated review), see docs/DEPENDENCIES.md.LowLowResolved by removing the ignore rule in .github/dependabot.yml. Residual: major-version PRs still require manual review/merge, so a patch can sit open until reviewed — recommend periodic triage of open Dependabot PRs rather than letting them accumulate..github/dependabot.yml
CORS/CSP/HSTS postureCORS is restricted to config.server.allowedOrigins with credentials: true (server/index.ts:24-29). Helmet's CSP allows 'unsafe-inline'/'unsafe-eval' in script-src only outside production (server/routes.ts:326-337); font-src/style-src are narrowed off helmet's https:-wildcard defaults to 'self' (plus data:/'unsafe-inline' respectively) since fonts/styles are all self-hosted. HSTS is enabled only when SSL is configured (server/routes.ts:365).LowMediumStandard hardening already in place. Ensure ALLOWED_ORIGINS is set correctly and SSL is enabled in any production deployment so HSTS actually applies.server/index.ts:24-29, server/routes.ts:323-376
Cross-Origin-Embedder-Policy not setHelmet's crossOriginEmbedderPolicy (COEP) is left disabled (only crossOriginOpenerPolicy is set, server/routes.ts:361-366) -- a first DAST scan (dast.yml, 2026-07-14) flagged this as a WARN-level finding (ZAP rule 90004).N/A (by design)LowAccepted risk, deliberately not enabled: COEP's require-corp mode blocks loading any cross-origin subresource that doesn't send a matching Cross-Origin-Resource-Policy/CORS header, which would break the IGDB and NexusMods game-cover/mod-image CDNs Questarr embeds and doesn't control. Suppressed with a documented reason in .zap/rules.tsv rather than left as a recurring unexplained scan finding.server/routes.ts:361-366, .zap/rules.tsv
CSP style-src unsafe-inlinestyle-src keeps 'unsafe-inline' (server/routes.ts:361) -- several components (GameGrid.tsx, ShareDiscordDialog.tsx, GameDetailsModal.tsx, chart.tsx, progress.tsx) render real inline style="..." attributes. A DAST scan (dast.yml, 2026-07-14/15) flags this as a WARN-level finding (ZAP rule 10055, same plugin as the wildcard-directive check already fixed).N/A (by design)LowAccepted risk, deliberately not removed: dropping 'unsafe-inline' from style-src would break rendering wherever those components set inline styles, not just tighten policy -- a real functional regression, not a hardening step. Matches Helmet's own CSP defaults, which don't gate style-src's 'unsafe-inline' on environment either. Suppressed with a documented reason in .zap/rules.tsv rather than left as a recurring unexplained scan finding.server/routes.ts:361, .zap/rules.tsv
TLS certificate verification bypass scope (sabnzbd)fetchInsecure()'s self-signed-cert fallback sets rejectUnauthorized: false whenever it detects any SSL error code, not just self-signed-specific ones — an expired cert or hostname mismatch on the configured sabnzbd URL would also fall through to a full verification bypass, not only the self-signed case it's meant for.LowMediumIntentional, tested fallback for self-hosted sabnzbd instances with self-signed certs (downloaders_ssl_fallback.test.ts), but broader than necessary. Follow-up: narrow the trigger to DEPTH_ZERO_SELF_SIGNED_CERT/SELF_SIGNED_CERT_IN_CHAIN specifically, and/or make the insecure fallback opt-in per downloader rather than automatic.server/downloaders/sabnzbd.ts:139-144
Container runs as rootThe Dockerfile has no USER directive in either build stage, so the containerized process runs as root inside the container.LowMediumNo fix yet. Follow-up: add a non-root USER directive before ENTRYPOINT/CMD in the production stage. Doesn't affect host security on its own (still requires a separate container-escape vulnerability), but is unnecessary privilege for a Node process with no need for root.Dockerfile
CI/supply-chain pinning gaps.github/dependabot.yml has no cooldown period configured, and one GitHub Actions workflow step references an action by a mutable tag rather than a pinned commit SHA.LowLowHygiene gaps, not exploitable app-level issues on their own. Follow-up: add a cooldown window to the Dependabot config, and pin the flagged Action to a full commit SHA..github/dependabot.yml, flagged workflow

Out of scope / already covered elsewhere

  • Full credential inventory, rotation procedures, and per-secret storage detail: docs/SECRETS.md.
  • Vulnerability disclosure process and repository/infrastructure access escalation policy: .github/SECURITY.md.
  • Exploitability assessments for known vulnerabilities in third-party dependencies and the container base image (satisfies OSPS-VM-04.02): docs/VEX.md and the feed itself at security/vex/questarr.openvex.json.
  • System actors and data-flow diagrams referenced throughout this register: docs/ARCHITECTURE.md.
  • Formal attack-surface analysis (trust boundaries, high-risk data flows, per-integration trust table, unauthenticated-route inventory): docs/THREAT_MODEL.md, which satisfies the related OSPS-SA-03.02 requirement. This document (SECURITY_ASSESSMENT.md) satisfies OSPS-SA-03.01 and takes a risk-register view rather than duplicating that analysis.