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
- What is in scope
- What is out of scope
- Why the existing controls are enough
- Supported versions
- Reporting a vulnerability
- What happens after a report
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/totpstep 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-totpinside the container is the recovery for a lost authenticator. - The cookie is
HttpOnlyandSameSite=Strict, and a middleware over the whole router refuses any non-GET request whoseOrigindoes not matchHost, including on/loginand/setup. - The vault is XChaCha20-Poly1305 under a key derived from
SHARERR_MASTER_KEYwith 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_atthat 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/arrare unauthenticated by design (gluetun’s hooks are barewgets, 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
Secureflag is decided per request. sharerr terminates no TLS, so it infers HTTPS fromX-Forwarded-Protoor RFC 7239Forwarded(first hop only) and treats the connection as plain HTTP otherwise. On the LAN it is meant for, the cookie travels withoutSecure; behind a TLS-terminating proxy it carries it automatically. Those headers are spoofable by anyone who can reach the port, and that is tolerated: claiminghttpson a plain connection only costs the spoofer their own sign-in, and claiminghttpon a TLS connection only dropsSecurefrom the response to their own request. Nothing else trusts either header; seearrived_over_httpsincrates/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_headersigns a request in as the user it names, andX-Forwarded-Forkeys the login throttle, only when the connection itself comes fromserver.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. Seevouched_userandclient_addressincrates/sharerr/src/web/auth.rs. - No account lockout, and security response headers only on request.
server.security_headersadds a strict CSP,X-Frame-Options,Referrer-Policyandnosniffto 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/loginand/setup(5 attempts/minute, 429 past it — seeThrottleincrates/sharerr/src/web/auth.rs) and a store-level cap on concurrent Argon2 operations (HASH_SLOTSincrates/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
.torrentdownload 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_KEYloses 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
reportendpoint 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 insharerr.tomlcan 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 isskip_serializingso it can never be written back out. Treat asharerr.tomlcarrying an unconsumed block the same as a raw vault secret in a text file. SeeSETTINGS.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
unsafecode (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 indocker/.trivyignore). After: every pushed image, amainbuild’ssha-<7>included, carries signed build provenance and an SPDX SBOM checkable withgh attestation verify, andimage-scan.ymlre-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 oftools.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), andstep-security/harden-runneraudits every job’s network egress, blocking it outright in the two release jobs that hold write tokens. The detail is inRELEASING.mdandCONTRIBUTING.md. - What this does not cover, by design: no account lockout, security response headers off unless
server.security_headersis 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 inserver.trusted_proxiesso 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.