> Running this on the public internet is on you: put it behind HTTPS and whatever access control you would give any other self-hosted app. What follows is what the app does on its…
> Source: https://zedensix.com/nuvio-web/canary/docs/security · Site index: https://zedensix.com/llms.txt

# Security

Running this on the public internet is on you: put it behind HTTPS and whatever
access control you would give any other self-hosted app. What follows is what
the app does on its own.

## What the server holds

Almost nothing. The app is a client for `api.nuvio.tv`, and your account, your
profiles, your addons, your library and your settings live there.

The exceptions:

- **Two cookies.** `nuvio_session` holds the upstream session (access token,
  refresh token, expiry, user) and `nuvio_profile` holds the picked profile
  index. Both are `httpOnly`, `sameSite=lax`, and `secure` outside dev, with a
  thirty-day lifetime. The session is refreshed transparently when it expires,
  and a refresh that fails clears the cookie rather than looping.
- **The admin database**, covering sign-ins and the allowlist for this instance.
  See [The admin page](https://github.com/zedensix/nuvio-web/blob/main/docs/admin).

Subtitle conversion and cast-biography lookups both happen in the browser, so
neither subtitle files nor third-party biography text transit the server.

## Outbound requests to addons

Addon URLs are user-supplied and the server fetches them, so every addon fetch
goes through an SSRF guard:

- **Scheme allowlist** — `https`, plus `http` only where an addon manifest needs
  it.
- **Address blocks** — loopback, link-local (the cloud metadata endpoint
  `169.254.169.254` included), benchmark, multicast and reserved ranges, for
  both IPv4 and IPv6 (link-local, deprecated site-local `fec0::/10`, multicast
  `ff00::/8`, and the whole of `::/96`, which covers IPv4-compatible
  `::a.b.c.d`). An address that embeds an IPv4 host, IPv4-mapped `::ffff:0:0/96`
  in either spelling or NAT64 `64:ff9b::/96`, gets the IPv4 rules; local-use
  NAT64 `64:ff9b:1::/48` is refused outright.
- **Manual redirect following**, so every hop is re-checked rather than only the
  first.
- **The LAN is allowed** — private ranges (`10/8`, `172.16/12`, `192.168/16`),
  carrier-grade NAT `100.64/10` and IPv6 unique-local `fc00::/7` are reachable,
  so an addon or backend self-hosted on the network works. On a server shared
  with people you don't trust, any of them can reach what that network exposes;
  firewall the container's egress if that matters.

DNS rebinding between check and connect is still possible in principle. Put an
egress filter in front if that is in your threat model.

Addon responses are treated as untrusted data throughout: a body past its size
cap (1 MB for a manifest, 5 MB for anything else) is refused while it streams,
and an entry missing what installing it needs, or what displaying it needs, is
dropped rather than rendered. A failed addon request reaches the browser as a
fixed reason code (timeout, HTTP status, invalid, blocked, unreachable), never
the transport's own message, which would name the host and port it tried; the
detail stays in the server log.

## The download proxy

`/api/downloads/proxy?url=…` passes a download's bytes through when the browser
can't fetch the source itself (no CORS headers, or `http:` under an `https:`
app). It isn't an open proxy:

- it answers signed-in users only;
- the target goes through the same SSRF guard as addon URLs, with loopback and
  link-local addresses refused and every redirect hop re-checked;
- only `Range` goes upstream, and only content headers come back (no cookies
  either way);
- the body streams through and is never stored.

The download worker tries every source directly first, so the proxy only carries
traffic that has no other way.

## Downloads kept on disk

With `NUVIO_DOWNLOADS_DIR` set (the desktop app sets it), the server fetches
downloads itself, through the same SSRF guard, and writes them under that
folder. Only that instance does: unset, which is the default, the server never
writes a download anywhere, so a hosted instance can't be made to fill its disk.

- Every command and `/api/downloads/local/…` answer the signed-in profile only,
  and only for its own downloads.
