Security policy

The threat model sharerr is built against, what is in and out of scope, why the existing controls fit that model, and how to report a vulnerability and what happens next. sharerr is experimental and pre-1.0; see Supported versions.

Contents

Threat model

sharerr is designed to run on a trusted LAN, for one operator and the friends they explicitly grant a key to, not as a service exposed to the open internet. Everything below is measured against that. Where each trust boundary sits (operator and vault, this instance and a friend, the lighthouse, the services sharerr drives) and what crosses it is in ARCHITECTURE.md.

What is in scope

sharerr handles real credentials (*arr API keys, torrent-client credentials, the tracker’s announce token, gluetun API keys, the notification webhook and heartbeat push URLs, the metrics and management API bearer tokens, and this instance’s Ed25519 gossip signing key) in its encrypted vault, and exposes a web UI, a Torznab feed, gossip, and a BitTorrent tracker over HTTP. Anything that could read, write, or bypass authentication for any of those is in scope: vault or session handling, the auth guard in front of every UI page except /setup, /login, /login/totp, /logout and /assets, the per-peer key model behind the feed, gossip and tracker, and the lighthouse’s privacy properties (see LIGHTHOUSE.md).

What is already there, so a report can say which layer it gets past:

  • Login passwords are Argon2id hashes with a per-user salt; an unknown username still pays a full verification against a decoy hash, and the form gives one message for both cases, so accounts cannot be enumerated.
  • Sessions are 256-bit tokens held only in memory (a restart revokes them all) with a 14-day idle expiry; a password change re-checks the current password and revokes every other session.
  • Two-step sign-in, when an account turns it on (Settings → Account), asks for an RFC 6238 code after the password. The password step then grants only a five-minute pending sign-in, five wrong codes end it, the /login/totp step shares /login’s per-address throttle, and a code is accepted once. Turning it on or off needs the password and a code; turning it on revokes every other session. The secret sits beside the password hash in SQLite, readable, so sign-in still works without the master key (migrations/0015_totp.sql): it defends against a guessed or reused password, not against a copy of the data directory. sharerr account disable-totp inside the container is the recovery for a lost authenticator.
  • The cookie is HttpOnly and SameSite=Strict, and a middleware over the whole router refuses any non-GET request whose Origin does not match Host, including on /login and /setup.
  • The vault is XChaCha20-Poly1305 under a key derived from SHARERR_MASTER_KEY with Argon2id and a per-vault salt.
  • Peer keys are stored only as SHA-256; revocation is enforced in the query itself, and a friend’s key hash doubles as their announce token, so revoking a friend cuts off their announces too. Comparisons are constant-time.
  • The tracker fails closed: an unreadable database or a locked vault refuses every announce, a bad token is refused before the instance reveals whether it holds the info hash, and a scrape must name an info hash.
  • Gossip records are Ed25519-signed by the peer they describe, with a signed_at that blocks replaying an older record, and a pull only returns the intersection with keys the caller already proved it knows.
  • The pairing endpoint (/api/gossip/pair) rides the same per-peer key and only fills a friendship whose outbound half is empty: a second call, or one whose signed record names an identity other than the pinned one, is refused. A pasted invite pins the identity it carries at pairing time, by the operator, instead of trusting the first record a friend later signs, and is refused seven days after it was issued.
  • The gluetun hook endpoints and /hooks/arr are unauthenticated by design (gluetun’s hooks are bare wgets, and an *arr Webhook connection has no credential sharerr checks) but answer only private source addresses, and can only ask for work sharerr would do anyway: a re-poll, or a sync pass.

