From 533a0ad9dde01e8499d1984973bb1e8360fde436 Mon Sep 17 00:00:00 2001 From: "Zonily Jame (@kuyazee)" Date: Wed, 12 Aug 2026 11:36:11 +0800 Subject: [PATCH] docs: make the secret and revocation claims match the code (T1.2.18) Six places described behavior the code no longer has, or never had. lib/auth.js said admin sessions are revoked by rotating sessionSecret and that a password change keeps it. Admin sessions are signed with adminSecret, and the password route rotates that; sessionSecret signs capability links and unlock cookies, and a password change leaves it alone. server.js said the session secret is created at first login otherwise. Login does not create it. The setup route does, and so does the first private or password publish. docs/coolify-minio.md called auth.json's contents "session secret", singular. There are two. docs/deploy.md said a change takes effect on the other replicas only when they restart. Since the merge-on-write change, a replica also picks it up when it writes to auth.json itself, which an ordinary managed-key request does at most once every 5 minutes. The bootstrap key and an admin session cookie write nothing, so a replica serving only those still needs the restart. SECURITY.md never said revocation is per replica at all. It is a known limitation now, next to the other four. .env.example did not say the admin seed is checked by the same rules as the setup screen, so an operator learned about the 8-character minimum when the boot stopped. No behavior change: the two code files carry comment edits only. --- .env.example | 3 +++ SECURITY.md | 3 ++- docs/coolify-minio.md | 2 +- docs/deploy.md | 10 +++++----- lib/auth.js | 6 ++++-- server.js | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index a03ae3b..9a10e77 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ ARTIFACTS_API_KEY= # Optional. Seed the single admin account (dashboard login) on first boot. If # unset, the dashboard shows a one-time "Create admin account" screen instead. +# Both are checked by the same rules that screen uses, and a value either one +# refuses stops the boot and names the variable: username 3-32 chars from +# [a-zA-Z0-9._-], password at least 8 characters. # ARTIFACTS_ADMIN_USERNAME=admin # ARTIFACTS_ADMIN_PASSWORD= diff --git a/SECURITY.md b/SECURITY.md index 5d19f2e..19b3465 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,9 +25,10 @@ Not vulnerabilities: ## Known limitations (accepted tradeoffs) -These are consequences of the single-origin design, disclosed rather than hidden. You are the sole uploader, which bounds all three. +These are consequences of the single-origin design, disclosed rather than hidden. You are the sole uploader, which bounds them. - **Artifacts are not isolated from each other.** Every artifact shares one origin, and the unlock cookie attaches by request path, so JavaScript in one artifact can `fetch` another artifact's URL with credentials and read the body. `HttpOnly` and path scoping do not prevent this. The only real fix is per-slug origins (`.example.com`), which this design does not implement. Treat hostile JS in any artifact as able to read any artifact you have unlocked in that browser. - **`password` mode prompts on the artifact origin.** A malicious artifact can render a look-alike password prompt and harvest the shared password. `private` mode does not have this problem — it uses capability links and never prompts. Prefer `private`. - **A redirect's target is visible to every `read` key, and one stored with credentials keeps serving.** `GET /api/artifacts` returns `target` for a redirect, so any `read`-scoped key sees where every redirect points, including a private one. A target carrying a username or password is refused at publish time, but one stored before that rule keeps redirecting rather than turning into a 404 on upgrade, so it stays visible until you repoint it. A redirect is a public hop either way: whoever follows the link hands those credentials to the target host. +- **Revoking a key or changing the password takes effect one replica at a time.** Each process answers from the copy of `auth.json` it holds in memory and replaces that copy only when it writes to `auth.json` itself. On a single instance the change is immediate. On a fleet behind a load balancer, a revoked key keeps working, and a stolen admin cookie keeps its full scope, on every replica that has not written since. Restart every replica after any change you are making in response to a leak. Details and what counts as a write: [running multiple replicas](docs/deploy.md#running-multiple-replicas). - **Capability tokens appear in access logs.** The `?k=` share link is written in full to any ingress/proxy access log and to browser history. Tokens expire (`CAP_TOKEN_TTL_DAYS`, default 30) and one `rotate` invalidates them, but treat a share link as sensitive as a password, and disable query-string logging at your ingress. diff --git a/docs/coolify-minio.md b/docs/coolify-minio.md index 6a42adc..08e5935 100644 --- a/docs/coolify-minio.md +++ b/docs/coolify-minio.md @@ -21,7 +21,7 @@ artifacts (this repo) ── STORAGE_BACKEND=s3 → MinIO ── no volume, sta ``` Because artifacts state (every artifact **and** the reserved `auth.json` — admin account, -session secret, managed keys) all go through the storage backend, once the backend is MinIO the +both HMAC secrets, managed keys) all go through the storage backend, once the backend is MinIO the app container is throwaway. Back up MinIO's volume and you have backed up everything. ## Why MinIO "just works" here diff --git a/docs/deploy.md b/docs/deploy.md index 9ae5c78..a1b1e5a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -49,7 +49,7 @@ Or keep the configuration in a file: `cp .env.example .env`, edit it, then `npm | `DEFAULT_VISIBILITY` | no | `private` | Visibility for a new artifact when the caller gives none: `private` or `public`. Ships `private` (opt in to public). | | `CAP_TOKEN_TTL_DAYS` | no | `30` | Lifetime of a capability share link (`?k=` token) for `private`/`password` artifacts. A value that is not a positive number (a typo, `0`, a negative) logs a warning and uses 30. | -Day-to-day, give CLI and MCP clients scoped [managed API keys](auth.md) rather than the bootstrap key. Auth state (the admin account, two HMAC secrets, and the managed keys) persists under a reserved `auth.json` object through the storage backend, so it survives a restart on any backend that is itself durable (see [storage backends](#storage-backends)) with no migration. Like the frame config, it is loaded once at boot and cached in memory. +Day-to-day, give CLI and MCP clients scoped [managed API keys](auth.md) rather than the bootstrap key. Auth state (the admin account, two HMAC secrets, and the managed keys) persists under a reserved `auth.json` object through the storage backend, so it survives a restart on any backend that is itself durable (see [storage backends](#storage-backends)) with no migration. Like the frame config, it is loaded at boot and cached in memory; every write reloads it, so the cache is also as fresh as the last write this process made. The two secrets are separate on purpose, and neither is written at boot: @@ -90,16 +90,16 @@ Check it took. A JSON typo stops the boot with a message (see [a corrupt auth.js ### Changing a password or a key on a fleet -The change itself is safe to make on a live fleet: it sticks. What it does not do is take effect on the other replicas until they restart. +The change itself is safe to make on a live fleet: it sticks. What it does not do is take effect on the other replicas until each of them writes to `auth.json` itself, or restarts. Writes merge. Every write reloads `auth.json`, applies the one change to what the backend holds, and writes that back, so a replica that has been up since before your change no longer reverts it. Up to and including v1.3.1 it did: each replica held the whole record from boot and wrote all of it back on any change of its own, and an ordinary authenticated read was enough to trigger one, because a key's `lastUsedAt` goes through the same path. The reload and the write are still two steps and no backend here offers compare-and-set, so two replicas writing inside that window can still lose one of the changes. The window is now one request rather than the lifetime of a process. -Reads are still per-process, and that is the part to plan around: +Reads are still per-process, and that is the part to plan around. A replica serves from the copy it holds in memory, and it replaces that copy only when it writes to `auth.json` itself: every write reloads the stored record first, so the write pulls in whatever anyone else changed. A managed-key request counts, because refreshing that key's `lastUsedAt` is a write, though at most one per key every 5 minutes. The bootstrap key and an admin session cookie do not write, so a replica that only serves those never catches up on its own. Until it does: -- A managed key minted on one replica answers `401` everywhere else until each replica restarts. A key you revoke or disable on one replica keeps working everywhere else until each replica restarts. +- A managed key minted on one replica answers `401` everywhere else. A key you revoke or disable on one replica keeps working everywhere else. - A password change rotates `adminSecret` only on the replica that served it. A stolen session cookie stays valid, with full admin scope, on every other replica for the rest of its 30-day life, and the old password still logs in there while the new one is refused. A password change on a live fleet revokes nothing by itself. -So when you are responding to a suspected leak, restart every replica after the change. When you are doing routine key housekeeping, you can leave the fleet up and let the restart happen whenever you next deploy. +So when you are responding to a suspected leak, restart every replica after the change rather than waiting for one to catch up. When you are doing routine key housekeeping, you can leave the fleet up and let the restart happen whenever you next deploy. Capability links are unaffected by all of it. They are signed with `sessionSecret` and checked against per-artifact state that is read from storage on every request, so `PATCH {"rotateToken": true}` does take effect across a fleet immediately. diff --git a/lib/auth.js b/lib/auth.js index 091f03b..1c504f5 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -100,8 +100,10 @@ export function hashKey(token) { return crypto.createHash('sha256').update(token).digest('hex'); } -// Session cookie = base64url(payload).HMAC(payload). Stateless; revocation of the -// admin session is by rotating sessionSecret (password change keeps it, by design). +// Session cookie = base64url(payload).HMAC(payload). Stateless, so the only way to +// refuse one already issued is to rotate the secret that signed it. Admin sessions are +// signed with adminSecret, which POST /api/auth/password rotates; capability links and +// unlock cookies are signed with sessionSecret, which a password change leaves alone. export function signSession(payload, secret) { const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); const sig = crypto.createHmac('sha256', secret).update(body).digest('base64url'); diff --git a/server.js b/server.js index 3812824..ba55baa 100644 --- a/server.js +++ b/server.js @@ -666,7 +666,7 @@ async function saveArtifact({ content, type = 'html', slug, title, expiresAt, fr await storage.flush?.(); // durably commit the completed write (git); no-op elsewhere dropMdRender(finalSlug); // A non-public artifact needs the session secret resident to mint its capability token; - // it is created lazily (first login otherwise), so force it here (tokenEpoch ⇒ non-public). + // it is created lazily (at setup otherwise), so force it here (tokenEpoch ⇒ non-public). if (meta.tokenEpoch !== undefined) await ensureSessionSecret(); const out = { slug: finalSlug, url: tokenedUrl(meta), visibility: meta.visibility || 'public' }; // A redirect answers with the target it stored, which is not always the string that was sent: