No token can mint a token, an agent or a sandbox (#42)
Tokens could turn themselves into wider or longer-lived credentials: - Web forms ignored token scope and repository limits, and a non-browser client can send its own double-submit CSRF cookie, so any token could create an unrestricted admin token at /settings/tokens. Web forms now refuse any request that carries a token. - POST /diffinery/tokens, /diffinery/agents and /diffinery/agents/:login/tokens accepted an admin token. Minting now needs a browser session. - Imports (REST and MCP) didn't refuse repository-limited tokens. - Job and sandbox tokens could start sandboxes, whose tokens never expired. They can't any more; sandbox tokens last 24 hours at most and stop as soon as their creator is disabled or loses access. - The live test checks that a token can't mint one through the API or the web forms. Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo
Canberk Demirci authored · GitHub committed signed1 parent 728ca60commit f67cee5fa7b7a19555b73dc7b1df52989d94b4ceBrowse files
22 files changed+136−63
Files (22)
- .github/workflows/live-test.yml+17−1
- apps/cli/src/commands/admin.ts+2−1
- apps/cli/src/commands/auth.ts+1−1
- apps/diffinery/docs/accounts/tokens.md+8−7
- apps/diffinery/docs/agents/mcp.md+1−1
- apps/diffinery/docs/agents/overview.md+3−3
- apps/diffinery/docs/agents/sandboxes.md+2−1
- apps/diffinery/docs/quickstart.md+1−7
- apps/diffinery/docs/reference/limits.md+2−1
- apps/diffinery/docs/reference/rest-api.md+5−4
- apps/diffinery/docs/repositories/git.md+1−1
- apps/diffinery/src/auth/principal.ts+19−1
- apps/diffinery/src/docs/api-meta.ts+3−3
- apps/diffinery/src/http/api.ts+8−9
- apps/diffinery/src/http/imports.ts+2−0
- apps/diffinery/src/mcp/server.ts+1−0
- apps/diffinery/src/repo/actions.ts+6−1
- apps/diffinery/src/web/csrf.ts+4−1
- apps/diffinery/test/e2e/e2e.test.mjs+8−8
- apps/diffinery/test/e2e/email.test.mjs+2−2
- apps/diffinery/test/e2e/registry.test.mjs+2−2
- apps/diffinery/test/e2e/security.test.mjs+38−8
modified.github/workflows/live-test.yml+17−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | # End-to-end checks against the real diffinery.com after each deploy: email |
| 4 | 4 | # sending, web UI features (syntax highlighting, settings pages, view transitions), hosted Actions jobs in Cloudflare Containers (with Docker builds and services), the container registry, agent sandboxes, |
| 5 | -# webhooks, scheduled workflows, Git LFS, imports from GitHub, and pull requests (test merge commits, pull_request_target and the merge | |
| 5 | +# webhooks, scheduled workflows, Git LFS, imports from GitHub, that tokens can't mint tokens, and pull requests (test merge commits, pull_request_target and the merge | |
| 6 | 6 | # queue). Each run works in a throwaway private repository that it |
| 7 | 7 | # deletes at the end. |
| 8 | 8 | # |
| @@ -367,6 +367,22 @@ | ||
| 367 | 367 | echo "$hook" | jq '{target: .config.url, last_response, updated_at}' |
| 368 | 368 | [ "$(jq -r '.last_response.code // empty' <<<"$hook")" != "" ] || { echo "::error::the push webhook was never delivered"; exit 1; } |
| 369 | 369 | |
| 370 | + - name: Tokens can't mint tokens (API or web forms) | |
| 371 | + if: steps.token.outputs.skip != 'true' | |
| 372 | + run: | | |
| 373 | + set -euo pipefail | |
| 374 | + # Revokes anything a failed check minted, then fails. | |
| 375 | + minted() { echo "::error::$1"; curl -fsS "$DIFFINERY/api/v3/diffinery/tokens" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name == "live-test escalation") | .id' | while read -r id; do curl -sS -o /dev/null -X DELETE "$DIFFINERY/api/v3/diffinery/tokens/$id" -H "Authorization: Bearer $TOKEN"; done; exit 1; } | |
| 376 | + code=$(curl -sS -o /tmp/out -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/tokens" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"name":"live-test escalation","scopes":["read"],"expires_in_days":1}') | |
| 377 | + [ "$code" = 403 ] || minted "an admin token minted a token through the API ($code)" | |
| 378 | + grep -q "browser session" /tmp/out || minted "unexpected refusal: $(cat /tmp/out)" | |
| 379 | + code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/agents" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"login":"live-test-escalation"}') | |
| 380 | + [ "$code" = 403 ] || { echo "::error::an admin token created an agent ($code); disable live-test-escalation by hand"; exit 1; } | |
| 381 | + # A web form with a token and a forged double-submit CSRF pair. | |
| 382 | + code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/settings/tokens" -H "Authorization: Bearer $TOKEN" -H "cookie: dfy_csrf=forged" --data "_csrf=forged&name=live-test%20escalation&scope=read&days=1") | |
| 383 | + [ "$code" = 403 ] || minted "a token submitted the web token form ($code)" | |
| 384 | + echo "tokens can't mint tokens or agents, through the API or the web forms" | |
| 385 | + | |
| 370 | 386 | - name: Agent sandbox |
| 371 | 387 | if: steps.token.outputs.skip != 'true' |
| 372 | 388 | run: | |
modifiedapps/cli/src/commands/admin.ts+2−1View file
| @@ -119,7 +119,7 @@ | ||
| 119 | 119 | const tokenCreate: Command = { |
| 120 | 120 | name: "token create", |
| 121 | 121 | summary: "Create a personal access token (printed once)", |
| 122 | - description: "Needs an admin-scoped token (or create tokens in the web UI).", | |
| 122 | + description: "Tokens can't create tokens, so on diffinery.com this needs the web UI (Settings → Access tokens); the command works against a local development server.", | |
| 123 | 123 | options: { |
| 124 | 124 | name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" }, |
| 125 | 125 | scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" }, |
| @@ -178,6 +178,7 @@ | ||
| 178 | 178 | const agentCreate: Command = { |
| 179 | 179 | name: "agent create", |
| 180 | 180 | summary: "Create an agent account and print its token once", |
| 181 | + description: "Creating an agent mints its first token, which tokens can't do: on diffinery.com create agents in the web UI (Settings → Agents); the command works against a local development server.", | |
| 181 | 182 | args: "<login>", |
| 182 | 183 | options: { |
| 183 | 184 | org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" }, |
modifiedapps/cli/src/commands/auth.ts+1−1View file
| @@ -18,7 +18,7 @@ | ||
| 18 | 18 | summary: "Log in with a personal or agent token", |
| 19 | 19 | description: `Stores the server URL and token in the config file (mode 0600). |
| 20 | 20 | The token is read from --token, from stdin when piped, or prompted for. |
| 21 | -Create a token in the web UI (Settings → Access tokens) or with 'diffinery token create'.`, | |
| 21 | +Create a token in the web UI (Settings → Access tokens).`, | |
| 22 | 22 | options: { |
| 23 | 23 | url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" }, |
| 24 | 24 | token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" }, |
modifiedapps/diffinery/docs/accounts/tokens.md+8−7View file
| @@ -47,12 +47,13 @@ | ||
| 47 | 47 | | Mark notifications read (`PUT /api/v3/notifications`), change notification settings, send yourself a test email | `write` or `admin` | Refused | |
| 48 | 48 | | Watch or unwatch a repository (`PUT`/`DELETE /api/v3/repos/{owner}/{repo}/subscription`) | `write` or `admin` | Allowed for the listed repositories | |
| 49 | 49 | | Upload or remove a profile picture (`/api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`) | `write` or `admin` | Refused | |
| 50 | -| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | `admin` | Allowed | | |
| 50 | +| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | No token can: a browser session only | Refused | | |
| 51 | 51 | | Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused | |
| 52 | 52 | | Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused | |
| 53 | 53 | | Invite people (`POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations`, `diffinery invite`) | `admin` | Refused | |
| 54 | 54 | | List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused | |
| 55 | -| Create an agent, create a token for an agent, disable an agent (`/api/v3/diffinery/agents`, `diffinery agent create`) | `admin` | Refused | | |
| 55 | +| Create an agent or a token for an agent (`POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `diffinery agent create`) | No token can: a browser session only | Refused | | |
| 56 | +| Disable an agent (`DELETE /api/v3/diffinery/agents/{login}`) | `admin` | Refused | | |
| 56 | 57 | | Send every email template (site administrators) | `admin` | Refused | |
| 57 | 58 | | Manage passkeys and browser sessions; change your username, display name or appearance | Not possible with a token; use the browser | | |
| 58 | 59 | |
| @@ -85,7 +86,7 @@ | ||
| 85 | 86 | diffinery login |
| 86 | 87 | ``` |
| 87 | 88 | |
| 88 | -Then create a token. The CLI must be logged in with an `admin`-scoped token: | |
| 89 | +No token can create another token, so on diffinery.com `diffinery token create` gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`: create tokens in the web UI. The command works against a local development server (see [Deploy](/docs/self-hosting/deploy)): | |
| 89 | 90 | |
| 90 | 91 | ```sh |
| 91 | 92 | # A write token for CI, valid for 30 days, only for acme/website |
| @@ -104,11 +105,11 @@ | ||
| 104 | 105 | |
| 105 | 106 | ### With the API |
| 106 | 107 | |
| 107 | -`POST /api/v3/diffinery/tokens`, with an `admin`-scoped token or from a browser session: | |
| 108 | +`POST /api/v3/diffinery/tokens`, from a browser session (the session cookie you get by signing in with a passkey). No token can create a token, whatever its scope, so a leaked or limited token can never turn itself into a wider or longer-lived one: | |
| 108 | 109 | |
| 109 | 110 | ```sh |
| 110 | 111 | curl -X POST https://diffinery.com/api/v3/diffinery/tokens \ |
| 111 | - -H "Authorization: Bearer dfy_..." \ | |
| 112 | + -H "Cookie: __Host-dfy_session=..." \ | |
| 112 | 113 | -H "Content-Type: application/json" \ |
| 113 | 114 | -d '{"name": "ci", "scopes": ["write"], "repositories": ["acme/website"], "expires_in_days": 30}' |
| 114 | 115 | ``` |
| @@ -126,7 +127,7 @@ | ||
| 126 | 127 | { "token": "dfy_...", "id": "9b2f0c1d4e5a6b7c8d9e0f1a2b3c4d5e", "expiresAt": "2027-01-11T09:30:00.000Z" } |
| 127 | 128 | ``` |
| 128 | 129 | |
| 129 | -A token without the `admin` scope gets `403 creating tokens requires an admin-scoped token or a browser session`. The CLI checks scope names before sending and exits with `unknown scope "<name>" (read, write or admin)`. | |
| 130 | +Any request authenticated with a token (personal, agent, job or sandbox) gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. The CLI checks scope names before sending and exits with `unknown scope "<name>" (read, write or admin)`. | |
| 130 | 131 | |
| 131 | 132 | ## Restrict a token to repositories |
| 132 | 133 | |
| @@ -264,7 +265,7 @@ | ||
| 264 | 265 | | `403 this token needs the admin scope` | Account-level actions such as inviting, creating organizations or agents, organization secrets and revoking tokens need an `admin` token. | |
| 265 | 266 | | `403 this token is limited to repositories and can't change account settings` | The token has a repository list. Use a token without one, or the web UI. | |
| 266 | 267 | | `422 scopes must be a non-empty list of: read, write, admin` | The `scopes` field was empty, not a list, or had an unknown name. | |
| 267 | -| `403 creating tokens requires an admin-scoped token or a browser session` | Use an `admin` token or the web UI to create tokens. | | |
| 268 | +| `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` | Tokens can't create tokens. Create it in the web UI. | | |
| 268 | 269 | | `403 this needs a write or admin token` | Watching or unwatching a repository needs a `write` or `admin` token. | |
| 269 | 270 | | `403 changing a profile picture needs a write or admin token that is not limited to repositories` | Use a `write` or `admin` token without a repository restriction, or the web UI. | |
| 270 | 271 | | The CLI adds "token invalid or expired; run `diffinery login`" | Run `diffinery login` with a new token. If `DIFFINERY_TOKEN` is set, it overrides the saved token. | |
modifiedapps/diffinery/docs/agents/mcp.md+1−1View file
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | |
| 39 | 39 | ## Connect your client |
| 40 | 40 | |
| 41 | -Create a token first: for an agent, in **Settings → Agents** or with `diffinery agent create` (which needs an `admin`-scoped token or a browser session); for yourself, in **Settings → Access tokens**. Keep it out of files you commit; most clients below can read it from an environment variable. | |
| 41 | +Create a token first in the web UI: for an agent, in **Settings → Agents**; for yourself, in **Settings → Access tokens**. Tokens can't create tokens. Keep it out of files you commit; most clients below can read it from an environment variable. | |
| 42 | 42 | |
| 43 | 43 | ### Claude Code |
| 44 | 44 |
modifiedapps/diffinery/docs/agents/overview.md+3−3View file
| @@ -33,14 +33,14 @@ | ||
| 33 | 33 | |
| 34 | 34 | ### With the CLI |
| 35 | 35 | |
| 36 | -Install the CLI (needs Node.js 22 or later) and log in as yourself, with a personal token that has the `admin` scope and isn't restricted to repositories (creating and disabling agents and their tokens are account-level actions): | |
| 36 | +Creating an agent mints its first token, and no token can create a token, so on diffinery.com create agents in the web UI (above). `diffinery agent create` works against a local development server. Listing and disabling agents work from the CLI with a personal token that has the `admin` scope and isn't restricted to repositories: | |
| 37 | 37 | |
| 38 | 38 | ```sh |
| 39 | 39 | curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery |
| 40 | 40 | diffinery login |
| 41 | 41 | ``` |
| 42 | 42 | |
| 43 | -Then create the agent. The command also creates its first token and prints it once: | |
| 43 | +On a local development server, create the agent like this. The command also creates its first token and prints it once: | |
| 44 | 44 | |
| 45 | 45 | ```sh |
| 46 | 46 | diffinery agent create review-bot --org acme --name "Review bot" --scope write --expires-days 90 |
| @@ -76,7 +76,7 @@ | ||
| 76 | 76 | |
| 77 | 77 | `token.scopes` (and `scopes` when you create another token) must be a non-empty list of `read`, `write` and `admin`; leaving it out gives `["write"]`. An empty list or an unknown name returns `422 scopes must be a non-empty list of: read, write, admin`. |
| 78 | 78 | |
| 79 | -The `POST` and `DELETE` endpoints below need a browser session or a personal token with the `admin` scope that isn't restricted to repositories. Other tokens get `403 this token needs the admin scope` or `403 this token is limited to repositories and can't change account settings`. | |
| 79 | +The `POST` endpoints below create tokens, so they need a browser session: every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. `DELETE` also accepts a personal token with the `admin` scope that isn't restricted to repositories. | |
| 80 | 80 | |
| 81 | 81 | | Endpoint | Purpose | Who | |
| 82 | 82 | | --- | --- | --- | |
modifiedapps/diffinery/docs/agents/sandboxes.md+2−1View file
| @@ -26,7 +26,8 @@ | ||
| 26 | 26 | |
| 27 | 27 | - Creating and using a sandbox needs **write** access to the repository. A `read`-scoped token can't create one. |
| 28 | 28 | - Each call on a sandbox is allowed for the account that created it and for repository admins; anyone else gets `not your sandbox`. |
| 29 | -- Inside the sandbox, git authenticates as the creator, with the role the creator had when the sandbox was created, capped at write (even if the creator is an admin). Pushes from the sandbox follow the same rules as any push: branch protection, push protection (secret scanning) and, for agents, the [agent guardrails](/docs/agents/overview#guardrails). An agent can't push to the default branch from a sandbox either. | |
| 29 | +- Inside the sandbox, git authenticates as the creator, with the role the creator had when the sandbox was created, capped at write (even if the creator is an admin) and at the creator's current role. The sandbox's credentials stop working 24 hours after it starts, when it is closed, or as soon as the creator's account is disabled or loses access to the repository. | |
| 30 | +- Job and sandbox tokens can't create sandboxes (`403 job and sandbox tokens can't start sandboxes`). Pushes from the sandbox follow the same rules as any push: branch protection, push protection (secret scanning) and, for agents, the [agent guardrails](/docs/agents/overview#guardrails). An agent can't push to the default branch from a sandbox either. | |
| 30 | 31 | - Archived repositories can't have sandboxes. |
| 31 | 32 | |
| 32 | 33 | ## What's inside |
modifiedapps/diffinery/docs/quickstart.md+1−7View file
| @@ -223,14 +223,8 @@ | ||
| 223 | 223 | |
| 224 | 224 | **Web UI.** Go to **Settings → Agents**. Under **Create an agent**, enter the **Agent username** `review-bot`, a **Display name**, the **Owner** (you or `acme`) and the **Maximum role** (keep **Write**). Click **Create agent**. Then open **Create a token for review-bot**, keep the defaults (**Scope** **Write**, 30 **Days**) and click **Create token**. Copy the token; it is shown once. |
| 225 | 225 | |
| 226 | -**CLI.** Creating an agent is an account-level action, so the CLI needs a token with the `admin` scope (the `write` token from step 2 gets `this token needs the admin scope`). Create an `admin` token under **Settings → Access tokens** first, then: | |
| 226 | +Creating an agent also creates its first token, and tokens can't create tokens, so create agents in the web UI under **Settings → Agents**. It shows the agent's token once (scope `write`, valid for 90 days). An agent owned by `acme` can work in every `acme` repository, up to its maximum role. Creating an organization agent needs the owner or admin role in the organization. | |
| 227 | 227 | |
| 228 | -```sh | |
| 229 | -DIFFINERY_TOKEN=dfy_... diffinery agent create review-bot --org acme | |
| 230 | -``` | |
| 231 | - | |
| 232 | -This prints the agent's token once (scope `write`, valid for 90 days). An agent owned by `acme` can work in every `acme` repository, up to its maximum role. Creating an organization agent needs the owner or admin role in the organization. | |
| 233 | - | |
| 234 | 228 | ### Point your MCP client at Diffinery |
| 235 | 229 | |
| 236 | 230 | Diffinery's MCP server is at `https://diffinery.com/mcp` (Streamable HTTP). Give the client the agent's token as a bearer token: |
modifiedapps/diffinery/docs/reference/limits.md+2−1View file
| @@ -31,7 +31,8 @@ | ||
| 31 | 31 | | An agent's first token | 90 days, `write` scope | The token created together with an agent, unless you pass `token.expires_in_days` or `token.scopes`. | |
| 32 | 32 | | Expiry reminder email | 7 days before expiry | Sent once per token, and only for tokens that were valid for more than 8 days. | |
| 33 | 33 | | "Last used" accuracy | 1 hour | A token's last-used time is updated at most once an hour. | |
| 34 | -| Creating tokens through the API | `admin` scope or a browser session | `POST /api/v3/diffinery/tokens` with a `read` or `write` token returns `403 creating tokens requires an admin-scoped token or a browser session`. | | |
| 34 | +| Creating tokens through the API | A browser session | No token can create a token or an agent: `POST /api/v3/diffinery/tokens` and the agent endpoints return `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` for every token. | | |
| 35 | +| Sandbox token lifetime | 24 hours | A sandbox's git and API credentials stop working 24 hours after it starts, or earlier when it is closed or its creator loses access. | | |
| 35 | 36 | | Token scopes | `read`, `write` or `admin`; default `["write"]` | A new token (personal or agent) without `scopes` gets `write`. An empty list or any other name returns `422 scopes must be a non-empty list of: read, write, admin`. Repeated names are stored once. | |
| 36 | 37 | | Writes with a `read` token | Refused | Every change returns `403 this token needs the write scope`. Git fetch and Git LFS downloads still work. | |
| 37 | 38 | | Account-level actions | `write` to create repositories, mark notifications read, change notification settings and send a test email; `admin` for creating organizations, invitations, organization secrets, agents and revoking tokens | A token restricted to some repositories can't do any of these (`403 this token is limited to repositories and can't change account settings`). | |
modifiedapps/diffinery/docs/reference/rest-api.md+5−4View file
| @@ -105,7 +105,7 @@ | ||
| 105 | 105 | |
| 106 | 106 | ## Authentication |
| 107 | 107 | |
| 108 | -Most endpoints need you to be signed in. Scripts, CI jobs, agents and the CLI authenticate with a personal access token or an agent token. Tokens start with `dfy_`. Create one under **Settings → Access tokens** (`/settings/tokens`), with `diffinery token create`, or with `POST /api/v3/diffinery/tokens` (see [Personal access tokens](/docs/accounts/tokens)). | |
| 108 | +Most endpoints need you to be signed in. Scripts, CI jobs, agents and the CLI authenticate with a personal access token or an agent token. Tokens start with `dfy_`. Create one under **Settings → Access tokens** (`/settings/tokens`). No token can create another token, so `POST /api/v3/diffinery/tokens` needs a browser session (see [Personal access tokens](/docs/accounts/tokens)). | |
| 109 | 109 | |
| 110 | 110 | Send the token in the `Authorization` header. Three schemes are accepted, so any GitHub client works: |
| 111 | 111 | |
| @@ -165,12 +165,13 @@ | ||
| 165 | 165 | | `PUT /api/v3/notifications`, `PUT /api/v3/diffinery/notification-settings`, `POST /api/v3/diffinery/emails/test` | A `write` or `admin` token not restricted to repositories (`403 this token needs the write scope` for a `read` token). | |
| 166 | 166 | | `PUT` and `DELETE /api/v3/repos/{owner}/{repo}/subscription` | A `write` or `admin` token (`403 this needs a write or admin token`). | |
| 167 | 167 | | `PUT` and `DELETE /api/v3/user/avatar` and `/api/v3/orgs/{org}/avatar` | A `write` or `admin` token that is not limited to repositories (`403 changing a profile picture needs a write or admin token that is not limited to repositories`). | |
| 168 | -| `POST /api/v3/diffinery/tokens` | An `admin` token (`403 creating tokens requires an admin-scoped token or a browser session` otherwise). | | |
| 168 | +| `POST /api/v3/diffinery/tokens` | A browser session. Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. | | |
| 169 | 169 | | `DELETE /api/v3/diffinery/tokens/{id}` | An `admin` token not restricted to repositories. | |
| 170 | 170 | | `POST /api/v3/user/orgs` | An `admin` token not restricted to repositories. | |
| 171 | 171 | | `POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations` | An `admin` token not restricted to repositories. | |
| 172 | 172 | | `GET`, `PUT` and `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | An `admin` token not restricted to repositories. | |
| 173 | -| `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. | | |
| 173 | +| `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens` | A browser session (they mint tokens). Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. | | |
| 174 | +| `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. | | |
| 174 | 175 | | `POST /api/v3/diffinery/admin/emails/samples` | A site administrator with an `admin` token not restricted to repositories. | |
| 175 | 176 | |
| 176 | 177 | A token whose scope is too low for one of these gets `403 this token needs the write scope` or `403 this token needs the admin scope`. |
| @@ -190,7 +191,7 @@ | ||
| 190 | 191 | |
| 191 | 192 | Requests that carry a token in the `Authorization` header are exempt: they carry no ambient credentials, so this check does not apply to curl, scripts, the CLI, MCP clients or runners. If you see `cross-site request refused` from a script, it means the script sent no token; add the `Authorization` header. |
| 192 | 193 | |
| 193 | -Machines should always use tokens, never browser cookies. Passkeys and browser sessions can only be managed in the web UI; tokens can't add or remove passkeys. | |
| 194 | +Machines should always use tokens, never browser cookies. Passkeys and browser sessions can only be managed in the web UI; tokens can't add or remove passkeys, and they can't create tokens or agents. Web forms (`/settings/...`, repository settings and the other pages' buttons) refuse requests that carry a token with `403 web forms need a browser session; use the API with a token`, so a token's scope and repository limits always apply. | |
| 194 | 195 | |
| 195 | 196 | ## Read-your-writes and the x-d1-bookmark header |
| 196 | 197 |
modifiedapps/diffinery/docs/repositories/git.md+1−1View file
| @@ -37,7 +37,7 @@ | ||
| 37 | 37 | |
| 38 | 38 | Anonymous callers can only read: they can clone and fetch public repositories, but every push needs a token. A `read`-scoped token works for clone and fetch (and for downloading [Git LFS](/docs/repositories/git-lfs) files) on any repository its account can read, and is refused for every push. |
| 39 | 39 | |
| 40 | -Create a token under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`) or with `diffinery token create`. See [Personal access tokens](/docs/accounts/tokens). | |
| 40 | +Create a token under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). See [Personal access tokens](/docs/accounts/tokens). | |
| 41 | 41 | |
| 42 | 42 | When Git asks for credentials: |
| 43 | 43 |
modifiedapps/diffinery/src/auth/principal.ts+19−1View file
| @@ -120,6 +120,18 @@ | ||
| 120 | 120 | if (p.type === "account" && p.via === "token" && p.repoIds) throw new HttpError(403, "this token is limited to repositories and can't change account settings"); |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | +/** | |
| 124 | + * Minting credentials (tokens, agents and their tokens) needs a person signed in with a | |
| 125 | + * passkey. No token can create another, so a leaked, repository-limited, agent, job or | |
| 126 | + * sandbox token can never turn itself into a wider or longer-lived one. | |
| 127 | + */ | |
| 128 | +export function requireBrowserSession(c: AppContext): Account { | |
| 129 | + const p = c.get("principal"); | |
| 130 | + if (p.type === "account" && (p.via === "session" || p.via === "dev")) return requireAccount(c); | |
| 131 | + if (p.type === "anonymous") throw new HttpError(401, "sign in required"); | |
| 132 | + throw new HttpError(403, `creating tokens needs a browser session: sign in at ${c.env.PUBLIC_URL}/settings/tokens`); | |
| 133 | +} | |
| 134 | + | |
| 123 | 135 | /** Validates scopes sent by a client: known names only, default write. */ |
| 124 | 136 | export function parseScopes(input: unknown): TokenScope[] { |
| 125 | 137 | if (input === undefined || input === null) return ["write"]; |
| @@ -147,7 +159,13 @@ | ||
| 147 | 159 | if (auth.kind === "job") { |
| 148 | 160 | return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: auth.actorKind === "agent" ? "agent" : "system", role: auth.role, permissions: auth.permissions }; |
| 149 | 161 | } |
| 150 | - const cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role; | |
| 162 | + // A sandbox acts as the person or agent who started it, never beyond what they can do now: | |
| 163 | + // disabling the account or losing access ends its sandboxes' tokens too. | |
| 164 | + const account = await dir.account(auth.caller.id); | |
| 165 | + const now = account?.status === "active" ? await dir.roleFor(account, repo) : null; | |
| 166 | + if (!now) return null; | |
| 167 | + let cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role; | |
| 168 | + if (ROLE_LEVEL[now] < ROLE_LEVEL[cap]) cap = now; | |
| 151 | 169 | return { ...auth.caller, role: cap }; |
| 152 | 170 | } |
| 153 | 171 | if (p.type !== "account") { |
modifiedapps/diffinery/src/docs/api-meta.ts+3−3View file
| @@ -1134,7 +1134,7 @@ | ||
| 1134 | 1134 | "GET /diffinery/tokens": { summary: "List your tokens", access: "account" }, |
| 1135 | 1135 | "POST /diffinery/tokens": { |
| 1136 | 1136 | summary: "Create a token", |
| 1137 | - description: "From a browser session or an admin-scoped token. The token value is returned once. Unknown scopes or an empty list fail with 422.", | |
| 1137 | + description: "From a browser session only: no token can create a token. The token value is returned once. Unknown scopes or an empty list fail with 422.", | |
| 1138 | 1138 | access: "account", |
| 1139 | 1139 | status: 201, |
| 1140 | 1140 | body: { |
| @@ -1148,7 +1148,7 @@ | ||
| 1148 | 1148 | "GET /diffinery/agents": { summary: "List your agents", description: "Agents you own, and agents of organizations you administer.", access: "account" }, |
| 1149 | 1149 | "POST /diffinery/agents": { |
| 1150 | 1150 | summary: "Create an agent", |
| 1151 | - description: "People only. Returns the agent and its first token (write scope, 90 days, unless you say otherwise). Unknown scopes or an empty list fail with 422.", | |
| 1151 | + description: "People, from a browser session only (no token can create a token). Returns the agent and its first token (write scope, 90 days, unless you say otherwise). Unknown scopes or an empty list fail with 422.", | |
| 1152 | 1152 | access: "account", |
| 1153 | 1153 | status: 201, |
| 1154 | 1154 | body: { |
| @@ -1161,7 +1161,7 @@ | ||
| 1161 | 1161 | }, |
| 1162 | 1162 | "POST /diffinery/agents/{login}/tokens": { |
| 1163 | 1163 | summary: "Create a token for an agent", |
| 1164 | - description: "The agent's owner, or an owner or admin of the organization that owns it. Default scope `write`; unknown scopes or an empty list fail with 422. Audited as `agent.token.create`.", | |
| 1164 | + description: "From a browser session only (no token can create a token): the agent's owner, or an owner or admin of the organization that owns it. Default scope `write`; unknown scopes or an empty list fail with 422. Audited as `agent.token.create`.", | |
| 1165 | 1165 | access: "account", |
| 1166 | 1166 | status: 201, |
| 1167 | 1167 | body: { name: ["string", "Token name."], scopes: ["string[]", "`read`, `write` (default) and/or `admin`."], expires_in_days: ["integer", "Lifetime in days."] }, |
modifiedapps/diffinery/src/http/api.ts+8−9View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Hono } from "hono"; |
| 2 | 2 | import { fromUtf8 } from "@diffinery/git-core"; |
| 3 | 3 | import type { Env } from "../env.js"; |
| 4 | -import { callerFor, parseScopes, requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import { callerFor, parseScopes, requireAccount, requireAccountScope, requireBrowserSession, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 5 | 5 | import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js"; |
| 6 | 6 | import { encryptSecret } from "../lib/crypto.js"; |
| 7 | 7 | import { publicJwk } from "../lib/jwt.js"; |
| @@ -913,10 +913,8 @@ | ||
| 913 | 913 | |
| 914 | 914 | api.get("/diffinery/tokens", async (c) => c.json(await c.get("dir").tokens(requireAccount(c).id))); |
| 915 | 915 | api.post("/diffinery/tokens", async (c) => { |
| 916 | - // Minting a token needs a browser session or an admin token for the whole account: a token limited | |
| 917 | - // to some repositories must not be able to create one that isn't. | |
| 918 | - requireAccountScope(c, "admin"); | |
| 919 | - const a = requireAccount(c); | |
| 916 | + // Minting a token needs a browser session: no token (however wide) can create another. | |
| 917 | + const a = requireBrowserSession(c); | |
| 920 | 918 | const b = await body<{ name: string; scopes?: TokenScope[]; repositories?: string[]; expires_in_days?: number }>(c); |
| 921 | 919 | const scopes = parseScopes(b.scopes); |
| 922 | 920 | let repoIds: string[] | null = null; |
| @@ -945,8 +943,8 @@ | ||
| 945 | 943 | return c.json((await c.get("dir").agentsOwnedBy([a.id, ...orgs])).map((x) => ({ ...userJson(c, x), owner_id: x.owner_id, policy: x.agent_policy ? JSON.parse(x.agent_policy) : {} }))); |
| 946 | 944 | }); |
| 947 | 945 | api.post("/diffinery/agents", async (c) => { |
| 948 | - requireAccountScope(c, "admin"); | |
| 949 | - const a = requireAccount(c); | |
| 946 | + // Creating an agent mints its first token, so it needs a browser session too. | |
| 947 | + const a = requireBrowserSession(c); | |
| 950 | 948 | if (a.kind !== "user") throw new HttpError(403, "only people can create agents"); |
| 951 | 949 | const b = await body<{ login: string; name?: string; org?: string; policy?: Record<string, unknown>; token?: { scopes?: TokenScope[]; expires_in_days?: number } }>(c); |
| 952 | 950 | const dir = c.get("dir"); |
| @@ -957,8 +955,7 @@ | ||
| 957 | 955 | return c.json({ agent: userJson(c, agent), token }, 201); |
| 958 | 956 | }); |
| 959 | 957 | api.post("/diffinery/agents/:login/tokens", async (c) => { |
| 960 | - requireAccountScope(c, "admin"); | |
| 961 | - const a = requireAccount(c); | |
| 958 | + const a = requireBrowserSession(c); | |
| 962 | 959 | const dir = c.get("dir"); |
| 963 | 960 | const agent = await dir.accountByLogin(c.req.param("login")); |
| 964 | 961 | if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found"); |
| @@ -984,6 +981,8 @@ | ||
| 984 | 981 | }); |
| 985 | 982 | |
| 986 | 983 | api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => { |
| 984 | + // A sandbox's token outlives the job that would start it, so job and sandbox tokens can't start one. | |
| 985 | + if (c.get("principal").type === "repo-token") throw new HttpError(403, "job and sandbox tokens can't start sandboxes"); | |
| 987 | 986 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 988 | 987 | const b = await body<{ ref?: string }>(c).catch(() => ({}) as { ref?: string }); |
| 989 | 988 | return c.json(await stub.createSandbox(caller, b.ref), 201); |
modifiedapps/diffinery/src/http/imports.ts+2−0View file
| @@ -105,6 +105,8 @@ | ||
| 105 | 105 | |
| 106 | 106 | imports.post("/diffinery/imports", async (c) => { |
| 107 | 107 | const a = actor(c); |
| 108 | + // An import creates a repository and mints its push token: not for tokens limited to repositories. | |
| 109 | + requireAccountScope(c, "write"); | |
| 108 | 110 | const svc = new ImportService(c.env); |
| 109 | 111 | const row = await svc.create(a, scopesOf(c), await body(c)); |
| 110 | 112 | return c.json(await importJson(c.env, svc, row, { children: true }), 201); |
modifiedapps/diffinery/src/mcp/server.ts+1−0View file
| @@ -453,6 +453,7 @@ | ||
| 453 | 453 | required: ["source", "owner"], |
| 454 | 454 | run: async (c, a) => { |
| 455 | 455 | const me = requireAccount(c); |
| 456 | + requireAccountScope(c, "write"); | |
| 456 | 457 | const svc = new ImportService(c.env); |
| 457 | 458 | const row = await svc.create(me, importScopes(c), { |
| 458 | 459 | source: str(a, "source"), |
modifiedapps/diffinery/src/repo/actions.ts+6−1View file
| @@ -31,6 +31,9 @@ | ||
| 31 | 31 | import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js"; |
| 32 | 32 | import { ancestors, combineResults, jobStatus, runnerLabels, type JobNode } from "./job-graph.js"; |
| 33 | 33 | |
| 34 | +/** How long a sandbox token works after the sandbox starts. */ | |
| 35 | +export const SANDBOX_TOKEN_TTL_MS = 24 * 3600_000; | |
| 36 | + | |
| 34 | 37 | const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; |
| 35 | 38 | const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; |
| 36 | 39 | |
| @@ -1121,8 +1124,10 @@ | ||
| 1121 | 1124 | return { kind: "job", jobId: j.id, runId: j.run_id, role: grant.role, permissions: grant.permissions, actorKind }; |
| 1122 | 1125 | } |
| 1123 | 1126 | if (parts[0] === "dst") { |
| 1124 | - const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0]; | |
| 1127 | + const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string; created_at: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0]; | |
| 1125 | 1128 | if (!s || s.secret !== hash || s.status !== "running") return null; |
| 1129 | + // A sandbox's token lasts a day at most, even if nobody closes the sandbox. | |
| 1130 | + if (Date.now() - Date.parse(s.created_at) > SANDBOX_TOKEN_TTL_MS) return null; | |
| 1126 | 1131 | return { kind: "sandbox", sandboxId: s.id, caller: JSON.parse(s.created_by) as Caller }; |
| 1127 | 1132 | } |
| 1128 | 1133 | return null; |
modifiedapps/diffinery/src/web/csrf.ts+4−1View file
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { getCookie, setCookie } from "hono/cookie"; |
| 2 | -import type { AppContext } from "../auth/principal.js"; | |
| 2 | +import { credentialFrom, type AppContext } from "../auth/principal.js"; | |
| 3 | 3 | import { HttpError, randomToken, safeEqual } from "../lib/util.js"; |
| 4 | 4 | |
| 5 | 5 | /** |
| @@ -82,6 +82,9 @@ | ||
| 82 | 82 | |
| 83 | 83 | /** Reject state-changing requests without a matching token (and cross-origin requests). */ |
| 84 | 84 | export async function verifyCsrf(c: AppContext, maxBytes = MAX_FORM_BYTES): Promise<FormData> { |
| 85 | + // Web forms are for people in a browser. A token (personal, agent, job or sandbox) uses the API, | |
| 86 | + // where its scope and repository limits apply; here it could forge the double-submit cookie. | |
| 87 | + if (credentialFrom(c.req.raw)) throw new HttpError(403, "web forms need a browser session; use the API with a token"); | |
| 85 | 88 | const origin = c.req.header("origin"); |
| 86 | 89 | if (origin && origin !== "null") { |
| 87 | 90 | const self = new URL(c.req.url).origin; |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+8−8View file
| @@ -41,7 +41,7 @@ | ||
| 41 | 41 | async function api(method, path, body, tok = token) { |
| 42 | 42 | const res = await fetch(`${base}/api/v3${path}`, { |
| 43 | 43 | method, |
| 44 | - headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, | |
| 44 | + headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, | |
| 45 | 45 | body: body === undefined ? undefined : JSON.stringify(body), |
| 46 | 46 | }); |
| 47 | 47 | const text = await res.text(); |
| @@ -267,7 +267,7 @@ | ||
| 267 | 267 | git(clone, ["reset", "-q", "--hard", "origin/main"]); |
| 268 | 268 | |
| 269 | 269 | // ------------------------------------------------------------ an AI agent opens a PR |
| 270 | - const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" }); | |
| 270 | + const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" }, null); | |
| 271 | 271 | agentToken = created.token.token; |
| 272 | 272 | const agentClone = join(work, "agent"); |
| 273 | 273 | git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken); |
| @@ -299,7 +299,7 @@ | ||
| 299 | 299 | assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge)); |
| 300 | 300 | |
| 301 | 301 | // Another agent's approval is advisory and does not unblock the merge. |
| 302 | - const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" })).token.token; | |
| 302 | + const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" }, null)).token.token; | |
| 303 | 303 | await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "bot says ok" }, reviewer); |
| 304 | 304 | assert.equal((await ok("GET", `/repos/${owner}/demo/pulls/1/merge`)).mergeable, false); |
| 305 | 305 | |
| @@ -656,7 +656,7 @@ | ||
| 656 | 656 | test("triggers from workflows: a job's repository_dispatch starts runs, a follower of agent code stays an agent's", { timeout: 600_000 }, async () => { |
| 657 | 657 | const owner = (await ok("GET", "/user")).login; |
| 658 | 658 | await ok("POST", "/user/repos", { name: "relay" }); |
| 659 | - const bot = (await ok("POST", "/diffinery/agents", { login: "relay-bot" })).token.token; | |
| 659 | + const bot = (await ok("POST", "/diffinery/agents", { login: "relay-bot" }, null)).token.token; | |
| 660 | 660 | const src = join(work, "relay"); |
| 661 | 661 | mkdirSync(join(src, ".github/workflows"), { recursive: true }); |
| 662 | 662 | writeFileSync( |
| @@ -994,7 +994,7 @@ | ||
| 994 | 994 | const settingsPath = `/repos/${owner}/policy/actions/permissions/workflow`; |
| 995 | 995 | // New repositories start like GitHub's: read-only token, no pull request approvals. |
| 996 | 996 | assert.deepEqual(await ok("GET", settingsPath), { default_workflow_permissions: "read", can_approve_pull_request_reviews: false }); |
| 997 | - const bot = (await ok("POST", "/diffinery/agents", { login: "policy-bot" })).token.token; | |
| 997 | + const bot = (await ok("POST", "/diffinery/agents", { login: "policy-bot" }, null)).token.token; | |
| 998 | 998 | assert.equal((await api("PUT", settingsPath, { can_approve_pull_request_reviews: true }, bot)).status, 403, "agents cannot change what workflows may do"); |
| 999 | 999 | assert.equal((await api("PUT", settingsPath, { default_workflow_permissions: "admin" })).status, 422); |
| 1000 | 1000 | // The same settings on the repository's Settings → Actions page. |
| @@ -1785,7 +1785,7 @@ | ||
| 1785 | 1785 | // Drafts are for writers only; a read-only token sees neither the draft nor its tag lookup. |
| 1786 | 1786 | const draft = await gh.rest.repos.createRelease({ ...R, tag_name: "v2.0.0-rc.1", name: "Next", draft: true, prerelease: true }); |
| 1787 | 1787 | assert.equal(draft.data.draft, true); |
| 1788 | - const reader = await ok("POST", "/diffinery/tokens", { name: "reader", scopes: ["read"] }); | |
| 1788 | + const reader = await ok("POST", "/diffinery/tokens", { name: "reader", scopes: ["read"] }, null); | |
| 1789 | 1789 | const ro = new Octokit({ auth: reader.token, baseUrl: `${base}/api/v3`, request: { retries: 0 } }); |
| 1790 | 1790 | assert.deepEqual((await ro.rest.repos.listReleases(R)).data.map((x) => x.tag_name), ["v1.0.0"]); |
| 1791 | 1791 | await assert.rejects(ro.rest.repos.getRelease({ ...R, release_id: draft.data.id }), (e) => e.status === 404); |
| @@ -2064,7 +2064,7 @@ | ||
| 2064 | 2064 | assert.equal(gif.status, 422); |
| 2065 | 2065 | assert.equal((await gif.json()).message, "Use a JPEG, PNG or WebP image."); |
| 2066 | 2066 | // Read-only tokens can't change a profile. |
| 2067 | - const ro = (await ok("POST", "/diffinery/tokens", { name: "ro", scopes: ["read"] })).token; | |
| 2067 | + const ro = (await ok("POST", "/diffinery/tokens", { name: "ro", scopes: ["read"] }, null)).token; | |
| 2068 | 2068 | assert.equal((await put("/user/avatar", pngGradient(8, 8), ro)).status, 403); |
| 2069 | 2069 | |
| 2070 | 2070 | // The web form (multipart, CSRF double-submit) takes the original file and the server resizes it. |
| @@ -2270,7 +2270,7 @@ | ||
| 2270 | 2270 | assert.equal((await api("POST", "/diffinery/imports", { source: "octo/app", owner, options: { nope: true }, github_api_url: apiUrl })).status, 422); |
| 2271 | 2271 | assert.equal((await api("POST", "/diffinery/imports", { source: "octo/app", owner: "nobody-here", github_api_url: apiUrl })).status, 422); |
| 2272 | 2272 | assert.equal((await api("POST", "/diffinery/imports", { source: "octo/app", owner, github_token: "wrong", github_api_url: apiUrl })).status, 422); |
| 2273 | - const bot = (await ok("POST", "/diffinery/agents", { login: "import-bot" })).token.token; | |
| 2273 | + const bot = (await ok("POST", "/diffinery/agents", { login: "import-bot" }, null)).token.token; | |
| 2274 | 2274 | const botTry = await api("POST", "/diffinery/imports", { source: "octo/app", owner, github_token: GH_TOKEN, github_api_url: apiUrl }, bot); |
| 2275 | 2275 | assert.equal(botTry.status, 403, "agents cannot import into a person's account"); |
| 2276 | 2276 |
modifiedapps/diffinery/test/e2e/email.test.mjs+2−2View file
| @@ -39,7 +39,7 @@ | ||
| 39 | 39 | async function api(method, path, body, tok = token) { |
| 40 | 40 | const res = await fetch(`${base}/api/v3${path}`, { |
| 41 | 41 | method, |
| 42 | - headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, | |
| 42 | + headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, | |
| 43 | 43 | body: body === undefined ? undefined : JSON.stringify(body), |
| 44 | 44 | }); |
| 45 | 45 | const text = await res.text(); |
| @@ -191,7 +191,7 @@ | ||
| 191 | 191 | assert.equal(sub.subscribed, true); |
| 192 | 192 | |
| 193 | 193 | // Adding an agent is a security email. |
| 194 | - const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" }); | |
| 194 | + const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" }, null); | |
| 195 | 195 | const botToken = bot.token.token; |
| 196 | 196 | await email("agent added", (e) => e.kind === "agent_created"); |
| 197 | 197 |
modifiedapps/diffinery/test/e2e/registry.test.mjs+2−2View file
| @@ -65,7 +65,7 @@ | ||
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | async function api(method, path, body, tok = token) { |
| 68 | - const res = await fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 68 | + const res = await fetch(`${base}/api/v3${path}`, { method, headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 69 | 69 | const text = await res.text(); |
| 70 | 70 | let json = null; |
| 71 | 71 | try { |
| @@ -300,7 +300,7 @@ | ||
| 300 | 300 | }); |
| 301 | 301 | |
| 302 | 302 | test("registry: token scopes, the packages API and repository links", async () => { |
| 303 | - const read = (await ok("POST", "/diffinery/tokens", { name: "pull only", scopes: ["read"] })).token; | |
| 303 | + const read = (await ok("POST", "/diffinery/tokens", { name: "pull only", scopes: ["read"] }, null)).token; | |
| 304 | 304 | const name = `${owner}/scoped`; |
| 305 | 305 | const img = await pushImage(name, "latest"); |
| 306 | 306 | assert.equal((await reg("GET", `${name}/manifests/latest`, { tok: read })).status, 200); |
modifiedapps/diffinery/test/e2e/security.test.mjs+38−8View file
| @@ -39,7 +39,7 @@ | ||
| 39 | 39 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 40 | 40 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 41 | 41 | const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") }; |
| 42 | -const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}` }; | |
| 42 | +const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}` }; | |
| 43 | 43 | const OWNER = "olive"; |
| 44 | 44 | |
| 45 | 45 | const freePort = () => |
| @@ -133,6 +133,7 @@ | ||
| 133 | 133 | account("a-helper", "olive-helper", "agent", "active", "u-olive"), |
| 134 | 134 | token("t10", "a-helper", T.helper, ["write"]), |
| 135 | 135 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`, |
| 136 | + session("s4", "u-sam", S.sam), | |
| 136 | 137 | ].join("; "), |
| 137 | 138 | ); |
| 138 | 139 | dev = spawn( |
| @@ -408,10 +409,10 @@ | ||
| 408 | 409 | await status(403, "POST", "/diffinery/tokens", { name: "escalate", scopes: ["admin"] }, T.reader); |
| 409 | 410 | await status(403, "PUT", "/notifications", {}, T.reader); |
| 410 | 411 | // Token scopes are checked on the server. |
| 411 | - await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: ["root"] }); | |
| 412 | - await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: [] }); | |
| 413 | - await status(422, "POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["superuser"] }); | |
| 414 | - const minted = await ok("POST", "/diffinery/tokens", { name: "fine", scopes: ["read", "read"] }); | |
| 412 | + await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: ["root"] }, { cookie: S.olive }); | |
| 413 | + await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: [] }, { cookie: S.olive }); | |
| 414 | + await status(422, "POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["superuser"] }, { cookie: S.olive }); | |
| 415 | + const minted = await ok("POST", "/diffinery/tokens", { name: "fine", scopes: ["read", "read"] }, { cookie: S.olive }); | |
| 415 | 416 | assert.ok(minted.token); |
| 416 | 417 | assert.deepEqual((await ok("GET", "/diffinery/tokens")).find((t) => t.id === minted.id).scopes, ["read"]); |
| 417 | 418 | |
| @@ -528,11 +529,11 @@ | ||
| 528 | 529 | test("security: limited tokens, invisible repositories, readers' labels and the audit trail", async () => { |
| 529 | 530 | await repoWithReadme("limits"); |
| 530 | 531 | // ------------------------------------------------------------ a token limited to some repositories can't mint one that isn't |
| 531 | - const limited = (await ok("POST", "/diffinery/tokens", { name: "limited", scopes: ["admin"], repositories: [`${OWNER}/limits`] })).token; | |
| 532 | + const limited = (await ok("POST", "/diffinery/tokens", { name: "limited", scopes: ["admin"], repositories: [`${OWNER}/limits`] }, { cookie: S.olive })).token; | |
| 532 | 533 | await status(403, "POST", "/diffinery/tokens", { name: "wider", scopes: ["admin"] }, limited); |
| 533 | 534 | // ------------------------------------------------------------ private repositories you can't see stay unknown |
| 534 | 535 | await ok("POST", "/user/repos", { name: "hidden", private: true }, T.rita); |
| 535 | - await status(422, "POST", "/diffinery/tokens", { name: "peek", repositories: ["rita/hidden"] }); | |
| 536 | + await status(422, "POST", "/diffinery/tokens", { name: "peek", repositories: ["rita/hidden"] }, { cookie: S.olive }); | |
| 536 | 537 | await status(404, "POST", "/repos/rita/hidden/issues", { title: "hi" }, T.reader); |
| 537 | 538 | // An anonymous push is asked for credentials (git only sends them after a 401), even to a public repository. |
| 538 | 539 | await ok("PATCH", `/repos/${OWNER}/limits`, { visibility: "public" }); |
| @@ -634,7 +635,7 @@ | ||
| 634 | 635 | await ok("PATCH", "/orgs/shop", { default_repository_permission: "none" }); |
| 635 | 636 | await ok("POST", "/orgs/shop/repos", { name: "sam-lab", private: true }, T.sam); |
| 636 | 637 | await ok("POST", "/orgs/shop/repos", { name: "tools", private: true }); |
| 637 | - const helper = (await ok("POST", "/diffinery/agents", { login: "sam-helper" }, T.sam)).agent.login; | |
| 638 | + const helper = (await ok("POST", "/diffinery/agents", { login: "sam-helper" }, { cookie: S.sam })).agent.login; | |
| 638 | 639 | await status(204, "PUT", "/repos/shop/tools/collaborators/sam", { permission: "write" }); |
| 639 | 640 | await status(204, "PUT", `/repos/shop/tools/collaborators/${helper}`, { permission: "write" }); |
| 640 | 641 | assert.equal((await ok("GET", "/repos/shop/sam-lab", undefined, T.sam)).permissions.admin, true); |
| @@ -1185,3 +1186,32 @@ | ||
| 1185 | 1186 | const held = await opened("held"); |
| 1186 | 1187 | assert.equal(await bypassForm(held), null); |
| 1187 | 1188 | }); |
| 1189 | + | |
| 1190 | +test("security: no token can mint a token, an agent or a sandbox, through the API or the web forms", async () => { | |
| 1191 | + await repoWithReadme("minting"); | |
| 1192 | + const limited = (await ok("POST", "/diffinery/tokens", { name: "limited-mint", scopes: ["admin"], repositories: [`${OWNER}/minting`] }, { cookie: S.olive })).token; | |
| 1193 | + // A job token's shape: minting refuses job and sandbox tokens before any repository checks them. | |
| 1194 | + const job = `djt_repo_job_${"0".repeat(48)}`; | |
| 1195 | + const tokens = async () => (await ok("GET", "/diffinery/tokens", undefined, { cookie: S.olive })).length; | |
| 1196 | + const before = await tokens(); | |
| 1197 | + // Admin, agent, read-only and repository-limited tokens, and job tokens: never. | |
| 1198 | + for (const as of [T.olive, T.bot, T.reader, limited, job]) { | |
| 1199 | + const r = await api("POST", "/diffinery/tokens", { name: "escalate", scopes: ["admin"] }, as); | |
| 1200 | + assert.equal(r.status, 403, JSON.stringify(r.json)); | |
| 1201 | + assert.equal((await api("POST", "/diffinery/agents", { login: `mint-${randomBytes(3).toString("hex")}` }, as)).status, 403); | |
| 1202 | + assert.equal((await api("POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["admin"] }, as)).status, 403); | |
| 1203 | + } | |
| 1204 | + assert.match((await api("POST", "/diffinery/tokens", { name: "escalate" }, T.olive)).json.message, /browser session/); | |
| 1205 | + // The web form with a token and a forged double-submit cookie: refused, nothing minted. | |
| 1206 | + const form = (fields) => ({ method: "POST", redirect: "manual", body: new URLSearchParams({ _csrf: "forged", ...fields }), headers: { authorization: `Bearer ${T.olive}`, cookie: "dfy_csrf=forged" } }); | |
| 1207 | + assert.equal((await fetch(`${base}/settings/tokens`, form({ name: "escalate", scope: "admin", expires: "90" }))).status, 403); | |
| 1208 | + assert.equal((await fetch(`${base}/settings/agents`, form({ login: "web-escalate" }))).status, 403); | |
| 1209 | + assert.equal(await tokens(), before); | |
| 1210 | + // A browser session still mints. | |
| 1211 | + await ok("POST", "/diffinery/tokens", { name: "from the browser", scopes: ["read"] }, { cookie: S.olive }); | |
| 1212 | + assert.equal(await tokens(), before + 1); | |
| 1213 | + // A repository-limited token can't create repositories through an import either. | |
| 1214 | + await status(403, "POST", "/diffinery/imports", { source: "octo/hello", owner: OWNER }, limited); | |
| 1215 | + // Job and sandbox tokens can't start sandboxes, whose tokens would outlive them. | |
| 1216 | + await status(403, "POST", `/repos/${OWNER}/minting/diffinery/sandboxes`, {}, job); | |
| 1217 | +}); |