A few things are by design, not a vulnerability report waiting to happen:

  • The session cookie’s Secure flag is decided per request. sharerr terminates no TLS, so it infers HTTPS from X-Forwarded-Proto or RFC 7239 Forwarded (first hop only) and treats the connection as plain HTTP otherwise. On the LAN it is meant for, the cookie travels without Secure; behind a TLS-terminating proxy it carries it automatically. Those headers are spoofable by anyone who can reach the port, and that is tolerated: claiming https on a plain connection only costs the spoofer their own sign-in, and claiming http on a TLS connection only drops Secure from the response to their own request. Nothing else trusts either header; see arrived_over_https in crates/sharerr/src/web/auth.rs. If your network is not a trusted LAN, put a TLS-terminating proxy in front.
  • A forward-auth header is believed only from a listed proxy. server.auth_header signs a request in as the user it names, and X-Forwarded-For keys the login throttle, only when the connection itself comes from server.trusted_proxies; both are off until that list is set. Whoever controls a listed address can sign in as anyone, so it should name the proxy alone, and the proxy must overwrite the header rather than pass a client’s through. See vouched_user and client_address in crates/sharerr/src/web/auth.rs.
  • No account lockout, and security response headers only on request. server.security_headers adds a strict CSP, X-Frame-Options, Referrer-Policy and nosniff to the web UI; it is off by default, as nothing on a trusted LAN frames or injects into it. A per-source-address throttle on /login and /setup (5 attempts/minute, 429 past it — see Throttle in crates/sharerr/src/web/auth.rs) and a store-level cap on concurrent Argon2 operations (HASH_SLOTS in crates/sharerr-store/src/users.rs) close the CPU/memory amplification an unauthenticated flood would otherwise create, but neither ever locks an account out: with exactly one operator account, a lockout would hand an attacker a free denial of service against the one person allowed in. Argon2’s cost per attempt remains the actual brake on guessing itself, the trade a LAN tool with one operator account makes.
  • The feed API key and the .torrent download token travel as query-string parameters, and the tracker’s announce and scrape tokens as path segments. Consistent with the threat model, but query strings and paths commonly end up in access logs if a reverse proxy sits in front.
  • Losing SHARERR_MASTER_KEY loses every stored credential. There is no recovery path; the vault is encrypted with it and nothing else.
  • The lighthouse answers an invalid key with a plausible fabricated record, not an error. That is the anti-scraping property, not leakage. Its report endpoint answers honestly, so posting under a guessed key hash reveals whether that hash is in use; a peer whose reports are refused has to be able to find out. And the first keypair to report under a key hash keeps it until the record ages out: someone who learns a key hash before the legitimate peer ever reports can claim the slot and deny that pair the rendezvous, but cannot impersonate anyone, and the remedy is a new key.
  • A [[peers]] block in sharerr.toml can hold a friend’s gossip key in plaintext, deliberately, as a one-time, self-deleting restore path after a full data-directory loss. It is drained into the vault and stripped from the file the moment anything reads it, and the field is skip_serializing so it can never be written back out. Treat a sharerr.toml carrying an unconsumed block the same as a raw vault secret in a text file. See SETTINGS.md. The Friends page’s “export as backup block” is the one place the web UI shows a previously stored secret again, to produce that block from a live instance; it sits behind the same session guard as every other page.

What is out of scope

Vulnerabilities in a service sharerr talks to (Sonarr, Radarr, qBittorrent, Transmission, rTorrent, Prowlarr, gluetun) belong to those projects, unless sharerr is misusing their API in a way that creates the exposure.

CodeQL findings are either fixed or dismissed in the Security tab with a recorded reason; there is no in-source suppression. The record of each one resolved by more than an obvious fix (the rust/path-injection guard on the config path, and the rust/cleartext-logging renames in doctor) is CODEQL.md.

RUSTSEC-2023-0071 (the rsa crate’s Marvin Attack) is not in this list, though a stale Scorecard report may claim it should be. rsa would ride in only via sqlx-mysql, and sqlx 0.9’s mysql backend does not depend on it, so it is absent from the lockfile (cargo tree -i rsa matches nothing). Kept as the worked example of documenting a lockfile-only finding; deny.toml carries the matching note.

Why the existing controls are enough

