Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ jobs:
if: needs.gate.outputs.full == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
# The providers' keys are the secrets of this environment, given to this
# job and to the parity check alone rather than to every job of the
# repository. Forgejo, which runs this workflow too, has no environments
# and reads its own secrets.
environment: provider-keys

services:
# The cache's second tier, which the cache spec reads back; the server is
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,18 @@ jobs:
name: Skyhook v2 parity
runs-on: ubuntu-latest
timeout-minutes: 20
# The providers' keys are the secrets of this environment, given to this
# job and to the end-to-end tests alone. Forgejo, which runs this workflow
# too, has no environments and reads its own secrets.
environment: provider-keys

steps:
# The server answers the series from TheTVDB and TMDB, so the run needs
# their keys, as the end-to-end tests do: the repository secrets
# TMDB_API_KEY and TVDB_API_KEY. Said before anything is fetched or
# built, so that a run without them costs a few seconds. A secret
# cannot be tested in an `if`, which is why it is an environment
# variable here, and why the answer is an output.
# their keys, as the end-to-end tests do: the secrets TMDB_API_KEY and
# TVDB_API_KEY of the environment provider-keys. Said before anything
# is fetched or built, so that a run without them costs a few seconds.
# A secret cannot be tested in an `if`, which is why it is an
# environment variable here, and why the answer is an output.
- name: Look for the provider keys
id: keys
env:
Expand Down
6 changes: 4 additions & 2 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
# (`shift+mod+enter`, the editor's *Save and open the next*), the fake
# `api_key=abcdef123456` the log's tests write on purpose, to see it come
# out masked, and the fake `api_key=0123456789abcdef` the TMDB relay's tests
# write, to see it kept out of what a client is handed. None is a value that
# could open anything.
# write, to see it kept out of what a client is handed. And the interface's
# list of the services' host names, which the generic-key rule reads as a key
# behind the word `api`. None is a value that could open anything.

[extend]
useDefault = true
Expand All @@ -28,6 +29,7 @@ regexes = [
'''keys[=:]\s*["']shift\+mod\+enter["']''',
'''api_key=abcdef123456''',
'''api_key=0123456789abcdef''',
'''skyhook\.sonarr\.tv, api\.radarr\.video, api\.themoviedb\.org, api4\.thetvdb\.com''',
]

[[rules]]
Expand Down
17 changes: 17 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Findings of the history that are not secrets, by fingerprint — the commit,
# the file, the rule and the line, which `gitleaks git` prints for each. A
# fingerprint names one line of one commit and nothing else: it loosens no
# rule, and a line like it written today would be found again. What is
# allowed on every commit, whatever its line, is in .gitleaks.toml.

# A test's fake key — `ams_` and the base64 of a made-up phrase, not written
# here so that this file is no finding of its own — the key a Plex address
# carries in the test that checks it is masked in a logged path. It opens
# nothing; a later commit wrote one that says so on its face.
cf133cdde69916eb02aea11e847b383d37112bb1:src/telemetry.rs:generic-api-key:316

# The host name api.radarr.video, in the help text that lists the addresses
# the clients' door answers for (the French text, then the English): Radarr's
# own public API address, which the generic-key rule reads as a key.
c2f01484fd17d70fc97bc9af2d762cc60640e8a0:frontend/src/lib/lang/fr.ts:generic-api-key:867
c2f01484fd17d70fc97bc9af2d762cc60640e8a0:frontend/src/lib/lang/en.ts:generic-api-key:860
100 changes: 100 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,106 @@ may change what the API or the configuration means, and says so here.

## [Unreleased]

### Security

A review of the whole server, area by area, and of what semgrep, CodeQL,
gitleaks, trivy and zizmor find in it. Nothing let a caller past a guard; what
follows let one cost the server too much, see what was not theirs, or keep
what they had lost.

- **Signing in.** Failed sign-ins locked an account for everyone, so a guesser
could shut the administrator out of their own server; they are counted per
account and address now (an IPv6 /64 as one), each before its password is
checked and with one atomic count on the cache server, and guessing from
many addresses meets a bar five times higher that never applies to an
address the account signed in from in the last thirty days. Concurrent
guesses no longer slip past the count while it is read, and a full count
forgets its oldest rather than stop counting. A run of failed sign-ins, or of
returns from the identity provider, from one address is one audit row with
its number; a return nobody started here is not written at all.
- **Accounts and keys.** A member could get round an administrator's pause of
their key by deleting it and making another; the pause stands on the member
now, until an administrator enables one of their keys or lifts it
(`DELETE /api/v1/users/{id}/keys-pause`). Tying an identity-provider login to
an account asks for the account's password — or has the provider sign the
person in again, for an account with none — where a session alone did; the
owner can untie it from their account page, an administrator's password
reset unties it, and the journal says who tied and who untied.
- **The identity provider's client** refuses link-local and metadata addresses
at connect time (loopback excepted), judges the provider's endpoints before
it asks any of them, and sends no Referer.
- **A trusted proxy** that forwarded a request without an `X-Forwarded-For`
hop made every client look like the proxy, whose address the allowlist
admits; the client is unknown now, refused by every allowlist, and the log
says so.
- **Load.** One address could hold every connection of a door — idle HTTP/2
connections that answer pings, slow readers: no network holds more than 64
now (a trusted proxy's and this machine's excepted), a connection with no
request in flight for two minutes is closed, and a response its peer does
not read is given up on after a minute. Searches, facets, lists and figures
no cache held are read three at a time, a caller waiting more than three
seconds answered `503` with `Retry-After`. Link previews, a network's page
and `/ready` asked the database afresh for every request: previews count
against the caller's allowance and are kept five seconds, the catalogue's
mark and `/ready`'s answer are asked once in five seconds, and a network's
slug is matched in memory. `/tmdb-images/` has a quota of its own, ten times
the requests', and hands on 16 pictures at a time. Search terms are cut at
200 characters at every door, as the catalogue's own search always did.
- **Pictures.** One picture could take some 700 MB to decode, as only the
decoder's own buffer was bounded: decoding is counted by the byte a pixel
from the picture's header before anything is decoded, a picture that would
hold more than 256 MB is served whole, and the pictures decoding together
share those 256 MB. A copy made on demand waits for its turn before reading
the original, at most 32 are made at once, and each counts against the
caller's rate limit. An upload is cut off as it is read, at the weight its
first bytes say it may have, and a writer who is not an administrator keeps
1 GiB of uploads all told. A writer is told only that a picture could not be
fetched, no longer how or after how many tries — which let a typed-in
address map the private network.
- **Relays.** TheTVDB's relay read every season, episode and character kept
without a TheTVDB id on each request for a negative number: a number no id
made here can be is answered at once, the others are found in maps read once
while the catalogue stays as it was, and seasons and orders' episodes are
indexed by TheTVDB's id (migration 0053). A reference list asked with a
made-up parameter or header filed a new database row each time: lists are
filed under their path and the language and region asked, 512 at most per
relay. TheTVDB's and AniList's relays key their answers on the Accept,
Accept-Language and Content-Type they ask with, as TMDB's does.
- **Outbound requests** send no Referer, which handed the next host of a
redirect the address it came from, key included; TMDB, Fanart.tv, TheTVDB
and MyAnimeList follow a redirect only within the origin first asked. A
series' IMDb id went into TMDB's `/find` path unchecked; it is checked as a
film's is and written as one segment.
- **Logs.** A sign-in's `code`, `state` and `session_state`, TMDB's
`session_id`, `guest_session_id` and `request_token`, and an `auth`
parameter are masked as the other keys are; an import rule that could not
fetch its list wrote reqwest's error, address and key included, into its
history and the log; a database address whose password holds an `@`, or
that carries it as `?password=`, was written in part.
- **Backups.** Archives, and the copy of the database one is made from, were
readable by every user of the machine: they are written for the server's
user alone (0600, new directories 0700), `config check` warns when archives
are not encrypted, and downloading one is written to the audit trail. A
restore wrote the archive's column names into SQL as they stood, so a crafted
archive could run statements of its own: names are quoted, and such an
archive is refused.
- **Elsewhere.** The trailer's frame is sandboxed. `trust-ca.sh` checked a
pinned fingerprint against a decoding of the whole file while installing
every certificate in it: a fetched or pinned file must be exactly one
certificate. History lines gitleaks takes for secrets are named, each with
why it is none, in `.gitleaksignore`.

