Skip to content

Security

What Noct protects, what it doesn’t, and what to do before the server can be reached by anyone but you.

The first time the server starts, nothing but /api/health and the setup routes answer. Setting up gives the server its login, a username and a password of at least 8 characters, and makes the first profile, the admin. From then on a device has to give that login once before it is even shown the profiles, every other route needs a signed-in profile, and the routes that run the server or change the library (folders, scanning, editing, settings, backups, profiles) need the admin. Which group a route is in is in crates/noct-core/src/api/mod.rs, and a route that isn’t added to the public group needs a token, so a new one is closed unless someone opens it.

/api/metrics is in the public group but checks a token of its own, the one in NOCT_METRICS_TOKEN, and isn’t there without one: it says what’s in the library, and a scraper shouldn’t hold a token that can do anything else. See metrics.

A profile can have a passcode of 4 to 6 digits, or none, in which case anyone whose device has been let in can open it. The passcode is for keeping a profile out of the way of the household, not for keeping out someone determined: see below. The admin is a profile like any other that may also look after the server; it has no password of its own, the server’s login is the password.

The server’s login, passcodes and tokens

Section titled “The server’s login, passcodes and tokens”
  • The server’s password and the passcodes are hashed with argon2 and never stored or logged.
  • A device that has given the login holds a device token, and a signed-in device holds a token: each 32 random bytes. Only the SHA-256 is stored, so a copy of the database can’t be used to sign in. Signing a device out, or changing the profile’s passcode, ends its tokens at once, and changing the server’s password ends every device token.
  • Guessing is slowed and then refused. After 5 wrong guesses at a profile, or at the server’s login, even the right answer is refused for 30 seconds, then 1 minute, doubling to 15 minutes, until a right one. Guesses are forgotten after an hour without one. The count is in the database, so restarting the server doesn’t clear it. This keeps a 4-digit passcode from falling in minutes, but it doesn’t stop someone from locking a profile out on purpose, and the admin can lift it by setting a new passcode.

A TV has nowhere to type a password. It asks the server to pair (POST /api/pairing), is given a six-character code to show, and keeps a secret to itself. Someone signed in (the web app’s Devices page, or another app) types the code in, which says whose device it is, and the TV, asking every few seconds with its secret, is then given a token of its own, once.

  • A code is good for 10 minutes and can be approved once. It uses no 0, O, 1, I or L, so it can be read off a screen, and is read in any case, with or without a dash.
  • Seeing the code isn’t enough. What the TV asks with is the secret, which only a hash of is kept; the code and the pairing’s ID are both on the screen or in the address. Without the secret there’s no token, and the token is never given twice.
  • Whose device it is is the profile approving, and only the admin can say another’s, so a profile can’t hand itself the admin’s token.
  • At most 50 devices can be waiting at once, so an unauthenticated caller can’t fill the database, and what has expired is cleared when the next one asks.
How Used by Carries
Authorization: Bearer <token> the apps everything
noct_token cookie, HttpOnly, SameSite=Strict the web app everything
?token=<token> players that open a URL themselves reading media only: GET or HEAD of /stream, /hls, subtitles and images
  • The web app never holds its token in JavaScript, so a script on the page can’t read it.
  • A request that changes something and is signed in only by the cookie is refused when it came from another site: when Origin isn’t this server’s Host, or when the browser says (Sec-Fetch-Site) it wasn’t same-origin. Requests with a bearer token aren’t checked, because a browser never adds one by itself.
  • The request log records the method and path and never the query, so a token in a media URL isn’t written to it. Anything in front of Noct (a reverse proxy, say) has its own log, which will have the whole address: don’t put a media URL with a token in it anywhere that is kept.

A library comes from wherever it does, and an EPUB or a PDF in it is a stranger’s file.

  • Reading one on the server is bounded in entries, in what each may be, in all that is read and in time, never opens an entry whose path leaves the archive, and runs nothing. Only an EPUB’s container, package, contents and cover are opened (its text isn’t read), and a PDF’s metadata is read by a parser that is guarded so that a file that makes it panic is unreadable and not a crash. A file that breaks a limit is unreadable with the reason, and never a stall.
  • Serving one is as an attachment with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox, so a browser never shows or runs a library file itself.
  • Showing one in the web app is by a reader that has to open the book’s own pages in a frame it can reach into, so every page of an EPUB is cleaned of anything that runs first (scripts, event attributes, javascript: links, frames and plugins, a refresh to another page, another page’s own policy) and is given a policy that allows nothing but its own pictures, styles and fonts. A book written to reach the app can’t: the end-to-end spec 79-books-reading opens one that tries to rename the page, write to its storage and cookies and call the server, and checks it did none of that. A PDF is drawn by pdf.js without its scripting, to a canvas with its text laid over it.
  • Noct speaks plain HTTP. Anyone on the path can read a token or a password. Put it behind something that terminates TLS (a reverse proxy, Tailscale) before it leaves your own network: remote-access has Caddy, nginx and Tailscale. The proxy has to pass Host (or X-Forwarded-Host) through, which the cross-site check compares Origin to, and say X-Forwarded-Proto: https, which is when the cookie is marked Secure.
  • Give every profile you don’t want to be shared a passcode, and the server a password you don’t use anywhere else.
  • Nothing here limits how many requests anyone can make, only how many guesses at a password. That, and anything that isn’t described above, is not protected.