Security audit, part 3 Lows: bounded commit paging and anonymous search, expiring unsubscribe links, encrypted webhook secrets, job token lifetime (#202)
* Commit listings page at most 10,000 commits deep * Anonymous issue searches fan out to at most 10 repositories * Unsubscribe links expire after a year and don't show the email address * Docs: unsubscribe links expire after a year and don't show the email address * Webhook secrets are encrypted at rest * Job tokens expire 30 minutes after the job's timeout
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
20 files changed+408−31
Files (20)
- apps/diffinery/docs/accounts/notifications.md+1−1
- apps/diffinery/docs/actions/workflow-permissions.md+1−1
- apps/diffinery/docs/self-hosting/configuration.md+1−1
- apps/diffinery/src/app.ts+2−2
- apps/diffinery/src/http/api.ts+5−3
- apps/diffinery/src/http/search.ts+7−2
- apps/diffinery/src/mail/unsubscribe.ts+15−5
- apps/diffinery/src/mail/web.tsx+1−1
- apps/diffinery/src/repo/RepoDO.ts+9−4
- apps/diffinery/src/repo/actions.ts+23−2
- apps/diffinery/src/repo/collab.ts+62−6
- apps/diffinery/src/repo/schema.ts+8−1
- apps/diffinery/src/repo/types.ts+2−0
- apps/diffinery/test/e2e/email.test.mjs+4−1
- apps/diffinery/test/e2e/security.test.mjs+3−0
- apps/diffinery/test/unit/commit-log.test.ts+46−0
- apps/diffinery/test/unit/email.test.ts+21−1
- apps/diffinery/test/unit/job-token.test.ts+100−0
- apps/diffinery/test/unit/search-fanout.test.ts+11−0
- apps/diffinery/test/unit/webhooks.test.ts+86−0
modifiedapps/diffinery/docs/accounts/notifications.md+1−1View file
| @@ -191,7 +191,7 @@ | ||
| 191 | 191 | |
| 192 | 192 | The mail app's one-click button (`List-Unsubscribe`) turns off email for the group the email belongs to, the same as **Unsubscribe from emails like this**: for an issue or pull request email, that's every Participating email (or every Watching one, when you got it because you watch the repository). To mute just one thread, use **Mute this thread** in the footer instead. You can turn the group back on in notification settings. |
| 193 | 193 | |
| 194 | -The links are signed, so they work without signing in and can't be altered to change someone else's settings. Opening one shows a page naming the change and the account (for example "Mute acme/website#42") with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links. | |
| 194 | +The links are signed, so they work without signing in and can't be altered to change someone else's settings. Each link works for a year after the email was sent (links from before links carried an expiry work until October 16, 2027); after that, change what you get in notification settings. Opening one shows a page naming the change (for example "Mute acme/website#42") and the account's username, though not its email address, with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links. | |
| 195 | 195 | |
| 196 | 196 | ## Email headers |
| 197 | 197 |
modifiedapps/diffinery/docs/actions/workflow-permissions.md+1−1View file
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 16 | 16 | ``` |
| 17 | 17 | |
| 18 | -- It works only while the job runs. When the job completes, the token stops working. | |
| 18 | +- It works only while the job runs. When the job completes, the token stops working, and it never works more than 30 minutes past the job's timeout (counted from when the job started), even if the job is never marked complete. | |
| 19 | 19 | - It acts in the repository as `diffinery-actions[bot]`, so issues, comments and labels it creates show that name. |
| 20 | 20 | - Its role is `write` when any scope other than `id-token` is `write`, and `read` otherwise. It never has the `maintain` or `admin` role, so [branch protection](/docs/repositories/branch-protection) always applies to it. |
| 21 | 21 | - Each request is then checked against the scope its endpoint needs (see [What each endpoint needs](#what-each-endpoint-needs)). |
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−1View file
| @@ -159,7 +159,7 @@ | ||
| 159 | 159 | |
| 160 | 160 | | Secret | Format | Created by | What it does | If it is missing | |
| 161 | 161 | | --- | --- | --- | --- | --- | |
| 162 | -| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Open sign-up can't send codes. | | |
| 162 | +| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import and each webhook's signing secret are encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Webhooks can't be created or delivered. Open sign-up can't send codes. | | |
| 163 | 163 | | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. | |
| 164 | 164 | | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. | |
| 165 | 165 | | `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. | |
modifiedapps/diffinery/src/app.ts+2−2View file
| @@ -159,7 +159,7 @@ | ||
| 159 | 159 | |
| 160 | 160 | async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> { |
| 161 | 161 | const stub = repoStub(env, ev.repoId); |
| 162 | - const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]))); | |
| 162 | + const hooks = (await stub.deliverableWebhooks()).filter((h) => h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type])); | |
| 163 | 163 | if (!hooks.length) return; |
| 164 | 164 | const body = JSON.stringify(webhookPayload(env.PUBLIC_URL, ev, await new Directory(env).repoById(ev.repoId))); |
| 165 | 165 | // Each hook retries on its own (deliverWebhook requeues it). Once a hook may have been sent to, |
| @@ -170,7 +170,7 @@ | ||
| 170 | 170 | |
| 171 | 171 | const webhookDeps = (env: Env): WebhookDeps => ({ |
| 172 | 172 | ownHost: new URL(env.PUBLIC_URL).hostname, |
| 173 | - hook: async (repoId, hookId) => (await repoStub(env, repoId).webhooks()).find((h) => h.id === hookId && h.active) ?? null, | |
| 173 | + hook: async (repoId, hookId) => (await repoStub(env, repoId).deliverableWebhooks()).find((h) => h.id === hookId) ?? null, | |
| 174 | 174 | record: (repoId, hookId, status) => repoStub(env, repoId).recordDelivery(hookId, status), |
| 175 | 175 | requeue: async (r, delaySeconds) => { |
| 176 | 176 | await env.EVENTS.send(r as unknown as DiffineryEvent, { delaySeconds }); |
modifiedapps/diffinery/src/http/api.ts+5−3View file
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import { publicJwk } from "../lib/jwt.js"; |
| 12 | 12 | import { visibleTo } from "../lib/moderation.js"; |
| 13 | 13 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 14 | -import { atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js"; | |
| 14 | +import { MAX_LOG_DEPTH, atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js"; | |
| 15 | 15 | import type { JobInfo, RunInfo } from "../repo/actions.js"; |
| 16 | 16 | import type { CommitInfo } from "../repo/RepoDO.js"; |
| 17 | 17 | import type { QueueEntry } from "../repo/queue.js"; |
| @@ -631,6 +631,8 @@ | ||
| 631 | 631 | api.get("/repos/:owner/:repo/commits", async (c) => { |
| 632 | 632 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 633 | 633 | const p = pageOf(c); |
| 634 | + // GitHub's answer when a listing is paged deeper than it allows; the history walk stops at MAX_LOG_DEPTH commits. | |
| 635 | + if (p.offset >= MAX_LOG_DEPTH) throw new HttpError(422, "In order to keep the API fast for everyone, pagination is limited for this resource."); | |
| 634 | 636 | const rev = c.req.query("sha") ?? "HEAD"; |
| 635 | 637 | if (!(await stub.resolve(rev))) { |
| 636 | 638 | if (!(await stub.resolve("HEAD"))) throw new HttpError(409, "Git Repository is empty."); |
| @@ -1297,7 +1299,7 @@ | ||
| 1297 | 1299 | |
| 1298 | 1300 | api.get("/repos/:owner/:repo/hooks", async (c) => { |
| 1299 | 1301 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 1300 | - return c.json(paginate(c, (await stub.webhooks()).map(({ secret: _s, ...h }) => hookJson(c, h)))); | |
| 1302 | + return c.json(paginate(c, (await stub.webhooks()).map((h) => hookJson(c, h)))); | |
| 1301 | 1303 | }); |
| 1302 | 1304 | api.post("/repos/:owner/:repo/hooks", async (c) => { |
| 1303 | 1305 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| @@ -1307,7 +1309,7 @@ | ||
| 1307 | 1309 | const hook = (await stub.webhooks()).find((h) => h.id === created.id)!; |
| 1308 | 1310 | auditRepo(c, caller, repo, "repo.webhook.create", { id: created.id, url: b.config.url }); |
| 1309 | 1311 | // The generated secret is shown once, here (GitHub never returns secrets; Diffinery makes one when none is given). |
| 1310 | - return c.json({ ...hookJson(c, { ...hook, secret: undefined }), secret: b.config.secret ? undefined : created.secret }, 201); | |
| 1312 | + return c.json({ ...hookJson(c, hook), secret: b.config.secret ? undefined : created.secret }, 201); | |
| 1311 | 1313 | }); |
| 1312 | 1314 | api.delete("/repos/:owner/:repo/hooks/:id", async (c) => { |
| 1313 | 1315 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
modifiedapps/diffinery/src/http/search.ts+7−2View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Hono } from "hono"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | -import { callerFor, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 3 | +import { callerFor, type AppContext, type AppVars, type Principal } from "../auth/principal.js"; | |
| 4 | 4 | import type { Repo } from "../lib/directory.js"; |
| 5 | 5 | import { matchesText, parseIssueQuery, parseRepoQuery } from "../lib/search-query.js"; |
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| @@ -14,6 +14,11 @@ | ||
| 14 | 14 | |
| 15 | 15 | /** Searches fan out to at most this many repositories; more sets `incomplete_results`. */ |
| 16 | 16 | const MAX_REPOS = 50; |
| 17 | +/** Anonymous searches fan out to fewer: each repository searched is a Durable Object request, and anyone can send them. */ | |
| 18 | +const MAX_ANONYMOUS_REPOS = 10; | |
| 19 | + | |
| 20 | +/** How many repositories one issue search may fan out to for this caller. */ | |
| 21 | +export const searchFanOut = (p: Principal): number => (p.type === "anonymous" ? MAX_ANONYMOUS_REPOS : MAX_REPOS); | |
| 17 | 22 | const MAX_RESULTS = 1000; |
| 18 | 23 | /** GitHub's limit on the length of a search query. */ |
| 19 | 24 | const MAX_QUERY = 256; |
| @@ -78,7 +83,7 @@ | ||
| 78 | 83 | const owners = new Set(q.owners.map((o) => o.toLowerCase())); |
| 79 | 84 | repos = repos.filter((r) => owners.has(r.owner_login.toLowerCase())); |
| 80 | 85 | } |
| 81 | - const allowed = await authorized(c, repos, MAX_REPOS); | |
| 86 | + const allowed = await authorized(c, repos, searchFanOut(c.get("principal"))); | |
| 82 | 87 | // Each repository returns at most `want` hits as (number, sort key); only the page is loaded afterwards. |
| 83 | 88 | const want = Math.min(p.offset + p.perPage, MAX_RESULTS); |
| 84 | 89 | const results = await Promise.all(allowed.repos.map(async (r) => ({ repo: r, ...(await repoStub(c.env, r.id).searchIssues(q, want)) }))); |
modifiedapps/diffinery/src/mail/unsubscribe.ts+15−5View file
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | /** |
| 7 | 7 | * Unsubscribe links work without signing in, so each one carries a signed |
| 8 | 8 | * capability: whose settings to change and what to turn off. The key is |
| 9 | - * derived from MASTER_KEY, so links stay valid across deploys. | |
| 9 | + * derived from MASTER_KEY, so links stay valid across deploys, and each one | |
| 10 | + * expires about a year after it was sent. | |
| 10 | 11 | */ |
| 11 | 12 | export type UnsubScope = |
| 12 | 13 | /** Stop email for a settings group. */ |
| @@ -33,18 +34,27 @@ | ||
| 33 | 34 | return b64url(new Uint8Array(sig)); |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | -export async function unsubToken(env: Env, t: UnsubToken): Promise<string> { | |
| 37 | - const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope }))); | |
| 37 | +/** How long an unsubscribe link works: about a year, so old emails in someone's inbox stop being a capability. */ | |
| 38 | +export const UNSUB_TTL_MS = 365 * 24 * 60 * 60 * 1000; | |
| 39 | +/** Links signed before they carried an expiry keep working until this date (a year after expiries were added). */ | |
| 40 | +export const UNSUB_LEGACY_CUTOFF_MS = Date.UTC(2027, 9, 16); | |
| 41 | + | |
| 42 | +export async function unsubToken(env: Env, t: UnsubToken, now = Date.now()): Promise<string> { | |
| 43 | + // `e` is the expiry in seconds since the epoch. | |
| 44 | + const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope, e: Math.floor((now + UNSUB_TTL_MS) / 1000) }))); | |
| 38 | 45 | return `${payload}.${await sign(env, payload)}`; |
| 39 | 46 | } |
| 40 | 47 | |
| 41 | -export async function readUnsubToken(env: Env, token: string): Promise<UnsubToken | null> { | |
| 48 | +export async function readUnsubToken(env: Env, token: string, now = Date.now()): Promise<UnsubToken | null> { | |
| 42 | 49 | const [payload, sig] = token.split("."); |
| 43 | 50 | if (!payload || !sig || token.length > 2000) return null; |
| 44 | 51 | if (!safeEqual(sig, await sign(env, payload))) return null; |
| 45 | 52 | try { |
| 46 | - const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope }; | |
| 53 | + const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope; e?: unknown }; | |
| 47 | 54 | if (typeof v.a !== "string" || !v.s || typeof v.s !== "object") return null; |
| 55 | + // A link without an expiry was sent before links had one; those are honoured only until the cutoff. | |
| 56 | + const expires = v.e === undefined ? UNSUB_LEGACY_CUTOFF_MS : typeof v.e === "number" ? v.e * 1000 : 0; | |
| 57 | + if (now >= expires) return null; | |
| 48 | 58 | return { accountId: v.a, scope: v.s }; |
| 49 | 59 | } catch { |
| 50 | 60 | return null; |
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
| @@ -538,7 +538,7 @@ | ||
| 538 | 538 | { title: "Unsubscribe" }, |
| 539 | 539 | <UnsubPage title={scopeText(x.t.scope)}> |
| 540 | 540 | <p class="muted"> |
| 541 | - For <b>{x.account.login}</b> ({x.account.email}). | |
| 541 | + For <b>{x.account.login}</b>. | |
| 542 | 542 | </p> |
| 543 | 543 | <form method="post" action={c.req.path}> |
| 544 | 544 | <button class="btn primary">{scopeText(x.t.scope)}</button> |
modifiedapps/diffinery/src/repo/RepoDO.ts+9−4View file
| @@ -72,7 +72,7 @@ | ||
| 72 | 72 | import { repoToday } from "../agentic/today-repo.js"; |
| 73 | 73 | import { Social } from "./social.js"; |
| 74 | 74 | import { Statuses } from "./statuses.js"; |
| 75 | -import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; | |
| 75 | +import { MAX_LOG_DEPTH, atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; | |
| 76 | 76 | |
| 77 | 77 | const MAX_PUSH_BYTES = 100 * 1024 * 1024; |
| 78 | 78 | /** Pushed blobs this large are hashed, scanned and stored from their compressed bytes, never held inflated. */ |
| @@ -134,7 +134,7 @@ | ||
| 134 | 134 | migrateRepoSchema(this.sql); |
| 135 | 135 | const meta = this.readMeta(); |
| 136 | 136 | if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId, () => this.storageChanged()); |
| 137 | - this.collab = new Collab(this); | |
| 137 | + this.collab = new Collab(this, env); | |
| 138 | 138 | this.actions = new Actions(this, env); |
| 139 | 139 | this.queue = new MergeQueue(this); |
| 140 | 140 | this.autoMerge = new AutoMerge(this, env); |
| @@ -852,9 +852,11 @@ | ||
| 852 | 852 | const sha = await this.resolve(rev); |
| 853 | 853 | if (!sha) return []; |
| 854 | 854 | const limit = Math.min(opts.limit ?? 30, 100); |
| 855 | - const skip = opts.skip ?? 0; | |
| 855 | + const skip = Math.max(opts.skip ?? 0, 0); | |
| 856 | 856 | if (!opts.path) { |
| 857 | - const commits = await walkCommits(this.store, [sha], { limit: limit + skip }); | |
| 857 | + // Paging past MAX_LOG_DEPTH lists nothing rather than walking the whole history for one page. | |
| 858 | + if (skip >= MAX_LOG_DEPTH) return []; | |
| 859 | + const commits = await walkCommits(this.store, [sha], { limit: Math.min(limit + skip, MAX_LOG_DEPTH) }); | |
| 858 | 860 | return commits.slice(skip).map((c) => toInfo(c.oid, c.commit)); |
| 859 | 861 | } |
| 860 | 862 | // Path history: commits whose tree entry at `path` differs from their first parent's. |
| @@ -1139,6 +1141,9 @@ | ||
| 1139 | 1141 | webhooks(...a: Parameters<Collab["webhooks"]>) { |
| 1140 | 1142 | return this.collab.webhooks(...a); |
| 1141 | 1143 | } |
| 1144 | + deliverableWebhooks(...a: Parameters<Collab["deliverableWebhooks"]>) { | |
| 1145 | + return this.collab.deliverableWebhooks(...a); | |
| 1146 | + } | |
| 1142 | 1147 | createWebhook(url: string, events: string[], secret?: string) { |
| 1143 | 1148 | return this.collab.createWebhook(url, events, secret, new URL(this.env.PUBLIC_URL).hostname); |
| 1144 | 1149 | } |
modifiedapps/diffinery/src/repo/actions.ts+23−2View file
| @@ -41,6 +41,22 @@ | ||
| 41 | 41 | |
| 42 | 42 | /** How long a sandbox token works after the sandbox starts. */ |
| 43 | 43 | export const SANDBOX_TOKEN_TTL_MS = 24 * 3600_000; |
| 44 | +/** A job token works this long past the job's timeout, so a runner can still report a job that hit it. */ | |
| 45 | +const JOB_TOKEN_MARGIN_MS = 30 * 60_000; | |
| 46 | +/** A job token never works longer than this after the job started (timeouts are at most 360 minutes). */ | |
| 47 | +export const MAX_JOB_TOKEN_TTL_MS = 7 * 3600_000; | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * When a job's token stops working, in ms since the epoch: its timeout plus a margin after the job | |
| 51 | + * started (or, before it starts, after the token was issued), at most MAX_JOB_TOKEN_TTL_MS. The | |
| 52 | + * Workflow that times jobs out normally ends the token first; this holds even if it never does. | |
| 53 | + */ | |
| 54 | +export function jobTokenExpiry(j: Pick<JobRow, "started_at" | "token_at" | "timeout_minutes">): number | null { | |
| 55 | + const from = Date.parse(j.started_at ?? j.token_at ?? ""); | |
| 56 | + if (!Number.isFinite(from)) return null; | |
| 57 | + const minutes = Math.max(Number(j.timeout_minutes) || 0, 0); | |
| 58 | + return from + Math.min(minutes * 60_000 + JOB_TOKEN_MARGIN_MS, MAX_JOB_TOKEN_TTL_MS); | |
| 59 | +} | |
| 44 | 60 | /** Sandboxes one person or agent may have open in a repository at once: their containers are shared by the whole instance. */ |
| 45 | 61 | export const MAX_OPEN_SANDBOXES = 5; |
| 46 | 62 | |
| @@ -182,6 +198,8 @@ | ||
| 182 | 198 | steps: string; |
| 183 | 199 | failure: string | null; |
| 184 | 200 | token_hash: string | null; |
| 201 | + /** When token_hash was issued (null for tokens issued before it was recorded, until the schema backfills it). */ | |
| 202 | + token_at: string | null; | |
| 185 | 203 | summary: string | null; |
| 186 | 204 | container: string | null; |
| 187 | 205 | timeout_minutes: number; |
| @@ -1237,7 +1255,7 @@ | ||
| 1237 | 1255 | const { size, note } = this.runnerSize(job, run); |
| 1238 | 1256 | // A restarted job gets a new container: the replaced one may still be shutting down. |
| 1239 | 1257 | const container = containerName(jobId, job.restarts, size); |
| 1240 | - this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ?, size_note = ? WHERE id = ?", await sha256(token), container, note, jobId); | |
| 1258 | + this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, token_at = ?, container = ?, size_note = ? WHERE id = ?", await sha256(token), now(), container, note, jobId); | |
| 1241 | 1259 | await startCompute(this.env, { id: jobId, accountId: m.ownerId, repoId: m.repoId, kind: "job" }).catch((e: unknown) => console.error("compute usage failed", e)); |
| 1242 | 1260 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 1243 | 1261 | return { jobId, container, token, egress: await egressFor(this.env, m.ownerId), vcpu: RUNNER_SIZES[size].vcpu }; |
| @@ -1640,7 +1658,7 @@ | ||
| 1640 | 1658 | const m = this.repo.meta; |
| 1641 | 1659 | const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`; |
| 1642 | 1660 | const container = `self-hosted:${runnerName.slice(0, 64)}`; |
| 1643 | - this.sql.exec("UPDATE jobs SET token_hash = ?, container = ?, runner_id = ? WHERE id = ? AND container IS NULL", await sha256(token), container, runnerId, job.id); | |
| 1661 | + this.sql.exec("UPDATE jobs SET token_hash = ?, token_at = ?, container = ?, runner_id = ? WHERE id = ? AND container IS NULL", await sha256(token), now(), container, runnerId, job.id); | |
| 1644 | 1662 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id }); |
| 1645 | 1663 | return { jobId: job.id, token, runId: job.run_id, name: job.name }; |
| 1646 | 1664 | } |
| @@ -1941,6 +1959,9 @@ | ||
| 1941 | 1959 | if (parts[0] === "djt") { |
| 1942 | 1960 | const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", parts[2]!).toArray()[0]; |
| 1943 | 1961 | if (!j || j.token_hash !== hash || j.status === "completed") return null; |
| 1962 | + // Bounded in time too: a job whose Workflow died never completes, and its token mustn't live on. | |
| 1963 | + const expires = jobTokenExpiry(j); | |
| 1964 | + if (expires === null || Date.now() >= expires) return null; | |
| 1944 | 1965 | const grant = this.repo.runtime.grantFor(j.id); |
| 1945 | 1966 | // Code an agent wrote, pushed or opened a pull request with runs as that agent (repo/token-policy.ts). |
| 1946 | 1967 | const run = this.sql.exec<{ agent_origin: number }>("SELECT agent_origin FROM runs WHERE id = ?", j.run_id).toArray()[0]; |
modifiedapps/diffinery/src/repo/collab.ts+62−6View file
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core"; |
| 2 | 2 | import { checkPublicUrl } from "../imports/options.js"; |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; | |
| 3 | 5 | import type { QueueEntry } from "./queue.js"; |
| 4 | 6 | import { HttpError, checkText, newId, now, randomToken } from "../lib/util.js"; |
| 5 | 7 | import type { RepoDO } from "./RepoDO.js"; |
| @@ -167,8 +169,22 @@ | ||
| 167 | 169 | /** Webhooks one repository may have, as on GitHub. */ |
| 168 | 170 | export const MAX_WEBHOOKS = 20; |
| 169 | 171 | |
| 172 | +/** A webhook as listed: everything but its signing secret. */ | |
| 173 | +export interface Webhook { | |
| 174 | + id: string; | |
| 175 | + url: string; | |
| 176 | + events: string[]; | |
| 177 | + active: boolean; | |
| 178 | + createdAt: string; | |
| 179 | + lastStatus: number | null; | |
| 180 | + lastDeliveryAt: string | null; | |
| 181 | +} | |
| 182 | + | |
| 170 | 183 | export class Collab { |
| 171 | - constructor(private repo: RepoDO) {} | |
| 184 | + constructor( | |
| 185 | + private repo: RepoDO, | |
| 186 | + private env: Env, | |
| 187 | + ) {} | |
| 172 | 188 | |
| 173 | 189 | private get sql() { |
| 174 | 190 | return this.repo.sql; |
| @@ -1038,9 +1054,10 @@ | ||
| 1038 | 1054 | |
| 1039 | 1055 | // ---------------------------------------------------------------- webhooks |
| 1040 | 1056 | |
| 1041 | - webhooks(): Array<{ id: string; url: string; events: string[]; active: boolean; createdAt: string; lastStatus: number | null; lastDeliveryAt: string | null; secret: string }> { | |
| 1057 | + /** The repository's webhooks, without their signing secrets. */ | |
| 1058 | + webhooks(): Webhook[] { | |
| 1042 | 1059 | return this.sql |
| 1043 | - .exec<Record<string, string | number | null>>("SELECT * FROM webhooks ORDER BY created_at") | |
| 1060 | + .exec<Record<string, string | number | null>>("SELECT id, url, events, active, created_at, last_status, last_delivery_at FROM webhooks ORDER BY created_at") | |
| 1044 | 1061 | .toArray() |
| 1045 | 1062 | .map((w) => ({ |
| 1046 | 1063 | id: w.id as string, |
| @@ -1050,11 +1067,45 @@ | ||
| 1050 | 1067 | createdAt: w.created_at as string, |
| 1051 | 1068 | lastStatus: (w.last_status as number) ?? null, |
| 1052 | 1069 | lastDeliveryAt: (w.last_delivery_at as string) ?? null, |
| 1053 | - secret: w.secret as string, | |
| 1054 | 1070 | })); |
| 1055 | 1071 | } |
| 1056 | 1072 | |
| 1057 | - createWebhook(url: string, events: string[], secret: string | undefined, ownHost: string): { id: string; secret: string } { | |
| 1073 | + /** | |
| 1074 | + * The active webhooks with their signing secrets, for delivery. Secrets are stored encrypted; a hook | |
| 1075 | + * saved before that (secret_encrypted = 0) is still signed with its plaintext secret, which is | |
| 1076 | + * encrypted in place here so it doesn't stay in the clear. | |
| 1077 | + */ | |
| 1078 | + async deliverableWebhooks(): Promise<Array<Webhook & { secret: string }>> { | |
| 1079 | + const rows = new Map( | |
| 1080 | + this.sql | |
| 1081 | + .exec<{ id: string; secret: string; secret_encrypted: number }>("SELECT id, secret, secret_encrypted FROM webhooks WHERE active = 1") | |
| 1082 | + .toArray() | |
| 1083 | + .map((r) => [r.id, r]), | |
| 1084 | + ); | |
| 1085 | + const out: Array<Webhook & { secret: string }> = []; | |
| 1086 | + for (const h of this.webhooks()) { | |
| 1087 | + const r = rows.get(h.id); | |
| 1088 | + if (!r) continue; | |
| 1089 | + if (r.secret_encrypted) { | |
| 1090 | + // A secret that doesn't decrypt (a changed MASTER_KEY, a damaged row) leaves that one hook undelivered, not every hook. | |
| 1091 | + const secret = await decryptSecret(this.env.MASTER_KEY, this.hookScope(h.id), r.secret).catch((e: Error) => void console.error("webhook secret unreadable", h.id, e.message)); | |
| 1092 | + if (secret !== undefined) out.push({ ...h, secret }); | |
| 1093 | + continue; | |
| 1094 | + } | |
| 1095 | + const sealed = await encryptSecret(this.env.MASTER_KEY, this.hookScope(h.id), r.secret); | |
| 1096 | + // Only if the row is still the plaintext read above (no concurrent re-encryption or delete). | |
| 1097 | + this.sql.exec("UPDATE webhooks SET secret = ?, secret_encrypted = 1 WHERE id = ? AND secret_encrypted = 0 AND secret = ?", sealed, h.id, r.secret); | |
| 1098 | + out.push({ ...h, secret: r.secret }); | |
| 1099 | + } | |
| 1100 | + return out; | |
| 1101 | + } | |
| 1102 | + | |
| 1103 | + /** Each hook's secret gets its own key, so a ciphertext can't be moved to another hook or into the repository's Actions secrets. */ | |
| 1104 | + private hookScope(id: string): string { | |
| 1105 | + return `repo:${this.repo.meta.repoId}:webhook:${id}`; | |
| 1106 | + } | |
| 1107 | + | |
| 1108 | + async createWebhook(url: string, events: string[], secret: string | undefined, ownHost: string): Promise<{ id: string; secret: string }> { | |
| 1058 | 1109 | // HTTPS to a public host other than Diffinery's own, at most 20 per repository (as on GitHub), so a |
| 1059 | 1110 | // repository can't aim deliveries at internal addresses or multiply every event into a flood. |
| 1060 | 1111 | checkPublicUrl(url, false, "webhook URL", ownHost); |
| @@ -1062,7 +1113,12 @@ | ||
| 1062 | 1113 | if (count >= MAX_WEBHOOKS) throw new HttpError(422, `a repository can have at most ${MAX_WEBHOOKS} webhooks`); |
| 1063 | 1114 | const id = newId(); |
| 1064 | 1115 | const s = secret ?? randomToken(24); |
| 1065 | - this.sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES (?, ?, ?, ?, ?)", id, url, s, JSON.stringify(events.length ? events : ["*"]), now()); | |
| 1116 | + const sealed = await encryptSecret(this.env.MASTER_KEY, this.hookScope(id), s).catch((e: Error) => { | |
| 1117 | + throw new HttpError(422, `could not store the webhook secret: ${e.message || "encryption unavailable"} (is MASTER_KEY configured?)`); | |
| 1118 | + }); | |
| 1119 | + // Counted again after the await, so concurrent creates can't pass the limit together. | |
| 1120 | + if (this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM webhooks").one().n >= MAX_WEBHOOKS) throw new HttpError(422, `a repository can have at most ${MAX_WEBHOOKS} webhooks`); | |
| 1121 | + this.sql.exec("INSERT INTO webhooks (id, url, secret, secret_encrypted, events, created_at) VALUES (?, ?, ?, 1, ?, ?)", id, url, sealed, JSON.stringify(events.length ? events : ["*"]), now()); | |
| 1066 | 1122 | return { id, secret: s }; |
| 1067 | 1123 | } |
| 1068 | 1124 |
modifiedapps/diffinery/src/repo/schema.ts+8−1View file
| @@ -171,6 +171,8 @@ | ||
| 171 | 171 | // The commit a run reports its checks on: the PR head for pull_request runs, whose |
| 172 | 172 | // GITHUB_SHA is the test merge commit. Null means the run's own sha. |
| 173 | 173 | ["runs", "head_sha", "TEXT"], |
| 174 | + // 1 when a webhook's secret is an encrypted envelope (repo/collab.ts); older rows hold it in plaintext until their next delivery. | |
| 175 | + ["webhooks", "secret_encrypted", "INTEGER NOT NULL DEFAULT 0"], | |
| 174 | 176 | // 1 when the code a run executes came from an agent (repo/actions.ts agentOrigin): its jobs act as an agent. |
| 175 | 177 | ["runs", "agent_origin", "INTEGER NOT NULL DEFAULT 0"], |
| 176 | 178 | // The repository a pull request's head branch lives in, when it is not this one (a fork). |
| @@ -250,12 +252,17 @@ | ||
| 250 | 252 | ["comments", "minimized_reason", "TEXT"], |
| 251 | 253 | ["comments", "minimized_by", "TEXT"], |
| 252 | 254 | ["comments", "minimized_at", "TEXT"], |
| 255 | + // When the job's current token was issued (repo/actions.ts jobTokenExpiry); a job not yet started counts from it. | |
| 256 | + ["jobs", "token_at", "TEXT"], | |
| 253 | 257 | ]; |
| 254 | 258 | |
| 255 | 259 | export function migrateRepoSchema(sql: SqlStorage): void { |
| 256 | 260 | for (const [table, column, definition] of ADDED_COLUMNS) { |
| 257 | 261 | const has = sql.exec<{ n: number }>(`SELECT count(*) AS n FROM pragma_table_info('${table}') WHERE name = ?`, column).one().n > 0; |
| 258 | - if (!has) sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); | |
| 262 | + if (has) continue; | |
| 263 | + sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); | |
| 264 | + // Job tokens issued before token_at was recorded count from this upgrade, so they still expire. | |
| 265 | + if (table === "jobs" && column === "token_at") sql.exec("UPDATE jobs SET token_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE token_hash IS NOT NULL"); | |
| 259 | 266 | } |
| 260 | 267 | // Older runners' artifacts were first recorded in `artifacts`, which the REST API didn't list: they |
| 261 | 268 | // move to rt_artifacts, kept for 90 days from their upload. One shadowed by an artifact service |
modifiedapps/diffinery/src/repo/types.ts+2−0View file
| @@ -4,6 +4,8 @@ | ||
| 4 | 4 | export type Role = "read" | "triage" | "write" | "maintain" | "admin"; |
| 5 | 5 | export const ROLE_LEVEL: Record<Role, number> = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 }; |
| 6 | 6 | export const atLeast = (role: Role | null | undefined, needed: Role): boolean => !!role && ROLE_LEVEL[role] >= ROLE_LEVEL[needed]; |
| 7 | +/** How far back a commit listing may page: one request walks at most this many commits, however deep the page asks to go. */ | |
| 8 | +export const MAX_LOG_DEPTH = 10_000; | |
| 7 | 9 | |
| 8 | 10 | /** The authenticated caller, with their effective role on this repository. */ |
| 9 | 11 | export interface Caller extends Actor { |
modifiedapps/diffinery/test/e2e/email.test.mjs+4−1View file
| @@ -242,7 +242,10 @@ | ||
| 242 | 242 | assert.ok(link.startsWith(`${base}/-/unsubscribe/`)); |
| 243 | 243 | assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click"); |
| 244 | 244 | const confirm = await fetch(link); |
| 245 | - assert.match(await confirm.text(), /Turn off email about issues and pull requests you take part in/); | |
| 245 | + const confirmPage = await confirm.text(); | |
| 246 | + assert.match(confirmPage, /Turn off email about issues and pull requests you take part in/); | |
| 247 | + // Whoever holds the link doesn't learn the account's email address from it. | |
| 248 | + assert.ok(reply.to_addr && !confirmPage.includes(reply.to_addr), "the unsubscribe page doesn't show the email address"); | |
| 246 | 249 | const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" }); |
| 247 | 250 | assert.equal(unsub.status, 200, await unsub.clone().text()); |
| 248 | 251 | assert.equal((await ok("GET", "/diffinery/notification-settings")).participating.email, false); |
modifiedapps/diffinery/test/e2e/security.test.mjs+3−0View file
| @@ -2232,6 +2232,9 @@ | ||
| 2232 | 2232 | await status(201, "POST", `${R}/check-runs`, { name: "ext", head_sha: sha, details_url: "https://ci.example/run/1" }); |
| 2233 | 2233 | // Paging that isn't a number falls back to the defaults instead of an odd query. |
| 2234 | 2234 | assert.equal((await ok("GET", `${R}/actions/runs?page=-3&per_page=abc`)).total_count, 0); |
| 2235 | + // Commit history pages only so deep: one request doesn't walk millions of commits. | |
| 2236 | + assert.equal((await ok("GET", `${R}/commits?per_page=100&page=2`)).length, 0); | |
| 2237 | + assert.match((await status(422, "GET", `${R}/commits?per_page=100&page=101`)).message, /pagination is limited/); | |
| 2235 | 2238 | // One MCP request carries at most 20 calls. |
| 2236 | 2239 | const batch = Array.from({ length: 21 }, (_, i) => ({ jsonrpc: "2.0", id: i, method: "ping" })); |
| 2237 | 2240 | const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${T.olive}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify(batch) }); |
addedapps/diffinery/test/unit/commit-log.test.ts+46−0View file
| @@ -1,0 +1,46 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { MemoryStore, serializeCommit, serializeTree } from "@diffinery/git-core"; | |
| 3 | +import { RepoDO } from "../../src/repo/RepoDO.js"; | |
| 4 | +import { MAX_LOG_DEPTH } from "../../src/repo/types.js"; | |
| 5 | + | |
| 6 | +/** A linear history of `n` commits, newest last; returns the store and the tip. */ | |
| 7 | +async function history(n: number) { | |
| 8 | + const store = new MemoryStore(); | |
| 9 | + const tree = await store.write("tree", serializeTree([])); | |
| 10 | + let tip: string | null = null; | |
| 11 | + for (let i = 0; i < n; i++) { | |
| 12 | + const sig = { name: "a", email: "a@x.test", timestamp: 1_700_000_000 + i, timezone: "+0000" }; | |
| 13 | + tip = await store.write("commit", serializeCommit({ tree, parents: tip ? [tip] : [], author: sig, committer: sig, message: `c${i}\n` })); | |
| 14 | + } | |
| 15 | + return { store, tip: tip! }; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** RepoDO.log run against a bare object store, with `resolve` answering the tip for any revision. */ | |
| 19 | +function logOf(store: MemoryStore, tip: string, counter?: { reads: number }) { | |
| 20 | + const counted = counter ? { read: (oid: string) => (counter.reads++, store.read(oid)) } : store; | |
| 21 | + const self = { store: counted, resolve: async () => tip }; | |
| 22 | + return (opts: { limit?: number; skip?: number }) => RepoDO.prototype.log.call(self as unknown as RepoDO, "HEAD", opts); | |
| 23 | +} | |
| 24 | + | |
| 25 | +describe("commit listing depth", () => { | |
| 26 | + it("pages through history as usual", async () => { | |
| 27 | + const { store, tip } = await history(12); | |
| 28 | + const log = logOf(store, tip); | |
| 29 | + expect((await log({ limit: 5 })).map((c) => c.message.trim())).toEqual(["c11", "c10", "c9", "c8", "c7"]); | |
| 30 | + expect((await log({ limit: 5, skip: 10 })).map((c) => c.message.trim())).toEqual(["c1", "c0"]); | |
| 31 | + expect(await log({ limit: 5, skip: 20 })).toEqual([]); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("lists nothing past MAX_LOG_DEPTH and never walks further than it", async () => { | |
| 35 | + const { store, tip } = await history(MAX_LOG_DEPTH + 50); | |
| 36 | + const counter = { reads: 0 }; | |
| 37 | + const log = logOf(store, tip, counter); | |
| 38 | + expect(await log({ limit: 100, skip: MAX_LOG_DEPTH })).toEqual([]); | |
| 39 | + expect(await log({ limit: 100, skip: 10_000_000 })).toEqual([]); | |
| 40 | + expect(counter.reads).toBe(0); | |
| 41 | + // The last page inside the window is cut at the window's end, so it shows no further page. | |
| 42 | + const last = await log({ limit: 101, skip: MAX_LOG_DEPTH - 100 }); | |
| 43 | + expect(last).toHaveLength(100); | |
| 44 | + expect(counter.reads).toBeLessThanOrEqual(MAX_LOG_DEPTH + 1); | |
| 45 | + }); | |
| 46 | +}); |
modifiedapps/diffinery/test/unit/email.test.ts+21−1View file
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | import { buildMessage, senderAddress } from "../../src/mail/send.js"; |
| 7 | 7 | import { compose, toHtml, toText } from "../../src/mail/templates.js"; |
| 8 | 8 | import { categoryOf, isMandatory, type MailJob } from "../../src/mail/types.js"; |
| 9 | -import { readUnsubToken, unsubToken } from "../../src/mail/unsubscribe.js"; | |
| 9 | +import { UNSUB_LEGACY_CUTOFF_MS, UNSUB_TTL_MS, readUnsubToken, unsubToken } from "../../src/mail/unsubscribe.js"; | |
| 10 | 10 | |
| 11 | 11 | const PUB = "https://diffinery.example"; |
| 12 | 12 | const env = { PUBLIC_URL: PUB, MASTER_KEY: "test-master-key", MAIL_FROM: "notifications@diffinery.example" } as unknown as Env; |
| @@ -150,6 +150,26 @@ | ||
| 150 | 150 | expect(await readUnsubToken(env, `${payload}.${sig!.slice(0, -2)}xx`)).toBeNull(); |
| 151 | 151 | expect(await readUnsubToken({ ...env, MASTER_KEY: "another-key" } as Env, t)).toBeNull(); |
| 152 | 152 | }); |
| 153 | + | |
| 154 | + it("expire about a year after they're sent", async () => { | |
| 155 | + const sent = Date.UTC(2027, 0, 1); | |
| 156 | + const t = await unsubToken(env, { accountId: "acc1", scope: { t: "category", category: "watching" } }, sent); | |
| 157 | + expect(await readUnsubToken(env, t, sent + UNSUB_TTL_MS - 1000)).toEqual({ accountId: "acc1", scope: { t: "category", category: "watching" } }); | |
| 158 | + expect(await readUnsubToken(env, t, sent + UNSUB_TTL_MS)).toBeNull(); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it("keep links sent before expiries working only until the cutoff", async () => { | |
| 162 | + // A link as they were signed before: no `e`, signed with the same key. | |
| 163 | + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-unsubscribe-v1:${env.MASTER_KEY}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 164 | + const payload = Buffer.from(JSON.stringify({ a: "acc1", s: { t: "category", category: "watching" } })).toString("base64url"); | |
| 165 | + const old = `${payload}.${Buffer.from(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))).toString("base64url")}`; | |
| 166 | + expect(await readUnsubToken(env, old, UNSUB_LEGACY_CUTOFF_MS - 1000)).toEqual({ accountId: "acc1", scope: { t: "category", category: "watching" } }); | |
| 167 | + expect(await readUnsubToken(env, old, UNSUB_LEGACY_CUTOFF_MS)).toBeNull(); | |
| 168 | + // An expiry that isn't a number never passes. | |
| 169 | + const odd = Buffer.from(JSON.stringify({ a: "acc1", s: { t: "category", category: "watching" }, e: "9999999999" })).toString("base64url"); | |
| 170 | + const oddToken = `${odd}.${Buffer.from(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(odd))).toString("base64url")}`; | |
| 171 | + expect(await readUnsubToken(env, oddToken, UNSUB_LEGACY_CUTOFF_MS - 1000)).toBeNull(); | |
| 172 | + }); | |
| 153 | 173 | }); |
| 154 | 174 | |
| 155 | 175 | describe("Markdown in email", () => { |
addedapps/diffinery/test/unit/job-token.test.ts+100−0View file
| @@ -1,0 +1,100 @@ | ||
| 1 | +import { DatabaseSync } from "node:sqlite"; | |
| 2 | +import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | +import { sha256 } from "../../src/lib/util.js"; | |
| 5 | +import { Actions, MAX_JOB_TOKEN_TTL_MS, jobTokenExpiry } from "../../src/repo/actions.js"; | |
| 6 | +import type { RepoDO } from "../../src/repo/RepoDO.js"; | |
| 7 | +import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; | |
| 8 | + | |
| 9 | +const HOUR = 3600_000; | |
| 10 | +const t0 = Date.parse("2026-10-16T00:00:00.000Z"); | |
| 11 | +const at = (ms: number) => new Date(ms).toISOString(); | |
| 12 | + | |
| 13 | +describe("when a job token expires", () => { | |
| 14 | + it("counts the job's timeout plus half an hour from when it started", () => { | |
| 15 | + expect(jobTokenExpiry({ started_at: at(t0), token_at: at(t0 - HOUR), timeout_minutes: 60 })).toBe(t0 + 1.5 * HOUR); | |
| 16 | + // The longest timeout a job can have (360 minutes) still gets its margin. | |
| 17 | + expect(jobTokenExpiry({ started_at: at(t0), token_at: null, timeout_minutes: 360 })).toBe(t0 + 6.5 * HOUR); | |
| 18 | + }); | |
| 19 | + | |
| 20 | + it("counts from when the token was issued while the job hasn't started", () => { | |
| 21 | + expect(jobTokenExpiry({ started_at: null, token_at: at(t0), timeout_minutes: 10 })).toBe(t0 + 40 * 60_000); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("never lasts more than seven hours, whatever the row says", () => { | |
| 25 | + expect(jobTokenExpiry({ started_at: at(t0), token_at: null, timeout_minutes: 100_000 })).toBe(t0 + MAX_JOB_TOKEN_TTL_MS); | |
| 26 | + expect(jobTokenExpiry({ started_at: at(t0), token_at: null, timeout_minutes: -5 })).toBe(t0 + 30 * 60_000); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("is unknown without a start or issue time", () => { | |
| 30 | + expect(jobTokenExpiry({ started_at: null, token_at: null, timeout_minutes: 60 })).toBeNull(); | |
| 31 | + }); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +/** A minimal SqlStorage over node:sqlite with the repository schema. */ | |
| 35 | +function sqlStorage(): SqlStorage { | |
| 36 | + const db = new DatabaseSync(":memory:"); | |
| 37 | + db.exec(REPO_SCHEMA); | |
| 38 | + const sql = { | |
| 39 | + exec(query: string, ...bindings: unknown[]) { | |
| 40 | + const stmt = db.prepare(query); | |
| 41 | + if (/^\s*select/i.test(query)) { | |
| 42 | + const rows = stmt.all(...(bindings as never[])); | |
| 43 | + return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>; | |
| 44 | + } | |
| 45 | + stmt.run(...(bindings as never[])); | |
| 46 | + return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>; | |
| 47 | + }, | |
| 48 | + } as unknown as SqlStorage; | |
| 49 | + migrateRepoSchema(sql); | |
| 50 | + return sql; | |
| 51 | +} | |
| 52 | + | |
| 53 | +describe("job tokens", () => { | |
| 54 | + afterEach(() => void vi.useRealTimers()); | |
| 55 | + | |
| 56 | + async function fixture(o: { started_at: string | null; token_at: string | null; status?: string; timeout?: number }) { | |
| 57 | + const sql = sqlStorage(); | |
| 58 | + const token = "djt_r_j1_secretsecretsecret"; | |
| 59 | + sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", at(t0), at(t0)); | |
| 60 | + sql.exec( | |
| 61 | + "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, token_hash, token_at, started_at) VALUES ('j1', 'run1', 'build', 'build', ?, '{}', '{}', ?, ?, ?, ?)", | |
| 62 | + o.status ?? "in_progress", | |
| 63 | + o.timeout ?? 60, | |
| 64 | + await sha256(token), | |
| 65 | + o.token_at, | |
| 66 | + o.started_at, | |
| 67 | + ); | |
| 68 | + const repo = { sql, meta: { repoId: "r" }, runtime: { grantFor: () => ({ role: "write", permissions: {} }) } } as unknown as RepoDO; | |
| 69 | + return { actions: new Actions(repo, {} as Env), token, sql }; | |
| 70 | + } | |
| 71 | + | |
| 72 | + it("work while the job is within its timeout and margin, and not after, even if it never completes", async () => { | |
| 73 | + const { actions, token } = await fixture({ started_at: at(t0), token_at: at(t0 - 60_000) }); | |
| 74 | + vi.useFakeTimers({ now: t0 + 89 * 60_000 }); | |
| 75 | + expect((await actions.runnerAuth(token))?.kind).toBe("job"); | |
| 76 | + vi.setSystemTime(t0 + 90 * 60_000); | |
| 77 | + expect(await actions.runnerAuth(token)).toBeNull(); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("issued for a job that never started stop working too", async () => { | |
| 81 | + const { actions, token } = await fixture({ started_at: null, token_at: at(t0), status: "waiting" }); | |
| 82 | + vi.useFakeTimers({ now: t0 + 60_000 }); | |
| 83 | + expect((await actions.runnerAuth(token))?.kind).toBe("job"); | |
| 84 | + vi.setSystemTime(t0 + 2 * HOUR); | |
| 85 | + expect(await actions.runnerAuth(token)).toBeNull(); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("issued before token_at was recorded count from the upgrade", async () => { | |
| 89 | + const sql = sqlStorage(); | |
| 90 | + // Drop the column and add it back, as an upgrade would, over a job holding a token. | |
| 91 | + sql.exec("ALTER TABLE jobs DROP COLUMN token_at"); | |
| 92 | + sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", at(t0), at(t0)); | |
| 93 | + sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, token_hash) VALUES ('j1', 'run1', 'build', 'build', 'waiting', '{}', '{}', 'h')"); | |
| 94 | + sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy) VALUES ('j2', 'run1', 'test', 'test', 'queued', '{}', '{}')"); | |
| 95 | + migrateRepoSchema(sql); | |
| 96 | + const rows = sql.exec<{ id: string; token_at: string | null }>("SELECT id, token_at FROM jobs ORDER BY id").toArray(); | |
| 97 | + expect(Date.parse(rows[0]!.token_at!)).toBeGreaterThan(Date.now() - 60_000); | |
| 98 | + expect(rows[1]!.token_at).toBeNull(); | |
| 99 | + }); | |
| 100 | +}); |
addedapps/diffinery/test/unit/search-fanout.test.ts+11−0View file
| @@ -1,0 +1,11 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import type { Principal } from "../../src/auth/principal.js"; | |
| 3 | +import { searchFanOut } from "../../src/http/search.js"; | |
| 4 | + | |
| 5 | +describe("issue search fan-out", () => { | |
| 6 | + it("searches fewer repositories for anonymous callers than for signed-in ones", () => { | |
| 7 | + expect(searchFanOut({ type: "anonymous" })).toBe(10); | |
| 8 | + expect(searchFanOut({ type: "account" } as Principal)).toBe(50); | |
| 9 | + expect(searchFanOut({ type: "repo-token", repoId: "r", token: "t" })).toBe(50); | |
| 10 | + }); | |
| 11 | +}); |
modifiedapps/diffinery/test/unit/webhooks.test.ts+86−0View file
| @@ -1,4 +1,9 @@ | ||
| 1 | +import { DatabaseSync } from "node:sqlite"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | +import { Collab } from "../../src/repo/collab.js"; | |
| 5 | +import type { RepoDO } from "../../src/repo/RepoDO.js"; | |
| 6 | +import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; | |
| 2 | 7 | import { deliverWebhook, WEBHOOK_RETRY_DELAYS, type WebhookDeps, type WebhookRetry } from "../../src/lib/webhooks.js"; |
| 3 | 8 | |
| 4 | 9 | const hook = { id: "h1", url: "https://hooks.example/x", secret: "s3cret" }; |
| @@ -70,3 +75,84 @@ | ||
| 70 | 75 | } |
| 71 | 76 | }); |
| 72 | 77 | }); |
| 78 | + | |
| 79 | +// ---------------------------------------------------------------- signing secrets at rest | |
| 80 | + | |
| 81 | +/** A minimal SqlStorage over node:sqlite with the repository schema. */ | |
| 82 | +function sqlStorage(): SqlStorage { | |
| 83 | + const db = new DatabaseSync(":memory:"); | |
| 84 | + db.exec(REPO_SCHEMA); | |
| 85 | + const sql = { | |
| 86 | + exec(query: string, ...bindings: unknown[]) { | |
| 87 | + const stmt = db.prepare(query); | |
| 88 | + if (/^\s*select/i.test(query)) { | |
| 89 | + const rows = stmt.all(...(bindings as never[])); | |
| 90 | + return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>; | |
| 91 | + } | |
| 92 | + stmt.run(...(bindings as never[])); | |
| 93 | + return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>; | |
| 94 | + }, | |
| 95 | + } as unknown as SqlStorage; | |
| 96 | + migrateRepoSchema(sql); | |
| 97 | + return sql; | |
| 98 | +} | |
| 99 | + | |
| 100 | +describe("webhook secrets", () => { | |
| 101 | + const env = { MASTER_KEY: Buffer.alloc(32, 7).toString("base64") } as Env; | |
| 102 | + const collab = () => { | |
| 103 | + const sql = sqlStorage(); | |
| 104 | + return { sql, collab: new Collab({ sql, meta: { repoId: "r1" } } as unknown as RepoDO, env) }; | |
| 105 | + }; | |
| 106 | + const stored = (sql: SqlStorage, id: string) => sql.exec<{ secret: string; secret_encrypted: number }>("SELECT secret, secret_encrypted FROM webhooks WHERE id = ?", id).one(); | |
| 107 | + | |
| 108 | + it("are stored encrypted, never listed, and decrypted for delivery", async () => { | |
| 109 | + const { sql, collab: c } = collab(); | |
| 110 | + const made = await c.createWebhook("https://hooks.example/x", ["push"], "s3cret", "diffinery.test"); | |
| 111 | + expect(made.secret).toBe("s3cret"); | |
| 112 | + const row = stored(sql, made.id); | |
| 113 | + expect(row.secret_encrypted).toBe(1); | |
| 114 | + expect(row.secret).toMatch(/^v1\./); | |
| 115 | + expect(row.secret).not.toContain("s3cret"); | |
| 116 | + expect(c.webhooks()[0]).not.toHaveProperty("secret"); | |
| 117 | + expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["s3cret"]); | |
| 118 | + // A generated secret is shown once and encrypted the same way. | |
| 119 | + const generated = await c.createWebhook("https://hooks.example/y", [], undefined, "diffinery.test"); | |
| 120 | + expect(stored(sql, generated.id).secret).not.toContain(generated.secret); | |
| 121 | + expect((await c.deliverableWebhooks()).find((h) => h.id === generated.id)?.secret).toBe(generated.secret); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + it("can't be moved to another hook", async () => { | |
| 125 | + const { sql, collab: c } = collab(); | |
| 126 | + const a = await c.createWebhook("https://hooks.example/a", [], "aaa", "diffinery.test"); | |
| 127 | + const b = await c.createWebhook("https://hooks.example/b", [], "bbb", "diffinery.test"); | |
| 128 | + sql.exec("UPDATE webhooks SET secret = ? WHERE id = ?", stored(sql, a.id).secret, b.id); | |
| 129 | + // Only the hook whose secret doesn't decrypt goes undelivered. | |
| 130 | + expect((await c.deliverableWebhooks()).map((h) => [h.id, h.secret])).toEqual([[a.id, "aaa"]]); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it("saved in plaintext before keep signing, and are encrypted on their next delivery", async () => { | |
| 134 | + const { sql, collab: c } = collab(); | |
| 135 | + // A row as older versions wrote it; one that looks like an envelope is still taken as plaintext. | |
| 136 | + sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES ('old', 'https://hooks.example/old', 'v1.not.encrypted', '[\"*\"]', '2026-01-01T00:00:00Z')"); | |
| 137 | + expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["v1.not.encrypted"]); | |
| 138 | + const row = stored(sql, "old"); | |
| 139 | + expect(row.secret_encrypted).toBe(1); | |
| 140 | + expect(row.secret).not.toBe("v1.not.encrypted"); | |
| 141 | + expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["v1.not.encrypted"]); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it("leave inactive hooks out of delivery", async () => { | |
| 145 | + const { sql, collab: c } = collab(); | |
| 146 | + const h = await c.createWebhook("https://hooks.example/x", [], "s", "diffinery.test"); | |
| 147 | + sql.exec("UPDATE webhooks SET active = 0 WHERE id = ?", h.id); | |
| 148 | + expect(await c.deliverableWebhooks()).toEqual([]); | |
| 149 | + expect(c.webhooks()).toHaveLength(1); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + it("can't be stored without MASTER_KEY", async () => { | |
| 153 | + const sql = sqlStorage(); | |
| 154 | + const c = new Collab({ sql, meta: { repoId: "r1" } } as unknown as RepoDO, {} as Env); | |
| 155 | + await expect(c.createWebhook("https://hooks.example/x", [], "s", "diffinery.test")).rejects.toThrow(/is MASTER_KEY configured/); | |
| 156 | + expect(c.webhooks()).toEqual([]); | |
| 157 | + }); | |
| 158 | +}); |