### Changed

- `transfer` refuses a database URL with a password among its arguments: such
URLs are read from `AMS_TRANSFER_FROM` and `AMS_TRANSFER_TO`, which a
process list and a shell's history do not keep.
- A provider's upstream that redirects `http://` to `https://` is no longer
followed there: give it as `https://`.
- Tying an account to the identity provider is a button on the account page
that asks for the password (`POST /api/v1/account/provider`); untying is
beside it (`DELETE /api/v1/account/provider`).

## [0.14.0] — 2026-11-03

### Added
Expand Down
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ why. Say in the pull request why a new dependency is worth carrying.

**Secrets.** None, in the code, the tests or the fixtures.
`gitleaks detect --source . --redact` reads the history with
[`.gitleaks.toml`](.gitleaks.toml).
[`.gitleaks.toml`](.gitleaks.toml); the few lines of past commits that are no
secret are named, each with its reason, in [`.gitleaksignore`](.gitleaksignore).
CI's own keys — `TMDB_API_KEY`, `TVDB_API_KEY` and `FANARTTV_API_KEY`, which the
end-to-end tests and the parity check run against the real services with — are
the secrets of the GitHub environment `provider-keys`, given to those two jobs
alone. Without them, both say so and stop within seconds.

## The interface's design system

Expand Down
24 changes: 24 additions & 0 deletions docker/trust-ca.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
# shows — or the risk is accepted, on a network you control, with
# AMS_CA_INSECURE=1.
#
# What is fetched, or pinned, is one certificate and nothing else: a file
# with a second PEM block is refused, since update-ca-certificates would
# install every block of it.
#
# The same script is served by the server at /trust-ca.sh, and its
# authority at /ca.crt, so a deployment from the image alone has both.
# Mounted on purpose and finding nothing to install, it fails loudly: a
Expand All @@ -29,6 +33,9 @@ CA_NAME="arr-metadata.crt"
CA_PATH="/usr/local/share/ca-certificates/${CA_NAME}"
CA_FILE="${AMS_CA_FILE:-/arr-metadata/tls/ca.crt}"

# Set once the authority is fetched from AMS_CA_URL rather than found here.
FETCHED=""

if [ -s "${CA_PATH}" ]; then
echo "[trust-ca] using the authority mounted at ${CA_PATH}"
elif [ -s "${CA_FILE}" ]; then
Expand All @@ -55,6 +62,7 @@ elif [ -n "${AMS_CA_URL:-}" ]; then
;;
esac
echo "[trust-ca] fetching the authority from ${AMS_CA_URL}"
FETCHED=1
mkdir -p "$(dirname "${CA_PATH}")"
if command -v curl >/dev/null 2>&1; then
curl -fsS --retry 10 --retry-delay 3 --retry-connrefused -o "${CA_PATH}" "${AMS_CA_URL}"
Expand All @@ -74,6 +82,22 @@ if ! grep -q "BEGIN CERTIFICATE" "${CA_PATH}"; then
exit 1
fi

# A file that was fetched, or that a fingerprint is given for, is one
# certificate. The fingerprint below is taken of the file's base64 text as one
# stream, and a decoder is more lenient than OpenSSL: GNU's stops at a tab that
# OpenSSL skips, so a second certificate behind one goes unhashed, and
# update-ca-certificates installs every block of the file all the same. Any
# kind of PEM marker is counted — a TRUSTED CERTIFICATE block is a root to
# OpenSSL too — and the one let through is a CERTIFICATE.
if [ -n "${FETCHED}" ] || [ -n "${AMS_CA_FINGERPRINT:-}" ]; then
BLOCKS="$(grep -o -e '-----BEGIN' "${CA_PATH}" | wc -l | tr -d ' ')"
if [ "${BLOCKS}" != 1 ] || ! grep -q -e '^-----BEGIN CERTIFICATE-----' "${CA_PATH}"; then
echo "[trust-ca] ERROR: ${CA_PATH} holds ${BLOCKS} PEM blocks, or its one block is not a CERTIFICATE: an authority that is fetched or pinned is exactly one certificate, since a second block would be installed without being pinned; not installed" >&2
rm -f "${CA_PATH}" 2>/dev/null || true
exit 1
fi
fi

