Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<slug>.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=<token>` 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.
2 changes: 1 addition & 1 deletion docs/coolify-minio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading