Deployment and threat model¶
PlainShelf is local-first and single-user. Where you bind the server and which security mode you choose decide what it defends against — and, just as important, what it does not. This page names three deployment tiers, gives the configuration each one wants, and states plainly what protects access in each — then does the same for the desktop and reader apps, which are neither.
For the type and default of each security key on its own, see the
Configuration reference; this
page is about which combinations to choose.
It also records two standing non-goals that hold at every tier:
- No PlainShelf server holds your credentials, and none are baked into its binaries. The credentials that are persisted — a pCloud token you grant, and the access token for a PlainShelf server you add on Android — are stored on your own device by the Android app, never by a server. See Where credentials live.
local_tokenis a CSRF boundary, not access control. See Whatlocal_tokenactually protects.
Three deployment tiers¶
| Tier | Who it is for | server_conf.addr |
security.mode |
security.protect_read |
security.allowed_origins |
What actually controls access |
|---|---|---|---|---|---|---|
| A | One person, one machine (a local server you run) | 127.0.0.1:20000 |
local_token (default) |
false (default) |
loopback defaults | The loopback bind: nothing off the machine can connect. |
| B | Home LAN / NAS, including VPN remote | LAN address or 0.0.0.0:20000 |
local_token |
true |
loopback and your LAN and VPN origins | The network you trust — the LAN itself, or the VPN in front of it. |
| C | The public internet | — | not supported yet | — | — | Nothing PlainShelf ships. Do not deploy here. |
Tier A — loopback, single user¶
This is the default and needs no configuration. The server binds to loopback,
so only the machine it runs on can reach it, and local_token is on to stop a
web page open in your browser from forging requests to it.
server_conf:
addr: "127.0.0.1:20000"
app_conf:
security:
mode: local_token # the default when security is unset; shown for clarity
Tier B — home LAN / NAS, including VPN remote¶
This is what the Docker image is really for: a shelf on a NAS or home server that the household reads over the LAN, and that you occasionally reach from outside through a VPN. The access boundary here is the network, not PlainShelf: a trusted LAN, or a VPN (WireGuard, Tailscale, and the like) that only your own devices can join. PlainShelf authenticates no one — it assumes everyone who can reach the port is someone you trust.
Given that, keep mode: local_token. It is not access control (see
below), but it keeps the browser-CSRF and
origin checks that mode: none throws away, at no cost to the people you do
trust.
server_conf:
addr: "0.0.0.0:20000" # reachable from the LAN
app_conf:
security:
mode: local_token
protect_read: true # require the token for reads too, not only writes
allowed_origins:
# Setting this list REPLACES the loopback defaults, it does not add to
# them — so keep the local addresses if you ever open the UI on the
# machine itself.
- "http://127.0.0.1:20000"
- "http://localhost:20000"
- "http://nas.local:20000"
- "http://192.168.1.10:20000"
# add every hostname/port you actually open the UI through,
# including the address you reach it at over the VPN
Two things to get right on this tier:
allowed_originsreplaces the defaults, it does not extend them. PlainShelf applies its four loopback defaults —http://127.0.0.1:20000,http://localhost:20000, and the same two on the Vite dev port5173— only while the list is empty (server/security.go). Set it, and those defaults are gone: a browser opened on the NAS itself athttp://127.0.0.1:20000then gets403 forbidden originon every write. So the list has to name every origin you use, loopback included, with the exact scheme and port — each LAN hostname, each LAN IP, each VPN address, and the local ones you would otherwise lose.protect_read: trueis defence in depth, not a login. It makes even read requests carry the token, so a bare API scrape that never loads a page gets401. It does not keep anyone out who opens the Web UI: the token is handed to every page load (again, see below), so on a network where you do not trust every device, this setting is not the control you want — the network boundary is.
Do not reach for mode: none
mode: none on a non-loopback address disables the token and the origin
checks entirely, and any website open in any browser on that network can
then drive your server. The Web UI shows a persistent "no API auth" warning
whenever it detects this. Use local_token and a trusted network instead.
Tier C — the public internet¶
Not supported. PlainShelf has no password or session login, so there is no
configuration that makes it safe to expose directly to the public internet, and
this page will not offer a workaround that pretends otherwise. Putting a reverse
proxy in front does not change this: local_token hands its token to every
page load, so the first visitor to reach the proxy can read and write your
shelf.
A real answer is planned. security.mode reserves two values —
password and external — for authenticated deployments; setting either today
fails at startup with "reserved but not implemented yet". A public tier waits on
that work landing. Until then, keep an internet-facing deployment behind a VPN
and treat it as Tier B.
The desktop and standalone reader apps¶
Neither app is a row on that table. Both embed their API in the Wails window and
open no network port — neither calls ListenAndServe, so there is nothing
for another machine to connect to, and the tiers' question of who can reach the
port does not arise. What they are not is local_token:
- The desktop app runs the same server in-process with
security.mode: nonehard-coded (desktop/app.go). Undernonethe middleware returns before the token check, the origin allowlist and CORS (server/security.go), so its API has no token and no origin check. The static browser-hardening headers (X-Content-Type-Options,X-Frame-Options,Referrer-Policy) are still set. - The standalone reader does not go through
server/security.goat all. It serves its ownreaderapihandler from the Wails asset server (reader/main.go), so it has nosecurity.modeto set, and likewise no token and no origin check.
What protects them is the process boundary rather than a mode: the API answers only inside the app's own webview, and a browser you open separately cannot reach it, because there is no port to connect to. Against the web-origin CSRF that Tier A spends its token on, that is the stronger position of the two rather than a weaker one — Tier A's loopback port is reachable from a page in your ordinary browser, which is exactly why it needs the token, while here the request cannot be made at all.
The missing mode therefore costs these two apps nothing, because what remains is
local rather than networked and a token would not close it either: any program
already running as you can read the shelf files directly, with or without
PlainShelf, and anything that can inject script into the app's own webview is
inside the boundary already — under local_token too, since the token is written
into the page (server/spa.go) where same-origin script can simply read it.
What local_token actually protects¶
local_token mode generates a random token when the server starts and writes it,
in cleartext, into a <script> tag in index.html — that is how the frontend
picks it up on load. So anyone who can load the homepage already has the
token. On a loopback bind that is only you; on a LAN bind it is everyone on the
network.
That is the whole point to understand: local_token is not access control.
It does not authenticate a user and it does not keep a reachable network from
reading or writing your shelf. What it defends is narrower and still worth having:
- Web-origin CSRF. A malicious or compromised website open in your browser
cannot silently forge a mutating request to PlainShelf, because it cannot read
the token out of PlainShelf's own page (the browser's same-origin policy stops
it) and its requests carry a foreign
Originthat the allowlist rejects. - Combined with
protect_read: true, it also turns away an unauthenticated API client that never loads a page at all.
Read mode: local_token as "CSRF protection for a machine you already control,"
never as "a login." When you need a login, that is Tier C, and it is not built
yet.
Exactly which requests need the token¶
The rule is not "every POST." It is:
| Request | Needs the token |
|---|---|
/health and anything outside /api/ |
No |
Reads of the shelf (GET) |
Only with protect_read: true |
Refreshing the book list (POST .../scans) |
Only with protect_read: true |
| Every other write | Always |
/api/logs |
Always, whatever protect_read says |
Two of those rows are deliberate exceptions rather than oversights.
Refreshing the book list is a read. It walks the shelf and rebuilds the
server's cache; it changes nothing on disk, and its reply is two counts. So
protect_read governs it the way it governs any other read, and read-only mode
accepts it. Requiring a token for it would have contradicted this page for the
common case: the shipped defaults are local_token with protect_read: false,
under which a user is told reading needs no token and then shown a refresh
button that always failed. It is still not exempt from CSRF — a browser attaches
Origin to every cross-site POST, so a refresh arriving from a page that is
not in allowed_origins is refused, which is what stops a random website making
your server walk an SMB shelf. A request carrying no Origin at all is not a
browser and cannot be a forged cross-site request; the Android client is the
case that matters, since its native HTTP bridge sends none.
The token was never what stopped a device on your own network from looping that
request: it is printed into every page the server sends, so anything that can
reach the port can read it. The refresh is rate limited instead — five walks back
to back, then one more every 10 seconds, and 429 beyond that. That is what
bounds the CPU and SMB bandwidth a compromised or misconfigured LAN device can
take, with or without a token, and it is set far above the pace of pressing the
button by hand. See Rescanning on demand.
/api/logs is not shelf content. The logs record every request path, and so
the shelf's structure, together with access times and remote addresses. Turning
protect_read off to let the household read books says nothing about publishing
that, so the logs stay behind the token either way. It is not a setting, for the
same reason a safe default is not a choice worth offering.
Where credentials live¶
PlainShelf runs no server of its own, so no PlainShelf-operated service holds your accounts, your library, or your credentials, and nothing confidential is baked into its binaries for an attacker to lift from the server or the APK. What keeps it true:
- The server has no config field for a secret.
app_confcarries shelf paths, a store path, import and logging settings, and thesecurityblock — and nothing that reads a password, token, or API key for any external service. The one token PlainShelf handles on the server, thelocal_token, is generated fresh at each startup and never persisted. - SMB credentials are the operating system's, not PlainShelf's. PlainShelf
reads an SMB/NAS shelf only through a path the OS has already mounted; it does
not accept
smb://URLs and never sees the share's username or password. See Configure an SMB shelf file source. - No pCloud secret is baked into the Android app. pCloud access uses the
poll_tokenOAuth flow, which needs no app secret and no redirect URL, so there is nothing confidential shipped inside the APK.
Two credentials are nevertheless persisted, both by the Android app and both on the device itself. They are worth stating plainly rather than rounding off to "none":
- The access token of a PlainShelf server you add on Android is stored on the
device. A shelf entry that points at a server keeps that server's token in
the same Android Keystore-backed secure storage as the pCloud token below, one
secret per shelf entry (
frontend/src/providers/mobileConfig.ts); the non-secret part of the entry, such as the server address, goes to Capacitor Preferences, and no token is ever written there. It is thelocal_tokenof a server you run, entered by hand, and it is needed only to read from a server that setsprotect_read: true. It leaves the device only as a header to that server. - A pCloud OAuth token you grant is stored on your own device. If you connect
an Android pCloud shelf, the access token you approve is saved in the device's
Keystore-backed secure storage
(
frontend/src/providers/mobileConfig.ts), encrypted at rest. It is scoped to your own pCloud account, it is never sent to any PlainShelf server, and it is the only third-party credential the app keeps. The boundary to be clear on: the app can decrypt the token in order to use it, so an attacker who compromises both the device and the app could read it. If a device is lost, revoke the grant from your pCloud account settings.