# Pinned, when a fingerprint was given: the SHA-256 of the certificate's DER,
# as the administration page shows it, colons or not, in either case.
if [ -n "${AMS_CA_FINGERPRINT:-}" ]; then
Expand Down
49 changes: 43 additions & 6 deletions docs/access.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ account, and no sign-up ever makes an administrator. Uninvited sign-ups are
limited to `AMS_SIGNUPS_PER_HOUR` (5) per address — an IPv6 address counts by
its /64 — and `AMS_SIGNUPS_PER_HOUR_TOTAL` (100) for the whole server.

**Failed sign-ins** are counted for each account and address. After
`AMS_SIGNIN_FAILURES_PER_ACCOUNT` attempts (10) on an account from one address —
an IPv6 address counts by its /64 — in fifteen minutes, that address is refused
without its password being looked at, until the window closes; the attempt is
counted before the password is checked, so a burst sent together is held to the
same ten. Somebody guessing at an account from their address shuts out that
address, and not the account's owner at theirs. Guessing from many addresses is
held by a bar for the account as a whole, five times as high; it does not apply to
an address the account has signed in from in the last thirty days, so its owner's
usual places get through with the right password. Failures of somebody with no
credential — a refused password, a refused return from the identity provider —
are written to the journal as one row for each address and kind in a minute:
written at once, and amended when the minute is over to say how many there were
and what the last of them typed. A return from the provider that was not started
here, or with no provider set up, is not written at all.

A key a person holds is theirs to pause, and an administrator's to pause for
them from **Keys & network**: the pause then stands on the person, and until an
administrator enables one of their keys from there, or lifts it on their page
(`DELETE /users/{id}/keys-pause`), they can neither make a key nor resume one —
deleting the paused key and making another is no way round it.

The same page switches each API off — Sonarr's, Radarr's, the Plex provider, the
TMDB, TheTVDB and AniList relays, and the native API's keys (the interface's own
session is never cut). A switched-off API answers `503`, which Sonarr and Radarr
Expand Down Expand Up @@ -118,12 +140,27 @@ The flow is the authorization code with PKCE, a state and a nonce, carried in
a cookie sealed with a key only the running server holds (`__Host-` over
HTTPS); the ID token's signature — asymmetric only — issuer, audience, expiry
and `at_hash` are checked. The issuer, and every endpoint it publishes, must be
HTTPS unless it is a local address. A returning person is found by the
provider's own identifier. Someone who already has an account here ties it to
theirs at the provider from their account page, signed in, which proves both
sides; nothing is ever tied by e-mail, since an address typed into a profile
proves nothing. Otherwise, if allowed, an account is opened — a member's, or
the role a claim gives (`groups`, a path such as `realm_access.roles`, or a
HTTPS unless it is a local address, and none may be at a link-local, metadata,
multicast or unspecified address — written as one, or a name that resolves to
one; this machine's own loopback is the one such address a provider may have.
The provider is reached directly, whatever `HTTP_PROXY` says, and every address
it publishes is judged before it is asked for, its keys' included. A returning
person is found by the provider's own identifier. Someone who already has an
account here ties it to theirs at the provider from their account page: they give
their current password, are sent to sign in at the provider, and the tie is made
when they come back — which proves both halves are theirs. An account with no
password of its own has the provider sign its person in again (`prompt=login`,
`max_age=0`), and the provider's answer has to say when (`auth_time`). A session
alone ties nothing: one left open, or taken, would tie whoever holds it, and the
tie would outlive the password the account's owner then resets — nor does a
sign-in begun before the account was changed, a password reset above all, finish
its tie after it. Nothing is ever tied by e-mail, since an address typed into a
profile proves nothing. The owner can untie the account from the same page while
it has a password to sign in with — their other sessions are closed with it —
and an administrator can from the member's page; an administrator's reset of a
password unties the account too. The journal says who tied (`user.linked`) and
who untied (`user.unlinked`). Otherwise, if allowed, an account is opened — a
member's, or the role a claim gives (`groups`, a path such as `realm_access.roles`, or a
namespaced `https://…/roles`, with the values that make an administrator or an
editor). With a claim named, the provider decides the role at every sign-in,
but never takes away the last administrator, and a sign-in whose claims did
Expand Down
Loading
Loading