The assurance case follows from the threat model:

  • Every credential class uses a hash or cipher shaped for what it protects: Argon2id for the one class a human chose (login passwords), where offline guessing is the real risk; SHA-256 for machine-generated 160-bit peer tokens, where iteration defends against nothing and an indexed lookup on every feed request matters. Both choices are stated in sharerr-store/src/peers.rs’s header comment.
  • Every network-facing entry point either authenticates or narrows to a private-address allowlist. There is no endpoint that trusts unauthenticated input from the public internet.
  • The gossip layer’s integrity does not depend on transport security: a compromised relay can at worst refuse to forward a record.
  • Zero unsafe code (unsafe_code = "forbid" at the workspace level) removes the memory-safety class of bug, static analysis (CodeQL, clippy) runs on every code change, and cargo-deny on every dependency change and weekly against the dependency graph, so a new advisory in a dependency is caught without anyone looking.
  • What ships is checked before and after it ships. Before: a PR that adds a dependency with a known high or critical advisory fails dependency review, gitleaks scans the whole history for committed credentials, and a release is refused unless its tag is signed by a listed key and trivy finds no fixable HIGH or CRITICAL vulnerability in either image (an accepted finding needs a reason and an expiry in docker/.trivyignore). After: every pushed image, a main build’s sha-<7> included, carries signed build provenance and an SPDX SBOM checkable with gh attestation verify, and image-scan.yml re-scans both published images weekly. CI itself runs with least-privilege tokens, SHA-pinned actions, sha256-pinned tools (hadolint, trivy, cargo-deny, gitleaks, terraform, yq and the rest of tools.txt) and lockfile-pinned npm tools (an advisory against one that has no patched release is accepted with a reason in .github/osv-scanner.toml), and step-security/harden-runner audits every job’s network egress, blocking it outright in the two release jobs that hold write tokens. The detail is in RELEASING.md and CONTRIBUTING.md.
  • What this does not cover, by design: no account lockout, security response headers off unless server.security_headers is set, no recovery from a lost master key. Anyone deploying outside the model (a public-facing instance, an untrusted network) should treat this section as the boundary of what sharerr defends against and put a reverse proxy with its own rate limiting and headers in front, listed in server.trusted_proxies so the built-in login throttle (see above) keys on each client rather than collapsing to one global limit at the proxy’s address.

Supported versions

Exactly one supported line, the same honest answer a solo pre-1.0 project can give: the newest tagged release, plus the main branch and the sha-<commit> image built from whatever commit is newest on it (see docs/RELEASING.md). There is no backporting to an older tag; “upgrade” means moving to the newest tagged release (or :latest, which tracks it). A tagged release publishes unattended: the release environment has no required reviewer, and the gates are enforced in CI instead (see docs/RELEASING.md): a v* tag signed by a key listed in .github/allowed_signers on main, the tagged commit on main with a green ci.yml run, and a vulnerability scan of both images before either is promoted.

Reporting a vulnerability

Report it privately through GitHub’s security advisories for this repository, not a public issue. Private vulnerability reporting is enabled, so that link works without being a collaborator. Include what you found, how to reproduce it, and what you think the impact is. There is no bug bounty; this is a personal project maintained by one person, and turnaround depends on their spare time.

What happens after a report

  • Acknowledgement within 14 days of the advisory being filed. A target, not a contractual SLA.
  • Triage: reproduced if possible and given a severity. You will hear which of “confirmed, working on a fix”, “confirmed, won’t fix” (with the reason; see What is out of scope), or “not reproducible, need more detail” applies.
  • Fix and disclosure: a confirmed vulnerability is fixed in the private advisory’s own fork first, so the fix does not announce the bug before an image carries it. The advisory is published once a patched sha-<commit> image or tagged release is available, and a CVE is requested through GitHub’s advisory flow where severity warrants it.
  • Credit: reporters are credited by name or handle in the advisory and the release notes unless they ask to stay anonymous.

Last reviewed 2026-09.


This site uses Just the Docs, a documentation theme for Jekyll.