- A file is served only once its download is finished, by the download's id (a
  UUID) and a file its record names (the video, a subtitle), or for an HLS copy
  one of its playlists and segments; a hidden file never is. It goes out as
  media (the service worker's type rules), `nosniff`, with a sandboxing CSP.
- The page names a download to the app's mpv by id only (`nuvio-download:<id>`).
  The app finds the folder whose record has that id and plays the file the
  record names, a plain name inside that folder, so the page can't point mpv at
  any other file on the computer.

## Response headers

Every response carries:

- a **Content Security Policy** with `default-src 'self'`,
  `frame-ancestors 'none'`, `object-src 'none'`, `base-uri 'self'` and
  `form-action 'self'`. `img-src` and `media-src` are open to `https:` because
  addon artwork and streams come from arbitrary hosts; `frame-src` allows only
  `youtube-nocookie.com`, for trailers.
- `x-content-type-options: nosniff`
- `x-frame-options: DENY`
- `referrer-policy: strict-origin-when-cross-origin`
- `permissions-policy` denying camera, microphone, geolocation and
  browsing-topics
- `strict-transport-security` outside dev

`script-src` includes `'unsafe-inline'`. That is unavoidable without SvelteKit's
CSP nonce integration, which this setup does not use; everything else is locked
down.

## Errors

Uncaught errors get a short random error id, logged server-side with the stack
and shown to the user as just the id. In production the message is replaced with
a generic one, because an unknown error is the only kind whose text could carry
internals — a driver string, a path, a query.

An error thrown deliberately by the app (a guard's 403, a rejected remote
function argument) keeps the message that was written for a user to read, and is
logged at warn rather than error: it is the app working as designed.

## Authorization

Route guards read the request's own service scope. `requireProfile()` demands a
signed-in request with an active profile; `requireAdmin()` 404s anyone not in
`NUVIO_ADMIN_EMAILS`, or signed in through a backend other than the instance
default.

Guards run in **loads and in remote functions independently**. A remote function
never assumes the page that called it was guarded.

## Backends other than Nuvio's

The backend a user signs in through decides who they are: it hands back the user
id and the email, and a hostile one can claim any of them. So the server only
believes a backend about its own users.

- **Three kinds.** The official Nuvio API; the instance default, when an admin
  replaced it on the admin page; and a custom backend a user typed in, only when
  an admin allows custom backends. Only the instance default (the official API
  unless replaced) is trusted for `NUVIO_ADMIN_EMAILS` and the allowlist: an
  admin email arriving through any other backend is not an admin, and a locked
  instance refuses every custom backend at sign-in and signs its sessions out.
- **Identity is namespaced.** Everything this server keys by user (library,
  progress and history in SQLite, the sync cursors, the stored sessions, the
  live event streams, the addon registry cache, the browser's IndexedDB owner
  key) uses an account key: the bare user id on the official API, so existing
  data keeps its key, and `custom-` plus a SHA-256 of the backend URL and the
  user id on any other. The same user id on two backends is two accounts that
  can never read each other's data. The sign-in rate limit per email is also per
  backend.
- **The session pins its backend.** The URL and key are stored with the session,
  encrypted like the tokens. Every client built for that session (page loads,
  token refresh, the background sync, avatars) talks to that backend and nothing
  falls back to the official API. Banning a backend, disallowing custom
  backends, locking the instance or replacing the default ends the affected
  sessions, and with them their background sync.
- **Every request to a custom backend goes through the SSRF guard** of outbound
  addon requests: public addresses only, every redirect re-checked, the
  connection pinned to the checked address. Each request also has a 12 second
  deadline and a 10 MB response cap, and a failure reaches the user as a fixed
  reason (blocked, unreachable, invalid answer), never the transport's own text.
  The admin's default is the one exception to the address block, since an admin
  chose it; its scheme, deadline and cap still apply.
- **Everything a backend says is input.** URLs are HTTP or HTTPS only, without
  credentials, whitespace, control characters, query or fragment, at most 2048
  characters, and stored as origin plus path. A publishable key is a bounded
  token charset (it goes out as a header, so nothing that can split one). A
  custom backend's session must carry a user id of at most 128 safe characters
  and a well-formed email before anything is keyed by them. Avatar URLs and
  colours from a backend reach the page only as HTTP or HTTPS URLs and hex
  colours. Queries bind every value, and log lines name a backend by its origin
  only.
- **The official key stays with the official API.** A custom backend needs its
  own key; the field is required.

## The built-in backend

`/api/nuvio` serves the Nuvio API from this server's own SQLite. It never reads
the app's session cookie: a request is anonymous, or carries its own bearer
token.

- **Publishable key.** Every `auth/v1` and `rest/v1` request needs the
  instance's `apikey` (header or query), compared in constant time; without it
  the answer is Supabase's gateway 401. The health check and the avatar images
  are public, as on Nuvio's.
- **Passwords** are argon2id (`Bun.password`), at least 8 characters. Emails are
  folded to lowercase. An unknown email still costs a hash check, so timing
  doesn't tell accounts apart.
- **Access tokens** are HS256 JWTs, one hour, `role` and `aud` `authenticated`,
  signed with a key HKDF-derived from the session secret for this purpose only.
  Verification takes only `HS256` (no `none`, no other algorithm), compares the
  signature in constant time, checks expiry, and checks that the token's session
  still exists, so signing out or a revoked session kills its access tokens
  before they expire.
- **Refresh tokens** are 256 bits, stored as their SHA-256, valid 30 days, and
  single use: each refresh hands out a new one and spends the old. Presenting a
  spent one revokes the whole session (the family), on the assumption that it
  was stolen, with GoTrue's one exception: within 10 seconds of being spent, and
  while its successor is still the live token, it gets that same successor
  again, so a client that lost the answer to its refresh stays signed in. The
  successor is an HMAC of the spent token (key derived from the session secret),
  which is how it can be handed out again without being stored.
- **Emailed links** (password reset, email confirmation) carry 256 random bits,
  stored as their SHA-256, single use, valid an hour (reset) or a day
  (confirmation); a new link of the same kind voids the previous one. The link
  is built on the origin the admin saved the built-in backend as the default
  from, never on the request's `Host`, so a forged host can't put its own
  address in the email: without SMTP or with another default, there are no links
  and `/recover` says so (`email_provider_disabled`) for every address.
  Otherwise `/recover` answers 200 whether or not the address has an account,
  and sends in the background, so its timing doesn't tell either. The link opens
  a page with a button: a mail scanner fetching it doesn't spend it. Following
  either kind confirms the address; a reset ends every other session.
- **Email confirmation** is off by default (an admin setting). Without it,
  nothing proves an address belongs to whoever signs up with it. So an email in
  `NUVIO_ADMIN_EMAILS` can't sign up through the API at all: an admin creates
  their account from the admin page, where their current session proves who they
  are. Add an email to `NUVIO_ADMIN_EMAILS` only once its built-in account (if
  any) is known to be theirs. The allowlist has the same weakness, without the
  escalation: whoever signs up first with an allowlisted address gets in, unless
  confirmation is on. Accounts that existed before it was turned on count as
  confirmed.
- **Account deletion** (`DELETE /auth/v1/user`, not a GoTrue route) takes the
  bearer's own account only, behind its password, rate-limited like sign-in, and
  erases every row it owns in one transaction.
- **Policy.** Sign-up, the password grant, the refresh grant and following an
  emailed link obey the lock and the allowlist (refused before the password is
  checked), and sign-up, sign-in, `/recover` and `/verify` are rate-limited per
  address (and per email where there is one) through the same limiter as the
  app's forms.
- **Input.** Request bodies are capped at 1 MB and every array at 2000 entries,
  every field is validated (valibot) before it reaches SQL, every value is a
  bound parameter, and RPC names are looked up in a fixed map. The one table
  read (`addons`) takes only the query the client sends (`select=*`,
  `profile_id=eq.N`, `order=sort_order`) and refuses anything else with a
  PostgREST 400. Every row is scoped to the token's user.
- **CORS** is open (`*`): there is no cookie to ride on, a request carries its
  own key and token.

## Reporting something

Open an issue on
[the repository](https://github.com/orochibraru/nuvio-web/issues). If it is
sensitive, say so in the issue without the details and a private channel will be
arranged.
