Security
What monoki defends against, how, and — just as importantly — what it does not.
Authentication
Session cookies only. No API keys, no OAuth, no tokens to leak in a shell history.
- Cookie
monoki_session:HttpOnly,SameSite=Lax, andSecurewhenever the request arrived over TLS (directly or viaX-Forwarded-Proto). - The cookie value is 256 bits of randomness. Only its SHA-256 is stored, so a database copy does not hand over live sessions.
- Sliding 30-day expiry.
- Passwords: argon2id, 64 MiB / t=1 / p=4, parameters stored in the hash so they can be raised later without a migration. Verification is constant-time.
- Login is rate-limited per source address: 10 failures in a minute locks that address out for 5 minutes, and the lockout holds even for a correct password.
- A wrong password and a disabled account return the same generic error, so login cannot be used to enumerate accounts.
Authorization
Every access decision lives in one file (internal/api/authz.go); the storage
layer enforces nothing and takes no "acting user" argument, so there is exactly
one place to audit.
- The catalog is deliberately visible to everyone signed in. Every app, every icon, every responsible user. That is the point of a catalog, and it is the one thing here that is shared by design. Do not put a secret in an app's URL or title.
- Everything else is private to one person: your board, your groups, your resources. Asking for one of those that is not yours returns 404, not 403 — a stranger must not be able to tell an id that exists from one that does not.
- A
403appears in exactly one place: refusing to edit a catalog app. You can already see it, so there is nothing left to hide. - Editing an app is limited to its creator, its responsible users, and admins.
Changing the URL of an app that more than one person has needs an explicit
confirmation, and is recorded in the audit log as
old -> new. That is the sharpest edge the shared catalog introduces: repointing a widely-used app reaches every board at once. - Icons follow their owner. An app's icon is readable by any signed-in user; a resource's is readable only by its owner. There is no unauthenticated icon URL, however well it would cache — it would leak which apps you run.
- Admin role grants user management, the audit log, and the ability to fix a catalog entry whose owner has left. It grants no access to anyone's board, groups or resources. See Users & roles.
Tab reuse and noopener
By default a tile carries a stable window name, so clicking Grafana five times
gives you one Grafana tab rather than five. That cannot be combined with
rel="noopener", and the trade-off is worth stating plainly.
When noopener is set, the spec forces the target to _blank in a new browsing
context group whose name the opener can never find again — so every click would
spawn a fresh tab, which is the exact opposite of the feature. So the reuse path
omits noopener and severs the link itself immediately afterwards
(w.opener = null, which is settable cross-origin and is the pre-rel defence
against reverse tabnabbing).
That is weaker than rel="noopener", not equivalent: it is best-effort, and
there is a brief window before it runs. It is proportionate here because monoki
points at apps you run and URLs you pasted, not at arbitrary web content.
If you would rather not make that trade, set an app's link target to always
open in a new tab — that path uses noopener,noreferrer and gives up reuse.
The tile menu's Open in a new tab does the same for a single click.
Referer is suppressed globally by a <meta name="referrer" content="no-referrer">
rather than per-link, which covers more than the attribute did (subresources, and
the menu's own links) and avoids noreferrer, which implies noopener.
Server-side fetching (SSRF)
monoki fetches URLs a user typed, from inside your network. This is its one genuinely risky surface, and it is treated that way.
- The address check runs at dial time, not before it. A pre-flight DNS
lookup would be checking a different resolution than the connection actually
uses, so a hostname answering
1.2.3.4once and127.0.0.1a moment later would walk straight past it. Checking the concrete IP as the socket is opened closes DNS rebinding, redirect-to-internal, and multi-record hosts in one place — and it re-runs for every hop of a redirect chain. - IPv4-mapped IPv6 addresses are unmapped before classification, so
::ffff:127.0.0.1is recognised as loopback. - Redirects are capped at 5 hops and re-validated per hop.
- Layered timeouts: 5s dial, 5s TLS handshake, 5s response headers, 10s total, 15s for the whole request.
- HTML is read up to 512 KiB, icons up to 256 KiB. Oversize icons are rejected, not truncated.
- Content types are allowlisted and cross-checked against the actual bytes.
- URLs with embedded credentials are refused, as are non-HTTP schemes.
- Lookups are rate-limited per user.
MONOKI_ALLOW_PRIVATE_FETCH defaults to true, which is the deliberate
exception: bookmarking http://nas.lan:5000 is the product's main use case,
and it requires reaching a private address. Set it to false if your instance
has users you would not trust with a curl on the server. All the other
controls above apply either way. See Configuration.
Stored icons
Icon bytes come from arbitrary sites, and monoki serves them from its own
origin — so every icon response carries X-Content-Type-Options: nosniff,
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox, and Content-Disposition: inline. SVG is accepted because too many
modern sites offer nothing else; the UI only ever renders icons through an
<img> tag, which does not execute SVG script regardless.
Audit log
Every mutating action writes a row with a dotted action name — group.share,
app.update, user.update. It is readable by admins.
The log deliberately has no foreign key to the accounts table, so deleting a user leaves their trail intact rather than cascading it away.
What monoki does not do
Stated plainly, because a security page that only lists strengths is not much use:
- No second factor. Passwords are the only credential.
- No encryption at rest. Anyone who can read
monoki.dbcan read every URL, every icon, and every password hash. Protect the file. - No CSRF tokens. The defence is
SameSite=Laxplus the fact that every mutating endpoint requires a non-GET method and a JSON body. That is adequate for current browsers and is not defence in depth. - No account lockout beyond per-address rate limiting. An attacker with many source addresses is limited only by password strength.
- No egress allowlist. With private fetching on, any account can make your server issue a GET to any address it can reach. The response is reduced to a title and an image, but the request itself happens.
- No protection against a hostile admin. An admin can reset any password and then sign in as that person. There is no key escrow and no separation of duties.
- Not audited. No third party has reviewed this.
Reporting a problem
Open a confidential issue on the GitLab project rather than a public one.