diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 34a3562..bb5a04c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml
index 1c0696c..59e6f83 100644
--- a/.github/workflows/parity.yml
+++ b/.github/workflows/parity.yml
@@ -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:
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 6d1ae60..5943f11 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -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
@@ -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]]
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 0000000..938e6ad
--- /dev/null
+++ b/.gitleaksignore
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 998e54f..b21a7bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cbd73b1..0c887a2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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
diff --git a/docker/trust-ca.sh b/docker/trust-ca.sh
index b7a2b14..51ce809 100755
--- a/docker/trust-ca.sh
+++ b/docker/trust-ca.sh
@@ -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
@@ -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
@@ -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}"
@@ -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
diff --git a/docs/access.md b/docs/access.md
index 8edea04..dddb21b 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -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
@@ -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
diff --git a/docs/cli.md b/docs/cli.md
index 1996b90..666ba24 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -24,7 +24,9 @@ docker run --rm -it -v arr-metadata-server_metadata-data:/data --env-file .env \
`-it` is for a command that asks for a password at the terminal. One read from standard input
— `--password-stdin`, `--passphrase-stdin` — needs `-i` alone, and nothing is ever taken as an
-argument, which a process list and a shell's history would keep:
+argument, which a process list and a shell's history would keep — the password of a database
+address, which `transfer` takes, is read from the environment instead
+([below](#the-server)):
```bash
printf '%s\n' "$NEW_PASSWORD" | \
@@ -41,7 +43,7 @@ not exactly this binary's, rather than migrate it under a server that runs anoth
| --- | --- |
| *(none)*, `serve` | Runs the server. |
| `healthcheck` | Asks the running server's `/health`, over loopback; exits non-zero if it is unwell. The image's health check. |
-| `transfer FROM TO [--force]` | Copies a database into another, whatever their engines. |
+| `transfer [FROM] [TO] [--force]` | Copies a database into another, whatever their engines. A URL with a password is read from `AMS_TRANSFER_FROM` or `AMS_TRANSFER_TO`, never an argument. |
| `backup [--out PATH] [--include-media] [--passphrase-stdin]` | Makes an archive. |
| `restore ARCHIVE [--replace] [--passphrase-stdin]` | Restores one. Refused while a server runs on the database. |
| `user list` | Lists the accounts. |
@@ -65,9 +67,26 @@ one moment, so a server may go on writing to it meanwhile; the copy is one trans
failure leaves the target as it was:
```bash
-arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams:secret@localhost/ams'
+arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams@localhost/ams'
```
+A password is never an argument, which a process list and a shell's history would keep. A URL
+that carries one — after the user's name, or as `?password=` — is refused as an argument, with a
+message that says where to put it: `AMS_TRANSFER_FROM` names the source and `AMS_TRANSFER_TO` the
+target, each standing in for the argument left out, so the source may be an argument and the
+target a variable. An argument that is given wins over its variable, and a `.env` is read as for
+every other variable. Typed with nothing shown, the variable is not in the shell's history
+either:
+
+```bash
+read -rs AMS_TRANSFER_TO # the target's URL, password and all
+export AMS_TRANSFER_TO
+arr-metadata-server transfer 'sqlite://data/ams.db'
+```
+
+In a container, hand it the variable by name — `docker compose run --rm -e AMS_TRANSFER_TO metadata
+transfer 'sqlite:///data/ams.db'` — and Docker takes its value from the shell.
+
## Backups
`backup` makes an archive where the server keeps them — `AMS_BACKUP_DIR`, or the bucket among
@@ -76,7 +95,9 @@ several instances — listed on **Administration › Backups** like any other, a
puts the media kept on disk in it; `--out PATH` writes it to a file instead, or into a directory
under its usual name, and does not list it; `--passphrase-stdin` encrypts it with a passphrase
read from standard input — asked for, unseen, at a terminal — rather than `AMS_BACKUP_PASSPHRASE`.
-It says where it wrote the archive, its size and its SHA-256.
+It says where it wrote the archive, its size and its SHA-256. The archive is written readable by
+the user who runs the command alone (`0600`), wherever it goes, the directories made for it
+`0700`; [Installing and running](install.md#backups) says who else can be given it.
`restore` puts an archive back: a file, or the name of one the server keeps. It has the database
to itself, or it does not start: a server holding it — the server's own lock beside an SQLite
@@ -134,10 +155,12 @@ TheTVDB or IMDb id: the document travels from one catalogue to another.
`config check` prints the configuration as the server reads it — every secret masked, as the log
masks it — and then probes what it names: the database, and where its migrations stand; the media
-store, a directory or a bucket; where the archives are kept, written to and listed; the public
-address's `/health`. A configuration the server would refuse to start with is said as the start
-would say it. It exits non-zero when the database, the store or the archives' place does not
-answer; the public address is only reported, since a proxy may route it from outside alone.
+store, a directory or a bucket; where the archives are kept, written to and listed — with a
+warning when `AMS_BACKUP_PASSPHRASE` is unset, since an archive then goes unencrypted with every
+account's password hash in it; the public address's `/health`. A configuration the server would
+refuse to start with is said as the start would say it. It exits non-zero when the database, the
+store or the archives' place does not answer; the public address is only reported, since a proxy
+may route it from outside alone.
`migrate status` lists this binary's migrations — applied, pending, or failed — and any the
database has that it does not know, which a later version applied. `migrate run` applies those
diff --git a/docs/configuration.md b/docs/configuration.md
index 034578a..d3423ff 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -73,8 +73,8 @@ naming the variable. Flags take `true`, `false`, `1`, `0`, `yes`, `no`, `on` and
| `AMS_ALLOWED_HOSTS` | unset, any name | The host names the interface's door answers to, separated by commas; another is answered `421`, but for `/health`, `/ready`, `/ca.crt` and `/trust-ca.sh`. |
| `AMS_CORS_ORIGINS` | unset, none | Origins whose pages may call the native API from a browser, separated by commas. `*` is ignored, with a warning. |
| `AMS_REQUEST_TIMEOUT` | `60` | Seconds a request may take before it is answered `504`; 1 to 86400. |
-| `AMS_MAX_CONNECTIONS` | `1024` | Connections each door holds at once; past it, a new one waits to be accepted. |
-| `AMS_HEADER_READ_TIMEOUT` | `30` | Seconds a connection has to send a request's head, and may sit idle between two requests; 1 to 600. |
+| `AMS_MAX_CONNECTIONS` | `1024` | Connections each door holds at once; past it, a new one waits to be accepted. No one address — an IPv6 /64 as one — holds more than 64 of them, a trusted proxy's and this machine's excepted. |
+| `AMS_HEADER_READ_TIMEOUT` | `30` | Seconds a connection has to send a request's head, and may sit idle between two requests; 1 to 600. A connection with no request in flight for two minutes — an HTTP/2 one too — is closed. |
| `AMS_LOG` (`RUST_LOG`) | `info` | What is logged, as a `tracing` filter: `debug`, `info,arr_metadata_server=debug`. The administration's **Server log** keeps the same lines. |
| `AMS_LOG_FORMAT` | unset | `json` writes one JSON object per line, for a log shipper, each with the `trace_id` of the request it was written in. |
@@ -125,8 +125,8 @@ OpenTelemetry collector. These are OpenTelemetry's own variables; see
| `AMS_AUTH_DISABLED` | `false` | Opens every surface. For closed networks and a first setup only. |
| `AMS_ALLOWLIST` (`AMS_ARR_ALLOWLIST`) | loopback and the private ranges | The first value of the list of allowed addresses, separated by commas; read against an empty list only. The default is `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `fc00::/7`. |
| `AMS_PUBLIC_BROWSE` | unset | `true` gives `site.access` the first value `public`; `false` keeps the site private whatever the setting says. |
-| `AMS_RATE_LIMIT_PER_MINUTE` | `600` | Requests a minute, per address — an IPv6 address by its /64. TMDB's pictures under `/tmdb-images/` are not counted for an address their guard lets in. |
-| `AMS_SIGNIN_FAILURES_PER_ACCOUNT` | `10` | Failed sign-ins an account takes in fifteen minutes before it is refused unchecked until the window closes. |
+| `AMS_RATE_LIMIT_PER_MINUTE` | `600` | Requests a minute, per address — an IPv6 address by its /64. TMDB's pictures under `/tmdb-images/` are counted apart, against ten times as many, for an address their guard lets in. A copy of a picture made on demand under `/media/` counts for one each time it is asked for before it is kept; copies kept and pictures count for none. |
+| `AMS_SIGNIN_FAILURES_PER_ACCOUNT` | `10` | Failed sign-ins an account takes from one address in fifteen minutes (an IPv6 /64 counts as one) before that address is refused unchecked until the window closes; five times as many from every address together, which does not apply to an address the account signed in from in the last thirty days. |
| `AMS_SIGNUPS_PER_HOUR` | `5` | Sign-ups without an invitation, per address and per hour. |
| `AMS_SIGNUPS_PER_HOUR_TOTAL` | `100` | The same, for the whole server. |
| `AMS_OIDC_CLIENT_SECRET` | unset | The identity provider's client secret, which wins over the one stored in the settings. |
@@ -163,7 +163,9 @@ These give a setting its first value, and are not read again once it has one —
Each provider is asked at its own address unless one is given. An address given may be
anywhere — a mirror on the same machine included; where it redirects is judged as any other
-address the server fetches. See
+address the server fetches, and TMDB, Fanart.tv, TheTVDB and MyAnimeList, which a key or a
+client id travels to, are followed only within the origin first asked: an upstream that
+redirects `http://` to `https://` must be given as `https://`. See
[If you redirect at the resolver](integration.md#if-you-redirect-at-the-resolver).
| Variable | Default |
diff --git a/docs/install.md b/docs/install.md
index 89b82ed..c25da85 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -110,7 +110,7 @@ Changing `AMS_DATABASE_URL` points the server at a different database; it does
not carry your catalogue across. To move it:
```bash
-arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams:secret@localhost/ams'
+arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams@localhost/ams'
```
The target is migrated first and must be empty, unless you pass `--force` to add
@@ -118,6 +118,11 @@ to what is already there. Everything goes across, the lists read whole — TVmaz
index, Wikidata's links — included. An archive does the same: one made on SQLite
restores on PostgreSQL, and the other way round (see [Backups](#backups)).
+A URL that carries a password is not an argument, which a process list and the shell's
+history would keep: the command refuses one, and takes the source from `AMS_TRANSFER_FROM`
+and the target from `AMS_TRANSFER_TO` instead, each standing in for an argument left out
+([the command line](cli.md#the-server)).
+
## Backups
The server makes archives of itself: its database — every work, lock, account, key, setting,
@@ -142,6 +147,13 @@ downloads, deletes and restores them; [the command line](cli.md) does the same f
put together in `AMS_BACKUP_DIR` all the same: that is where the room for a copy of the
database is wanted, not in the system's temporary directory, often a small tmpfs in a
container.
+- **Who can read them.** The user who runs the server, alone: each archive is written `0600`,
+ and the copy of the database it is put together from, and the directories the server makes
+ for them — `AMS_BACKUP_DIR` when it is not there yet, the `.work` folder in it — `0700`,
+ whatever the umask says. A directory that exists already keeps the permissions it has, and so
+ do the archives an earlier version wrote: `chmod 600` those. A job that copies the directory as
+ another user has to run as root, or be given the right to read each archive, which nothing
+ gives it by default.
- **Before an upgrade.** A start that finds migrations to apply first makes an archive of the
database as the version before left it, without the media:
`ams-20261019-020000-upgrade-m0037.tar.zst`, made before the migrations that bring it to 0037,
diff --git a/docs/integration.md b/docs/integration.md
index cad685e..edeeec1 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -174,6 +174,8 @@ That fetch is plain HTTP on the stack's own network, and what it installs is a
root the container then trusts for everything — so over `http://` the script
refuses to install anything it cannot check: set `AMS_CA_FINGERPRINT` to the
SHA-256 shown on the Opening & APIs page, and anything else is refused. A
+fetched or pinned authority must be exactly one certificate: a file with a
+second PEM block is refused. A
stack's very first start has no fingerprint to copy yet; `AMS_CA_INSECURE=1`
accepts the fetch unpinned, with a warning in the container's log, on a
network you control — `compose.integration.yaml` sets it until a fingerprint
@@ -262,7 +264,8 @@ that is not in that list — it carries `X-Forwarded-For`, `Forwarded` or
`X-Real-IP` — is therefore refused on these surfaces, with a warning naming the
proxy; Sonarr and Radarr calling directly never send one. From a trusted proxy
every `X-Forwarded-For` line is read, in order, right to left past the other
-trusted hops; a hop that is no address leaves the client unknown, and an unknown
+trusted hops; a hop that is no address, or a trusted proxy that sends only
+`Forwarded` or `X-Real-IP`, leaves the client unknown, and an unknown
client is refused by every allowlist rather than taken for the proxy.
### Changes need this server's own pages
@@ -744,9 +747,10 @@ An upstream named this way may be anywhere — a mirror on the same machine
or the same network included: it is yours. Where it *redirects* is not: a
hop to an address that only means something from where this server stands
(loopback, link-local, a cloud's metadata service), written as an address
-or as a name that resolves there, is refused, and MyAnimeList's client id is
-never carried to another origin — scheme, host and port — than the one first
-asked.
+or as a name that resolves there, is refused, and the key or client id TMDB,
+Fanart.tv, TheTVDB and MyAnimeList are asked with is never carried to another
+origin — scheme, host and port — than the one first asked: an upstream that
+redirects `http://` to `https://` must be given as `https://`.
## When a provider does not answer
diff --git a/docs/media.md b/docs/media.md
index 33d904d..15066cd 100644
--- a/docs/media.md
+++ b/docs/media.md
@@ -29,7 +29,12 @@ default: nothing is kept until it is asked for.
and whatever it redirects to. A picture mirror on a private network is
reached all the same; `AMS_MEDIA_PRIVATE_NETWORKS=false` keeps the store
and the NFO export out of `10/8`, `172.16/12`, `192.168/16`, `100.64/10`
- and IPv6's unique-local `fc00::/7` too (`true` by default).
+ and IPv6's unique-local `fc00::/7` too (`true` by default). A writer who is
+ not an administrator is told of a picture that failed only that it "could
+ not be fetched", and not how many tries it took: what the address answered,
+ or that it did not, is the administrator's to read on **Administration ›
+ Media** — an address is whatever a writer typed in, and what came back for
+ each would be a map of the network the server is on.
- **How they are served.** Every copy is addressed by the hash of its bytes
under `/media/`, and served immutable. The rows keep the providers'
addresses: a work is rewritten as it is read, never as it is written, and
@@ -51,7 +56,12 @@ default: nothing is kept until it is asked for.
read for what it is, never for what it is called; SVG is refused, since a
document that can carry a script must not be served from here — and a
copy taken away. An upload's file goes with it; a provider's copy is
- fetched again at the next refresh.
+ fetched again at the next refresh. A picture may weigh 25 MiB and a theme
+ 60, a writer who is not an administrator keeps 1 GiB of uploads all told,
+ and an upload is cut off at the weight of what it is as it is read —
+ told by its first bytes, or by the `kind` of the form when that comes
+ before the file —, not once all of it is held in memory; one whose first
+ bytes are neither a picture nor a sound is refused with them.
- **The sweep.** Daily, and on request: what no work points at any more —
a work removed, a picture a provider dropped, an upload taken off — is
forgotten and its file deleted, and a file no row names is removed once it
@@ -116,8 +126,16 @@ anything — and asks for the right files from its first picture.
poster's 480, a background's 1280. Any other is made the first time a
page asks for it — a few hundred milliseconds for a large background —
and served from the store after. A width no page asks for is refused, so
- a picture has a dozen copies at the most. The thumbnails earlier versions
- made are still served; none is made any more.
+ a picture has a dozen copies at the most, a picture's own width being one
+ only where it is no wider than 1920. The thumbnails earlier versions
+ made are still served; none is made any more. Making one is work, and is
+ held to what the rest of the server's is: the address that asks is counted
+ against `AMS_RATE_LIMIT_PER_MINUTE` each time it asks for a copy that is not
+ kept yet — a copy already kept is served, and a picture, for nothing —; at
+ most thirty-two are made at once, and a page that asks for one when there
+ is no room is sent the picture itself, for that time, and asks again; and
+ a copy waits for its turn among the pictures being decoded before its
+ picture is read from the store, not after.
- **Turned on.** The pages ask for the copies at once, and the pictures
kept already have theirs made as they are asked for: a catalogue's first
pages after the switch are a little slower to fill.
@@ -129,9 +147,22 @@ anything — and asks for the right files from its first picture.
[Deleting the copies of a format](#deleting-the-copies-of-a-format).
- **Read safely.** A picture is decoded in Rust, never by libwebp: a
picture past 16,384 pixels a side or 100 million pixels is not decoded
- at all, nor one whose pixels would take more than 256 MB, and a decoder
- that panics on what it is given is a copy refused, not a server brought
- down. Such a picture is served whole.
+ at all, nor one whose decoding would hold more than 256 MB at its worst,
+ and a decoder that panics on what it is given is a copy refused, not a
+ server brought down. Such a picture is served whole. What decoding holds
+ is counted from the picture's header, before a byte of it is decoded, by
+ the byte a pixel at the worst moment of the way from the file to the copy:
+ the decoder's buffer and what it keeps beside it — a progressive JPEG's
+ coefficients, six bytes a pixel more than its three —, the copy a quarter
+ turn makes, the copy that makes the pixels eight bits an RGB or RGBA
+ channel, what the resizer holds beside them, and what is held at the end:
+ 3 to 9 bytes a pixel for a picture in colour, 16 for one with transparency.
+ A grey picture of 10,000 by 10,000 pixels is 100 MB for its decoder, and
+ took some 700 with the copies made of it; it is counted at 400 MB, or 900
+ where a copy is made of it, and refused. The pictures being decoded hold
+ 256 MB between them, whatever their sizes: a large one waits for those
+ before it, and small ones go side by side. What an encoder holds to write
+ a copy is its own matter, and a copy is never wider than 1920 pixels.
- **The square for Plex.** A background kept is also cut into a square
from its middle, 1080 pixels a side, as a JPEG — Plex's
`backgroundSquare`: see [Plex](plex.md#what-is-served).
diff --git a/docs/perf/README.md b/docs/perf/README.md
index ab08d35..b8b4c14 100644
--- a/docs/perf/README.md
+++ b/docs/perf/README.md
@@ -174,3 +174,16 @@ background: whole after 255 s (258 080 names of works, 39 976 people,
345 214 episodes), the search answering from what is written meanwhile. The
file grows from 336 MB to 596 MB: the index's table, its FTS5 trigrams and
its three indexes weigh 283 MB, less the 0022 index the migration drops.
+
+### The reads no cache answers go through three places
+
+The figures above were measured before the server took the reads no cache
+answers — a search nobody made lately, the facets of a new filter, a page of
+the catalogue in an order nobody asked for, the figures — three at a time
+(`src/cache/gate.rs`). Thirty-two connections asking for new ones now queue
+for those three places: the database's own limit is still what the rate is,
+but a request that waits more than three seconds is answered `503` with
+`Retry-After: 5`, which a load tool reports as an error. A scenario that
+repeats a request is not touched, since what the cache holds takes no place;
+a cold one is compared by the rate of its answers and the figures of those
+that were answered, not by its failures.
diff --git a/docs/relays.md b/docs/relays.md
index 9ab8f1e..b033c1a 100644
--- a/docs/relays.md
+++ b/docs/relays.md
@@ -270,6 +270,15 @@ service fails and the memory holds nothing — after a restart, for instance. On
older than 180 days is neither served nor kept, and the server deletes it as it
goes; a database transfer carries the others.
+A reference list is filed in the database under its path and the language and
+region the request names (`language`, `region`, `watch_region`), and under nothing
+else: not the rest of the query, not the caller's headers. A request that adds
+parameters of its own finds the row the others wrote, rather than making one. A
+language or a region that is not written as one — empty, longer than sixteen
+characters, anything but letters, digits, `-` and `_` — or that a request gives
+twice, is still answered, and kept in the database under none. A relay keeps at
+most 512 of them; past that, the oldest go first.
+
`relays.staleIfError`, on unless an administrator turns it off, decides all of
this: off, the service's failure is handed on as it came, and the reference lists,
still written, are not read. It is set on **Opening & APIs**, under *When a service
@@ -288,6 +297,12 @@ answers with what it kept, or `429` with the seconds left in `Retry-After`. A
client's AniList query is never spaced out as the server's own fetches from AniList
are.
+What a relay keeps in memory is filed under the path, the query and the headers it
+hands on that may change the answer: `Accept` and `Accept-Language` for TMDB, and
+for TheTVDB and AniList `Content-Type` too, which says how AniList reads a body.
+Two callers who ask with other headers are two entries; none is answered what the
+service said to the other.
+
A client's `If-None-Match` and `If-Modified-Since` reach the service only with a
request answered for that client alone, and handed back as the service wrote it: a
request kept or shared with others is asked without them, since a "not modified" is
diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md
index 12ca120..076b6c1 100644
--- a/docs/reverse-proxy.md
+++ b/docs/reverse-proxy.md
@@ -86,14 +86,17 @@ From a trusted proxy, and from nobody else:
| Header | Read as |
| --- | --- |
-| `X-Forwarded-For` | The client's address. Its lines are read as one list, in the order they arrived, and walked from the right: the first address that is not itself a trusted proxy is the client. A hop that is no address leaves the client unknown: refused by every list of allowed addresses, and counted with the other unknowns. |
+| `X-Forwarded-For` | The client's address. Its lines are read as one list, in the order they arrived, and walked from the right: the first address that is not itself a trusted proxy is the client. A hop that is no address leaves the client unknown: refused by every list of allowed addresses, and counted with the other unknowns. So does a request forwarded with no hop to read: a line left empty, or only a `Forwarded` or an `X-Real-IP`. |
| `X-Forwarded-Proto` | The scheme the request reached the proxy by, `https` or `http`: its first value. |
| `X-Forwarded-Host` | The name the request reached the proxy by: its first value. It counts as this server's own origin when a change is asked, and names the links of a feed when `AMS_PUBLIC_URL` is unset. |
`Forwarded` and `X-Real-IP` are only recognised, never read: a proxy that sends either without
-`X-Forwarded-For` leaves every client looking like the proxy. With several proxies in a row,
-every one between the client and the server has to be in `AMS_TRUSTED_PROXIES`. A request from
-an address that is not trusted keeps its own address, whatever it claims in these headers.
+an `X-Forwarded-For` that has an address in it leaves its client unknown — refused by every
+list of allowed addresses, counted with the other unknowns, and warned of in the log with the
+proxy's address — rather than looking like the proxy. Only a request that carries none of the
+three is taken to be the proxy's own. With several proxies in a row, every one between the
+client and the server has to be in `AMS_TRUSTED_PROXIES`. A request from an address that is not
+trusted keeps its own address, whatever it claims in these headers.
The `Host` header is read as it arrives. With `AMS_ALLOWED_HOSTS` set, it has to be one of
those names, or the request is answered `421` — so the proxy passes the client's `Host` on, and
@@ -118,11 +121,16 @@ name, and the paths they call are best not forwarded by it at all:
| `/v1/…`, `/v2/…` | Sonarr and Radarr |
| `/3/…`, `/4/…` | TMDB clients |
| `/v4/…` | TheTVDB clients |
+| `/tmdb-images/…` | TMDB clients, for TMDB's pictures when `tmdb.images` names this server |
They are guarded either way — by the list of allowed addresses, or by a key — but Plex sends
a provider no credential, so the Plex provider is guarded by address alone unless its address
-carries a key ([Keeping it on your network](plex.md#keeping-it-on-your-network)). The
-interface asks for none of these paths. A Plex that is elsewhere and reaches this server
+carries a key ([Keeping it on your network](plex.md#keeping-it-on-your-network)). So are
+TMDB's pictures, since a picture's address carries no key. An address the list lets in is held
+to a quota of its own, ten times the requests' — a library's scan asks for pictures in bursts —
+and pictures are handed on from TMDB sixteen at a time; a proxy that forwards `/tmdb-images/…`
+from the internet, to a TMDB policy that is `open`, still gives anyone a way to TMDB's picture
+server through this one. The interface asks for none of these paths. A Plex that is elsewhere and reaches this server
through the proxy is given a key in its address instead (`AMS_PLEX_AUTH=apikey`,
[A key in the address](plex.md#a-key-in-the-address)), and `/plex` is then forwarded.
@@ -182,7 +190,7 @@ server {
client_max_body_size 61m;
# The surfaces the clients call stay on the local network.
- location ~ ^/(plex|v1|v2|v4|3|4)(/|$) {
+ location ~ ^/(plex|v1|v2|v4|3|4|tmdb-images)(/|$) {
return 404;
}
@@ -210,6 +218,7 @@ certificate resolver `letsencrypt` in Traefik's own configuration:
Host(`metadata.example.com`)
&& !PathPrefix(`/plex`) && !PathPrefix(`/v1/`) && !PathPrefix(`/v2/`)
&& !PathPrefix(`/v4/`) && !PathPrefix(`/3/`) && !PathPrefix(`/4/`)
+ && !PathPrefix(`/tmdb-images/`)
traefik.http.routers.metadata.entrypoints: websecure
traefik.http.routers.metadata.tls.certresolver: letsencrypt
traefik.http.services.metadata.loadbalancer.server.port: "8080"
@@ -224,7 +233,7 @@ Caddy obtains the certificate for the name, passes the `Host` on and sets the
```
metadata.example.com {
- @clients path /plex /plex/* /v1/* /v2/* /v4/* /3/* /4/*
+ @clients path /plex /plex/* /v1/* /v2/* /v4/* /3/* /4/* /tmdb-images/*
respond @clients 404
reverse_proxy metadata:8080
diff --git a/docs/security.md b/docs/security.md
index f8e3847..517a511 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -31,37 +31,68 @@ is in [Accounts and access](access.md), and publishing the interface in
browser's `Origin` is not this server's (`AMS_PUBLIC_URL` and
`AMS_CORS_ORIGINS` count as this server's). Keys, Sonarr, Radarr and scripts
are not concerned.
-- **Every response carries a content security policy** with `frame-ancestors
- 'none'`, so no page elsewhere can frame this one and borrow an administrator's
- clicks, and `Referrer-Policy: no-referrer`, so the ids of what you are looking
- at do not travel to the artwork hosts; a `Permissions-Policy` that denies the
- camera, the microphone, the location and payments; on pages, a
+- **Every response of the interface carries a content security policy** with
+ `frame-ancestors 'self'`, and `X-Frame-Options: SAMEORIGIN` beside it for an
+ engine older than the directive: no page elsewhere can frame this one and
+ borrow an administrator's clicks. This server's own pages may, on purpose —
+ the editor shows a work's public page in a frame, as a preview of it. The
+ clients' door, which serves no page, sends `default-src 'none';
+ frame-ancestors 'none'; sandbox`. Both send `Referrer-Policy: no-referrer`,
+ so the ids of what you are looking at do not travel to the artwork hosts; the
+ interface also sends a `Permissions-Policy` that denies the camera, the
+ microphone, the location and payments; on pages, a
`Cross-Origin-Opener-Policy`; and, over TLS — this server's own or a trusted
proxy's — `Strict-Transport-Security` for a year.
-- **Every surface is rate limited**, the arr and TMDB ones included
+- **Every API surface is rate limited**, the arr and TMDB ones included
(`AMS_RATE_LIMIT_PER_MINUTE`, 600 by default, per address — an IPv6 address
- by its /64), save TMDB's pictures under `/tmdb-images/` for an address their
- guard lets in, which a library's scan asks for in bursts; a refusal says
- when to come back in `Retry-After`, and the per-address limit its quota in
- `RateLimit` and `RateLimit-Policy`. Signing
- in has two brakes of its own: an account that failed
- `AMS_SIGNIN_FAILURES_PER_ACCOUNT` times (10) in fifteen minutes is refused
- without being checked until the window closes, from wherever the attempts
- come; and passwords are checked a few at a time — as many as the machine has
+ by its /64). TMDB's pictures under `/tmdb-images/` are counted apart for an
+ address their guard lets in, against a quota ten times as high, since a
+ library's scan asks for them in bursts; and they are handed on from TMDB
+ sixteen at a time, the others waiting their turn for five seconds before
+ they are answered `503`. A refusal says when to come back in `Retry-After`,
+ and the per-address limit its quota in `RateLimit` and `RateLimit-Policy`.
+ Signing in has two brakes of its own: an address — an IPv6 /64 as one — that
+ failed `AMS_SIGNIN_FAILURES_PER_ACCOUNT` times (10) in fifteen minutes for an
+ account is refused for it without being checked until the window closes, each
+ attempt counted before its password is; five times as many from every address
+ together refuse the account to the rest, never to an address it signed in from
+ in the last thirty days, so a guesser cannot shut its owner out; and passwords
+ are checked a few at a time — as many as the machine has
cores, two to eight — so a burst of sign-ins waits, or is told to come back,
rather than taking the memory argon2 needs for each. With passwords off, the
name `AMS_ADMIN_USERNAME` keeps its door, and takes as long to refuse as any
other name.
+- **What costs the database more than it takes to ask for is done a few at a
+ time.** A search nobody made lately, the facets of a filter nobody chose, a
+ page of the catalogue, its figures: read three at a time, the others waiting
+ their turn for three seconds before they are answered `503` with
+ `Retry-After`. What the cache holds is answered whatever. A link's preview —
+ a work's, a list's, a genre's — is looked up on the caller's account, counted
+ against the allowance above as the API's requests are, and kept five seconds;
+ a caller who has spent its allowance is given the page without one.
+ `/ready` asks the database once in five seconds, however often it is asked.
- **Each door holds `AMS_MAX_CONNECTIONS` connections at once** (1024); past
- that, a new one waits to be accepted. A connection has
+ that, a new one waits to be accepted. No one network holds more than 64 of
+ them — an address, or an IPv6 /64 — and a connection past that is closed as
+ it is accepted; a trusted proxy's addresses and this machine's own are not
+ counted, since everybody behind a proxy has the proxy's. A connection has
`AMS_HEADER_READ_TIMEOUT` seconds (30) to send a request's head, and may sit
- idle between two requests no longer.
+ idle between two requests no longer. One with no request in flight for two
+ minutes is closed — an HTTP/2 one too, which is pinged and answers the
+ pings — and a response its peer does not read is given up on once a write
+ has stood still for a minute.
- **What is logged has its secrets masked:** the values of `api_key`, `apikey`,
- `token` and their like in any address, what follows `Bearer` or `Basic`, the
+ `token`, `auth`, a sign-in's `code`, `state` and `session_state`, TMDB's
+ `session_id`, `guest_session_id` and `request_token`, and their like in any
+ address, what follows `Bearer` or `Basic`, the
password of an address, the configured keys and passwords, the keys kept for
your own Sonarr and Radarr, and the addresses, tokens and signing secrets of
notification destinations. A failed sign-in's username is logged quoted, its
line breaks escaped.
+- **Requests this server makes send no `Referer`,** which would hand the next
+ host of a redirect the address it came from, key included; and the providers
+ a key or a client id travels to — TMDB, Fanart.tv, TheTVDB, MyAnimeList —
+ are followed only within the origin first asked.
- **An image URL may not name an address only this server can reach** — loopback,
link-local, `169.254.169.254`. The NFO export downloads stored artwork, which
makes an image URL a request this server makes on somebody else's say-so.
diff --git a/frontend/e2e/oidc.serial.spec.ts b/frontend/e2e/oidc.serial.spec.ts
index a330d3a..36b5d5c 100644
--- a/frontend/e2e/oidc.serial.spec.ts
+++ b/frontend/e2e/oidc.serial.spec.ts
@@ -302,7 +302,11 @@ test.describe('signing in through an identity provider', () => {
provider.signInAs({ sub: `sub-${username}`, preferred_username: `${username}-there`, groups: [] })
await them.goto('/account')
- await them.getByRole('link', { name: /tie my account|lier mon compte/i }).click()
+ // Tying asks for the password first: a session alone ties nothing.
+ await them.getByRole('button', { name: /tie my account|lier mon compte/i }).click()
+ const asked = them.getByRole('dialog')
+ await asked.getByLabel(/^(current password|mot de passe actuel)$/i).fill(password)
+ await asked.getByRole('button', { name: /tie my account|lier mon compte/i }).click()
await them.waitForURL('**/account')
await expect(them.getByText(/tied to the identity provider|lié au fournisseur d’identité/i)).toBeVisible()
diff --git a/frontend/openapi.json b/frontend/openapi.json
index 4a975b6..346faa0 100644
--- a/frontend/openapi.json
+++ b/frontend/openapi.json
@@ -217,7 +217,7 @@
"description": "A field was rejected"
},
"403": {
- "description": "The key limit is reached, or a key asked"
+ "description": "The key limit is reached, a key asked, or an administrator paused your keys"
},
"409": {
"description": "You already have a key by that name"
@@ -288,6 +288,9 @@
"400": {
"description": "A field was rejected"
},
+ "403": {
+ "description": "An administrator paused your keys, and only one can resume them"
+ },
"404": {
"description": "Not one of your keys"
},
@@ -423,6 +426,74 @@
}
}
},
+ "/api/v1/account/provider": {
+ "post": {
+ "tags": [
+ "Account"
+ ],
+ "summary": "Tie your account to the person the identity provider knows, so that you\ncan sign in through it as well.",
+ "description": "A session is not proof enough: one left open, or taken, would tie whoever\nholds it, and the tie would outlive the password the account's owner then\nresets. So the account's current password is asked for — or, for an account\nwith none, the provider is asked to have you sign in again, and its answer\nmust say you did. The answer is the provider's address to send the browser\nto; the tie is made when the browser comes back from it.",
+ "operationId": "oidc_tie",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TieRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Where to send the browser; the cookie of the flow is set",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TieStarted"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The password was left out, or is too long"
+ },
+ "401": {
+ "description": "The password was wrong"
+ },
+ "403": {
+ "description": "A key cannot manage an account"
+ },
+ "409": {
+ "description": "The account is tied already, or no provider is set up"
+ },
+ "429": {
+ "description": "Too many wrong passwords lately"
+ },
+ "502": {
+ "description": "The provider did not answer"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Account"
+ ],
+ "summary": "Untie your account from the identity provider. Not one that would be left\nwith no way in: a password of its own, which this server asks for, comes\nfirst. Your other sessions are closed — one of them may have been opened\nthrough the tie.",
+ "operationId": "oidc_untie",
+ "responses": {
+ "204": {
+ "description": "Untied; the other sessions are closed"
+ },
+ "403": {
+ "description": "A key cannot manage an account"
+ },
+ "409": {
+ "description": "The account is not tied, or would be left with no way in"
+ }
+ }
+ }
+ },
"/api/v1/account/sessions": {
"get": {
"tags": [
@@ -1977,7 +2048,7 @@
"tags": [
"Session"
],
- "summary": "Go to the identity provider to sign in — or, with `link`, to tie the\nsigned-in account to the person the provider knows.",
+ "summary": "Go to the identity provider to sign in. Tying an account to the person the\nprovider knows is asked for with `POST /account/provider`.",
"operationId": "oidc_start",
"parameters": [
{
@@ -1992,7 +2063,7 @@
{
"name": "link",
"in": "query",
- "description": "Set by a signed-in person tying their account to the provider.",
+ "description": "Refused: a tie is asked for from the account page, with\n`POST /account/provider`, which proves it by the account's password.",
"required": false,
"schema": {
"type": "string"
@@ -2425,6 +2496,7 @@
"Clients"
],
"summary": "Enable or disable a key without revoking it.",
+ "description": "A person's key paused from here pauses its owner's keys: its owner can\nneither resume it nor make another, or resume any other of theirs, until an\nadministrator enables a key of theirs from here, or lifts the pause\n(`DELETE /users/{id}/keys-pause`).",
"operationId": "clients_update",
"parameters": [
{
@@ -3486,6 +3558,9 @@
},
"400": {
"description": "A filter was not one the list understands"
+ },
+ "503": {
+ "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"
}
}
}
@@ -3577,6 +3652,9 @@
}
}
}
+ },
+ "503": {
+ "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"
}
}
}
@@ -4254,6 +4332,9 @@
},
"401": {
"description": "No valid credential was presented"
+ },
+ "503": {
+ "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"
}
}
},
@@ -8161,6 +8242,9 @@
},
"400": {
"description": "The term is too long, a kind is not one of work, person and episode, or the language is not a language tag"
+ },
+ "503": {
+ "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"
}
}
}
@@ -9043,6 +9127,34 @@
}
}
},
+ "/api/v1/users/{id}/keys-pause": {
+ "delete": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Lift the pause an administrator put on a person's keys — by disabling one\nof them from **Keys & network** — so that they can make keys and resume\nthem again. Enabling a key of theirs from there lifts it too; this is for\na person who has deleted theirs, with none left to enable.",
+ "operationId": "users_lift_keys_pause",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Lifted, or there was none"
+ },
+ "403": {
+ "description": "The caller is not an administrator"
+ },
+ "404": {}
+ }
+ }
+ },
"/api/v1/users/{id}/oidc": {
"delete": {
"tags": [
@@ -9077,7 +9189,7 @@
"tags": [
"Users"
],
- "summary": "Give an account a new password; its sessions are closed.",
+ "summary": "Give an account a new password; its sessions are closed, and so is its tie\nto the identity provider, if it has one: whoever tied it — the person, or\nsomebody holding a session of theirs — signed in as them through the\nprovider, which no password reset would otherwise end.",
"operationId": "users_reset_password",
"parameters": [
{
@@ -9742,11 +9854,14 @@
"404": {
"description": "TMDB has no such picture at that size"
},
+ "429": {
+ "description": "The caller's quota of pictures is spent for now; `Retry-After` says when to ask again"
+ },
"502": {
"description": "TMDB's pictures could not be reached"
},
"503": {
- "description": "The TMDB relay is switched off"
+ "description": "The TMDB relay is switched off, or as many pictures as are handed on from TMDB at once are under way (`Retry-After` says when to ask again)"
}
},
"security": []
@@ -24654,6 +24769,37 @@
}
}
},
+ "TieRequest": {
+ "type": "object",
+ "properties": {
+ "currentPassword": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The account's current password: the proof, from an account that has\none, that its owner is asking and not a session left open. An account\nwith none leaves it out, and is sent to sign in at the provider again\ninstead."
+ },
+ "next": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The page of this site to come back to."
+ }
+ }
+ },
+ "TieStarted": {
+ "type": "object",
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Where to send the browser: the identity provider's sign-in. The\ncookie that carries the flow back is set with this answer."
+ }
+ }
+ },
"TimeOfDay": {
"type": "object",
"required": [
@@ -25117,6 +25263,13 @@
"items": {
"$ref": "#/components/schemas/Session"
}
+ },
+ "keysPausedBy": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Who paused this person's keys, while the pause stands: until an\nadministrator lifts it ([`lift_keys_pause`]), they can neither make a\nkey nor resume one."
}
}
},
diff --git a/frontend/src/components/Theatre.tsx b/frontend/src/components/Theatre.tsx
index b61194a..883b2cc 100644
--- a/frontend/src/components/Theatre.tsx
+++ b/frontend/src/components/Theatre.tsx
@@ -171,6 +171,10 @@ export function Lightbox({
* no-cookie domain is the one this server's page policy lets frame, and the
* frame sends YouTube this server's origin — which its player now refuses to
* play without — and nothing more.
+ *
+ * The frame is sandboxed: YouTube's player runs, keeps its own origin's
+ * storage and goes full screen, and its links open in a window of their own;
+ * it can neither navigate this page, open a form, nor download anything.
*/
export function Trailer({
youtubeId,
@@ -207,6 +211,7 @@ export function Trailer({
src={trailerEmbed(youtubeId)}
title={heading}
className="size-full"
+ sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="strict-origin"
diff --git a/frontend/src/lib/api.gen.ts b/frontend/src/lib/api.gen.ts
index 642d2c1..7832d71 100644
--- a/frontend/src/lib/api.gen.ts
+++ b/frontend/src/lib/api.gen.ts
@@ -203,6 +203,38 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/account/provider": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Tie your account to the person the identity provider knows, so that you
+ * can sign in through it as well.
+ * @description A session is not proof enough: one left open, or taken, would tie whoever
+ * holds it, and the tie would outlive the password the account's owner then
+ * resets. So the account's current password is asked for — or, for an account
+ * with none, the provider is asked to have you sign in again, and its answer
+ * must say you did. The answer is the provider's address to send the browser
+ * to; the tie is made when the browser comes back from it.
+ */
+ post: operations["oidc_tie"];
+ /**
+ * Untie your account from the identity provider. Not one that would be left
+ * with no way in: a password of its own, which this server asks for, comes
+ * first. Your other sessions are closed — one of them may have been opened
+ * through the tie.
+ */
+ delete: operations["oidc_untie"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/account/sessions": {
parameters: {
query?: never;
@@ -1024,8 +1056,8 @@ export interface paths {
cookie?: never;
};
/**
- * Go to the identity provider to sign in — or, with `link`, to tie the
- * signed-in account to the person the provider knows.
+ * Go to the identity provider to sign in. Tying an account to the person the
+ * provider knows is asked for with `POST /account/provider`.
*/
get: operations["oidc_start"];
put?: never;
@@ -1224,7 +1256,13 @@ export interface paths {
delete: operations["clients_remove"];
options?: never;
head?: never;
- /** Enable or disable a key without revoking it. */
+ /**
+ * Enable or disable a key without revoking it.
+ * @description A person's key paused from here pauses its owner's keys: its owner can
+ * neither resume it nor make another, or resume any other of theirs, until an
+ * administrator enables a key of theirs from here, or lifts the pause
+ * (`DELETE /users/{id}/keys-pause`).
+ */
patch: operations["clients_update"];
trace?: never;
};
@@ -3672,6 +3710,28 @@ export interface paths {
patch: operations["users_update"];
trace?: never;
};
+ "/api/v1/users/{id}/keys-pause": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Lift the pause an administrator put on a person's keys — by disabling one
+ * of them from **Keys & network** — so that they can make keys and resume
+ * them again. Enabling a key of theirs from there lifts it too; this is for
+ * a person who has deleted theirs, with none left to enable.
+ */
+ delete: operations["users_lift_keys_pause"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/users/{id}/oidc": {
parameters: {
query?: never;
@@ -3702,7 +3762,12 @@ export interface paths {
};
get?: never;
put?: never;
- /** Give an account a new password; its sessions are closed. */
+ /**
+ * Give an account a new password; its sessions are closed, and so is its tie
+ * to the identity provider, if it has one: whoever tied it — the person, or
+ * somebody holding a session of theirs — signed in as them through the
+ * provider, which no password reset would otherwise end.
+ */
post: operations["users_reset_password"];
delete?: never;
options?: never;
@@ -9734,6 +9799,24 @@ export interface components {
/** @description The issuer to try; the stored one when left out. */
issuer?: string | null;
};
+ TieRequest: {
+ /**
+ * @description The account's current password: the proof, from an account that has
+ * one, that its owner is asking and not a session left open. An account
+ * with none leaves it out, and is sent to sign in at the provider again
+ * instead.
+ */
+ currentPassword?: string | null;
+ /** @description The page of this site to come back to. */
+ next?: string | null;
+ };
+ TieStarted: {
+ /**
+ * @description Where to send the browser: the identity provider's sign-in. The
+ * cookie that carries the flow back is set with this answer.
+ */
+ url: string;
+ };
TimeOfDay: {
/** Format: int32 */
hours: number;
@@ -9925,6 +10008,12 @@ export interface components {
user: components["schemas"]["User"];
keys: components["schemas"]["ApiClient"][];
sessions: components["schemas"]["Session"][];
+ /**
+ * @description Who paused this person's keys, while the pause stands: until an
+ * administrator lifts it ([`lift_keys_pause`]), they can neither make a
+ * key nor resume one.
+ */
+ keysPausedBy?: string | null;
};
UsersPage: {
users: components["schemas"]["Listed"][];
@@ -10623,7 +10712,7 @@ export interface operations {
};
content?: never;
};
- /** @description The key limit is reached, or a key asked */
+ /** @description The key limit is reached, a key asked, or an administrator paused your keys */
403: {
headers: {
[name: string]: unknown;
@@ -10695,6 +10784,13 @@ export interface operations {
};
content?: never;
};
+ /** @description An administrator paused your keys, and only one can resume them */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
/** @description Not one of your keys */
404: {
headers: {
@@ -10839,6 +10935,104 @@ export interface operations {
};
};
};
+ oidc_tie: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["TieRequest"];
+ };
+ };
+ responses: {
+ /** @description Where to send the browser; the cookie of the flow is set */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TieStarted"];
+ };
+ };
+ /** @description The password was left out, or is too long */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The password was wrong */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description A key cannot manage an account */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The account is tied already, or no provider is set up */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Too many wrong passwords lately */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The provider did not answer */
+ 502: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ oidc_untie: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Untied; the other sessions are closed */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description A key cannot manage an account */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The account is not tied, or would be left with no way in */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
account_sessions: {
parameters: {
query?: never;
@@ -12393,7 +12587,10 @@ export interface operations {
query?: {
/** @description The page of this site to come back to. */
next?: string;
- /** @description Set by a signed-in person tying their account to the provider. */
+ /**
+ * @description Refused: a tie is asked for from the account page, with
+ * `POST /account/provider`, which proves it by the account's password.
+ */
link?: string;
};
header?: never;
@@ -13611,6 +13808,13 @@ export interface operations {
};
content?: never;
};
+ /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
};
};
feeds_added_atom: {
@@ -13697,6 +13901,13 @@ export interface operations {
"application/json": components["schemas"]["Figures"];
};
};
+ /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
};
};
browse_genre: {
@@ -14222,6 +14433,13 @@ export interface operations {
};
content?: never;
};
+ /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
};
};
items_create: {
@@ -17819,6 +18037,13 @@ export interface operations {
};
content?: never;
};
+ /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
};
};
seasons_chart: {
@@ -18625,6 +18850,39 @@ export interface operations {
};
};
};
+ users_lift_keys_pause: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Lifted, or there was none */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The caller is not an administrator */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
oidc_unlink: {
parameters: {
query?: never;
@@ -19176,6 +19434,13 @@ export interface operations {
};
content?: never;
};
+ /** @description The caller's quota of pictures is spent for now; `Retry-After` says when to ask again */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
/** @description TMDB's pictures could not be reached */
502: {
headers: {
@@ -19183,7 +19448,7 @@ export interface operations {
};
content?: never;
};
- /** @description The TMDB relay is switched off */
+ /** @description The TMDB relay is switched off, or as many pictures as are handed on from TMDB at once are under way (`Retry-After` says when to ask again) */
503: {
headers: {
[name: string]: unknown;
diff --git a/frontend/src/lib/lang/en.ts b/frontend/src/lib/lang/en.ts
index fdfffd2..8e42393 100644
--- a/frontend/src/lib/lang/en.ts
+++ b/frontend/src/lib/lang/en.ts
@@ -1000,9 +1000,18 @@ export const en = {
key: 'Your API key',
keysLead: 'For a script, an app or a TMDB or TheTVDB client. A key acts in your name, with your rights and no more.',
provider: 'Identity provider',
- providerLead: 'Sign in through the identity provider as well as with your password: tie this account to the one you have there. You sign in there once to prove it is yours.',
+ providerLead: 'Sign in through the identity provider as well as with your password: tie this account to the one you have there. You give your password here, then sign in there, which shows both halves are yours.',
+ providerLeadNoPassword: 'Tie this account to the one you have at the identity provider. You sign in there again, whatever session it holds for you, which shows the account is yours.',
providerTie: 'Tie my account',
+ providerTieTitle: 'Tie your account to the identity provider',
+ providerTieBody: 'Give your password to show that it is you asking, and not a session left open on this device. You are then sent to the identity provider to sign in.',
+ providerTieBodyNoPassword: 'This account has no password: the identity provider is asked to sign you in again, and the tie is made once it has.',
providerTied: 'Tied to the identity provider: you can sign in through it.',
+ providerUntie: 'Untie…',
+ providerUntieTitle: 'Untie your account from the identity provider?',
+ providerUntieBody: 'You will sign in with your password only. Your other sessions are closed; this one stays open.',
+ providerUntieConfirm: 'Untie',
+ providerUntieNeedsPassword: 'This account has no password of its own, so it cannot be untied: it would have no way back in.',
relayYes: 'Your keys read this catalogue’s API (/api/v1) and open the TMDB (/3) and TheTVDB (/v4) relays, for Jellyseerr, Yamtrack or Kodi.',
relayNo: 'Your keys read this catalogue’s API (/api/v1). The relays are kept for the catalogue’s editors here.',
keyCount: (used: number, limit: number) => `${used} of ${limit}`,
@@ -4022,6 +4031,7 @@ export const en = {
'dataset.imported': 'Dataset downloaded',
'backup.deleted': 'Archive deleted',
'backup.restored': 'Archive restored',
+ 'backup.downloaded': 'Archive downloaded',
'item.numbering_fallback': 'Numbered by TMDB in TheTVDB’s place',
'item.numbering_restored': 'Numbering given back to TheTVDB',
'order.created': 'Order made',
diff --git a/frontend/src/lib/lang/fr.ts b/frontend/src/lib/lang/fr.ts
index 0c430bd..3c8b29f 100644
--- a/frontend/src/lib/lang/fr.ts
+++ b/frontend/src/lib/lang/fr.ts
@@ -993,9 +993,18 @@ export const fr: Dict = {
key: 'Votre clé API',
keysLead: 'Pour un script, une application ou un client TMDB ou TheTVDB. Une clé agit en votre nom, avec vos droits et pas davantage.',
provider: 'Fournisseur d’identité',
- providerLead: 'Se connecter par le fournisseur d’identité en plus du mot de passe : liez ce compte à celui que vous avez là-bas. Vous vous y connectez une fois pour prouver qu’il est à vous.',
+ providerLead: 'Se connecter par le fournisseur d’identité en plus du mot de passe : liez ce compte à celui que vous avez là-bas. Vous donnez votre mot de passe ici, puis vous vous connectez là-bas : les deux moitiés sont à vous.',
+ providerLeadNoPassword: 'Liez ce compte à celui que vous avez chez le fournisseur d’identité. Vous vous y reconnectez, quelle que soit la session qu’il garde pour vous : c’est ce qui prouve que le compte est à vous.',
providerTie: 'Lier mon compte',
+ providerTieTitle: 'Lier votre compte au fournisseur d’identité',
+ providerTieBody: 'Donnez votre mot de passe pour montrer que c’est bien vous, et non une session restée ouverte sur cet appareil. Vous êtes ensuite envoyé chez le fournisseur d’identité pour vous y connecter.',
+ providerTieBodyNoPassword: 'Ce compte n’a pas de mot de passe : le fournisseur d’identité est prié de vous reconnecter, et le lien n’est fait qu’une fois que c’est fait.',
providerTied: 'Lié au fournisseur d’identité : vous pouvez vous connecter par lui.',
+ providerUntie: 'Délier…',
+ providerUntieTitle: 'Délier votre compte du fournisseur d’identité ?',
+ providerUntieBody: 'Vous ne vous connecterez plus qu’avec votre mot de passe. Vos autres sessions sont fermées ; celle-ci reste ouverte.',
+ providerUntieConfirm: 'Délier',
+ providerUntieNeedsPassword: 'Ce compte n’a pas de mot de passe à lui : il ne peut pas être délié, il n’aurait plus aucun moyen de se reconnecter.',
relayYes: 'Vos clés lisent l’API de ce catalogue (/api/v1) et ouvrent les relais TMDB (/3) et TheTVDB (/v4), pour Jellyseerr, Yamtrack ou Kodi.',
relayNo: 'Vos clés lisent l’API de ce catalogue (/api/v1). Les relais sont réservés ici aux éditeurs du catalogue.',
keyCount: (used: number, limit: number) => `${used} sur ${limit}`,
@@ -4023,6 +4032,7 @@ export const fr: Dict = {
'dataset.imported': 'Jeu de données téléchargé',
'backup.deleted': 'Archive supprimée',
'backup.restored': 'Archive restaurée',
+ 'backup.downloaded': 'Archive téléchargée',
'item.numbering_fallback': 'Numérotée par TMDB à la place de TheTVDB',
'item.numbering_restored': 'Numérotation rendue à TheTVDB',
'order.created': 'Ordre fait',
diff --git a/frontend/src/routes/Account.tsx b/frontend/src/routes/Account.tsx
index 38e950e..dfb3653 100644
--- a/frontend/src/routes/Account.tsx
+++ b/frontend/src/routes/Account.tsx
@@ -21,7 +21,6 @@ import { RoleChip } from '../components/account/people'
import { SecretReveal } from '../components/account/SecretReveal'
import {
Button,
- ButtonLink,
Chip,
Dialog,
EmptyState,
@@ -110,11 +109,16 @@ export function Account() {
* Tying this account to the one its owner has at the identity provider: a
* sign-in there, started from here while signed in here, so both sides are
* proved by the same person. Offered only when a provider is set up.
+ *
+ * A session alone is not asked to tie: one left open, or taken, would tie
+ * whoever holds it. The password is given first — an account without one is
+ * sent to sign in at the provider again instead — and the tie is made when
+ * the browser comes back. Untied by its owner, who keeps their password.
*/
function Provider({ me }: { me: User }) {
const { t } = useI18n()
const options = useAuthOptions()
- const { pathname } = useLocation()
+ const [asking, setAsking] = useState<'tie' | 'untie' | null>(null)
if (!options.data?.oidc && !me.oidcLinked) return null
@@ -123,24 +127,145 @@ function Provider({ me }: { me: User }) {
) : null}
diff --git a/migrations/postgres/0053_season_order_tvdb_index.sql b/migrations/postgres/0053_season_order_tvdb_index.sql
new file mode 100644
index 0000000..bf00d27
--- /dev/null
+++ b/migrations/postgres/0053_season_order_tvdb_index.sql
@@ -0,0 +1,6 @@
+-- The TheTVDB relay finds the work that holds a season, or an episode an
+-- order keeps, by TheTVDB's id of it, as it finds an episode (0026): a number
+-- nobody holds read every season and every episode of every order, on each
+-- request. Partial, as there: what is made by hand has no such id.
+CREATE INDEX ix_media_season_tvdb ON media_season (tvdb_id) WHERE tvdb_id IS NOT NULL;
+CREATE INDEX ix_order_episode_tvdb ON media_order_episode (tvdb_episode_id) WHERE tvdb_episode_id IS NOT NULL;
diff --git a/migrations/sqlite/0053_season_order_tvdb_index.sql b/migrations/sqlite/0053_season_order_tvdb_index.sql
new file mode 100644
index 0000000..bf00d27
--- /dev/null
+++ b/migrations/sqlite/0053_season_order_tvdb_index.sql
@@ -0,0 +1,6 @@
+-- The TheTVDB relay finds the work that holds a season, or an episode an
+-- order keeps, by TheTVDB's id of it, as it finds an episode (0026): a number
+-- nobody holds read every season and every episode of every order, on each
+-- request. Partial, as there: what is made by hand has no such id.
+CREATE INDEX ix_media_season_tvdb ON media_season (tvdb_id) WHERE tvdb_id IS NOT NULL;
+CREATE INDEX ix_order_episode_tvdb ON media_order_episode (tvdb_episode_id) WHERE tvdb_episode_id IS NOT NULL;
diff --git a/scripts/watch/common.py b/scripts/watch/common.py
index ecdfb47..6df1c11 100644
--- a/scripts/watch/common.py
+++ b/scripts/watch/common.py
@@ -101,6 +101,7 @@ def _closed(url, data, headers, method, timeout, limit):
"""One request on a connection of its own, as urllib makes them."""
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected — the URLs are the checks' own https constants
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read(limit) if limit else response.read()
return response.status, response.headers, _decoded(body, response.headers, limit)
@@ -145,8 +146,10 @@ def _kept(url, data, headers, method, timeout, limit):
def _connect(host, port, timeout):
proxy = urllib.request.getproxies().get("https")
if not proxy or urllib.request.proxy_bypass(host):
+ # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected — http.client verifies the certificate and the host name
return http.client.HTTPSConnection(host, port, timeout=timeout)
via = urllib.parse.urlsplit(proxy if "://" in proxy else f"http://{proxy}")
+ # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected — http.client verifies the certificate and the host name
connection = http.client.HTTPSConnection(via.hostname, via.port or 80, timeout=timeout)
tunnel = {}
if via.username:
diff --git a/src/api/anilist.rs b/src/api/anilist.rs
index 638dd77..d0e09dd 100644
--- a/src/api/anilist.rs
+++ b/src/api/anilist.rs
@@ -86,6 +86,12 @@ const COLLECTIONS: &[&str] = &["GenreCollection", "MediaTagCollection"];
/// What a relayed request keeps of the caller's headers: what describes it.
const ASKED_WITH: &[&str] = &["user-agent", "accept", "accept-language", "content-type"];
+/// Of those, the headers that may change AniList's answer: what a kept answer
+/// is told apart by besides the query — the type a body is posted as says how
+/// AniList reads it — as it travels asked with them, an answer filed without
+/// them would be handed to a caller that asked otherwise.
+const VARIES_BY: &[&str] = &["accept", "accept-language", "content-type"];
+
/// How many entries of one answer are looked up here, in the order the
/// answer lists them. A page is fifty at most; a user's whole list, in one
/// answer, is not patched beyond this.
@@ -258,7 +264,7 @@ pub async fn relay(State(state): State, request: Request) -> AppResult
.is_some_and(|query| !has_mutation(query) && !is_personal(query));
let ask = relay::Ask {
service: relay::Service::Anilist,
- key: anybodys.then(|| relay_key(&state, &parts.method, &parts.uri, &body)),
+ key: anybodys.then(|| relay_key(&state, &parts.method, &parts.uri, &parts.headers, &body)),
path: if document.as_deref().is_some_and(asks_collections_alone) {
cache::ANILIST_COLLECTIONS
} else {
@@ -535,13 +541,23 @@ fn is_personal(document: &str) -> bool {
}
/// What a relayed answer is filed under: the query asked — its text, its
-/// variables, whatever else the body says — digested, under the generation.
-fn relay_key(state: &AppState, method: &Method, uri: &Uri, body: &[u8]) -> String {
+/// variables, whatever else the body says — and the caller's headers that
+/// travel and may change AniList's answer ([`relay::varies_by`]), digested,
+/// under the generation.
+fn relay_key(
+ state: &AppState,
+ method: &Method,
+ uri: &Uri,
+ headers: &HeaderMap,
+ body: &[u8],
+) -> String {
let mut digest = Sha256::new();
digest.update(method.as_str().as_bytes());
digest.update(b"\n");
digest.update(relay::sorted_query(uri, relay::OUR_PARAMS).as_bytes());
digest.update(b"\n");
+ digest.update(relay::varies_by(headers, VARIES_BY).as_bytes());
+ digest.update(b"\n");
digest.update(body);
let digest: String = digest
.finalize()
@@ -2169,6 +2185,17 @@ mod tests {
/// What the relay answers `query`, asked with somebody's own AniList
/// token when there is one.
async fn relayed(state: &AppState, query: &str, token: Option<&str>) -> Response {
+ relayed_with(state, query, token, &[]).await
+ }
+
+ /// The same, with headers of the caller's own, which take the place of
+ /// the ones a client posts a query with.
+ async fn relayed_with(
+ state: &AppState,
+ query: &str,
+ token: Option<&str>,
+ headers: &[(header::HeaderName, &str)],
+ ) -> Response {
let mut request = axum::http::Request::builder()
.method(Method::POST)
.uri("/")
@@ -2177,7 +2204,12 @@ mod tests {
if let Some(token) = token {
request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
}
- let request = request.body(Body::from(posted(query))).unwrap();
+ let mut request = request.body(Body::from(posted(query))).unwrap();
+ for (name, value) in headers {
+ request
+ .headers_mut()
+ .insert(name.clone(), HeaderValue::from_str(value).unwrap());
+ }
match relay(State(state.clone()), request).await {
Ok(response) => response,
Err(error) => error.into_response(),
@@ -2195,22 +2227,91 @@ mod tests {
response.headers().get(name)?.to_str().ok()?.parse().ok()
}
+ /// The headers a client posts a query with, as [`relayed`] does.
+ fn posting() -> HeaderMap {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ header::CONTENT_TYPE,
+ HeaderValue::from_static("application/json"),
+ );
+ headers
+ }
+
/// What the relay space holds for `query`.
async fn kept(state: &AppState, query: &str) -> Option {
- let key = relay_key(state, &Method::POST, &Uri::from_static("/"), &posted(query));
+ let key = relay_key(
+ state,
+ &Method::POST,
+ &Uri::from_static("/"),
+ &posting(),
+ &posted(query),
+ );
state.caches.relay.get(&key).await
}
/// What was kept for `query`, made `seconds` old — as if AniList had
/// answered it then.
async fn aged(state: &AppState, query: &str, seconds: u64) {
- let key = relay_key(state, &Method::POST, &Uri::from_static("/"), &posted(query));
+ let key = relay_key(
+ state,
+ &Method::POST,
+ &Uri::from_static("/"),
+ &posting(),
+ &posted(query),
+ );
let mut kept = state.caches.relay.get(&key).await.expect("kept");
kept.expires_at =
cache::now_secs() + cache::relay_ttl(cache::ANILIST_QUERY).as_secs() - seconds;
state.caches.relay.insert(key, kept).await;
}
+ /// What AniList answered is kept for the callers who asked as its first
+ /// did — by the headers that travel to it and may change its answer: the
+ /// type a body is posted as says how AniList reads it — and not for
+ /// those who asked otherwise.
+ #[tokio::test]
+ async fn an_answer_is_kept_for_the_callers_who_asked_as_its_first_did() {
+ const FIRST: &str = r#"{"data":{"Media":{"id":9400001,"title":{"romaji":"First"}}}}"#;
+ const OTHER: &str = r#"{"data":{"Media":{"id":9400001,"title":{"romaji":"Other"}}}}"#;
+ let (at, asked) = stand_in(vec![reply("200 OK", FIRST), reply("200 OK", OTHER)]).await;
+ let state = server(&at).await;
+ let query = "{ Media(id: 9400001) { id title { romaji } } }";
+
+ assert_eq!(text(relayed(&state, query, None).await).await, FIRST);
+ assert_eq!(text(relayed(&state, query, None).await).await, FIRST);
+ assert_eq!(asked.load(Ordering::SeqCst), 1, "kept for the same caller");
+
+ // Another language: another entry, asked of AniList; and then kept.
+ let german = [(header::ACCEPT_LANGUAGE, "de")];
+ assert_eq!(
+ text(relayed_with(&state, query, None, &german).await).await,
+ OTHER
+ );
+ assert_eq!(asked.load(Ordering::SeqCst), 2);
+ assert_eq!(
+ text(relayed_with(&state, query, None, &german).await).await,
+ OTHER
+ );
+ assert_eq!(asked.load(Ordering::SeqCst), 2);
+ // The first caller's is as it was.
+ assert_eq!(text(relayed(&state, query, None).await).await, FIRST);
+ assert_eq!(asked.load(Ordering::SeqCst), 2);
+
+ // The same body posted as another type is another request to AniList,
+ // and another entry here.
+ let graphql = [(header::CONTENT_TYPE, "application/graphql")];
+ let _ = relayed_with(&state, query, None, &graphql).await;
+ assert_eq!(asked.load(Ordering::SeqCst), 3);
+ let _ = relayed_with(&state, query, None, &graphql).await;
+ assert_eq!(asked.load(Ordering::SeqCst), 3, "and then kept");
+ // And what does not change AniList's answer is not another entry.
+ let agent = [(header::USER_AGENT, "Yamtrack/1.0")];
+ assert_eq!(
+ text(relayed_with(&state, query, None, &agent).await).await,
+ FIRST
+ );
+ }
+
#[tokio::test]
async fn when_anilist_fails_its_last_answer_stands_in_for_a_day() {
const KEPT: &str = r#"{"data":{"Media":{"id":9300001,"title":{"romaji":"Kept"}}}}"#;
diff --git a/src/api/audit.rs b/src/api/audit.rs
index b849e43..812efb9 100644
--- a/src/api/audit.rs
+++ b/src/api/audit.rs
@@ -1,17 +1,25 @@
//! Recording and reading the audit trail.
+use std::{
+ collections::HashMap,
+ net::IpAddr,
+ sync::LazyLock,
+ time::{Duration, Instant},
+};
+
use axum::{
Extension, Json,
extract::{Query as AxumQuery, State},
response::{IntoResponse, Response},
};
+use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
use crate::{
api::extract::ClientIp,
- auth::Identity,
+ auth::{Identity, ratelimit::network_of},
db::repo::{
self,
audit::{Action, Entry},
@@ -35,13 +43,18 @@ pub struct Event<'a> {
/// refusing it after the fact because the trail could not be written would be
/// worse than an incomplete trail.
pub async fn record(state: &AppState, event: Event<'_>) {
+ record_row(state, event).await;
+}
+
+/// [`record`], and the id of the row it wrote — nothing when it could not.
+async fn record_row(state: &AppState, event: Event<'_>) -> Option {
let actor = event.identity.map(Identity::label);
let ip = event.ip.as_text();
let target = event.target.map(clip);
let detail = event.detail.map(clip);
- let result = repo::audit::record(
+ let result = repo::audit::record_returning(
&state.db,
repo::audit::Record {
actor: actor.as_deref(),
@@ -53,9 +66,13 @@ pub async fn record(state: &AppState, event: Event<'_>) {
)
.await;
- if let Err(e) = result {
- tracing::warn!(action = %event.action, error = %e, "could not write the audit entry");
- }
+ let row = match result {
+ Ok(id) => Some(id),
+ Err(e) => {
+ tracing::warn!(action = %event.action, error = %e, "could not write the audit entry");
+ None
+ }
+ };
// A lock, an edit, a sync: told to the operator's own Sonarr or Radarr
// too, when that is asked for.
@@ -73,6 +90,8 @@ pub async fn record(state: &AppState, event: Event<'_>) {
},
)
.await;
+
+ row
}
/// Who the trail says did what this server does of its own accord.
@@ -113,6 +132,221 @@ pub async fn record_system(state: &AppState, action: Action, target: &str, detai
.await;
}
+// ─── failures of somebody with no credential ─────────────────────────────────
+
+/// What kind of failure somebody with no credential made, for the trail to
+/// gather them by.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum Failure {
+ /// A password that was refused at the sign-in form.
+ Password,
+ /// A return from the identity provider that was refused.
+ Provider,
+}
+
+/// How long the failures of one kind from one network are gathered into one
+/// row.
+const GATHERED_FOR: Duration = Duration::from_secs(60);
+
+/// The most networks gathered at once. The addresses come from whoever can
+/// reach the port; past this the rest are gathered with the one every address
+/// that cannot be told shares, so that the rows written stay bounded however
+/// many addresses there are.
+const GATHERED_MOST: usize = 4096;
+
+/// What one network's failures of one kind have come to in the window open.
+struct Gathered {
+ opened: Instant,
+ /// The row the first of them was recorded in, to say how many there were
+ /// when the window closes — none when it could not be written.
+ row: Option,
+ /// Failures since the first, which was recorded at once: counted here,
+ /// and told together when the window closes.
+ held: u32,
+ /// The name and the note of the latest of them, for the row to read by.
+ last: (Option, Option),
+ /// Whether a task is waiting for the window to close to tell them.
+ waiting: bool,
+}
+
+static GATHERING: LazyLock>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Record a failed sign-in of somebody who came with no credential — a
+/// refused password, a refused return from the identity provider — and gather
+/// the ones that follow from the same network ([`crate::auth::ip::bucket`])
+/// in the next minute into that one row, which says how many there were.
+///
+/// What anybody can cause by sending requests has to be bounded by who sends
+/// them, not by how many: the sign-in form is reachable with no credential,
+/// and each failure was a row, a notification and a line of the journal's
+/// CSV, six hundred a minute from one address. The first of a run is
+/// recorded at once, as it always was — it is the one somebody is looking
+/// for, and a slip of one's own fingers is then a row, as before — and when
+/// the minute is over the row is amended to carry the number of the run and
+/// what the last of them typed. A minute with only the one stays as it was.
+pub async fn record_failure(
+ state: &AppState,
+ kind: Failure,
+ ip: &ClientIp,
+ target: Option<&str>,
+ detail: Option<&str>,
+) {
+ gather(state, kind, ip, target, detail, GATHERED_FOR).await;
+}
+
+async fn gather(
+ state: &AppState,
+ kind: Failure,
+ ip: &ClientIp,
+ target: Option<&str>,
+ detail: Option<&str>,
+ window: Duration,
+) {
+ let (noted, key) = note(
+ &mut GATHERING.lock(),
+ (network_of(ip.0), kind),
+ Instant::now(),
+ window,
+ GATHERED_MOST,
+ (target, detail),
+ );
+
+ match noted {
+ Noted::First => {
+ let row = record_row(
+ state,
+ Event {
+ identity: None,
+ ip,
+ action: Action::SignInFailed,
+ target,
+ detail,
+ },
+ )
+ .await;
+ if let Some(gathered) = GATHERING.lock().get_mut(&key) {
+ gathered.row = row;
+ }
+ }
+ Noted::Held => {}
+ Noted::HeldUntil(wait) => {
+ let (state, ip) = (state.clone(), *ip);
+ tokio::spawn(async move {
+ tokio::time::sleep(wait).await;
+ let Some(closed) = GATHERING.lock().remove(&key) else {
+ return;
+ };
+ if closed.held > 0 {
+ tell(&state, &ip, closed, window).await;
+ }
+ });
+ }
+ }
+}
+
+/// Say how many there were of a run that is over: in the row of the first,
+/// which stood for one until now; in a row of its own, for the failures after
+/// the first, when that row could not be written.
+async fn tell(state: &AppState, ip: &ClientIp, run: Gathered, window: Duration) {
+ let (target, last) = run.last;
+ // What the last of them typed, or what the provider said of it.
+ let last = last.or(target.clone());
+ let seconds = window.as_secs().max(1);
+ let after = |last: Option| {
+ last.map(|last| format!(" · the last: {last}"))
+ .unwrap_or_default()
+ };
+
+ if let Some(row) = &run.row {
+ let said = clip(&format!(
+ "{} in all within {seconds} s{}",
+ run.held.saturating_add(1),
+ after(last)
+ ));
+ match repo::audit::amend_detail(&state.db, row, &said).await {
+ // Taken by the retention meanwhile: nothing to say it in.
+ Ok(_) => {}
+ Err(e) => tracing::warn!(error = %e, "could not amend the audit entry"),
+ }
+ return;
+ }
+
+ let said = format!("{} more within {seconds} s{}", run.held, after(last));
+ record(
+ state,
+ Event {
+ identity: None,
+ ip,
+ action: Action::SignInFailed,
+ target: target.as_deref(),
+ detail: Some(&said),
+ },
+ )
+ .await;
+}
+
+/// What a failure is to the trail.
+#[derive(Debug, PartialEq, Eq)]
+enum Noted {
+ /// The first of a run: recorded now.
+ First,
+ /// One more in a run, to be recorded with the rest.
+ Held,
+ /// The first one more of a run: held, and somebody is to wait this long
+ /// for the window to close and record what has been held.
+ HeldUntil(Duration),
+}
+
+/// Note a failure under `key` — the network and the kind, or the shared
+/// place for every network at once when `most` of them are already being
+/// gathered, which a flood of addresses is the only way to reach. Returns
+/// what it is, and the key it was noted under.
+fn note(
+ gathering: &mut HashMap<(IpAddr, Failure), Gathered>,
+ mut key: (IpAddr, Failure),
+ now: Instant,
+ window: Duration,
+ most: usize,
+ (target, detail): (Option<&str>, Option<&str>),
+) -> (Noted, (IpAddr, Failure)) {
+ if !gathering.contains_key(&key) && gathering.len() >= most {
+ gathering.retain(|_, g| g.waiting || now.duration_since(g.opened) < window);
+ if gathering.len() >= most {
+ key.0 = network_of(None);
+ }
+ }
+
+ // An entry somebody waits to record is held to, a moment past its window
+ // too: replaced, what it held would be lost.
+ let noted = match gathering.get_mut(&key) {
+ Some(g) if g.waiting || now.duration_since(g.opened) < window => {
+ g.held = g.held.saturating_add(1);
+ g.last = (target.map(str::to_string), detail.map(str::to_string));
+ if g.waiting {
+ Noted::Held
+ } else {
+ g.waiting = true;
+ Noted::HeldUntil(window.saturating_sub(now.duration_since(g.opened)))
+ }
+ }
+ _ => {
+ gathering.insert(
+ key,
+ Gathered {
+ opened: now,
+ row: None,
+ held: 0,
+ last: (None, None),
+ waiting: false,
+ },
+ );
+ Noted::First
+ }
+ };
+ (noted, key)
+}
+
/// The most of any one field this trail will hold.
///
/// Some of what is recorded comes from whoever is calling — the username of a
@@ -537,6 +771,7 @@ pub(crate) fn cell_of(value: Option<&serde_json::Value>) -> String {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::auth::testing::client_at;
#[test]
fn every_action_has_a_distinct_name() {
@@ -783,6 +1018,243 @@ mod tests {
}
}
+ fn at(address: &str) -> (IpAddr, Failure) {
+ (address.parse().unwrap(), Failure::Password)
+ }
+
+ #[test]
+ fn a_run_from_one_network_is_recorded_once_and_held_for_a_row_that_counts() {
+ let mut gathering = HashMap::new();
+ let window = Duration::from_secs(60);
+ let start = Instant::now();
+ let fail = |gathering: &mut _, key, after: u64, target: &str| {
+ note(
+ gathering,
+ key,
+ start + Duration::from_secs(after),
+ window,
+ 8,
+ (Some(target), None),
+ )
+ };
+
+ let key = at("192.0.2.1");
+ assert_eq!(fail(&mut gathering, key, 0, "a").0, Noted::First);
+ // The first held is told to be waited for; the rest only counted.
+ assert_eq!(
+ fail(&mut gathering, key, 10, "b").0,
+ Noted::HeldUntil(Duration::from_secs(50))
+ );
+ assert_eq!(fail(&mut gathering, key, 20, "c").0, Noted::Held);
+ let held = &gathering[&key];
+ assert_eq!(held.held, 2);
+ assert_eq!(held.last.0.as_deref(), Some("c"));
+
+ // Another kind from the same address, and another network, are runs of
+ // their own; an IPv6 network is one.
+ assert_eq!(
+ fail(&mut gathering, (key.0, Failure::Provider), 20, "x").0,
+ Noted::First
+ );
+ assert_eq!(
+ fail(&mut gathering, at("192.0.2.2"), 20, "x").0,
+ Noted::First
+ );
+ let v6 = |tail: &str| at(&format!("2001:db8:1:2::{tail}"));
+ let bucket = |(ip, kind): (IpAddr, Failure)| (crate::auth::ip::bucket(ip), kind);
+ assert_eq!(
+ fail(&mut gathering, bucket(v6("1")), 20, "x").0,
+ Noted::First
+ );
+ assert!(matches!(
+ fail(&mut gathering, bucket(v6("2")), 21, "x").0,
+ Noted::HeldUntil(_)
+ ));
+
+ // Once the flush has taken what was held, the window is another.
+ gathering.remove(&key);
+ assert_eq!(fail(&mut gathering, key, 70, "d").0, Noted::First);
+ // A window that closed and was not flushed yet still takes what comes.
+ assert_eq!(
+ fail(&mut gathering, key, 80, "e").0,
+ Noted::HeldUntil(Duration::from_secs(50))
+ );
+ assert_eq!(fail(&mut gathering, key, 200, "f").0, Noted::Held);
+ assert_eq!(gathering[&key].held, 2);
+ }
+
+ /// Past what is gathered at once, the networks beyond are gathered as one.
+ #[test]
+ fn a_flood_of_addresses_is_gathered_under_one_key() {
+ let mut gathering = HashMap::new();
+ let window = Duration::from_secs(60);
+ let now = Instant::now();
+
+ for n in 0..4 {
+ let key = at(&format!("192.0.2.{n}"));
+ let (noted, used) = note(&mut gathering, key, now, window, 4, (None, None));
+ assert_eq!((noted, used), (Noted::First, key));
+ }
+ // The fifth address is nobody's in particular, and so is the sixth:
+ // one run between them.
+ let (first, used) = note(
+ &mut gathering,
+ at("198.51.100.1"),
+ now,
+ window,
+ 4,
+ (None, None),
+ );
+ assert_eq!(first, Noted::First);
+ assert_eq!(used, at("0.0.0.0"));
+ let (second, used) = note(
+ &mut gathering,
+ at("198.51.100.2"),
+ now,
+ window,
+ 4,
+ (None, None),
+ );
+ assert!(matches!(second, Noted::HeldUntil(_)));
+ assert_eq!(used, at("0.0.0.0"));
+ assert_eq!(gathering.len(), 5);
+
+ // Windows that closed make room again.
+ let later = now + window;
+ let (noted, used) = note(
+ &mut gathering,
+ at("198.51.100.3"),
+ later,
+ window,
+ 4,
+ (None, None),
+ );
+ assert_eq!((noted, used), (Noted::First, at("198.51.100.3")));
+ }
+
+ /// The failed sign-ins of the trail, the oldest first.
+ async fn failed_sign_ins(state: &AppState) -> Vec {
+ let mut rows = repo::audit::list(
+ &state.db,
+ &repo::audit::Query {
+ action: Some(Action::SignInFailed.as_str().into()),
+ limit: 100,
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+ rows.reverse();
+ rows
+ }
+
+ /// What the sign-in form is flooded with is one row, written at once, and
+ /// said to be many when the minute is over — whatever the flood.
+ #[tokio::test]
+ async fn a_flood_of_failed_sign_ins_is_one_row_that_carries_the_count() {
+ let state = crate::api::native::testing::server().await;
+ let window = Duration::from_millis(300);
+ let ip = client_at(&state, "192.0.2.150").await;
+
+ for n in 0..8 {
+ let typed = format!("guess-{n}");
+ gather(&state, Failure::Password, &ip, Some(&typed), None, window).await;
+ }
+ let at_once = failed_sign_ins(&state).await;
+ assert_eq!(at_once.len(), 1);
+ assert_eq!(at_once[0].target.as_deref(), Some("guess-0"));
+ assert_eq!(at_once[0].ip.as_deref(), Some("192.0.2.150"));
+ assert_eq!(at_once[0].detail, None);
+
+ // The window closes, and the one row says how many it stood for.
+ let mut rows = at_once;
+ for _ in 0..200 {
+ if rows[0].detail.is_some() {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ rows = failed_sign_ins(&state).await;
+ }
+ assert_eq!(rows.len(), 1, "{rows:?}");
+ assert_eq!(rows[0].target.as_deref(), Some("guess-0"));
+ let said = rows[0].detail.as_deref().unwrap_or_default();
+ assert_eq!(said, "8 in all within 1 s · the last: guess-7");
+
+ // A failure after that is the first of another run, recorded at once.
+ gather(&state, Failure::Password, &ip, Some("again"), None, window).await;
+ assert_eq!(failed_sign_ins(&state).await.len(), 2);
+ }
+
+ /// A run of one stays as it was: a row, and nothing said of it later.
+ #[tokio::test]
+ async fn a_single_failure_is_one_row_as_it_always_was() {
+ let state = crate::api::native::testing::server().await;
+ let window = Duration::from_millis(100);
+ let ip = client_at(&state, "192.0.2.170").await;
+
+ gather(&state, Failure::Password, &ip, Some("slip"), None, window).await;
+ tokio::time::sleep(Duration::from_millis(400)).await;
+
+ let rows = failed_sign_ins(&state).await;
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].target.as_deref(), Some("slip"));
+ assert_eq!(rows[0].detail, None);
+ }
+
+ /// Where the first row could not be written there is none to amend: what
+ /// followed it is told in a row of its own.
+ #[tokio::test]
+ async fn a_run_whose_first_row_is_missing_is_told_in_a_row_of_its_own() {
+ let state = crate::api::native::testing::server().await;
+ let ip = client_at(&state, "192.0.2.180").await;
+ let run = Gathered {
+ opened: Instant::now(),
+ row: None,
+ held: 3,
+ last: (Some("guess-3".into()), None),
+ waiting: true,
+ };
+
+ tell(&state, &ip, run, Duration::from_secs(60)).await;
+
+ let rows = failed_sign_ins(&state).await;
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].target.as_deref(), Some("guess-3"));
+ assert_eq!(
+ rows[0].detail.as_deref(),
+ Some("3 more within 60 s · the last: guess-3")
+ );
+ }
+
+ /// One address's failures do not hold another's back.
+ #[tokio::test]
+ async fn each_network_and_kind_is_recorded_at_its_own_first() {
+ let state = crate::api::native::testing::server().await;
+ let window = Duration::from_secs(60);
+ let one = client_at(&state, "192.0.2.160").await;
+ let other = client_at(&state, "192.0.2.161").await;
+
+ gather(&state, Failure::Password, &one, Some("a"), None, window).await;
+ gather(&state, Failure::Password, &other, Some("b"), None, window).await;
+ gather(
+ &state,
+ Failure::Provider,
+ &one,
+ None,
+ Some("expired"),
+ window,
+ )
+ .await;
+ gather(&state, Failure::Password, &one, Some("c"), None, window).await;
+
+ let rows = failed_sign_ins(&state).await;
+ assert_eq!(
+ rows.iter().map(|r| r.target.as_deref()).collect::>(),
+ [Some("a"), Some("b"), None],
+ "the fourth is held for the row that counts"
+ );
+ }
+
#[test]
fn action_names_are_namespaced() {
// A log shipper filters on the prefix, so every name must carry one.
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 95ec515..4789f41 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -26,7 +26,7 @@ pub mod tmdb_images;
use axum::{
Router,
extract::Request,
- http::StatusCode,
+ http::{HeaderValue, StatusCode, header},
middleware::from_fn_with_state,
response::{IntoResponse, Response},
};
@@ -36,7 +36,29 @@ use utoipa::{
};
use utoipa_axum::router::OpenApiRouter;
-use crate::{auth::middleware as guards, state::AppState};
+use crate::{auth::middleware as guards, error::AppError, state::AppState};
+
+/// Seconds a caller turned away from work that takes a place
+/// ([`crate::cache::Gate`]) is told to wait: past those it waited for one,
+/// and long enough for what held them to finish.
+const BUSY_RETRY: u64 = 5;
+
+/// What a caller is answered when every place for a kind of work stayed taken
+/// as long as it waits — a read no cache answered, a picture handed on from
+/// TMDB: a 503, in the API's own shape, and when to come back.
+pub(crate) fn busy() -> Response {
+ let mut response = AppError::Disabled {
+ code: "busy",
+ message: "this server is answering as many such requests as it can; try again in a few \
+ seconds"
+ .into(),
+ }
+ .into_response();
+ response
+ .headers_mut()
+ .insert(header::RETRY_AFTER, HeaderValue::from(BUSY_RETRY));
+ response
+}
/// Spec metadata. Paths and schemas are collected from the handlers themselves,
/// so this cannot drift from what the server actually serves.
@@ -284,10 +306,16 @@ fn tmdb_surface(state: AppState, tmdb: Router) -> Router {
/// key policy read as the address list — a picture's address, built by the
/// client from TMDB's configuration, carries no key. Held to the limit of
/// requests an address may make only where its guard turns the caller
-/// away: a library's scan asks for pictures in bursts no relay's caller
-/// makes ([`guards::guard_tmdb_images`]).
+/// away — a library's scan asks for pictures in bursts no relay's caller
+/// makes ([`guards::guard_tmdb_images`]) — and, where it lets the caller in,
+/// to a quota of pictures ten times as high ([`tmdb_images::Pace`]).
fn tmdb_images_surface(state: AppState, images: Router) -> Router {
images
+ // Inside the guard, which has let the caller in and left it uncounted.
+ .layer(from_fn_with_state(
+ tmdb_images::Pace::new(state.clone()),
+ tmdb_images::pace,
+ ))
.layer(from_fn_with_state(state.clone(), guards::guard_tmdb_images))
.layer(from_fn_with_state(state, guards::reject_self_calls))
}
@@ -989,6 +1017,21 @@ mod tests {
);
}
+ /// Work turned away for want of a place is told so in the API's shape,
+ /// as a 503, with when to come back.
+ #[tokio::test]
+ async fn a_caller_turned_away_from_a_place_is_told_when_to_come_back() {
+ let response = busy();
+ assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+ assert_eq!(response.headers()[header::RETRY_AFTER], "5");
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ let said: serde_json::Value = serde_json::from_slice(&body).unwrap();
+ assert_eq!(said["error"], "busy");
+ assert!(said["message"].as_str().is_some_and(|m| !m.is_empty()));
+ }
+
/// The interface's types are generated from a copy of the spec kept in
/// `frontend/openapi.json` (`npm run types` there writes
/// `src/lib/api.gen.ts`), so a field added here reaches the pages' types
diff --git a/src/api/native/account.rs b/src/api/native/account.rs
index 57d5516..9e15ebd 100644
--- a/src/api/native/account.rs
+++ b/src/api/native/account.rs
@@ -339,7 +339,7 @@ fn clean_key_name(raw: &str) -> AppResult {
responses(
(status = 201, body = IssuedKey),
(status = 400, description = "A field was rejected"),
- (status = 403, description = "The key limit is reached, or a key asked"),
+ (status = 403, description = "The key limit is reached, a key asked, or an administrator paused your keys"),
(status = 409, description = "You already have a key by that name"),
),
)]
@@ -357,6 +357,10 @@ async fn create_key(
// would both find room for one more.
let _held = state.accounts_lock().await;
+ if clients::paused_by_admin(&state, &me.id).await?.is_some() {
+ return Err(keys_paused());
+ }
+
if let Some(limit) = limit_for(&state, me)
&& repo::client::count_owned(&state.db, &me.id).await? >= limit
{
@@ -412,6 +416,18 @@ async fn create_key(
))
}
+/// What a person is told while an administrator's pause on their keys stands:
+/// the pause is on them, not on one key, so that deleting a paused key and
+/// making another is no way round it.
+fn keys_paused() -> AppError {
+ AppError::Refused {
+ code: "keys_paused_by_admin",
+ message: "an administrator paused your keys: until one lifts that, you can neither make a \
+ key nor resume one"
+ .into(),
+ }
+}
+
/// One of your keys, or nobody's business.
async fn own_key(state: &AppState, me: &User, id: &str) -> AppResult {
repo::client::get(&state.db, id)
@@ -437,6 +453,7 @@ pub struct KeyUpdate {
responses(
(status = 200, body = ApiClient),
(status = 400, description = "A field was rejected"),
+ (status = 403, description = "An administrator paused your keys, and only one can resume them"),
(status = 404, description = "Not one of your keys"),
(status = 409, description = "You already have a key by that name"),
),
@@ -470,17 +487,14 @@ async fn update_key(
)));
}
- // Paused by an administrator, it stays paused until one resumes it: a
- // member whose key was stopped for hammering the relays does not get to
- // start it again.
+ // Paused by an administrator, the keys stay paused until one resumes
+ // them: a member whose key was stopped for hammering the relays does not
+ // get to start it again, nor another of theirs.
if update.is_enabled == Some(true)
&& !key.is_enabled
- && clients::paused_by_admin(&state, &id).await?
+ && clients::paused_by_admin(&state, &me.id).await?.is_some()
{
- return Err(AppError::Refused {
- code: "key_paused_by_admin",
- message: "an administrator paused this key; only an administrator can resume it".into(),
- });
+ return Err(keys_paused());
}
if let Some(name) = &name {
@@ -825,6 +839,124 @@ mod tests {
}
}
+ async fn opened(state: &AppState, name: &str, role: Role) -> User {
+ repo::user::create(
+ &state.db,
+ repo::user::NewUser {
+ username: name,
+ password_hash: repo::user::NO_PASSWORD,
+ role,
+ status: Status::Active,
+ display_name: None,
+ email: None,
+ invited_by: None,
+ oidc: None,
+ },
+ )
+ .await
+ .expect("an account")
+ }
+
+ /// Whether an answer is the refusal a pause tells.
+ fn paused(answer: Result) -> bool {
+ matches!(
+ answer,
+ Err(AppError::Refused {
+ code: "keys_paused_by_admin",
+ ..
+ })
+ )
+ }
+
+ /// A member whose key an administrator stopped could delete it and make
+ /// another, and the pause was gone with the key. It stands on the person:
+ /// until an administrator lifts it they make no key and resume none, and
+ /// deleting the one that was paused is no way round it.
+ #[tokio::test]
+ async fn an_administrators_pause_stands_on_the_person_and_outlives_the_key() {
+ use crate::settings::Scope;
+
+ let state = crate::api::native::testing::server().await;
+ state
+ .settings
+ .set(Scope::Server, "", "keys.maxPerUser", "5", None)
+ .await
+ .expect("room for several keys");
+ let admin = opened(&state, "root", Role::Admin).await;
+ let alice = opened(&state, "alice", Role::Member).await;
+ let as_alice = || Extension(Identity::User(Box::new(alice.clone())));
+ let as_admin = || Extension(Identity::User(Box::new(admin.clone())));
+
+ let make = |name: &'static str| {
+ create_key(
+ State(state.clone()),
+ as_alice(),
+ ClientIp::NONE,
+ Json(NewKey {
+ name: name.into(),
+ scopes: vec![],
+ }),
+ )
+ };
+ let toggle = |id: &str, enabled: bool| {
+ update_key(
+ State(state.clone()),
+ as_alice(),
+ ClientIp::NONE,
+ Path(id.to_string()),
+ Json(KeyUpdate {
+ name: None,
+ is_enabled: Some(enabled),
+ scopes: None,
+ }),
+ )
+ };
+ let from_admin = |id: &str, enabled: bool| {
+ clients::update(
+ State(state.clone()),
+ as_admin(),
+ ClientIp::NONE,
+ Path(id.to_string()),
+ Json(clients::UpdateRequest {
+ is_enabled: Some(enabled),
+ }),
+ )
+ };
+
+ let (_, Json(sonarr)) = make("Sonarr").await.expect("a key");
+ let (_, Json(radarr)) = make("Radarr").await.expect("another");
+
+ // Hers to pause and resume, while nobody else has said anything.
+ let _ = toggle(&radarr.key.id, false).await.expect("paused by her");
+ let _ = toggle(&radarr.key.id, true).await.expect("resumed by her");
+
+ // An administrator stops the first, which is hammering the relays.
+ let _ = from_admin(&sonarr.key.id, false).await.expect("paused");
+ assert!(paused(toggle(&sonarr.key.id, true).await));
+ // Nor does she resume another she pauses herself, or make a third.
+ let _ = toggle(&radarr.key.id, false).await.expect("paused by her");
+ assert!(paused(toggle(&radarr.key.id, true).await));
+ assert!(paused(make("Third").await));
+
+ // Deleting the key that was paused is no way round it.
+ delete_key(
+ State(state.clone()),
+ as_alice(),
+ ClientIp::NONE,
+ Path(sonarr.key.id.clone()),
+ )
+ .await
+ .expect("deleted");
+ assert!(paused(make("Sonarr again").await));
+ assert!(paused(toggle(&radarr.key.id, true).await));
+
+ // The administrator resumes the key she has left, and the pause lifts.
+ let _ = from_admin(&radarr.key.id, true).await.expect("resumed");
+ let _ = make("Sonarr again").await.expect("made again");
+ let _ = toggle(&radarr.key.id, false).await.expect("hers again");
+ let _ = toggle(&radarr.key.id, true).await.expect("hers again");
+ }
+
#[tokio::test]
async fn a_person_keeps_their_preferences_and_no_key_can() {
let state = crate::api::native::testing::server().await;
diff --git a/src/api/native/auth.rs b/src/api/native/auth.rs
index de6a1ad..36254aa 100644
--- a/src/api/native/auth.rs
+++ b/src/api/native/auth.rs
@@ -20,10 +20,11 @@ use crate::{
auth::{
Identity, ip,
middleware::{self, SESSION_COOKIE},
+ ratelimit::{self, Reserved},
secrets,
},
db::repo::{self, audit::Action},
- error::{AppError, AppResult},
+ error::{AppError, AppResult, Throttle},
state::AppState,
};
@@ -104,18 +105,12 @@ async fn login(
return Err(AppError::Unauthorized);
}
- // An account that has failed too often lately is not checked at all, from
- // wherever the attempt comes: guessing one person's password from many
- // addresses runs out as surely as from one. Asked before anything is
- // hashed, so a refused attempt costs nothing. Told when the account's
- // window closes: the failures are no quota of requests, so that alone.
- if let Err(wait) = state.limiter.sign_in_allowed(&request.username).await {
- tracing::warn!(
- username = ?request.username,
- "sign-in refused: too many failed attempts on this account lately"
- );
- return Err(AppError::RateLimited(crate::error::Throttle::after(wait)));
- }
+ // The attempt is counted before anything is looked up or hashed, and
+ // refused there when the account has taken as many from this address as
+ // it takes lately — or, from addresses it has not been signed in from,
+ // as many from everywhere. Told when the window closes: the failures
+ // are no quota of requests, so that alone.
+ let attempt = begin_attempt(&state, &request.username, &ip, None).await?;
// Passwords switched off: the identity provider signs people in, and only
// the account the environment names keeps a door — the way back in when
@@ -129,8 +124,13 @@ async fn login(
message: "this server signs people in through its identity provider".into(),
};
if passwords_off && !state.is_break_glass_name(&request.username) {
- secrets::verify_password_async(request.password.clone(), DUMMY_HASH.to_string()).await?;
- state.limiter.sign_in_failed(&request.username).await;
+ check_password(
+ &state,
+ attempt,
+ request.password.clone(),
+ DUMMY_HASH.to_string(),
+ )
+ .await?;
return Err(refused());
}
@@ -144,40 +144,53 @@ async fn login(
// An account an identity provider made has no password: checked
// against the dummy all the same, so it takes as long as any other.
Some(creds) if creds.password_hash == repo::user::NO_PASSWORD => {
- secrets::verify_password_async(request.password.clone(), DUMMY_HASH.to_string())
- .await?;
+ check_password(
+ &state,
+ attempt,
+ request.password.clone(),
+ DUMMY_HASH.to_string(),
+ )
+ .await?;
(Some(creds.user), false)
}
Some(creds) => {
- let ok = secrets::verify_password_async(request.password.clone(), creds.password_hash)
- .await?;
+ let ok = check_password(
+ &state,
+ attempt,
+ request.password.clone(),
+ creds.password_hash,
+ )
+ .await?;
(Some(creds.user), ok)
}
None => {
- secrets::verify_password_async(request.password.clone(), DUMMY_HASH.to_string())
- .await?;
+ check_password(
+ &state,
+ attempt,
+ request.password.clone(),
+ DUMMY_HASH.to_string(),
+ )
+ .await?;
(None, false)
}
};
let (Some(user), true) = (user, ok) else {
- state.limiter.sign_in_failed(&request.username).await;
- // Debug-formatted: quoted, its line breaks and control characters
- // escaped, so a name typed into the form cannot write a line of its
- // own into the log.
+ // Counted already, before the password was looked at. Debug-formatted:
+ // quoted, its line breaks and control characters escaped, so a name
+ // typed into the form cannot write a line of its own into the log.
tracing::warn!(username = ?request.username, "failed sign-in attempt");
// Recorded deliberately: a run of these is the one thing in this log
- // worth alerting on. The attempted username is kept; the password is not.
- audit::record(
+ // worth alerting on. The attempted username is kept; the password is
+ // not. A run from one network is one row at once and one that counts
+ // the rest — anybody can send these with no credential at all.
+ audit::record_failure(
&state,
- Event {
- identity: None,
- ip: &ip,
- action: Action::SignInFailed,
- target: Some(request.username.trim()),
- detail: None,
- },
+ audit::Failure::Password,
+ &ip,
+ Some(request.username.trim()),
+ None,
)
.await;
@@ -188,7 +201,7 @@ async fn login(
});
};
- state.limiter.sign_in_succeeded(&request.username).await;
+ state.limiter.sign_in_succeeded(attempt).await;
if passwords_off
&& !(user.role == repo::user::Role::Admin && user.status == repo::user::Status::Active)
@@ -220,6 +233,97 @@ async fn login(
Ok((StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(answer)))
}
+/// Count an attempt at `username`'s password from this caller's network
+/// before the password is looked at, and refuse it when it is not to be.
+/// Refused: when the network has made as many attempts on the account as a
+/// window allows, whatever it sends; and when the account has taken more
+/// than its bar from every network together and this one has not been signed
+/// in to it lately ([`ratelimit::address_known`]), so that guessing from
+/// many addresses runs out while the account's owner, from theirs, still
+/// gets in. A refused attempt costs nothing: no password is hashed for it.
+///
+/// `user` is the account's id when it is known; otherwise the account is
+/// looked up by its name, and only once the bar is passed.
+pub(crate) async fn begin_attempt(
+ state: &AppState,
+ username: &str,
+ ip: &ClientIp,
+ user: Option<&str>,
+) -> AppResult {
+ let network = ratelimit::network_of(ip.0);
+ let reserved = match state.limiter.sign_in_begin(username, network).await {
+ Ok(reserved) => reserved,
+ Err(wait) => {
+ tracing::warn!(
+ username = ?username,
+ "sign-in refused: too many failed attempts on this account from this address lately"
+ );
+ return Err(AppError::RateLimited(Throttle::after(wait)));
+ }
+ };
+
+ let Some(wait) = reserved.over_account_bar() else {
+ return Ok(reserved);
+ };
+ let known = match user {
+ Some(id) => ratelimit::address_known(&state.db, id, network).await,
+ None => match repo::user::find_by_username(&state.db, username.trim()).await? {
+ Some(found) => ratelimit::address_known(&state.db, &found.user.id, network).await,
+ None => false,
+ },
+ };
+ if known {
+ return Ok(reserved);
+ }
+
+ state.limiter.sign_in_given_back(reserved).await;
+ tracing::warn!(
+ username = ?username,
+ "sign-in refused: too many failed attempts on this account lately, from addresses it has \
+ not been signed in from"
+ );
+ Err(AppError::RateLimited(Throttle::after(wait)))
+}
+
+/// A password checked within an attempt counted by [`begin_attempt`]: whether
+/// it is right. When every hashing slot stayed taken nothing was checked, and
+/// the attempt is given back; the caller is told to come back.
+pub(crate) async fn check_password(
+ state: &AppState,
+ attempt: Reserved,
+ password: String,
+ hash: String,
+) -> AppResult {
+ match secrets::verify_password_async(password, hash).await {
+ Ok(ok) => Ok(ok),
+ Err(busy @ secrets::HashError::Busy) => {
+ state.limiter.sign_in_given_back(attempt).await;
+ Err(busy.into())
+ }
+ Err(other) => Err(other.into()),
+ }
+}
+
+/// A signed-in person's password, asked for again as proof that it is them
+/// at the keyboard and not whoever holds a session left open: counted and
+/// checked as a sign-in is, so that guessing it from a session that is not
+/// theirs runs out as guessing it at the form does. A wrong one is
+/// `Unauthorized`.
+pub(crate) async fn prove_password(
+ state: &AppState,
+ user: &repo::user::User,
+ hash: String,
+ typed: &str,
+ ip: &ClientIp,
+) -> AppResult<()> {
+ let attempt = begin_attempt(state, &user.username, ip, Some(&user.id)).await?;
+ if !check_password(state, attempt, typed.to_string(), hash).await? {
+ return Err(AppError::Unauthorized);
+ }
+ state.limiter.sign_in_succeeded(attempt).await;
+ Ok(())
+}
+
/// Open a session for someone who has shown who they are — by their password,
/// by signing up, or through the identity provider — and say so in the
/// journal. Returns the cookie to set and what the interface is told;
@@ -247,6 +351,11 @@ pub(crate) async fn open_session(
repo::user::mark_login(&state.db, &user.id).await?;
+ // However they came in: the address a person signs in from is one the
+ // account's own, which what is tried against it from elsewhere does not
+ // shut out ([`begin_attempt`]).
+ ratelimit::remember_address(&state.db, &user.id, ratelimit::network_of(ip.0)).await;
+
let identity = Identity::User(Box::new(user.clone()));
audit::record(
state,
@@ -454,11 +563,16 @@ async fn change_password(
return Err(AppError::BadRequest("that password is too long".into()));
}
- if !secrets::verify_password_async(request.current_password.clone(), creds.password_hash)
- .await?
- {
- return Err(AppError::Unauthorized);
- }
+ // Asked as a sign-in is: a session is not the password, and guessing the
+ // password through it is counted as guessing it at the form.
+ prove_password(
+ &state,
+ user,
+ creds.password_hash,
+ &request.current_password,
+ &ip,
+ )
+ .await?;
let hash = secrets::hash_password_async(request.new_password.clone()).await?;
@@ -485,3 +599,161 @@ async fn change_password(
Ok(StatusCode::NO_CONTENT)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ auth::testing::{client_at, server},
+ db::repo::user::{NewUser, Role, Status, User},
+ };
+
+ /// A password, drawn at random: the tests need none in particular, and one
+ /// written in the code reads as a secret kept there to whoever scans it.
+ fn password() -> String {
+ secrets::generate_session_token().unwrap().0
+ }
+
+ async fn account(state: &AppState, name: &str, password: &str) -> User {
+ repo::user::create(
+ &state.db,
+ NewUser {
+ username: name,
+ password_hash: &secrets::hash_password(password).unwrap(),
+ role: Role::Member,
+ status: Status::Active,
+ display_name: None,
+ email: None,
+ invited_by: None,
+ oidc: None,
+ },
+ )
+ .await
+ .expect("an account")
+ }
+
+ /// The sign-in form, filled in from an address.
+ async fn sign_in(
+ state: &AppState,
+ from: &str,
+ username: &str,
+ password: &str,
+ ) -> Result {
+ let ip = client_at(state, from).await;
+ login(
+ State(state.clone()),
+ ip,
+ None,
+ HeaderMap::new(),
+ Json(LoginRequest {
+ username: username.into(),
+ password: password.into(),
+ }),
+ )
+ .await
+ .map(|answer| answer.into_response().status())
+ }
+
+ /// What the lock-out is for is that a burst is held to its limit. Every
+ /// password of a burst was checked, once, when the count was made after
+ /// the check: all of them found it at nothing, and went on to be checked.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn a_burst_of_wrong_passwords_is_checked_no_more_than_the_limit_allows() {
+ let state = server(3).await;
+ account(&state, "alice", &password()).await;
+ let wrong = password();
+
+ let attempts: Vec<_> = (0..12)
+ .map(|_| {
+ let (state, wrong) = (state.clone(), wrong.clone());
+ tokio::spawn(async move { sign_in(&state, "198.51.100.9", "alice", &wrong).await })
+ })
+ .collect();
+ let (mut checked, mut turned_away) = (0, 0);
+ for attempt in attempts {
+ match attempt.await.unwrap() {
+ Err(AppError::Unauthorized) => checked += 1,
+ Err(AppError::RateLimited(_)) => turned_away += 1,
+ other => panic!("{other:?}"),
+ }
+ }
+ assert_eq!((checked, turned_away), (3, 9));
+ }
+
+ /// Somebody guessing at an account from their own addresses shuts those
+ /// addresses out, and — past a bar for the account as a whole — every
+ /// address that has not signed in to it. Its owner's usual address is not
+ /// one of them: with the right password it gets in, and the guessing
+ /// that was turned away cost nothing to check.
+ #[tokio::test]
+ async fn guessing_at_an_account_shuts_out_the_guessers_and_not_its_owner() {
+ let state = server(1).await;
+ let right = password();
+ account(&state, "alice", &right).await;
+ let (home, away) = ("203.0.113.7", "203.0.113.99");
+
+ // Their usual address, signed in from once: known to the account.
+ assert_eq!(
+ sign_in(&state, home, "alice", &right).await.unwrap(),
+ StatusCode::OK
+ );
+
+ // Five addresses, each its one failure — the account's bar is five
+ // times an address's — and the sixth is turned away unchecked.
+ let wrong = password();
+ for n in 1..=5 {
+ let from = format!("198.51.100.{n}");
+ assert!(matches!(
+ sign_in(&state, &from, "alice", &wrong).await,
+ Err(AppError::Unauthorized)
+ ));
+ }
+ assert!(matches!(
+ sign_in(&state, "198.51.100.6", "alice", &wrong).await,
+ Err(AppError::RateLimited(_))
+ ));
+ // One address, again: its own count is spent.
+ assert!(matches!(
+ sign_in(&state, "198.51.100.1", "alice", &wrong).await,
+ Err(AppError::RateLimited(_))
+ ));
+
+ // The owner, from where they have signed in, gets through; from
+ // somewhere they have not, they wait with the rest.
+ assert_eq!(
+ sign_in(&state, home, "alice", &right).await.unwrap(),
+ StatusCode::OK
+ );
+ assert!(matches!(
+ sign_in(&state, away, "alice", &right).await,
+ Err(AppError::RateLimited(_))
+ ));
+ }
+
+ /// A session is not the password: asking for it again is counted, so that
+ /// guessing it through a session that is not its owner's runs out as at
+ /// the form.
+ #[tokio::test]
+ async fn the_current_password_asked_for_again_is_counted_as_at_the_form() {
+ let state = server(1).await;
+ let right = password();
+ let alice = account(&state, "alice", &right).await;
+ let hash = repo::user::credentials(&state.db, &alice.id)
+ .await
+ .unwrap()
+ .unwrap()
+ .password_hash;
+ let ip = client_at(&state, "198.51.100.9").await;
+
+ let wrong = password();
+ assert!(matches!(
+ prove_password(&state, &alice, hash.clone(), &wrong, &ip).await,
+ Err(AppError::Unauthorized)
+ ));
+ // Spent: not even the right one is looked at now.
+ assert!(matches!(
+ prove_password(&state, &alice, hash, &right, &ip).await,
+ Err(AppError::RateLimited(_))
+ ));
+ }
+}
diff --git a/src/api/native/backup.rs b/src/api/native/backup.rs
index 5f159e7..a648cfe 100644
--- a/src/api/native/backup.rs
+++ b/src/api/native/backup.rs
@@ -185,6 +185,7 @@ async fn create(
async fn download(
State(state): State,
Extension(identity): Extension,
+ ip: ClientIp,
Path(name): Path,
) -> AppResult {
identity.require_admin()?;
@@ -194,6 +195,21 @@ async fn download(
.await?
.ok_or(AppError::NotFound)?;
+ // Written down as the archive starts to leave: it holds every account's
+ // password hash, and who took one is what the trail is read for after a
+ // leak. A name that is no archive's, or no longer kept, is not an entry.
+ audit::record(
+ &state,
+ Event {
+ identity: Some(&identity),
+ ip: &ip,
+ action: Action::BackupDownloaded,
+ target: Some(&name),
+ detail: None,
+ },
+ )
+ .await;
+
let mut response = Body::from_stream(found.stream).into_response();
let headers = response.headers_mut();
// Plain zstd says what it is; an encrypted one is bytes to whoever has
@@ -340,6 +356,7 @@ mod tests {
download(
State(state.clone()),
Extension(identity.clone()),
+ ClientIp::NONE,
Path("ams-20261019-020000-manual.tar.zst".into())
)
.await,
@@ -371,9 +388,14 @@ mod tests {
assert_eq!(archive.size_bytes, made.size);
assert!(!listed.overdue);
- let response = download(State(state.clone()), admin(), Path(made.name.clone()))
- .await
- .unwrap();
+ let response = download(
+ State(state.clone()),
+ admin(),
+ ClientIp::NONE,
+ Path(made.name.clone()),
+ )
+ .await
+ .unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()[header::ETAG],
@@ -394,11 +416,33 @@ mod tests {
// A name that is not an archive's names nothing, wherever it points.
for name in ["../ams.db", "ams.db", "ams-20261019-020000-manual.tar.zst"] {
assert!(matches!(
- download(State(state.clone()), admin(), Path(name.into())).await,
+ download(
+ State(state.clone()),
+ admin(),
+ ClientIp::NONE,
+ Path(name.into())
+ )
+ .await,
Err(AppError::NotFound)
));
}
+ // Written down: which archive was taken, and by whom — once, for the
+ // download that happened, none for the names that named nothing.
+ let trail = repo::audit::list(
+ &state.db,
+ &repo::audit::Query {
+ action: Some("backup.downloaded".into()),
+ limit: 5,
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(trail.len(), 1);
+ assert_eq!(trail[0].target.as_deref(), Some(made.name.as_str()));
+ assert!(trail[0].actor.is_some());
+
let gone = remove(
State(state.clone()),
admin(),
diff --git a/src/api/native/browse.rs b/src/api/native/browse.rs
index e6f36d0..e15ff68 100644
--- a/src/api/native/browse.rs
+++ b/src/api/native/browse.rs
@@ -9,6 +9,7 @@ use std::collections::{BTreeMap, HashMap};
use axum::{
Extension, Json,
extract::{Path, Query, State},
+ response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -86,13 +87,14 @@ pub struct CatalogueFacets {
responses(
(status = 200, body = CatalogueFacets),
(status = 400, description = "A filter was not one the list understands"),
+ (status = 503, description = "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"),
),
)]
async fn facets(
State(state): State,
Extension(identity): Extension,
Query(query): Query,
-) -> AppResult> {
+) -> AppResult {
let mut filters = super::items::to_query(&state, &identity, query)?;
// The page and its order are the list's; the counts are the same for all.
filters.sort = repo::item::Sort::default();
@@ -108,13 +110,22 @@ async fn facets(
let key = format!(
"facets:{filters:?}:{}:{}",
state.caches.stamp(),
- repo::item::catalogue_stamp(&state.db).await?
+ state.caches.catalogue_stamp(&state.db).await?
);
- if let Some(cached) = state.caches.searches.get(&key).await
- && let Ok(facets) = serde_json::from_str::(&cached)
- {
- return Ok(Json(facets));
+ if let Some(kept) = kept_facets(&state, &key).await {
+ return Ok(Json(kept).into_response());
+ }
+
+ // Counted over every work the filters leave, for filters nobody has asked
+ // about yet: a few at a time, so that asking for many of them is no way to
+ // take the database from everybody else.
+ let Some(_place) = state.caches.cold.take().await else {
+ return Ok(crate::api::busy());
+ };
+ // Asked and answered meanwhile, perhaps, by a caller that waited too.
+ if let Some(kept) = kept_facets(&state, &key).await {
+ return Ok(Json(kept).into_response());
}
// The works a misspelt term is taken for, as the list takes it: settled
@@ -132,7 +143,13 @@ async fn facets(
state.caches.searches.insert(key, encoded).await;
}
- Ok(Json(found))
+ Ok(Json(found).into_response())
+}
+
+/// The facets kept under a key, when they are.
+async fn kept_facets(state: &AppState, key: &str) -> Option {
+ let cached = state.caches.searches.get(key).await?;
+ serde_json::from_str(&cached).ok()
}
// ─── calendar ────────────────────────────────────────────────────────────────
@@ -735,33 +752,54 @@ async fn network(
}
/// The name of the network or the studio a slug stands for, as the works
-/// name it most often, or none: kept a while under the catalogue's mark,
-/// since every visit to the page asks.
+/// name it most often, or none.
+///
+/// Every visit to the page asks, and the slug is whatever the caller wrote:
+/// so it is matched in memory against the names the catalogue holds, which
+/// are kept — for each reading of adult titles and each mark of the
+/// catalogue — in one place, and never becomes the key of anything.
pub(crate) async fn network_named(
state: &AppState,
slug: &str,
adult: bool,
) -> AppResult