End-to-end tests: split the security file in two, so its halves run side by side (#246)
* End-to-end tests: split the security file in two, so its halves can run side by side
- 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)Failed
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)Failed
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)Failed
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
4 files changed+801−780
Files (4)
modifiedapps/diffinery/package.json+1−1View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | "typecheck": "tsc --noEmit && tsc --noEmit -p islands", |
| 10 | 10 | "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'", |
| 11 | 11 | "cf-typegen": "wrangler types", |
| 12 | - "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/accounts.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs", | |
| 12 | + "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/accounts.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/security-limits.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs", | |
| 13 | 13 | "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev", |
| 14 | 14 | "build": "node islands/build.mjs && node scripts/build-docs.mjs", |
| 15 | 15 | "predev": "node islands/build.mjs && node scripts/build-docs.mjs", |
addedapps/diffinery/test/e2e/security-harness.mjs+233−0View file
| @@ -1,0 +1,233 @@ | ||
| 1 | +// Shared by security.test.mjs and security-limits.test.mjs: a real `wrangler dev` Worker per test | |
| 2 | +// file, seeded with the same accounts, tokens and sessions, and the helpers both files use. | |
| 3 | +import { spawn, spawnSync, execFileSync } from "node:child_process"; | |
| 4 | +import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; | |
| 5 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 6 | +import { tmpdir } from "node:os"; | |
| 7 | +import { join, dirname, resolve } from "node:path"; | |
| 8 | +import { fileURLToPath } from "node:url"; | |
| 9 | +import { createServer } from "node:net"; | |
| 10 | +import { before, after } from "node:test"; | |
| 11 | +import assert from "node:assert/strict"; | |
| 12 | +import { migratedState } from "./d1-state.mjs"; | |
| 13 | + | |
| 14 | +export const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 15 | +export const work = mkdtempSync(join(tmpdir(), "diffinery-security-")); | |
| 16 | +export let base; | |
| 17 | +export let dev; | |
| 18 | +export let devLog = ""; | |
| 19 | + | |
| 20 | +export const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 21 | +export const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; | |
| 22 | +export const T = { sully: tok("sully"), 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") }; | |
| 23 | +export 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")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` }; | |
| 24 | +export const OWNER = "olive"; | |
| 25 | +/** One minute of an environment's wait timer, in this Worker (WAIT_TIMER_MINUTE_MS). */ | |
| 26 | +export const TIMER_MINUTE_MS = 10_000; | |
| 27 | + | |
| 28 | +export const freePort = () => | |
| 29 | + new Promise((res) => { | |
| 30 | + const s = createServer(); | |
| 31 | + s.listen(0, "127.0.0.1", () => { | |
| 32 | + const { port } = s.address(); | |
| 33 | + s.close(() => res(port)); | |
| 34 | + }); | |
| 35 | + }); | |
| 36 | + | |
| 37 | +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 38 | +export async function until(what, fn, timeoutMs = 60_000) { | |
| 39 | + const end = Date.now() + timeoutMs; | |
| 40 | + while (Date.now() < end) { | |
| 41 | + const v = await fn(); | |
| 42 | + if (v) return v; | |
| 43 | + await sleep(300); | |
| 44 | + } | |
| 45 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Call the REST API as a token (`as`), a session cookie (`{ cookie }`) or anonymously (null). */ | |
| 49 | +export async function api(method, path, body, as = T.olive) { | |
| 50 | + const headers = { "content-type": "application/json" }; | |
| 51 | + if (typeof as === "string") headers.authorization = `Bearer ${as}`; | |
| 52 | + else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; | |
| 53 | + const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 54 | + // wrangler dev closes idle keep-alive connections (the audit checks pause for seconds), so a reused one can be gone. | |
| 55 | + const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e))); | |
| 56 | + const text = await res.text(); | |
| 57 | + let json = null; | |
| 58 | + try { | |
| 59 | + json = text ? JSON.parse(text) : null; | |
| 60 | + } catch { | |
| 61 | + json = text; | |
| 62 | + } | |
| 63 | + return { status: res.status, json }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** | |
| 67 | + * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner` | |
| 68 | + * does (personal tokens can't claim jobs): one runner per repository and set of labels. | |
| 69 | + */ | |
| 70 | +export const e2eRunners = new Map(); | |
| 71 | +export async function runnerClaim(repoPath, labels = ["linux"]) { | |
| 72 | + const key = `${repoPath} ${labels.join(",")}`; | |
| 73 | + if (!e2eRunners.has(key)) { | |
| 74 | + const reg = await api("POST", `${repoPath}/actions/runners/registration-token`); | |
| 75 | + assert.equal(reg.status, 201, JSON.stringify(reg.json)); | |
| 76 | + const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels }); | |
| 77 | + assert.equal(runner.status, 201, JSON.stringify(runner.json)); | |
| 78 | + e2eRunners.set(key, runner.json.token); | |
| 79 | + } | |
| 80 | + const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key)); | |
| 81 | + // A repository deleted and created again under the same name needs a new runner. | |
| 82 | + if (r.status !== 401) return r; | |
| 83 | + e2eRunners.delete(key); | |
| 84 | + return runnerClaim(repoPath, labels); | |
| 85 | +} | |
| 86 | +export async function ok(method, path, body, as) { | |
| 87 | + const r = await api(method, path, body, as); | |
| 88 | + assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 89 | + return r.json; | |
| 90 | +} | |
| 91 | +export async function status(expected, method, path, body, as) { | |
| 92 | + const r = await api(method, path, body, as); | |
| 93 | + assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 94 | + return r.json; | |
| 95 | +} | |
| 96 | +export const b64 = (s) => Buffer.from(s).toString("base64"); | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * Give someone access to a repository as GitHub does: someone new gets an invitation (201), which | |
| 100 | + * they accept with their own token; an existing collaborator's role changes at once (204). | |
| 101 | + */ | |
| 102 | +export async function collaborator(repo, login, permission, invitee, as = T.olive) { | |
| 103 | + const r = await api("PUT", `${repo}/collaborators/${login}`, { permission }, as); | |
| 104 | + if (r.status === 204) return; | |
| 105 | + assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 106 | + await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee); | |
| 107 | +} | |
| 108 | + | |
| 109 | +before(async () => { | |
| 110 | + const port = await freePort(); | |
| 111 | + base = `http://127.0.0.1:${port}`; | |
| 112 | + const persist = join(work, "state"); | |
| 113 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 114 | + migratedState(APP, persist); | |
| 115 | + const at = new Date().toISOString(); | |
| 116 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 117 | + const account = (id, login, kind, status, owner = null) => `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', '${status}', ${owner ? `'${owner}'` : "NULL"}, '${at}')`; | |
| 118 | + const token = (id, account, t, scopes) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '${JSON.stringify(scopes)}', '${later}', '${at}')`; | |
| 119 | + const session = (id, account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; | |
| 120 | + wrangler( | |
| 121 | + "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 122 | + [ | |
| 123 | + account("u-olive", "olive", "user", "active"), | |
| 124 | + account("u-sully", "sully", "user", "active"), | |
| 125 | + "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'", | |
| 126 | + token("t-sully", "u-sully", T.sully, ["admin"]), | |
| 127 | + account("a-bot", "olive-bot", "agent", "active", "u-olive"), | |
| 128 | + account("u-rita", "rita", "user", "active"), | |
| 129 | + account("u-penny", "penny", "user", "pending"), | |
| 130 | + account("u-dora", "dora", "user", "disabled"), | |
| 131 | + account("u-gone", "gone", "user", "disabled"), | |
| 132 | + account("a-orphan", "gone-bot", "agent", "active", "u-gone"), | |
| 133 | + token("t1", "u-olive", T.olive, ["admin"]), | |
| 134 | + token("t2", "a-bot", T.bot, ["admin"]), | |
| 135 | + token("t3", "u-rita", T.rita, ["write"]), | |
| 136 | + token("t4", "u-dora", T.dora, ["write"]), | |
| 137 | + token("t5", "a-orphan", T.orphan, ["write"]), | |
| 138 | + account("o-guild", "guild", "org", "active"), | |
| 139 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`, | |
| 140 | + token("t6", "u-olive", T.reader, ["read"]), | |
| 141 | + token("t7", "u-rita", T.ritaAdmin, ["admin"]), | |
| 142 | + account("u-milo", "milo", "user", "active"), | |
| 143 | + token("t8", "u-milo", T.milo, ["admin"]), | |
| 144 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-milo', 'member', '${at}')`, | |
| 145 | + session("s1", "u-penny", S.penny), | |
| 146 | + session("s2", "u-dora", S.dora), | |
| 147 | + session("s3", "u-olive", S.olive), | |
| 148 | + account("o-shop", "shop", "org", "active"), | |
| 149 | + account("u-sam", "sam", "user", "active"), | |
| 150 | + token("t9", "u-sam", T.sam, ["admin"]), | |
| 151 | + account("a-helper", "olive-helper", "agent", "active", "u-olive"), | |
| 152 | + token("t10", "a-helper", T.helper, ["write"]), | |
| 153 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`, | |
| 154 | + session("s4", "u-sam", S.sam), | |
| 155 | + "UPDATE accounts SET email = 'sam@example.com' WHERE id = 'u-sam'", | |
| 156 | + // olive's other browsers: one signed in 3 hours ago, one 100 days ago (used every day since), one spare. | |
| 157 | + session("s5", "u-olive", S.oliveOld).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 3 * 3600_000).toISOString()}', '${at}'`), | |
| 158 | + session("s6", "u-olive", S.oliveStale).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 100 * 86400_000).toISOString()}', '${at}'`), | |
| 159 | + session("s7", "u-olive", S.oliveSpare), | |
| 160 | + ].join("; "), | |
| 161 | + ); | |
| 162 | + dev = spawn( | |
| 163 | + "pnpm", | |
| 164 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `WAIT_TIMER_MINUTE_MS:${TIMER_MINUTE_MS}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"], | |
| 165 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 166 | + ); | |
| 167 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 168 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 169 | + await until( | |
| 170 | + "wrangler dev", | |
| 171 | + async () => { | |
| 172 | + try { | |
| 173 | + return (await fetch(`${base}/healthz`)).ok; | |
| 174 | + } catch { | |
| 175 | + return false; | |
| 176 | + } | |
| 177 | + }, | |
| 178 | + 120_000, | |
| 179 | + ); | |
| 180 | +}); | |
| 181 | + | |
| 182 | +after(() => { | |
| 183 | + if (dev?.pid) { | |
| 184 | + try { | |
| 185 | + process.kill(-dev.pid, "SIGTERM"); | |
| 186 | + } catch {} | |
| 187 | + } | |
| 188 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 189 | +}); | |
| 190 | + | |
| 191 | +/** A private repository owned by olive with one commit on main. */ | |
| 192 | +export async function repoWithReadme(name, extra = {}) { | |
| 193 | + await ok("POST", "/user/repos", { name, ...extra }); | |
| 194 | + const put = await ok("PUT", `/repos/${OWNER}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }); | |
| 195 | + return put.commit.sha; | |
| 196 | +} | |
| 197 | + | |
| 198 | +/** Audit entries recorded so far, read straight from the local D1 database. The running dev server can hold its lock, so a busy database is retried. */ | |
| 199 | +export function auditLog(action) { | |
| 200 | + for (let attempt = 1; ; attempt++) { | |
| 201 | + try { | |
| 202 | + return readAuditLog(action); | |
| 203 | + } catch (e) { | |
| 204 | + if (attempt >= 10 || !/SQLITE_BUSY|database is locked/.test(String(e.stderr ?? e.message))) throw e; | |
| 205 | + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300 * attempt); | |
| 206 | + } | |
| 207 | + } | |
| 208 | +} | |
| 209 | + | |
| 210 | +export function readAuditLog(action) { | |
| 211 | + const out = execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", join(work, "state"), "--json", "--command", `SELECT actor_login, action, target, detail FROM audit_log WHERE action = '${action}'`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 212 | + return JSON.parse(out.toString())[0].results; | |
| 213 | +} | |
| 214 | + | |
| 215 | +export const oliveSubmits = (page, action, fields) => submitsAs(S.olive, page, action, fields); | |
| 216 | + | |
| 217 | +/** A web form post signed in with `session`, with the CSRF pair taken from `page`. */ | |
| 218 | +export async function submitsAs(session, page, action, fields) { | |
| 219 | + const cookie = `__Host-dfy_session=${session}`; | |
| 220 | + const res = await fetch(`${base}${page}`, { headers: { cookie } }); | |
| 221 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 222 | + const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 223 | + assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`); | |
| 224 | + const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); | |
| 225 | + return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")), text: await r.text() }; | |
| 226 | +} | |
| 227 | + | |
| 228 | +/** Runs git, returning its stderr; fails the test when git fails. */ | |
| 229 | +export function gitRun(args, cwd) { | |
| 230 | + const r = spawnSync("git", ["-c", "protocol.version=2", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { cwd, encoding: "utf8" }); | |
| 231 | + assert.equal(r.status, 0, `git ${args.join(" ")} failed:\n${r.stderr}`); | |
| 232 | + return r.stderr; | |
| 233 | +} |
addedapps/diffinery/test/e2e/security-limits.test.mjs+559−0View file
| @@ -1,0 +1,559 @@ | ||
| 1 | +// End-to-end security checks, continued from security.test.mjs (same seeded accounts, in a | |
| 2 | +// `wrangler dev` of this file's own, so the two files run side by side): environment reviewers, | |
| 3 | +// invitations by username, per-account quotas, kill switches and the Markdown image proxy. | |
| 4 | +// | |
| 5 | +// pnpm --filter @diffinery/app e2e | |
| 6 | +import { spawnSync, execFileSync } from "node:child_process"; | |
| 7 | +import { createHash, randomBytes } from "node:crypto"; | |
| 8 | +import { writeFileSync } from "node:fs"; | |
| 9 | +import { join } from "node:path"; | |
| 10 | +import { createServer as createHttpServer } from "node:http"; | |
| 11 | +import { test } from "node:test"; | |
| 12 | +import assert from "node:assert/strict"; | |
| 13 | +import { work, base, T, S, OWNER, TIMER_MINUTE_MS, sleep, until, api, runnerClaim, ok, status, b64, collaborator, repoWithReadme, auditLog, oliveSubmits, submitsAs, gitRun } from "./security-harness.mjs"; | |
| 14 | + | |
| 15 | +test("security: environment reviewers hold a deploy job until a listed person approves, and nobody else can", async () => { | |
| 16 | + const R = `/repos/${OWNER}/gated-deploy`; | |
| 17 | + await repoWithReadme("gated-deploy"); | |
| 18 | + await collaborator(R, "rita", "read", T.rita); | |
| 19 | + await collaborator(R, "sam", "read", T.sam); | |
| 20 | + await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" }); | |
| 21 | + const ritaId = (await ok("GET", "/user", undefined, T.rita)).id; | |
| 22 | + | |
| 23 | + // Only people with access can be reviewers, never an agent or a team; limits are GitHub's; agents can't set them. | |
| 24 | + await ok("PUT", `${R}/environments/production`, {}); | |
| 25 | + await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "olive-bot" }] }); | |
| 26 | + await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "Team", id: 1 }] }); | |
| 27 | + await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "milo" }] }); | |
| 28 | + await status(422, "PUT", `${R}/environments/production`, { wait_timer: 43_201 }); | |
| 29 | + await status(403, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "rita" }] }, T.bot); | |
| 30 | + await status(201, "PUT", `${R}/environments/production/secrets/DEPLOY_KEY`, { value: "deploy-value" }); | |
| 31 | + const env = await ok("PUT", `${R}/environments/production`, { reviewers: [{ type: "User", id: ritaId }, { type: "User", login: "sam" }, { type: "User", login: "olive" }], prevent_self_review: true, deployment_branch_policy: null }); | |
| 32 | + assert.equal(env.can_admins_bypass, false); | |
| 33 | + const rule = env.protection_rules.find((r) => r.type === "required_reviewers"); | |
| 34 | + assert.deepEqual(rule.reviewers.map((r) => r.reviewer.login).sort(), ["olive", "rita", "sam"]); | |
| 35 | + assert.equal(rule.prevent_self_review, true); | |
| 36 | + | |
| 37 | + const wf = (n) => `name: deploy\non: push\njobs:\n ship${n}:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo ship\n env:\n K: \${{ secrets.DEPLOY_KEY }}\n`; | |
| 38 | + const claim = async () => { | |
| 39 | + const r = await runnerClaim(R, ["linux"]); | |
| 40 | + if (r.status !== 200) return null; | |
| 41 | + const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json(); | |
| 42 | + await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 43 | + return spec; | |
| 44 | + }; | |
| 45 | + const waitingRun = async (sha) => { | |
| 46 | + const run = await until("a run waiting for review", async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs.find((r) => r.head_sha === sha && r.status === "waiting")); | |
| 47 | + const jobs = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs; | |
| 48 | + assert.equal(jobs.length, 1); | |
| 49 | + assert.equal(jobs[0].status, "waiting"); | |
| 50 | + // Held before anything starts: no runner can claim it, so no token or secret has left. | |
| 51 | + assert.equal(await claim(), null); | |
| 52 | + return run; | |
| 53 | + }; | |
| 54 | + | |
| 55 | + // olive pushes: the job waits, and the deployment says so. | |
| 56 | + const first = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy", content: b64(wf(1)) })).commit.sha; | |
| 57 | + const run = await waitingRun(first); | |
| 58 | + const pendingAs = async (as, id = run.id) => (await ok("GET", `${R}/actions/runs/${id}/pending_deployments`, undefined, as))[0]; | |
| 59 | + const p = await pendingAs(T.rita); | |
| 60 | + assert.equal(p.environment.name, "production"); | |
| 61 | + assert.equal(p.current_user_can_approve, true); | |
| 62 | + assert.equal((await pendingAs(T.olive)).current_user_can_approve, false, "olive started the run and the environment prevents self-review"); | |
| 63 | + const deployment = (await ok("GET", `${R}/deployments?environment=production`))[0]; | |
| 64 | + assert.equal((await ok("GET", `${R}/deployments/${deployment.id}/statuses`))[0].state, "waiting"); | |
| 65 | + | |
| 66 | + // Nobody but a listed person, other than the run's starter, with a token that may write, decides. | |
| 67 | + const body = { environment_ids: [p.environment.id], state: "approved", comment: "ship it" }; | |
| 68 | + assert.match((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body)).json.message, /doesn't let whoever started a run approve it/); | |
| 69 | + assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.bot)).status, 403); | |
| 70 | + assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.reader)).status, 403); | |
| 71 | + assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.milo)).status, 404); | |
| 72 | + await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, environment_ids: [999] }, T.rita); | |
| 73 | + const approved = await ok("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.rita); | |
| 74 | + assert.equal(approved[0].environment, "production"); | |
| 75 | + // One decision counts: a second one finds nothing waiting. | |
| 76 | + await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, state: "rejected" }, T.sam); | |
| 77 | + | |
| 78 | + // Now the job starts, and only now gets the environment's secret. | |
| 79 | + const spec = await until("the approved job", claim); | |
| 80 | + assert.equal(spec.secrets.DEPLOY_KEY, "deploy-value"); | |
| 81 | + const done = await until("the run done", async () => { | |
| 82 | + const r = await ok("GET", `${R}/actions/runs/${run.id}`); | |
| 83 | + return r.status === "completed" ? r : null; | |
| 84 | + }); | |
| 85 | + assert.equal(done.conclusion, "success"); | |
| 86 | + const approvals = await ok("GET", `${R}/actions/runs/${run.id}/approvals`); | |
| 87 | + assert.deepEqual(approvals.map((a) => [a.state, a.user.login, a.comment, a.environments[0].name]), [["approved", "rita", "ship it", "production"]]); | |
| 88 | + assert.deepEqual((await ok("GET", `${R}/deployments/${deployment.id}/statuses`)).map((s) => s.state), ["success", "in_progress", "waiting"]); | |
| 89 | + | |
| 90 | + // A rejection fails the job without starting it. sam decides in the browser, from the run page. | |
| 91 | + const file = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 92 | + const second = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 2", content: b64(wf(2)), sha: file.sha })).commit.sha; | |
| 93 | + const run2 = await waitingRun(second); | |
| 94 | + const page = await (await fetch(`${base}/${OWNER}/gated-deploy/actions/runs/${run2.id}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } })).text(); | |
| 95 | + assert.match(page, /Waiting for review/); | |
| 96 | + assert.match(page, /You started this run, and this environment doesn't let you approve it|You started this run, and this environment doesn't let you approve it/); | |
| 97 | + const envId = (await pendingAs(T.rita, run2.id)).environment.id; | |
| 98 | + const rejected = await submitsAs(S.sam, `/${OWNER}/gated-deploy/actions/runs/${run2.id}`, `/${OWNER}/gated-deploy/actions/runs/${run2.id}/review`, { environment: String(envId), comment: "not today", state: "rejected" }); | |
| 99 | + assert.equal(rejected.status, 303); | |
| 100 | + assert.match(rejected.flash, /Rejected/); | |
| 101 | + const failed = await until("the rejected run done", async () => { | |
| 102 | + const r = await ok("GET", `${R}/actions/runs/${run2.id}`); | |
| 103 | + return r.status === "completed" ? r : null; | |
| 104 | + }); | |
| 105 | + assert.equal(failed.conclusion, "failure"); | |
| 106 | + const job = (await ok("GET", `${R}/actions/runs/${run2.id}/jobs`)).jobs[0]; | |
| 107 | + assert.match(job.failure.message, /rejected by sam: not today/); | |
| 108 | + assert.equal(await claim(), null); | |
| 109 | + | |
| 110 | + // Self-review compares accounts, not names: sam starts a run, renames, and still can't approve it. | |
| 111 | + await collaborator(R, "sam", "write", T.sam); | |
| 112 | + const file3 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 113 | + const fourth = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 4", content: b64(wf(4)), sha: file3.sha }, T.sam)).commit.sha; | |
| 114 | + const run4 = await waitingRun(fourth); | |
| 115 | + const env4 = (await pendingAs(T.rita, run4.id)).environment.id; | |
| 116 | + const renamed = await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam-renamed", name: "sam" }); | |
| 117 | + assert.equal(renamed.status, 303); | |
| 118 | + try { | |
| 119 | + assert.equal((await ok("GET", "/user", undefined, T.sam)).login, "sam-renamed"); | |
| 120 | + assert.equal((await pendingAs(T.sam, run4.id)).current_user_can_approve, false); | |
| 121 | + const self = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.sam); | |
| 122 | + assert.equal(self.status, 403); | |
| 123 | + assert.match(self.json.message, /doesn't let whoever started a run approve it/); | |
| 124 | + } finally { | |
| 125 | + assert.equal((await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam", name: "sam" })).status, 303); | |
| 126 | + } | |
| 127 | + | |
| 128 | + // Access counts when deciding, not only when the list was set: on a public repository, where | |
| 129 | + // anyone may read, a reviewer removed from it no longer approves. | |
| 130 | + await ok("PATCH", R, { visibility: "public" }); | |
| 131 | + await status(204, "DELETE", `${R}/collaborators/rita`); | |
| 132 | + assert.equal((await pendingAs(T.rita, run4.id)).current_user_can_approve, false); | |
| 133 | + const removed = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.rita); | |
| 134 | + assert.equal(removed.status, 403); | |
| 135 | + assert.match(removed.json.message, /only people with access to this repository/); | |
| 136 | + await ok("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "rejected", comment: "cleanup" }); | |
| 137 | + await ok("PATCH", R, { visibility: "private" }); | |
| 138 | + await collaborator(R, "rita", "read", T.rita); | |
| 139 | + | |
| 140 | + // A wait timer alone holds the job until it passes; nobody skips it. | |
| 141 | + await ok("PUT", `${R}/environments/production`, { reviewers: [], wait_timer: 1 }); | |
| 142 | + const file2 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 143 | + const third = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 3", content: b64(wf(3)), sha: file2.sha })).commit.sha; | |
| 144 | + const run3 = await waitingRun(third); | |
| 145 | + const timer = await pendingAs(T.rita, run3.id); | |
| 146 | + assert.equal(timer.wait_timer, 1); | |
| 147 | + assert.equal(timer.current_user_can_approve, false); | |
| 148 | + await status(422, "POST", `${R}/actions/runs/${run3.id}/pending_deployments`, { environment_ids: [timer.environment.id], state: "approved" }, T.rita); | |
| 149 | + // The Worker runs with a short wait-timer minute (WAIT_TIMER_MINUTE_MS), so the timer passes in seconds. | |
| 150 | + if (Date.now() < Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 2000) assert.equal(await claim(), null, "nothing starts before the timer passes"); | |
| 151 | + const timed = await until("the job after its wait timer", claim, 60_000); | |
| 152 | + assert.ok(Date.now() >= Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 1000, "the job waited for its timer"); | |
| 153 | + assert.equal(timed.secrets.DEPLOY_KEY, "deploy-value"); | |
| 154 | +}); | |
| 155 | + | |
| 156 | +test("security: adding someone by username sends an invitation, and they get access only once they accept", async () => { | |
| 157 | + // ------------------------------------------------------------ a repository: milo is invited, not added | |
| 158 | + await repoWithReadme("by-name"); | |
| 159 | + const R = `/repos/${OWNER}/by-name`; | |
| 160 | + const inv = await status(201, "PUT", `${R}/collaborators/milo`, { permission: "write" }); | |
| 161 | + assert.equal(inv.invitee.login, "milo"); | |
| 162 | + assert.equal(inv.permissions, "write"); | |
| 163 | + await status(404, "GET", R, undefined, T.milo); | |
| 164 | + // The admin's list names the username, never the person's email address. | |
| 165 | + const listed = await ok("GET", `${R}/invitations`); | |
| 166 | + assert.deepEqual(listed.map((i) => [i.login, i.email, i.role]), [["milo", null, "write"]]); | |
| 167 | + // In-app and in milo's own list. | |
| 168 | + assert.ok((await ok("GET", "/notifications", undefined, T.milo)).some((n) => /olive invited you to olive\/by-name/.test(n.title))); | |
| 169 | + const mine = await ok("GET", "/user/repository_invitations", undefined, T.milo); | |
| 170 | + assert.deepEqual(mine.map((i) => [i.id, i.repository.full_name]), [[inv.id, `${OWNER}/by-name`]]); | |
| 171 | + // Nobody else can accept it, and a read token can't either. | |
| 172 | + await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.rita); | |
| 173 | + assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []); | |
| 174 | + await status(404, "GET", R, undefined, T.rita); | |
| 175 | + // Asking again changes the pending invitation's role rather than sending another. | |
| 176 | + assert.equal((await status(201, "PUT", `${R}/collaborators/milo`, { permission: "admin" })).id, inv.id); | |
| 177 | + // milo accepts and has the role offered. | |
| 178 | + await status(204, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo); | |
| 179 | + assert.equal((await ok("GET", R, undefined, T.milo)).permissions.admin, true); | |
| 180 | + await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo); | |
| 181 | + // A collaborator's role then changes at once. | |
| 182 | + await status(204, "PUT", `${R}/collaborators/milo`, { permission: "read" }); | |
| 183 | + assert.equal((await ok("GET", R, undefined, T.milo)).permissions.push, false); | |
| 184 | + // The settings form that sets a role invites someone new the same way. | |
| 185 | + const viaForm = await oliveSubmits(`/${OWNER}/by-name/settings/access`, `/${OWNER}/by-name/settings/access`, { login: "rita", role: "write" }); | |
| 186 | + assert.equal(viaForm.status, 303); | |
| 187 | + assert.match(viaForm.flash, /Invited rita/); | |
| 188 | + await status(404, "GET", R, undefined, T.rita); | |
| 189 | + // rita declines: no access, and the invitation is gone. | |
| 190 | + const ritas = await ok("GET", "/user/repository_invitations", undefined, T.rita); | |
| 191 | + assert.equal(ritas.length, 1); | |
| 192 | + await status(204, "DELETE", `/user/repository_invitations/${ritas[0].id}`, undefined, T.rita); | |
| 193 | + await status(404, "GET", R, undefined, T.rita); | |
| 194 | + assert.deepEqual(await ok("GET", `${R}/invitations`), []); | |
| 195 | + // Agents are still added directly: they act for a person who already has access. | |
| 196 | + await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" }); | |
| 197 | + | |
| 198 | + // ------------------------------------------------------------ an organization: Add people and the role form both invite | |
| 199 | + await ok("POST", "/user/orgs", { login: "by-name-org" }); | |
| 200 | + const members = async () => (await ok("GET", "/orgs/by-name-org/members")).map((u) => u.login); | |
| 201 | + const added = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/people", { who: "milo", role: "member" }); | |
| 202 | + assert.equal(added.status, 303); | |
| 203 | + assert.match(added.flash, /Invited milo to by-name-org/); | |
| 204 | + const roleForm = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/members", { login: "sam", role: "admin" }); | |
| 205 | + assert.match(roleForm.flash, /Invited sam to by-name-org/); | |
| 206 | + assert.deepEqual(await members(), ["olive"]); | |
| 207 | + assert.equal((await ok("GET", "/user/memberships/orgs/by-name-org", undefined, T.milo)).state, "pending"); | |
| 208 | + assert.deepEqual((await ok("GET", "/user/memberships/orgs?state=pending", undefined, T.milo)).map((m) => m.organization.login), ["by-name-org"]); | |
| 209 | + // milo accepts through the API, sam declines on the web. | |
| 210 | + assert.equal((await ok("PATCH", "/user/memberships/orgs/by-name-org", { state: "active" }, T.milo)).state, "active"); | |
| 211 | + const samInvite = (await ok("GET", "/orgs/by-name-org/invitations")).find((i) => i.login === "sam"); | |
| 212 | + const page = await (await fetch(`${base}/settings/invitations`, { headers: { cookie: `__Host-dfy_session=${S.sam}` } })).text(); | |
| 213 | + assert.match(page, /by-name-org/); | |
| 214 | + const declined = await submitsAs(S.sam, "/settings/invitations", `/settings/invitations/${samInvite.id}/decline`, {}); | |
| 215 | + assert.match(declined.flash, /Invitation declined/); | |
| 216 | + assert.deepEqual((await members()).sort(), ["milo", "olive"]); | |
| 217 | + await status(404, "GET", "/user/memberships/orgs/by-name-org", undefined, T.sam); | |
| 218 | + | |
| 219 | + // ------------------------------------------------------------ someone waiting for an invite accepts it from the waiting page | |
| 220 | + await repoWithReadme("for-penny"); | |
| 221 | + const pennyInvite = await status(201, "PUT", `/repos/${OWNER}/for-penny/collaborators/penny`, { permission: "read" }); | |
| 222 | + const waiting = await (await fetch(base, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).text(); | |
| 223 | + assert.match(waiting, /Waiting for an invite/); | |
| 224 | + assert.match(waiting, new RegExp(`${OWNER}/for-penny`)); | |
| 225 | + const accepted = await submitsAs(S.penny, "/", `/settings/invitations/${pennyInvite.id}/accept`, {}); | |
| 226 | + assert.equal(accepted.status, 303, accepted.flash); | |
| 227 | + assert.equal(accepted.location, `/${OWNER}/for-penny`); | |
| 228 | + assert.equal((await fetch(`${base}/${OWNER}/for-penny`, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).status, 200); | |
| 229 | + | |
| 230 | + // ------------------------------------------------------------ a repository with invitations can still be deleted | |
| 231 | + await repoWithReadme("invited-then-deleted"); | |
| 232 | + await status(201, "PUT", `/repos/${OWNER}/invited-then-deleted/collaborators/rita`, { permission: "read" }); | |
| 233 | + await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`); | |
| 234 | + assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []); | |
| 235 | +}); | |
| 236 | + | |
| 237 | +test("security: per-account quotas on hosted minutes, hosted jobs at once, sandboxes and repositories", async () => { | |
| 238 | + // Only site administrators see or change another account's quotas. | |
| 239 | + await status(403, "PATCH", "/admin/accounts/rita/quotas", { repositories: 1 }); | |
| 240 | + await status(403, "GET", "/admin/accounts/rita/quotas", undefined, T.rita); | |
| 241 | + await status(422, "PATCH", "/admin/accounts/rita/quotas", { storage: 1 }, T.sully); | |
| 242 | + const set = await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: 1, hosted_minutes: 0, sandboxes: 0 }, T.sully); | |
| 243 | + assert.deepEqual([set.repositories.limit, set.hosted_minutes.limit, set.sandboxes.limit, set.exempt], [1, 0, 0, false]); | |
| 244 | + await until("the audit entry", async () => auditLog("account.quotas.set").some((e) => e.target === "rita" && e.actor_login === "sully")); | |
| 245 | + // rita sees her own usage and limits. | |
| 246 | + const mine = await ok("GET", "/user/usage", undefined, T.rita); | |
| 247 | + assert.deepEqual([mine.account, mine.repositories.limit, mine.hosted_minutes.used], ["rita", 1, 0]); | |
| 248 | + | |
| 249 | + // ------------------------------------------------------------ repositories | |
| 250 | + const owned = mine.repositories.count; | |
| 251 | + await setRitaRepos(owned + 1); | |
| 252 | + await ok("POST", "/user/repos", { name: "rita-quota-1" }, T.rita); | |
| 253 | + assert.match((await status(403, "POST", "/user/repos", { name: "rita-quota-2" }, T.rita)).message, /rita has reached its limit of \d+ repositories/); | |
| 254 | + | |
| 255 | + // ------------------------------------------------------------ hosted minutes: a hosted job isn't started | |
| 256 | + const R = "/repos/rita/rita-quota-1"; | |
| 257 | + await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n") }, T.rita); | |
| 258 | + const job = await until("the job to fail", async () => { | |
| 259 | + const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs; | |
| 260 | + if (!runs.length) return null; | |
| 261 | + const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs; | |
| 262 | + return jobs[0]?.status === "completed" ? { run: runs[0].id, job: jobs[0] } : null; | |
| 263 | + }); | |
| 264 | + assert.equal(job.job.conclusion, "failure"); | |
| 265 | + const page = await (await fetch(`${base}/rita/rita-quota-1/actions/runs/${job.run}`, { headers: { authorization: `Bearer ${T.rita}` } })).text(); | |
| 266 | + assert.match(page, /used its 0 hosted minutes for this month/); | |
| 267 | + | |
| 268 | + // ------------------------------------------------------------ sandboxes: the minutes count for them too, and so does their own limit | |
| 269 | + assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /used its 0 hosted minutes/); | |
| 270 | + await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_minutes: null, hosted_jobs: 0 }, T.sully); | |
| 271 | + assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /already has 0 agent sandboxes open/); | |
| 272 | + | |
| 273 | + // ------------------------------------------------------------ hosted jobs at once: the job waits, then starts once there's room | |
| 274 | + const file = await ok("GET", `${R}/contents/.github/workflows/ci.yml`, undefined, T.rita); | |
| 275 | + await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "again", sha: file.sha, content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo again\n") }, T.rita); | |
| 276 | + const held = await until("a second run", async () => { | |
| 277 | + const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs; | |
| 278 | + if (runs.length < 2) return null; | |
| 279 | + const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs; | |
| 280 | + return jobs[0] ? { run: runs[0].id, job: jobs[0] } : null; | |
| 281 | + }); | |
| 282 | + await sleep(3000); | |
| 283 | + assert.equal((await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status, "queued"); | |
| 284 | + // With room again, the alarm starts it within a minute (here it then fails: no containers under wrangler dev). | |
| 285 | + await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_jobs: null }, T.sully); | |
| 286 | + await until("the held job to leave the queue", async () => (await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status !== "queued", 120_000); | |
| 287 | +}); | |
| 288 | + | |
| 289 | +test("security: a per-account storage quota refuses pushes, Git LFS and release asset uploads past it; reads and deletes keep working", async () => { | |
| 290 | + const R = "/repos/milo/hoard"; | |
| 291 | + await ok("POST", "/user/repos", { name: "hoard" }, T.milo); | |
| 292 | + const usage = async () => (await ok("GET", "/user/usage", undefined, T.milo)).storage_bytes; | |
| 293 | + assert.equal((await usage()).limit, 10 * 1024 ** 3); | |
| 294 | + // Push main and a topic branch, and upload a release asset, while there is room. | |
| 295 | + const clone = join(work, "hoard"); | |
| 296 | + const remote = `${base.replace("://", "://x:" + T.milo + "@")}/milo/hoard.git`; | |
| 297 | + execFileSync("git", ["init", "-q", "-b", "main", clone]); | |
| 298 | + const commit = (name) => { | |
| 299 | + writeFileSync(join(clone, name), randomBytes(2000).toString("hex")); | |
| 300 | + gitRun(["add", name], clone); | |
| 301 | + gitRun(["commit", "-q", "-m", name], clone); | |
| 302 | + }; | |
| 303 | + const push = (...args) => spawnSync("git", ["-c", "protocol.version=2", "push", remote, ...args], { cwd: clone, encoding: "utf8" }); | |
| 304 | + commit("a.txt"); | |
| 305 | + assert.equal(push("HEAD:main", "HEAD:refs/heads/topic").status, 0); | |
| 306 | + // Repositories report what they store as it changes: wait until the count stops moving. | |
| 307 | + const settled = async (what, test) => { | |
| 308 | + let last = -1; | |
| 309 | + return until(what, async () => { | |
| 310 | + const u = (await usage()).used; | |
| 311 | + if (u === last && test(u)) return u; | |
| 312 | + last = u; | |
| 313 | + await sleep(1000); | |
| 314 | + return null; | |
| 315 | + }); | |
| 316 | + }; | |
| 317 | + const pushed = await settled("the push to be counted", (u) => u > 2000); | |
| 318 | + const rel = await ok("POST", `${R}/releases`, { tag_name: "v1", target_commitish: "main" }, T.milo); | |
| 319 | + const upload = (bytes) => fetch(`${base}/api/uploads/repos/milo/hoard/releases/${rel.id}/assets?name=${bytes.length}.bin`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: bytes }); | |
| 320 | + const asset = await (await upload(randomBytes(3000))).json(); | |
| 321 | + assert.equal(asset.size, 3000); | |
| 322 | + const used = await settled("the asset to be counted", (u) => u === pushed + 3000); | |
| 323 | + assert.equal((await ok("GET", "/admin/accounts/milo/quotas", undefined, T.sully)).storage_bytes.used, used); | |
| 324 | + | |
| 325 | + // ------------------------------------------------------------ at the limit: nothing new is stored | |
| 326 | + const set = await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: used }, T.sully); | |
| 327 | + assert.deepEqual(set.storage_bytes, { used, limit: used }); | |
| 328 | + const full = new RegExp(`milo has reached its storage limit of ${used} bytes`); | |
| 329 | + commit("b.txt"); | |
| 330 | + const refused = push("HEAD:main"); | |
| 331 | + assert.notEqual(refused.status, 0); | |
| 332 | + assert.match(refused.stderr, new RegExp(`remote: Push refused: ${full.source}`)); | |
| 333 | + assert.match(refused.stderr, /storage limit reached/); | |
| 334 | + const lfsOid = (bytes) => createHash("sha256").update(bytes).digest("hex"); | |
| 335 | + const lfsBlob = randomBytes(1000); | |
| 336 | + const batch = async () => { | |
| 337 | + const r = await fetch(`${base}/milo/hoard.git/info/lfs/objects/batch`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, accept: "application/vnd.git-lfs+json", "content-type": "application/vnd.git-lfs+json" }, body: JSON.stringify({ operation: "upload", objects: [{ oid: lfsOid(lfsBlob), size: lfsBlob.length }] }) }); | |
| 338 | + assert.equal(r.status, 200); | |
| 339 | + return (await r.json()).objects[0]; | |
| 340 | + }; | |
| 341 | + const lfs = await batch(); | |
| 342 | + assert.equal(lfs.error.code, 507); | |
| 343 | + assert.match(lfs.error.message, full); | |
| 344 | + const put = await fetch(`${base}/milo/hoard.git/info/lfs/objects/${lfsOid(lfsBlob)}`, { method: "PUT", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: lfsBlob }); | |
| 345 | + assert.equal(put.status, 507); | |
| 346 | + const late = await upload(randomBytes(10)); | |
| 347 | + assert.equal(late.status, 403); | |
| 348 | + assert.match((await late.json()).message, full); | |
| 349 | + assert.match((await status(403, "PUT", `${R}/contents/c.txt`, { message: "c", content: b64("more") }, T.milo)).message, full); | |
| 350 | + assert.match((await status(403, "POST", `${R}/git/blobs`, { content: "more" }, T.milo)).message, full); | |
| 351 | + // A chunked registry upload declares no length, so its bytes are counted as they arrive. | |
| 352 | + const reg = { authorization: `Basic ${Buffer.from(`milo:${T.milo}`).toString("base64")}` }; | |
| 353 | + const started = await fetch(`${base}/v2/milo/hoard/blobs/uploads/`, { method: "POST", headers: reg }); | |
| 354 | + assert.equal(started.status, 202, await started.clone().text()); | |
| 355 | + const chunked = new ReadableStream({ | |
| 356 | + start(ctl) { | |
| 357 | + ctl.enqueue(randomBytes(1000)); | |
| 358 | + ctl.close(); | |
| 359 | + }, | |
| 360 | + }); | |
| 361 | + const sneak = await fetch(`${base}${started.headers.get("location")}`, { method: "PATCH", headers: { ...reg, "content-type": "application/octet-stream" }, body: chunked, duplex: "half" }); | |
| 362 | + assert.equal(sneak.status, 403); | |
| 363 | + assert.match(await sneak.text(), /milo has reached its storage limit/); | |
| 364 | + // An upload refused at the quota can't finish, so it is gone with its bytes: a retry has to start again. | |
| 365 | + assert.equal((await fetch(`${base}${started.headers.get("location")}`, { headers: reg })).status, 404); | |
| 366 | + | |
| 367 | + // ------------------------------------------------------------ reads, ref deletions and ref moves still work | |
| 368 | + assert.equal((await ok("GET", `${R}/contents/a.txt`, undefined, T.milo)).size, 4000); | |
| 369 | + assert.equal(push(":refs/heads/topic").status, 0); | |
| 370 | + assert.equal(push("HEAD~1:refs/heads/copy").status, 0); | |
| 371 | + assert.deepEqual((await ok("GET", `${R}/branches`, undefined, T.milo)).map((b) => b.name).sort(), ["copy", "main"]); | |
| 372 | + | |
| 373 | + // ------------------------------------------------------------ deleting brings usage down, and there is room again | |
| 374 | + await status(204, "DELETE", `${R}/releases/assets/${asset.id}`, undefined, T.milo); | |
| 375 | + await until("the asset's bytes to be given back", async () => (await usage()).used === used - 3000); | |
| 376 | + assert.ok((await batch()).actions.upload); | |
| 377 | + await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: null }, T.sully); | |
| 378 | + assert.equal(push("HEAD:main").status, 0); | |
| 379 | +}); | |
| 380 | + | |
| 381 | +/** Set rita's repository quota (site administrators only). */ | |
| 382 | +async function setRitaRepos(n) { | |
| 383 | + await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully); | |
| 384 | +} | |
| 385 | + | |
| 386 | +test("security: kill switches suspend Actions for a repository or an account, and pause hosted compute on the instance", async () => { | |
| 387 | + const R = `/repos/${OWNER}/halted`; | |
| 388 | + const S = `/admin/repos/${OWNER}/halted/actions-suspension`; | |
| 389 | + const A = `/admin/accounts/${OWNER}/actions-suspension`; | |
| 390 | + const P = "/admin/hosted-compute-pause"; | |
| 391 | + await repoWithReadme("halted"); | |
| 392 | + const runs = async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs; | |
| 393 | + const html = async (path, as = T.olive) => (await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${as}` } })).text(); | |
| 394 | + const push = async (path, text) => ok("PUT", `${R}/contents/${path}`, { message: path, content: b64(text) }); | |
| 395 | + // A self-hosted job that no runner takes yet: the run stays in progress. | |
| 396 | + await push(".github/workflows/ci.yml", "name: ci\non:\n push:\n workflow_dispatch:\n repository_dispatch:\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n"); | |
| 397 | + const first = await until("a run waiting for a runner", async () => { | |
| 398 | + const [run] = await runs(); | |
| 399 | + return run && (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs.length ? run : null; | |
| 400 | + }); | |
| 401 | + | |
| 402 | + try { | |
| 403 | + // ------------------------------------------------------------ only site administrators | |
| 404 | + for (const path of [S, A, P]) { | |
| 405 | + for (const method of ["GET", "PUT", "DELETE"]) assert.match((await status(403, method, path, method === "PUT" ? { reason: "mine" } : undefined, T.olive)).message, /only site administrators/); | |
| 406 | + await status(401, "PUT", path, { reason: "no token" }, null); | |
| 407 | + } | |
| 408 | + await status(422, "PUT", S, {}, T.sully); | |
| 409 | + await status(404, "PUT", `/admin/repos/${OWNER}/nowhere/actions-suspension`, { reason: "x" }, T.sully); | |
| 410 | + await status(404, "PUT", "/admin/accounts/olive-bot/actions-suspension", { reason: "x" }, T.sully); | |
| 411 | + assert.equal((await ok("GET", S, undefined, T.sully)).suspended, false); | |
| 412 | + | |
| 413 | + // ------------------------------------------------------------ a repository: what runs stops, nothing new starts | |
| 414 | + const s = await ok("PUT", S, { reason: "crypto mining" }, T.sully); | |
| 415 | + assert.deepEqual([s.suspended, s.reason, s.suspended_by, s.stopped.runs, s.stopped.jobs], [true, "crypto mining", "sully", 1, 1]); | |
| 416 | + const cancelled = await until("the run to be cancelled", async () => { | |
| 417 | + const r = await ok("GET", `${R}/actions/runs/${first.id}`); | |
| 418 | + return r.status === "completed" ? r : null; | |
| 419 | + }); | |
| 420 | + assert.equal(cancelled.conclusion, "cancelled"); | |
| 421 | + assert.match(await html(`/${OWNER}/halted/actions/runs/${first.id}`), /Cancelled: Actions are suspended for this repository by a site administrator: crypto mining/); | |
| 422 | + assert.equal((await runnerClaim(R, ["linux"])).status, 204); | |
| 423 | + assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for this repository by a site administrator: crypto mining/); | |
| 424 | + assert.match((await status(403, "POST", `${R}/dispatches`, { event_type: "go" })).message, /suspended for this repository/); | |
| 425 | + assert.match((await status(403, "POST", `${R}/actions/runs/${first.id}/rerun`)).message, /suspended for this repository/); | |
| 426 | + assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /suspended for this repository/); | |
| 427 | + const before = (await runs()).length; | |
| 428 | + await push("one.txt", "one\n"); | |
| 429 | + await sleep(3000); | |
| 430 | + assert.equal((await runs()).length, before, "a push starts no run while Actions are suspended"); | |
| 431 | + for (const path of [`/${OWNER}/halted/actions`, `/${OWNER}/halted/settings`]) assert.match(await html(path), /Actions are suspended for this repository by a site administrator/, path); | |
| 432 | + assert.match(await html("/settings/admin/kill-switches", T.sully), new RegExp(`${OWNER}/halted`)); | |
| 433 | + await until("the suspension's audit entry", async () => auditLog("actions.suspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully" && JSON.parse(e.detail).reason === "crypto mining")); | |
| 434 | + | |
| 435 | + // Lifting it lets new work start; nothing restarts. | |
| 436 | + assert.equal((await ok("DELETE", S, undefined, T.sully)).suspended, false); | |
| 437 | + assert.equal((await ok("GET", `${R}/actions/runs/${first.id}`)).conclusion, "cancelled"); | |
| 438 | + await push("two.txt", "two\n"); | |
| 439 | + await until("a new run", async () => (await runs()).length > before); | |
| 440 | + await ok("POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" }); | |
| 441 | + const sandbox = await api("POST", `${R}/diffinery/sandboxes`, {}); | |
| 442 | + assert.notEqual(sandbox.status, 403, JSON.stringify(sandbox.json)); // 503 here: no sandbox pool under this test | |
| 443 | + await until("the lift's audit entry", async () => auditLog("actions.unsuspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully")); | |
| 444 | + | |
| 445 | + // ------------------------------------------------------------ an account: every repository it owns | |
| 446 | + const a = await ok("PUT", A, { reason: "abuse report" }, T.sully); | |
| 447 | + assert.deepEqual([a.account, a.suspended, a.reason], [OWNER, true, "abuse report"]); | |
| 448 | + assert.ok(a.stopped.runs >= 1, JSON.stringify(a.stopped)); | |
| 449 | + assert.equal((await ok("GET", S, undefined, T.sully)).owner_suspended, true); | |
| 450 | + assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for every repository of olive by a site administrator: abuse report/); | |
| 451 | + assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /every repository of olive/); | |
| 452 | + const during = (await runs()).length; | |
| 453 | + await push("three.txt", "three\n"); | |
| 454 | + await sleep(3000); | |
| 455 | + assert.equal((await runs()).length, during); | |
| 456 | + assert.match(await html(`/${OWNER}/halted/actions`), /Actions are suspended for every repository of olive by a site administrator/); | |
| 457 | + assert.equal((await ok("DELETE", A, undefined, T.sully)).suspended, false); | |
| 458 | + await until("the account's audit entries", async () => auditLog("actions.suspend").some((e) => e.target === OWNER && JSON.parse(e.detail).kind === "account") && auditLog("actions.unsuspend").some((e) => e.target === OWNER)); | |
| 459 | + | |
| 460 | + // ------------------------------------------------------------ the instance: hosted compute stops, self-hosted runners go on | |
| 461 | + const p = await ok("PUT", P, { reason: "cost overrun" }, T.sully); | |
| 462 | + assert.deepEqual([p.paused, p.reason, p.paused_by], [true, "cost overrun", "sully"]); | |
| 463 | + assert.equal((await ok("GET", P, undefined, T.sully)).paused, true); | |
| 464 | + assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /Agent sandboxes are paused on this instance by a site administrator: cost overrun/); | |
| 465 | + assert.match(await html("/settings/admin", T.sully), /Hosted compute is paused on this instance/); | |
| 466 | + assert.match(await html(`/${OWNER}/halted/actions`), /Hosted runners are paused on this instance by a site administrator/); | |
| 467 | + await push(".github/workflows/hosted.yml", "name: hosted\non: push\njobs:\n cloud:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"); | |
| 468 | + const hosted = await until("the hosted job to fail", async () => { | |
| 469 | + const run = (await runs()).find((r) => r.name === "hosted"); | |
| 470 | + if (!run) return null; | |
| 471 | + const [job] = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs; | |
| 472 | + return job?.status === "completed" ? { run: run.id, job } : null; | |
| 473 | + }); | |
| 474 | + assert.equal(hosted.job.conclusion, "failure"); | |
| 475 | + assert.match(await html(`/${OWNER}/halted/actions/runs/${hosted.run}`), /The job was not started: Hosted runners are paused on this instance by a site administrator: cost overrun/); | |
| 476 | + // The self-hosted job of the same push is still handed to a runner. | |
| 477 | + const claim = await until("a self-hosted job claimed", async () => { | |
| 478 | + const r = await runnerClaim(R, ["linux"]); | |
| 479 | + return r.status === 200 ? r.json : null; | |
| 480 | + }); | |
| 481 | + await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 482 | + assert.equal((await ok("DELETE", P, undefined, T.sully)).paused, false); | |
| 483 | + assert.doesNotMatch(await html("/settings/admin", T.sully), /Hosted compute is paused/); | |
| 484 | + assert.notEqual((await api("POST", `${R}/diffinery/sandboxes`, {})).status, 403); | |
| 485 | + await until("the pause's audit entries", async () => auditLog("compute.pause").some((e) => e.actor_login === "sully" && JSON.parse(e.detail).reason === "cost overrun") && auditLog("compute.unpause").length > 0); | |
| 486 | + } finally { | |
| 487 | + for (const path of [S, A, P]) await api("DELETE", path, undefined, T.sully); | |
| 488 | + } | |
| 489 | +}); | |
| 490 | + | |
| 491 | +test("security: Markdown images from other sites load through Diffinery's signed image proxy", async () => { | |
| 492 | + // A stand-in for another site, which records what it is sent. | |
| 493 | + const seen = []; | |
| 494 | + const PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea7d6a4c50000000049454e44ae426082", "hex"); | |
| 495 | + const site = createHttpServer((req, res) => { | |
| 496 | + seen.push({ url: req.url, headers: req.headers }); | |
| 497 | + if (req.url === "/pixel.png") return res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" }).end(PNG); | |
| 498 | + if (req.url === "/moved.png") return res.writeHead(302, { location: "/pixel.png" }).end(); | |
| 499 | + if (req.url === "/page.html") return res.writeHead(200, { "content-type": "text/html" }).end("<script>alert(1)</script>"); | |
| 500 | + if (req.url === "/drawing.svg") return res.writeHead(200, { "content-type": "image/svg+xml" }).end('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'); | |
| 501 | + res.writeHead(404).end(); | |
| 502 | + }); | |
| 503 | + await new Promise((r) => site.listen(0, "127.0.0.1", r)); | |
| 504 | + const other = `http://127.0.0.1:${site.address().port}`; | |
| 505 | + try { | |
| 506 | + await repoWithReadme("pictures"); | |
| 507 | + const body = [``, `[](${other}/page.html)`, ``, ``, ""].join("\n\n"); | |
| 508 | + const issue = await ok("POST", `/repos/${OWNER}/pictures/issues`, { title: "pictures", body }); | |
| 509 | + const page = await fetch(`${base}/${OWNER}/pictures/issues/${issue.number}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } }); | |
| 510 | + assert.equal(page.status, 200); | |
| 511 | + const csp = page.headers.get("content-security-policy") ?? ""; | |
| 512 | + assert.match(csp, /img-src 'self' data: blob:(;|$)/); | |
| 513 | + assert.doesNotMatch(csp, /img-src[^;]*https:/); | |
| 514 | + const html = await page.text(); | |
| 515 | + const srcs = [...html.matchAll(/<img src="([^"]*)" alt="(pixel|moved|page|drawing|own)"/g)].map((m) => [m[2], m[1].replaceAll("&", "&")]); | |
| 516 | + const src = Object.fromEntries(srcs); | |
| 517 | + assert.deepEqual(Object.keys(src).sort(), ["drawing", "moved", "own", "page", "pixel"]); | |
| 518 | + for (const name of ["pixel", "moved", "page", "drawing"]) assert.match(src[name], /^\/-\/camo\/[\w-]{43}\/[\w-]+$/, name); | |
| 519 | + assert.equal(src.own, `/${OWNER}/pictures/raw/main/static/none.png`); | |
| 520 | + assert.ok(!html.includes(`src="${other}`), "no image loads from the other site directly"); | |
| 521 | + // Links keep their original target. | |
| 522 | + assert.ok(html.includes(`href="${other}/page.html"`)); | |
| 523 | + assert.equal(seen.length, 0, "rendering fetches nothing"); | |
| 524 | + | |
| 525 | + // The proxy fetches the image without the viewer's cookies, and answers from its cache next time. | |
| 526 | + const get = (path, init = {}) => fetch(`${base}${path}`, init); | |
| 527 | + const first = await get(src.pixel, { headers: { cookie: `__Host-dfy_session=${S.olive}`, "user-agent": "viewer-browser/1" } }); | |
| 528 | + assert.equal(first.status, 200); | |
| 529 | + assert.equal(first.headers.get("content-type"), "image/png"); | |
| 530 | + assert.equal(first.headers.get("x-content-type-options"), "nosniff"); | |
| 531 | + assert.match(first.headers.get("cache-control") ?? "", /^public, max-age=3600$/); | |
| 532 | + assert.equal(first.headers.get("set-cookie"), null); | |
| 533 | + assert.deepEqual(Buffer.from(await first.arrayBuffer()), PNG); | |
| 534 | + assert.equal(seen.length, 1); | |
| 535 | + assert.equal(seen[0].headers.cookie, undefined); | |
| 536 | + assert.match(seen[0].headers["user-agent"] ?? "", /^Diffinery-Camo/); | |
| 537 | + assert.equal((await get(src.pixel)).status, 200); | |
| 538 | + assert.equal(seen.filter((r) => r.url === "/pixel.png").length, 1, "the second view is served from the cache"); | |
| 539 | + | |
| 540 | + // Redirects are followed; HTML is refused; SVG can't run scripts. | |
| 541 | + assert.equal((await get(src.moved)).status, 200); | |
| 542 | + assert.equal((await get(src.page)).status, 404); | |
| 543 | + const svg = await get(src.drawing); | |
| 544 | + assert.equal(svg.status, 200); | |
| 545 | + assert.equal(svg.headers.get("content-type"), "image/svg+xml"); | |
| 546 | + assert.equal(svg.headers.get("content-security-policy"), "default-src 'none'; style-src 'unsafe-inline'; sandbox"); | |
| 547 | + | |
| 548 | + // Only URLs Diffinery signed: a tampered signature or URL, or one Diffinery never signed, is a 404 that fetches nothing. | |
| 549 | + const before = seen.length; | |
| 550 | + const [, , , sig, enc] = src.pixel.split("/"); | |
| 551 | + const evil = Buffer.from(`${other}/page.html?x`).toString("base64url"); | |
| 552 | + for (const path of [`/-/camo/${sig}/${evil}`, `/-/camo/${sig.slice(0, -2)}AA/${enc}`, `/-/camo/x/${enc}`, `/-/camo/${sig}`, `/-/camo/${"A".repeat(43)}/${Buffer.from(base).toString("base64url")}`]) { | |
| 553 | + assert.equal((await get(path)).status, 404, path); | |
| 554 | + } | |
| 555 | + assert.equal(seen.length, before); | |
| 556 | + } finally { | |
| 557 | + site.close(); | |
| 558 | + } | |
| 559 | +}); |
modifiedapps/diffinery/test/e2e/security.test.mjs+8−779View file
| @@ -20,206 +20,18 @@ | ||
| 20 | 20 | // images) that only readers can follow; emptied organizations can be deleted. |
| 21 | 21 | // Agents land changes on protected branches only through auto-merge (or the |
| 22 | 22 | // merge queue) where a person allowed it and a required check gates it, never by bypass. |
| 23 | -// Site administrators' kill switches stop Actions and sandboxes for a repository, an | |
| 24 | -// account or (hosted compute) the whole instance. Images from other sites in Markdown | |
| 25 | -// load through Diffinery's signed image proxy, never straight from the other site. | |
| 26 | -import { spawn, spawnSync, execFileSync } from "node:child_process"; | |
| 27 | -import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; | |
| 28 | -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 23 | +// Continued in security-limits.test.mjs (environment reviewers, quotas, kill switches, the image | |
| 24 | +// proxy), which runs beside this file on a server of its own; the setup is in security-harness.mjs. | |
| 25 | +import { spawnSync, execFileSync } from "node:child_process"; | |
| 26 | +import { createHash, randomBytes } from "node:crypto"; | |
| 27 | +import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 29 | 28 | import { gzipSync } from "node:zlib"; |
| 30 | -import { tmpdir } from "node:os"; | |
| 31 | -import { join, dirname, resolve } from "node:path"; | |
| 32 | -import { fileURLToPath } from "node:url"; | |
| 33 | -import { createServer } from "node:net"; | |
| 34 | -import { createServer as createHttpServer } from "node:http"; | |
| 35 | -import { test, before, after } from "node:test"; | |
| 29 | +import { join } from "node:path"; | |
| 30 | +import { test } from "node:test"; | |
| 36 | 31 | import assert from "node:assert/strict"; |
| 37 | -import { migratedState } from "./d1-state.mjs"; | |
| 32 | +import { APP, work, base, hash, T, S, OWNER, sleep, until, api, runnerClaim, ok, status, b64, collaborator, repoWithReadme, auditLog, oliveSubmits, submitsAs, gitRun } from "./security-harness.mjs"; | |
| 38 | 33 | |
| 39 | -const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 40 | -const work = mkdtempSync(join(tmpdir(), "diffinery-security-")); | |
| 41 | -let base; | |
| 42 | -let dev; | |
| 43 | -let devLog = ""; | |
| 44 | 34 | |
| 45 | -const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 46 | -const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; | |
| 47 | -const T = { sully: tok("sully"), 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") }; | |
| 48 | -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")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` }; | |
| 49 | -const OWNER = "olive"; | |
| 50 | -/** One minute of an environment's wait timer, in this Worker (WAIT_TIMER_MINUTE_MS). */ | |
| 51 | -const TIMER_MINUTE_MS = 10_000; | |
| 52 | - | |
| 53 | -const freePort = () => | |
| 54 | - new Promise((res) => { | |
| 55 | - const s = createServer(); | |
| 56 | - s.listen(0, "127.0.0.1", () => { | |
| 57 | - const { port } = s.address(); | |
| 58 | - s.close(() => res(port)); | |
| 59 | - }); | |
| 60 | - }); | |
| 61 | - | |
| 62 | -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 63 | -async function until(what, fn, timeoutMs = 60_000) { | |
| 64 | - const end = Date.now() + timeoutMs; | |
| 65 | - while (Date.now() < end) { | |
| 66 | - const v = await fn(); | |
| 67 | - if (v) return v; | |
| 68 | - await sleep(300); | |
| 69 | - } | |
| 70 | - assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 71 | -} | |
| 72 | - | |
| 73 | -/** Call the REST API as a token (`as`), a session cookie (`{ cookie }`) or anonymously (null). */ | |
| 74 | -async function api(method, path, body, as = T.olive) { | |
| 75 | - const headers = { "content-type": "application/json" }; | |
| 76 | - if (typeof as === "string") headers.authorization = `Bearer ${as}`; | |
| 77 | - else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; | |
| 78 | - const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 79 | - // wrangler dev closes idle keep-alive connections (the audit checks pause for seconds), so a reused one can be gone. | |
| 80 | - const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e))); | |
| 81 | - const text = await res.text(); | |
| 82 | - let json = null; | |
| 83 | - try { | |
| 84 | - json = text ? JSON.parse(text) : null; | |
| 85 | - } catch { | |
| 86 | - json = text; | |
| 87 | - } | |
| 88 | - return { status: res.status, json }; | |
| 89 | -} | |
| 90 | - | |
| 91 | -/** | |
| 92 | - * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner` | |
| 93 | - * does (personal tokens can't claim jobs): one runner per repository and set of labels. | |
| 94 | - */ | |
| 95 | -const e2eRunners = new Map(); | |
| 96 | -async function runnerClaim(repoPath, labels = ["linux"]) { | |
| 97 | - const key = `${repoPath} ${labels.join(",")}`; | |
| 98 | - if (!e2eRunners.has(key)) { | |
| 99 | - const reg = await api("POST", `${repoPath}/actions/runners/registration-token`); | |
| 100 | - assert.equal(reg.status, 201, JSON.stringify(reg.json)); | |
| 101 | - const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels }); | |
| 102 | - assert.equal(runner.status, 201, JSON.stringify(runner.json)); | |
| 103 | - e2eRunners.set(key, runner.json.token); | |
| 104 | - } | |
| 105 | - const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key)); | |
| 106 | - // A repository deleted and created again under the same name needs a new runner. | |
| 107 | - if (r.status !== 401) return r; | |
| 108 | - e2eRunners.delete(key); | |
| 109 | - return runnerClaim(repoPath, labels); | |
| 110 | -} | |
| 111 | -async function ok(method, path, body, as) { | |
| 112 | - const r = await api(method, path, body, as); | |
| 113 | - assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 114 | - return r.json; | |
| 115 | -} | |
| 116 | -async function status(expected, method, path, body, as) { | |
| 117 | - const r = await api(method, path, body, as); | |
| 118 | - assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 119 | - return r.json; | |
| 120 | -} | |
| 121 | -const b64 = (s) => Buffer.from(s).toString("base64"); | |
| 122 | - | |
| 123 | -/** | |
| 124 | - * Give someone access to a repository as GitHub does: someone new gets an invitation (201), which | |
| 125 | - * they accept with their own token; an existing collaborator's role changes at once (204). | |
| 126 | - */ | |
| 127 | -async function collaborator(repo, login, permission, invitee, as = T.olive) { | |
| 128 | - const r = await api("PUT", `${repo}/collaborators/${login}`, { permission }, as); | |
| 129 | - if (r.status === 204) return; | |
| 130 | - assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 131 | - await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee); | |
| 132 | -} | |
| 133 | - | |
| 134 | -before(async () => { | |
| 135 | - const port = await freePort(); | |
| 136 | - base = `http://127.0.0.1:${port}`; | |
| 137 | - const persist = join(work, "state"); | |
| 138 | - const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 139 | - migratedState(APP, persist); | |
| 140 | - const at = new Date().toISOString(); | |
| 141 | - const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 142 | - const account = (id, login, kind, status, owner = null) => `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', '${status}', ${owner ? `'${owner}'` : "NULL"}, '${at}')`; | |
| 143 | - const token = (id, account, t, scopes) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '${JSON.stringify(scopes)}', '${later}', '${at}')`; | |
| 144 | - const session = (id, account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; | |
| 145 | - wrangler( | |
| 146 | - "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 147 | - [ | |
| 148 | - account("u-olive", "olive", "user", "active"), | |
| 149 | - account("u-sully", "sully", "user", "active"), | |
| 150 | - "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'", | |
| 151 | - token("t-sully", "u-sully", T.sully, ["admin"]), | |
| 152 | - account("a-bot", "olive-bot", "agent", "active", "u-olive"), | |
| 153 | - account("u-rita", "rita", "user", "active"), | |
| 154 | - account("u-penny", "penny", "user", "pending"), | |
| 155 | - account("u-dora", "dora", "user", "disabled"), | |
| 156 | - account("u-gone", "gone", "user", "disabled"), | |
| 157 | - account("a-orphan", "gone-bot", "agent", "active", "u-gone"), | |
| 158 | - token("t1", "u-olive", T.olive, ["admin"]), | |
| 159 | - token("t2", "a-bot", T.bot, ["admin"]), | |
| 160 | - token("t3", "u-rita", T.rita, ["write"]), | |
| 161 | - token("t4", "u-dora", T.dora, ["write"]), | |
| 162 | - token("t5", "a-orphan", T.orphan, ["write"]), | |
| 163 | - account("o-guild", "guild", "org", "active"), | |
| 164 | - `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`, | |
| 165 | - token("t6", "u-olive", T.reader, ["read"]), | |
| 166 | - token("t7", "u-rita", T.ritaAdmin, ["admin"]), | |
| 167 | - account("u-milo", "milo", "user", "active"), | |
| 168 | - token("t8", "u-milo", T.milo, ["admin"]), | |
| 169 | - `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-milo', 'member', '${at}')`, | |
| 170 | - session("s1", "u-penny", S.penny), | |
| 171 | - session("s2", "u-dora", S.dora), | |
| 172 | - session("s3", "u-olive", S.olive), | |
| 173 | - account("o-shop", "shop", "org", "active"), | |
| 174 | - account("u-sam", "sam", "user", "active"), | |
| 175 | - token("t9", "u-sam", T.sam, ["admin"]), | |
| 176 | - account("a-helper", "olive-helper", "agent", "active", "u-olive"), | |
| 177 | - token("t10", "a-helper", T.helper, ["write"]), | |
| 178 | - `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`, | |
| 179 | - session("s4", "u-sam", S.sam), | |
| 180 | - "UPDATE accounts SET email = 'sam@example.com' WHERE id = 'u-sam'", | |
| 181 | - // olive's other browsers: one signed in 3 hours ago, one 100 days ago (used every day since), one spare. | |
| 182 | - session("s5", "u-olive", S.oliveOld).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 3 * 3600_000).toISOString()}', '${at}'`), | |
| 183 | - session("s6", "u-olive", S.oliveStale).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 100 * 86400_000).toISOString()}', '${at}'`), | |
| 184 | - session("s7", "u-olive", S.oliveSpare), | |
| 185 | - ].join("; "), | |
| 186 | - ); | |
| 187 | - dev = spawn( | |
| 188 | - "pnpm", | |
| 189 | - ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `WAIT_TIMER_MINUTE_MS:${TIMER_MINUTE_MS}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"], | |
| 190 | - { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 191 | - ); | |
| 192 | - dev.stdout.on("data", (d) => (devLog += d)); | |
| 193 | - dev.stderr.on("data", (d) => (devLog += d)); | |
| 194 | - await until( | |
| 195 | - "wrangler dev", | |
| 196 | - async () => { | |
| 197 | - try { | |
| 198 | - return (await fetch(`${base}/healthz`)).ok; | |
| 199 | - } catch { | |
| 200 | - return false; | |
| 201 | - } | |
| 202 | - }, | |
| 203 | - 120_000, | |
| 204 | - ); | |
| 205 | -}); | |
| 206 | - | |
| 207 | -after(() => { | |
| 208 | - if (dev?.pid) { | |
| 209 | - try { | |
| 210 | - process.kill(-dev.pid, "SIGTERM"); | |
| 211 | - } catch {} | |
| 212 | - } | |
| 213 | - if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 214 | -}); | |
| 215 | - | |
| 216 | -/** A private repository owned by olive with one commit on main. */ | |
| 217 | -async function repoWithReadme(name, extra = {}) { | |
| 218 | - await ok("POST", "/user/repos", { name, ...extra }); | |
| 219 | - const put = await ok("PUT", `/repos/${OWNER}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }); | |
| 220 | - return put.commit.sha; | |
| 221 | -} | |
| 222 | - | |
| 223 | 35 | test("security: search, required checks, environments, reviews, locks, archives, drafts and tree names", async () => { |
| 224 | 36 | // ------------------------------------------------------------ setup: a private repo the agent and rita can use |
| 225 | 37 | const R = `/repos/${OWNER}/guarded`; |
| @@ -572,23 +384,6 @@ | ||
| 572 | 384 | assert.notEqual(bypassed.sha, mainSha); |
| 573 | 385 | }); |
| 574 | 386 | |
| 575 | -/** Audit entries recorded so far, read straight from the local D1 database. The running dev server can hold its lock, so a busy database is retried. */ | |
| 576 | -function auditLog(action) { | |
| 577 | - for (let attempt = 1; ; attempt++) { | |
| 578 | - try { | |
| 579 | - return readAuditLog(action); | |
| 580 | - } catch (e) { | |
| 581 | - if (attempt >= 10 || !/SQLITE_BUSY|database is locked/.test(String(e.stderr ?? e.message))) throw e; | |
| 582 | - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300 * attempt); | |
| 583 | - } | |
| 584 | - } | |
| 585 | -} | |
| 586 | - | |
| 587 | -function readAuditLog(action) { | |
| 588 | - const out = execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", join(work, "state"), "--json", "--command", `SELECT actor_login, action, target, detail FROM audit_log WHERE action = '${action}'`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 589 | - return JSON.parse(out.toString())[0].results; | |
| 590 | -} | |
| 591 | - | |
| 592 | 387 | test("security: limited tokens, invisible repositories, readers' labels and the audit trail", async () => { |
| 593 | 388 | await repoWithReadme("limits"); |
| 594 | 389 | // ------------------------------------------------------------ a token limited to some repositories can't mint one that isn't |
| @@ -716,19 +511,6 @@ | ||
| 716 | 511 | }); |
| 717 | 512 | |
| 718 | 513 | /** A web form post as olive (signed in by session), with the CSRF pair taken from `page`. */ |
| 719 | -const oliveSubmits = (page, action, fields) => submitsAs(S.olive, page, action, fields); | |
| 720 | - | |
| 721 | -/** A web form post signed in with `session`, with the CSRF pair taken from `page`. */ | |
| 722 | -async function submitsAs(session, page, action, fields) { | |
| 723 | - const cookie = `__Host-dfy_session=${session}`; | |
| 724 | - const res = await fetch(`${base}${page}`, { headers: { cookie } }); | |
| 725 | - const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 726 | - const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 727 | - assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`); | |
| 728 | - const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); | |
| 729 | - return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")), text: await r.text() }; | |
| 730 | -} | |
| 731 | - | |
| 732 | 514 | /** Call an MCP tool with a token. */ |
| 733 | 515 | async function mcpTool(as, name, args) { |
| 734 | 516 | const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${as}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) }); |
| @@ -804,13 +586,6 @@ | ||
| 804 | 586 | return { status: res.status, location: res.headers.get("location"), json, text }; |
| 805 | 587 | } |
| 806 | 588 | |
| 807 | -/** Runs git, returning its stderr; fails the test when git fails. */ | |
| 808 | -function gitRun(args, cwd) { | |
| 809 | - const r = spawnSync("git", ["-c", "protocol.version=2", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { cwd, encoding: "utf8" }); | |
| 810 | - assert.equal(r.status, 0, `git ${args.join(" ")} failed:\n${r.stderr}`); | |
| 811 | - return r.stderr; | |
| 812 | -} | |
| 813 | - | |
| 814 | 589 | /** Uploads a blob, then a one-layer manifest tagged `tag`, through the registry API. */ |
| 815 | 590 | async function pushTinyImage(name, tag, as) { |
| 816 | 591 | const auth = { authorization: `Basic ${Buffer.from(`anyone:${as}`).toString("base64")}` }; |
| @@ -2246,549 +2021,3 @@ | ||
| 2246 | 2021 | // Names people would trust aren't for the taking. |
| 2247 | 2022 | for (const login of ["ghost", "github-actions", "noreply", "support", "abuse"]) await status(422, "POST", "/user/orgs", { login }); |
| 2248 | 2023 | }); |
| 2249 | - | |
| 2250 | -test("security: environment reviewers hold a deploy job until a listed person approves, and nobody else can", async () => { | |
| 2251 | - const R = `/repos/${OWNER}/gated-deploy`; | |
| 2252 | - await repoWithReadme("gated-deploy"); | |
| 2253 | - await collaborator(R, "rita", "read", T.rita); | |
| 2254 | - await collaborator(R, "sam", "read", T.sam); | |
| 2255 | - await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" }); | |
| 2256 | - const ritaId = (await ok("GET", "/user", undefined, T.rita)).id; | |
| 2257 | - | |
| 2258 | - // Only people with access can be reviewers, never an agent or a team; limits are GitHub's; agents can't set them. | |
| 2259 | - await ok("PUT", `${R}/environments/production`, {}); | |
| 2260 | - await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "olive-bot" }] }); | |
| 2261 | - await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "Team", id: 1 }] }); | |
| 2262 | - await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "milo" }] }); | |
| 2263 | - await status(422, "PUT", `${R}/environments/production`, { wait_timer: 43_201 }); | |
| 2264 | - await status(403, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "rita" }] }, T.bot); | |
| 2265 | - await status(201, "PUT", `${R}/environments/production/secrets/DEPLOY_KEY`, { value: "deploy-value" }); | |
| 2266 | - const env = await ok("PUT", `${R}/environments/production`, { reviewers: [{ type: "User", id: ritaId }, { type: "User", login: "sam" }, { type: "User", login: "olive" }], prevent_self_review: true, deployment_branch_policy: null }); | |
| 2267 | - assert.equal(env.can_admins_bypass, false); | |
| 2268 | - const rule = env.protection_rules.find((r) => r.type === "required_reviewers"); | |
| 2269 | - assert.deepEqual(rule.reviewers.map((r) => r.reviewer.login).sort(), ["olive", "rita", "sam"]); | |
| 2270 | - assert.equal(rule.prevent_self_review, true); | |
| 2271 | - | |
| 2272 | - const wf = (n) => `name: deploy\non: push\njobs:\n ship${n}:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo ship\n env:\n K: \${{ secrets.DEPLOY_KEY }}\n`; | |
| 2273 | - const claim = async () => { | |
| 2274 | - const r = await runnerClaim(R, ["linux"]); | |
| 2275 | - if (r.status !== 200) return null; | |
| 2276 | - const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json(); | |
| 2277 | - await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 2278 | - return spec; | |
| 2279 | - }; | |
| 2280 | - const waitingRun = async (sha) => { | |
| 2281 | - const run = await until("a run waiting for review", async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs.find((r) => r.head_sha === sha && r.status === "waiting")); | |
| 2282 | - const jobs = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs; | |
| 2283 | - assert.equal(jobs.length, 1); | |
| 2284 | - assert.equal(jobs[0].status, "waiting"); | |
| 2285 | - // Held before anything starts: no runner can claim it, so no token or secret has left. | |
| 2286 | - assert.equal(await claim(), null); | |
| 2287 | - return run; | |
| 2288 | - }; | |
| 2289 | - | |
| 2290 | - // olive pushes: the job waits, and the deployment says so. | |
| 2291 | - const first = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy", content: b64(wf(1)) })).commit.sha; | |
| 2292 | - const run = await waitingRun(first); | |
| 2293 | - const pendingAs = async (as, id = run.id) => (await ok("GET", `${R}/actions/runs/${id}/pending_deployments`, undefined, as))[0]; | |
| 2294 | - const p = await pendingAs(T.rita); | |
| 2295 | - assert.equal(p.environment.name, "production"); | |
| 2296 | - assert.equal(p.current_user_can_approve, true); | |
| 2297 | - assert.equal((await pendingAs(T.olive)).current_user_can_approve, false, "olive started the run and the environment prevents self-review"); | |
| 2298 | - const deployment = (await ok("GET", `${R}/deployments?environment=production`))[0]; | |
| 2299 | - assert.equal((await ok("GET", `${R}/deployments/${deployment.id}/statuses`))[0].state, "waiting"); | |
| 2300 | - | |
| 2301 | - // Nobody but a listed person, other than the run's starter, with a token that may write, decides. | |
| 2302 | - const body = { environment_ids: [p.environment.id], state: "approved", comment: "ship it" }; | |
| 2303 | - assert.match((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body)).json.message, /doesn't let whoever started a run approve it/); | |
| 2304 | - assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.bot)).status, 403); | |
| 2305 | - assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.reader)).status, 403); | |
| 2306 | - assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.milo)).status, 404); | |
| 2307 | - await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, environment_ids: [999] }, T.rita); | |
| 2308 | - const approved = await ok("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.rita); | |
| 2309 | - assert.equal(approved[0].environment, "production"); | |
| 2310 | - // One decision counts: a second one finds nothing waiting. | |
| 2311 | - await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, state: "rejected" }, T.sam); | |
| 2312 | - | |
| 2313 | - // Now the job starts, and only now gets the environment's secret. | |
| 2314 | - const spec = await until("the approved job", claim); | |
| 2315 | - assert.equal(spec.secrets.DEPLOY_KEY, "deploy-value"); | |
| 2316 | - const done = await until("the run done", async () => { | |
| 2317 | - const r = await ok("GET", `${R}/actions/runs/${run.id}`); | |
| 2318 | - return r.status === "completed" ? r : null; | |
| 2319 | - }); | |
| 2320 | - assert.equal(done.conclusion, "success"); | |
| 2321 | - const approvals = await ok("GET", `${R}/actions/runs/${run.id}/approvals`); | |
| 2322 | - assert.deepEqual(approvals.map((a) => [a.state, a.user.login, a.comment, a.environments[0].name]), [["approved", "rita", "ship it", "production"]]); | |
| 2323 | - assert.deepEqual((await ok("GET", `${R}/deployments/${deployment.id}/statuses`)).map((s) => s.state), ["success", "in_progress", "waiting"]); | |
| 2324 | - | |
| 2325 | - // A rejection fails the job without starting it. sam decides in the browser, from the run page. | |
| 2326 | - const file = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 2327 | - const second = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 2", content: b64(wf(2)), sha: file.sha })).commit.sha; | |
| 2328 | - const run2 = await waitingRun(second); | |
| 2329 | - const page = await (await fetch(`${base}/${OWNER}/gated-deploy/actions/runs/${run2.id}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } })).text(); | |
| 2330 | - assert.match(page, /Waiting for review/); | |
| 2331 | - assert.match(page, /You started this run, and this environment doesn't let you approve it|You started this run, and this environment doesn't let you approve it/); | |
| 2332 | - const envId = (await pendingAs(T.rita, run2.id)).environment.id; | |
| 2333 | - const rejected = await submitsAs(S.sam, `/${OWNER}/gated-deploy/actions/runs/${run2.id}`, `/${OWNER}/gated-deploy/actions/runs/${run2.id}/review`, { environment: String(envId), comment: "not today", state: "rejected" }); | |
| 2334 | - assert.equal(rejected.status, 303); | |
| 2335 | - assert.match(rejected.flash, /Rejected/); | |
| 2336 | - const failed = await until("the rejected run done", async () => { | |
| 2337 | - const r = await ok("GET", `${R}/actions/runs/${run2.id}`); | |
| 2338 | - return r.status === "completed" ? r : null; | |
| 2339 | - }); | |
| 2340 | - assert.equal(failed.conclusion, "failure"); | |
| 2341 | - const job = (await ok("GET", `${R}/actions/runs/${run2.id}/jobs`)).jobs[0]; | |
| 2342 | - assert.match(job.failure.message, /rejected by sam: not today/); | |
| 2343 | - assert.equal(await claim(), null); | |
| 2344 | - | |
| 2345 | - // Self-review compares accounts, not names: sam starts a run, renames, and still can't approve it. | |
| 2346 | - await collaborator(R, "sam", "write", T.sam); | |
| 2347 | - const file3 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 2348 | - const fourth = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 4", content: b64(wf(4)), sha: file3.sha }, T.sam)).commit.sha; | |
| 2349 | - const run4 = await waitingRun(fourth); | |
| 2350 | - const env4 = (await pendingAs(T.rita, run4.id)).environment.id; | |
| 2351 | - const renamed = await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam-renamed", name: "sam" }); | |
| 2352 | - assert.equal(renamed.status, 303); | |
| 2353 | - try { | |
| 2354 | - assert.equal((await ok("GET", "/user", undefined, T.sam)).login, "sam-renamed"); | |
| 2355 | - assert.equal((await pendingAs(T.sam, run4.id)).current_user_can_approve, false); | |
| 2356 | - const self = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.sam); | |
| 2357 | - assert.equal(self.status, 403); | |
| 2358 | - assert.match(self.json.message, /doesn't let whoever started a run approve it/); | |
| 2359 | - } finally { | |
| 2360 | - assert.equal((await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam", name: "sam" })).status, 303); | |
| 2361 | - } | |
| 2362 | - | |
| 2363 | - // Access counts when deciding, not only when the list was set: on a public repository, where | |
| 2364 | - // anyone may read, a reviewer removed from it no longer approves. | |
| 2365 | - await ok("PATCH", R, { visibility: "public" }); | |
| 2366 | - await status(204, "DELETE", `${R}/collaborators/rita`); | |
| 2367 | - assert.equal((await pendingAs(T.rita, run4.id)).current_user_can_approve, false); | |
| 2368 | - const removed = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.rita); | |
| 2369 | - assert.equal(removed.status, 403); | |
| 2370 | - assert.match(removed.json.message, /only people with access to this repository/); | |
| 2371 | - await ok("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "rejected", comment: "cleanup" }); | |
| 2372 | - await ok("PATCH", R, { visibility: "private" }); | |
| 2373 | - await collaborator(R, "rita", "read", T.rita); | |
| 2374 | - | |
| 2375 | - // A wait timer alone holds the job until it passes; nobody skips it. | |
| 2376 | - await ok("PUT", `${R}/environments/production`, { reviewers: [], wait_timer: 1 }); | |
| 2377 | - const file2 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 2378 | - const third = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 3", content: b64(wf(3)), sha: file2.sha })).commit.sha; | |
| 2379 | - const run3 = await waitingRun(third); | |
| 2380 | - const timer = await pendingAs(T.rita, run3.id); | |
| 2381 | - assert.equal(timer.wait_timer, 1); | |
| 2382 | - assert.equal(timer.current_user_can_approve, false); | |
| 2383 | - await status(422, "POST", `${R}/actions/runs/${run3.id}/pending_deployments`, { environment_ids: [timer.environment.id], state: "approved" }, T.rita); | |
| 2384 | - // The Worker runs with a short wait-timer minute (WAIT_TIMER_MINUTE_MS), so the timer passes in seconds. | |
| 2385 | - if (Date.now() < Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 2000) assert.equal(await claim(), null, "nothing starts before the timer passes"); | |
| 2386 | - const timed = await until("the job after its wait timer", claim, 60_000); | |
| 2387 | - assert.ok(Date.now() >= Date.parse(timer.wait_timer_started_at) + TIMER_MINUTE_MS - 1000, "the job waited for its timer"); | |
| 2388 | - assert.equal(timed.secrets.DEPLOY_KEY, "deploy-value"); | |
| 2389 | -}); | |
| 2390 | - | |
| 2391 | -test("security: adding someone by username sends an invitation, and they get access only once they accept", async () => { | |
| 2392 | - // ------------------------------------------------------------ a repository: milo is invited, not added | |
| 2393 | - await repoWithReadme("by-name"); | |
| 2394 | - const R = `/repos/${OWNER}/by-name`; | |
| 2395 | - const inv = await status(201, "PUT", `${R}/collaborators/milo`, { permission: "write" }); | |
| 2396 | - assert.equal(inv.invitee.login, "milo"); | |
| 2397 | - assert.equal(inv.permissions, "write"); | |
| 2398 | - await status(404, "GET", R, undefined, T.milo); | |
| 2399 | - // The admin's list names the username, never the person's email address. | |
| 2400 | - const listed = await ok("GET", `${R}/invitations`); | |
| 2401 | - assert.deepEqual(listed.map((i) => [i.login, i.email, i.role]), [["milo", null, "write"]]); | |
| 2402 | - // In-app and in milo's own list. | |
| 2403 | - assert.ok((await ok("GET", "/notifications", undefined, T.milo)).some((n) => /olive invited you to olive\/by-name/.test(n.title))); | |
| 2404 | - const mine = await ok("GET", "/user/repository_invitations", undefined, T.milo); | |
| 2405 | - assert.deepEqual(mine.map((i) => [i.id, i.repository.full_name]), [[inv.id, `${OWNER}/by-name`]]); | |
| 2406 | - // Nobody else can accept it, and a read token can't either. | |
| 2407 | - await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.rita); | |
| 2408 | - assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []); | |
| 2409 | - await status(404, "GET", R, undefined, T.rita); | |
| 2410 | - // Asking again changes the pending invitation's role rather than sending another. | |
| 2411 | - assert.equal((await status(201, "PUT", `${R}/collaborators/milo`, { permission: "admin" })).id, inv.id); | |
| 2412 | - // milo accepts and has the role offered. | |
| 2413 | - await status(204, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo); | |
| 2414 | - assert.equal((await ok("GET", R, undefined, T.milo)).permissions.admin, true); | |
| 2415 | - await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo); | |
| 2416 | - // A collaborator's role then changes at once. | |
| 2417 | - await status(204, "PUT", `${R}/collaborators/milo`, { permission: "read" }); | |
| 2418 | - assert.equal((await ok("GET", R, undefined, T.milo)).permissions.push, false); | |
| 2419 | - // The settings form that sets a role invites someone new the same way. | |
| 2420 | - const viaForm = await oliveSubmits(`/${OWNER}/by-name/settings/access`, `/${OWNER}/by-name/settings/access`, { login: "rita", role: "write" }); | |
| 2421 | - assert.equal(viaForm.status, 303); | |
| 2422 | - assert.match(viaForm.flash, /Invited rita/); | |
| 2423 | - await status(404, "GET", R, undefined, T.rita); | |
| 2424 | - // rita declines: no access, and the invitation is gone. | |
| 2425 | - const ritas = await ok("GET", "/user/repository_invitations", undefined, T.rita); | |
| 2426 | - assert.equal(ritas.length, 1); | |
| 2427 | - await status(204, "DELETE", `/user/repository_invitations/${ritas[0].id}`, undefined, T.rita); | |
| 2428 | - await status(404, "GET", R, undefined, T.rita); | |
| 2429 | - assert.deepEqual(await ok("GET", `${R}/invitations`), []); | |
| 2430 | - // Agents are still added directly: they act for a person who already has access. | |
| 2431 | - await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" }); | |
| 2432 | - | |
| 2433 | - // ------------------------------------------------------------ an organization: Add people and the role form both invite | |
| 2434 | - await ok("POST", "/user/orgs", { login: "by-name-org" }); | |
| 2435 | - const members = async () => (await ok("GET", "/orgs/by-name-org/members")).map((u) => u.login); | |
| 2436 | - const added = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/people", { who: "milo", role: "member" }); | |
| 2437 | - assert.equal(added.status, 303); | |
| 2438 | - assert.match(added.flash, /Invited milo to by-name-org/); | |
| 2439 | - const roleForm = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/members", { login: "sam", role: "admin" }); | |
| 2440 | - assert.match(roleForm.flash, /Invited sam to by-name-org/); | |
| 2441 | - assert.deepEqual(await members(), ["olive"]); | |
| 2442 | - assert.equal((await ok("GET", "/user/memberships/orgs/by-name-org", undefined, T.milo)).state, "pending"); | |
| 2443 | - assert.deepEqual((await ok("GET", "/user/memberships/orgs?state=pending", undefined, T.milo)).map((m) => m.organization.login), ["by-name-org"]); | |
| 2444 | - // milo accepts through the API, sam declines on the web. | |
| 2445 | - assert.equal((await ok("PATCH", "/user/memberships/orgs/by-name-org", { state: "active" }, T.milo)).state, "active"); | |
| 2446 | - const samInvite = (await ok("GET", "/orgs/by-name-org/invitations")).find((i) => i.login === "sam"); | |
| 2447 | - const page = await (await fetch(`${base}/settings/invitations`, { headers: { cookie: `__Host-dfy_session=${S.sam}` } })).text(); | |
| 2448 | - assert.match(page, /by-name-org/); | |
| 2449 | - const declined = await submitsAs(S.sam, "/settings/invitations", `/settings/invitations/${samInvite.id}/decline`, {}); | |
| 2450 | - assert.match(declined.flash, /Invitation declined/); | |
| 2451 | - assert.deepEqual((await members()).sort(), ["milo", "olive"]); | |
| 2452 | - await status(404, "GET", "/user/memberships/orgs/by-name-org", undefined, T.sam); | |
| 2453 | - | |
| 2454 | - // ------------------------------------------------------------ someone waiting for an invite accepts it from the waiting page | |
| 2455 | - await repoWithReadme("for-penny"); | |
| 2456 | - const pennyInvite = await status(201, "PUT", `/repos/${OWNER}/for-penny/collaborators/penny`, { permission: "read" }); | |
| 2457 | - const waiting = await (await fetch(base, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).text(); | |
| 2458 | - assert.match(waiting, /Waiting for an invite/); | |
| 2459 | - assert.match(waiting, new RegExp(`${OWNER}/for-penny`)); | |
| 2460 | - const accepted = await submitsAs(S.penny, "/", `/settings/invitations/${pennyInvite.id}/accept`, {}); | |
| 2461 | - assert.equal(accepted.status, 303, accepted.flash); | |
| 2462 | - assert.equal(accepted.location, `/${OWNER}/for-penny`); | |
| 2463 | - assert.equal((await fetch(`${base}/${OWNER}/for-penny`, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).status, 200); | |
| 2464 | - | |
| 2465 | - // ------------------------------------------------------------ a repository with invitations can still be deleted | |
| 2466 | - await repoWithReadme("invited-then-deleted"); | |
| 2467 | - await status(201, "PUT", `/repos/${OWNER}/invited-then-deleted/collaborators/rita`, { permission: "read" }); | |
| 2468 | - await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`); | |
| 2469 | - assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []); | |
| 2470 | -}); | |
| 2471 | - | |
| 2472 | -test("security: per-account quotas on hosted minutes, hosted jobs at once, sandboxes and repositories", async () => { | |
| 2473 | - // Only site administrators see or change another account's quotas. | |
| 2474 | - await status(403, "PATCH", "/admin/accounts/rita/quotas", { repositories: 1 }); | |
| 2475 | - await status(403, "GET", "/admin/accounts/rita/quotas", undefined, T.rita); | |
| 2476 | - await status(422, "PATCH", "/admin/accounts/rita/quotas", { storage: 1 }, T.sully); | |
| 2477 | - const set = await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: 1, hosted_minutes: 0, sandboxes: 0 }, T.sully); | |
| 2478 | - assert.deepEqual([set.repositories.limit, set.hosted_minutes.limit, set.sandboxes.limit, set.exempt], [1, 0, 0, false]); | |
| 2479 | - await until("the audit entry", async () => auditLog("account.quotas.set").some((e) => e.target === "rita" && e.actor_login === "sully")); | |
| 2480 | - // rita sees her own usage and limits. | |
| 2481 | - const mine = await ok("GET", "/user/usage", undefined, T.rita); | |
| 2482 | - assert.deepEqual([mine.account, mine.repositories.limit, mine.hosted_minutes.used], ["rita", 1, 0]); | |
| 2483 | - | |
| 2484 | - // ------------------------------------------------------------ repositories | |
| 2485 | - const owned = mine.repositories.count; | |
| 2486 | - await setRitaRepos(owned + 1); | |
| 2487 | - await ok("POST", "/user/repos", { name: "rita-quota-1" }, T.rita); | |
| 2488 | - assert.match((await status(403, "POST", "/user/repos", { name: "rita-quota-2" }, T.rita)).message, /rita has reached its limit of \d+ repositories/); | |
| 2489 | - | |
| 2490 | - // ------------------------------------------------------------ hosted minutes: a hosted job isn't started | |
| 2491 | - const R = "/repos/rita/rita-quota-1"; | |
| 2492 | - await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n") }, T.rita); | |
| 2493 | - const job = await until("the job to fail", async () => { | |
| 2494 | - const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs; | |
| 2495 | - if (!runs.length) return null; | |
| 2496 | - const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs; | |
| 2497 | - return jobs[0]?.status === "completed" ? { run: runs[0].id, job: jobs[0] } : null; | |
| 2498 | - }); | |
| 2499 | - assert.equal(job.job.conclusion, "failure"); | |
| 2500 | - const page = await (await fetch(`${base}/rita/rita-quota-1/actions/runs/${job.run}`, { headers: { authorization: `Bearer ${T.rita}` } })).text(); | |
| 2501 | - assert.match(page, /used its 0 hosted minutes for this month/); | |
| 2502 | - | |
| 2503 | - // ------------------------------------------------------------ sandboxes: the minutes count for them too, and so does their own limit | |
| 2504 | - assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /used its 0 hosted minutes/); | |
| 2505 | - await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_minutes: null, hosted_jobs: 0 }, T.sully); | |
| 2506 | - assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /already has 0 agent sandboxes open/); | |
| 2507 | - | |
| 2508 | - // ------------------------------------------------------------ hosted jobs at once: the job waits, then starts once there's room | |
| 2509 | - const file = await ok("GET", `${R}/contents/.github/workflows/ci.yml`, undefined, T.rita); | |
| 2510 | - await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "again", sha: file.sha, content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo again\n") }, T.rita); | |
| 2511 | - const held = await until("a second run", async () => { | |
| 2512 | - const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs; | |
| 2513 | - if (runs.length < 2) return null; | |
| 2514 | - const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs; | |
| 2515 | - return jobs[0] ? { run: runs[0].id, job: jobs[0] } : null; | |
| 2516 | - }); | |
| 2517 | - await sleep(3000); | |
| 2518 | - assert.equal((await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status, "queued"); | |
| 2519 | - // With room again, the alarm starts it within a minute (here it then fails: no containers under wrangler dev). | |
| 2520 | - await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_jobs: null }, T.sully); | |
| 2521 | - await until("the held job to leave the queue", async () => (await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status !== "queued", 120_000); | |
| 2522 | -}); | |
| 2523 | - | |
| 2524 | -test("security: a per-account storage quota refuses pushes, Git LFS and release asset uploads past it; reads and deletes keep working", async () => { | |
| 2525 | - const R = "/repos/milo/hoard"; | |
| 2526 | - await ok("POST", "/user/repos", { name: "hoard" }, T.milo); | |
| 2527 | - const usage = async () => (await ok("GET", "/user/usage", undefined, T.milo)).storage_bytes; | |
| 2528 | - assert.equal((await usage()).limit, 10 * 1024 ** 3); | |
| 2529 | - // Push main and a topic branch, and upload a release asset, while there is room. | |
| 2530 | - const clone = join(work, "hoard"); | |
| 2531 | - const remote = `${base.replace("://", "://x:" + T.milo + "@")}/milo/hoard.git`; | |
| 2532 | - execFileSync("git", ["init", "-q", "-b", "main", clone]); | |
| 2533 | - const commit = (name) => { | |
| 2534 | - writeFileSync(join(clone, name), randomBytes(2000).toString("hex")); | |
| 2535 | - gitRun(["add", name], clone); | |
| 2536 | - gitRun(["commit", "-q", "-m", name], clone); | |
| 2537 | - }; | |
| 2538 | - const push = (...args) => spawnSync("git", ["-c", "protocol.version=2", "push", remote, ...args], { cwd: clone, encoding: "utf8" }); | |
| 2539 | - commit("a.txt"); | |
| 2540 | - assert.equal(push("HEAD:main", "HEAD:refs/heads/topic").status, 0); | |
| 2541 | - // Repositories report what they store as it changes: wait until the count stops moving. | |
| 2542 | - const settled = async (what, test) => { | |
| 2543 | - let last = -1; | |
| 2544 | - return until(what, async () => { | |
| 2545 | - const u = (await usage()).used; | |
| 2546 | - if (u === last && test(u)) return u; | |
| 2547 | - last = u; | |
| 2548 | - await sleep(1000); | |
| 2549 | - return null; | |
| 2550 | - }); | |
| 2551 | - }; | |
| 2552 | - const pushed = await settled("the push to be counted", (u) => u > 2000); | |
| 2553 | - const rel = await ok("POST", `${R}/releases`, { tag_name: "v1", target_commitish: "main" }, T.milo); | |
| 2554 | - const upload = (bytes) => fetch(`${base}/api/uploads/repos/milo/hoard/releases/${rel.id}/assets?name=${bytes.length}.bin`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: bytes }); | |
| 2555 | - const asset = await (await upload(randomBytes(3000))).json(); | |
| 2556 | - assert.equal(asset.size, 3000); | |
| 2557 | - const used = await settled("the asset to be counted", (u) => u === pushed + 3000); | |
| 2558 | - assert.equal((await ok("GET", "/admin/accounts/milo/quotas", undefined, T.sully)).storage_bytes.used, used); | |
| 2559 | - | |
| 2560 | - // ------------------------------------------------------------ at the limit: nothing new is stored | |
| 2561 | - const set = await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: used }, T.sully); | |
| 2562 | - assert.deepEqual(set.storage_bytes, { used, limit: used }); | |
| 2563 | - const full = new RegExp(`milo has reached its storage limit of ${used} bytes`); | |
| 2564 | - commit("b.txt"); | |
| 2565 | - const refused = push("HEAD:main"); | |
| 2566 | - assert.notEqual(refused.status, 0); | |
| 2567 | - assert.match(refused.stderr, new RegExp(`remote: Push refused: ${full.source}`)); | |
| 2568 | - assert.match(refused.stderr, /storage limit reached/); | |
| 2569 | - const lfsOid = (bytes) => createHash("sha256").update(bytes).digest("hex"); | |
| 2570 | - const lfsBlob = randomBytes(1000); | |
| 2571 | - const batch = async () => { | |
| 2572 | - const r = await fetch(`${base}/milo/hoard.git/info/lfs/objects/batch`, { method: "POST", headers: { authorization: `Bearer ${T.milo}`, accept: "application/vnd.git-lfs+json", "content-type": "application/vnd.git-lfs+json" }, body: JSON.stringify({ operation: "upload", objects: [{ oid: lfsOid(lfsBlob), size: lfsBlob.length }] }) }); | |
| 2573 | - assert.equal(r.status, 200); | |
| 2574 | - return (await r.json()).objects[0]; | |
| 2575 | - }; | |
| 2576 | - const lfs = await batch(); | |
| 2577 | - assert.equal(lfs.error.code, 507); | |
| 2578 | - assert.match(lfs.error.message, full); | |
| 2579 | - const put = await fetch(`${base}/milo/hoard.git/info/lfs/objects/${lfsOid(lfsBlob)}`, { method: "PUT", headers: { authorization: `Bearer ${T.milo}`, "content-type": "application/octet-stream" }, body: lfsBlob }); | |
| 2580 | - assert.equal(put.status, 507); | |
| 2581 | - const late = await upload(randomBytes(10)); | |
| 2582 | - assert.equal(late.status, 403); | |
| 2583 | - assert.match((await late.json()).message, full); | |
| 2584 | - assert.match((await status(403, "PUT", `${R}/contents/c.txt`, { message: "c", content: b64("more") }, T.milo)).message, full); | |
| 2585 | - assert.match((await status(403, "POST", `${R}/git/blobs`, { content: "more" }, T.milo)).message, full); | |
| 2586 | - // A chunked registry upload declares no length, so its bytes are counted as they arrive. | |
| 2587 | - const reg = { authorization: `Basic ${Buffer.from(`milo:${T.milo}`).toString("base64")}` }; | |
| 2588 | - const started = await fetch(`${base}/v2/milo/hoard/blobs/uploads/`, { method: "POST", headers: reg }); | |
| 2589 | - assert.equal(started.status, 202, await started.clone().text()); | |
| 2590 | - const chunked = new ReadableStream({ | |
| 2591 | - start(ctl) { | |
| 2592 | - ctl.enqueue(randomBytes(1000)); | |
| 2593 | - ctl.close(); | |
| 2594 | - }, | |
| 2595 | - }); | |
| 2596 | - const sneak = await fetch(`${base}${started.headers.get("location")}`, { method: "PATCH", headers: { ...reg, "content-type": "application/octet-stream" }, body: chunked, duplex: "half" }); | |
| 2597 | - assert.equal(sneak.status, 403); | |
| 2598 | - assert.match(await sneak.text(), /milo has reached its storage limit/); | |
| 2599 | - // An upload refused at the quota can't finish, so it is gone with its bytes: a retry has to start again. | |
| 2600 | - assert.equal((await fetch(`${base}${started.headers.get("location")}`, { headers: reg })).status, 404); | |
| 2601 | - | |
| 2602 | - // ------------------------------------------------------------ reads, ref deletions and ref moves still work | |
| 2603 | - assert.equal((await ok("GET", `${R}/contents/a.txt`, undefined, T.milo)).size, 4000); | |
| 2604 | - assert.equal(push(":refs/heads/topic").status, 0); | |
| 2605 | - assert.equal(push("HEAD~1:refs/heads/copy").status, 0); | |
| 2606 | - assert.deepEqual((await ok("GET", `${R}/branches`, undefined, T.milo)).map((b) => b.name).sort(), ["copy", "main"]); | |
| 2607 | - | |
| 2608 | - // ------------------------------------------------------------ deleting brings usage down, and there is room again | |
| 2609 | - await status(204, "DELETE", `${R}/releases/assets/${asset.id}`, undefined, T.milo); | |
| 2610 | - await until("the asset's bytes to be given back", async () => (await usage()).used === used - 3000); | |
| 2611 | - assert.ok((await batch()).actions.upload); | |
| 2612 | - await ok("PATCH", "/admin/accounts/milo/quotas", { storage_bytes: null }, T.sully); | |
| 2613 | - assert.equal(push("HEAD:main").status, 0); | |
| 2614 | -}); | |
| 2615 | - | |
| 2616 | -/** Set rita's repository quota (site administrators only). */ | |
| 2617 | -async function setRitaRepos(n) { | |
| 2618 | - await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully); | |
| 2619 | -} | |
| 2620 | - | |
| 2621 | -test("security: kill switches suspend Actions for a repository or an account, and pause hosted compute on the instance", async () => { | |
| 2622 | - const R = `/repos/${OWNER}/halted`; | |
| 2623 | - const S = `/admin/repos/${OWNER}/halted/actions-suspension`; | |
| 2624 | - const A = `/admin/accounts/${OWNER}/actions-suspension`; | |
| 2625 | - const P = "/admin/hosted-compute-pause"; | |
| 2626 | - await repoWithReadme("halted"); | |
| 2627 | - const runs = async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs; | |
| 2628 | - const html = async (path, as = T.olive) => (await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${as}` } })).text(); | |
| 2629 | - const push = async (path, text) => ok("PUT", `${R}/contents/${path}`, { message: path, content: b64(text) }); | |
| 2630 | - // A self-hosted job that no runner takes yet: the run stays in progress. | |
| 2631 | - await push(".github/workflows/ci.yml", "name: ci\non:\n push:\n workflow_dispatch:\n repository_dispatch:\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n"); | |
| 2632 | - const first = await until("a run waiting for a runner", async () => { | |
| 2633 | - const [run] = await runs(); | |
| 2634 | - return run && (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs.length ? run : null; | |
| 2635 | - }); | |
| 2636 | - | |
| 2637 | - try { | |
| 2638 | - // ------------------------------------------------------------ only site administrators | |
| 2639 | - for (const path of [S, A, P]) { | |
| 2640 | - for (const method of ["GET", "PUT", "DELETE"]) assert.match((await status(403, method, path, method === "PUT" ? { reason: "mine" } : undefined, T.olive)).message, /only site administrators/); | |
| 2641 | - await status(401, "PUT", path, { reason: "no token" }, null); | |
| 2642 | - } | |
| 2643 | - await status(422, "PUT", S, {}, T.sully); | |
| 2644 | - await status(404, "PUT", `/admin/repos/${OWNER}/nowhere/actions-suspension`, { reason: "x" }, T.sully); | |
| 2645 | - await status(404, "PUT", "/admin/accounts/olive-bot/actions-suspension", { reason: "x" }, T.sully); | |
| 2646 | - assert.equal((await ok("GET", S, undefined, T.sully)).suspended, false); | |
| 2647 | - | |
| 2648 | - // ------------------------------------------------------------ a repository: what runs stops, nothing new starts | |
| 2649 | - const s = await ok("PUT", S, { reason: "crypto mining" }, T.sully); | |
| 2650 | - assert.deepEqual([s.suspended, s.reason, s.suspended_by, s.stopped.runs, s.stopped.jobs], [true, "crypto mining", "sully", 1, 1]); | |
| 2651 | - const cancelled = await until("the run to be cancelled", async () => { | |
| 2652 | - const r = await ok("GET", `${R}/actions/runs/${first.id}`); | |
| 2653 | - return r.status === "completed" ? r : null; | |
| 2654 | - }); | |
| 2655 | - assert.equal(cancelled.conclusion, "cancelled"); | |
| 2656 | - assert.match(await html(`/${OWNER}/halted/actions/runs/${first.id}`), /Cancelled: Actions are suspended for this repository by a site administrator: crypto mining/); | |
| 2657 | - assert.equal((await runnerClaim(R, ["linux"])).status, 204); | |
| 2658 | - assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for this repository by a site administrator: crypto mining/); | |
| 2659 | - assert.match((await status(403, "POST", `${R}/dispatches`, { event_type: "go" })).message, /suspended for this repository/); | |
| 2660 | - assert.match((await status(403, "POST", `${R}/actions/runs/${first.id}/rerun`)).message, /suspended for this repository/); | |
| 2661 | - assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /suspended for this repository/); | |
| 2662 | - const before = (await runs()).length; | |
| 2663 | - await push("one.txt", "one\n"); | |
| 2664 | - await sleep(3000); | |
| 2665 | - assert.equal((await runs()).length, before, "a push starts no run while Actions are suspended"); | |
| 2666 | - for (const path of [`/${OWNER}/halted/actions`, `/${OWNER}/halted/settings`]) assert.match(await html(path), /Actions are suspended for this repository by a site administrator/, path); | |
| 2667 | - assert.match(await html("/settings/admin/kill-switches", T.sully), new RegExp(`${OWNER}/halted`)); | |
| 2668 | - await until("the suspension's audit entry", async () => auditLog("actions.suspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully" && JSON.parse(e.detail).reason === "crypto mining")); | |
| 2669 | - | |
| 2670 | - // Lifting it lets new work start; nothing restarts. | |
| 2671 | - assert.equal((await ok("DELETE", S, undefined, T.sully)).suspended, false); | |
| 2672 | - assert.equal((await ok("GET", `${R}/actions/runs/${first.id}`)).conclusion, "cancelled"); | |
| 2673 | - await push("two.txt", "two\n"); | |
| 2674 | - await until("a new run", async () => (await runs()).length > before); | |
| 2675 | - await ok("POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" }); | |
| 2676 | - const sandbox = await api("POST", `${R}/diffinery/sandboxes`, {}); | |
| 2677 | - assert.notEqual(sandbox.status, 403, JSON.stringify(sandbox.json)); // 503 here: no sandbox pool under this test | |
| 2678 | - await until("the lift's audit entry", async () => auditLog("actions.unsuspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully")); | |
| 2679 | - | |
| 2680 | - // ------------------------------------------------------------ an account: every repository it owns | |
| 2681 | - const a = await ok("PUT", A, { reason: "abuse report" }, T.sully); | |
| 2682 | - assert.deepEqual([a.account, a.suspended, a.reason], [OWNER, true, "abuse report"]); | |
| 2683 | - assert.ok(a.stopped.runs >= 1, JSON.stringify(a.stopped)); | |
| 2684 | - assert.equal((await ok("GET", S, undefined, T.sully)).owner_suspended, true); | |
| 2685 | - assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for every repository of olive by a site administrator: abuse report/); | |
| 2686 | - assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /every repository of olive/); | |
| 2687 | - const during = (await runs()).length; | |
| 2688 | - await push("three.txt", "three\n"); | |
| 2689 | - await sleep(3000); | |
| 2690 | - assert.equal((await runs()).length, during); | |
| 2691 | - assert.match(await html(`/${OWNER}/halted/actions`), /Actions are suspended for every repository of olive by a site administrator/); | |
| 2692 | - assert.equal((await ok("DELETE", A, undefined, T.sully)).suspended, false); | |
| 2693 | - await until("the account's audit entries", async () => auditLog("actions.suspend").some((e) => e.target === OWNER && JSON.parse(e.detail).kind === "account") && auditLog("actions.unsuspend").some((e) => e.target === OWNER)); | |
| 2694 | - | |
| 2695 | - // ------------------------------------------------------------ the instance: hosted compute stops, self-hosted runners go on | |
| 2696 | - const p = await ok("PUT", P, { reason: "cost overrun" }, T.sully); | |
| 2697 | - assert.deepEqual([p.paused, p.reason, p.paused_by], [true, "cost overrun", "sully"]); | |
| 2698 | - assert.equal((await ok("GET", P, undefined, T.sully)).paused, true); | |
| 2699 | - assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /Agent sandboxes are paused on this instance by a site administrator: cost overrun/); | |
| 2700 | - assert.match(await html("/settings/admin", T.sully), /Hosted compute is paused on this instance/); | |
| 2701 | - assert.match(await html(`/${OWNER}/halted/actions`), /Hosted runners are paused on this instance by a site administrator/); | |
| 2702 | - await push(".github/workflows/hosted.yml", "name: hosted\non: push\njobs:\n cloud:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"); | |
| 2703 | - const hosted = await until("the hosted job to fail", async () => { | |
| 2704 | - const run = (await runs()).find((r) => r.name === "hosted"); | |
| 2705 | - if (!run) return null; | |
| 2706 | - const [job] = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs; | |
| 2707 | - return job?.status === "completed" ? { run: run.id, job } : null; | |
| 2708 | - }); | |
| 2709 | - assert.equal(hosted.job.conclusion, "failure"); | |
| 2710 | - assert.match(await html(`/${OWNER}/halted/actions/runs/${hosted.run}`), /The job was not started: Hosted runners are paused on this instance by a site administrator: cost overrun/); | |
| 2711 | - // The self-hosted job of the same push is still handed to a runner. | |
| 2712 | - const claim = await until("a self-hosted job claimed", async () => { | |
| 2713 | - const r = await runnerClaim(R, ["linux"]); | |
| 2714 | - return r.status === 200 ? r.json : null; | |
| 2715 | - }); | |
| 2716 | - await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 2717 | - assert.equal((await ok("DELETE", P, undefined, T.sully)).paused, false); | |
| 2718 | - assert.doesNotMatch(await html("/settings/admin", T.sully), /Hosted compute is paused/); | |
| 2719 | - assert.notEqual((await api("POST", `${R}/diffinery/sandboxes`, {})).status, 403); | |
| 2720 | - await until("the pause's audit entries", async () => auditLog("compute.pause").some((e) => e.actor_login === "sully" && JSON.parse(e.detail).reason === "cost overrun") && auditLog("compute.unpause").length > 0); | |
| 2721 | - } finally { | |
| 2722 | - for (const path of [S, A, P]) await api("DELETE", path, undefined, T.sully); | |
| 2723 | - } | |
| 2724 | -}); | |
| 2725 | - | |
| 2726 | -test("security: Markdown images from other sites load through Diffinery's signed image proxy", async () => { | |
| 2727 | - // A stand-in for another site, which records what it is sent. | |
| 2728 | - const seen = []; | |
| 2729 | - const PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea7d6a4c50000000049454e44ae426082", "hex"); | |
| 2730 | - const site = createHttpServer((req, res) => { | |
| 2731 | - seen.push({ url: req.url, headers: req.headers }); | |
| 2732 | - if (req.url === "/pixel.png") return res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" }).end(PNG); | |
| 2733 | - if (req.url === "/moved.png") return res.writeHead(302, { location: "/pixel.png" }).end(); | |
| 2734 | - if (req.url === "/page.html") return res.writeHead(200, { "content-type": "text/html" }).end("<script>alert(1)</script>"); | |
| 2735 | - if (req.url === "/drawing.svg") return res.writeHead(200, { "content-type": "image/svg+xml" }).end('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'); | |
| 2736 | - res.writeHead(404).end(); | |
| 2737 | - }); | |
| 2738 | - await new Promise((r) => site.listen(0, "127.0.0.1", r)); | |
| 2739 | - const other = `http://127.0.0.1:${site.address().port}`; | |
| 2740 | - try { | |
| 2741 | - await repoWithReadme("pictures"); | |
| 2742 | - const body = [``, `[](${other}/page.html)`, ``, ``, ""].join("\n\n"); | |
| 2743 | - const issue = await ok("POST", `/repos/${OWNER}/pictures/issues`, { title: "pictures", body }); | |
| 2744 | - const page = await fetch(`${base}/${OWNER}/pictures/issues/${issue.number}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } }); | |
| 2745 | - assert.equal(page.status, 200); | |
| 2746 | - const csp = page.headers.get("content-security-policy") ?? ""; | |
| 2747 | - assert.match(csp, /img-src 'self' data: blob:(;|$)/); | |
| 2748 | - assert.doesNotMatch(csp, /img-src[^;]*https:/); | |
| 2749 | - const html = await page.text(); | |
| 2750 | - const srcs = [...html.matchAll(/<img src="([^"]*)" alt="(pixel|moved|page|drawing|own)"/g)].map((m) => [m[2], m[1].replaceAll("&", "&")]); | |
| 2751 | - const src = Object.fromEntries(srcs); | |
| 2752 | - assert.deepEqual(Object.keys(src).sort(), ["drawing", "moved", "own", "page", "pixel"]); | |
| 2753 | - for (const name of ["pixel", "moved", "page", "drawing"]) assert.match(src[name], /^\/-\/camo\/[\w-]{43}\/[\w-]+$/, name); | |
| 2754 | - assert.equal(src.own, `/${OWNER}/pictures/raw/main/static/none.png`); | |
| 2755 | - assert.ok(!html.includes(`src="${other}`), "no image loads from the other site directly"); | |
| 2756 | - // Links keep their original target. | |
| 2757 | - assert.ok(html.includes(`href="${other}/page.html"`)); | |
| 2758 | - assert.equal(seen.length, 0, "rendering fetches nothing"); | |
| 2759 | - | |
| 2760 | - // The proxy fetches the image without the viewer's cookies, and answers from its cache next time. | |
| 2761 | - const get = (path, init = {}) => fetch(`${base}${path}`, init); | |
| 2762 | - const first = await get(src.pixel, { headers: { cookie: `__Host-dfy_session=${S.olive}`, "user-agent": "viewer-browser/1" } }); | |
| 2763 | - assert.equal(first.status, 200); | |
| 2764 | - assert.equal(first.headers.get("content-type"), "image/png"); | |
| 2765 | - assert.equal(first.headers.get("x-content-type-options"), "nosniff"); | |
| 2766 | - assert.match(first.headers.get("cache-control") ?? "", /^public, max-age=3600$/); | |
| 2767 | - assert.equal(first.headers.get("set-cookie"), null); | |
| 2768 | - assert.deepEqual(Buffer.from(await first.arrayBuffer()), PNG); | |
| 2769 | - assert.equal(seen.length, 1); | |
| 2770 | - assert.equal(seen[0].headers.cookie, undefined); | |
| 2771 | - assert.match(seen[0].headers["user-agent"] ?? "", /^Diffinery-Camo/); | |
| 2772 | - assert.equal((await get(src.pixel)).status, 200); | |
| 2773 | - assert.equal(seen.filter((r) => r.url === "/pixel.png").length, 1, "the second view is served from the cache"); | |
| 2774 | - | |
| 2775 | - // Redirects are followed; HTML is refused; SVG can't run scripts. | |
| 2776 | - assert.equal((await get(src.moved)).status, 200); | |
| 2777 | - assert.equal((await get(src.page)).status, 404); | |
| 2778 | - const svg = await get(src.drawing); | |
| 2779 | - assert.equal(svg.status, 200); | |
| 2780 | - assert.equal(svg.headers.get("content-type"), "image/svg+xml"); | |
| 2781 | - assert.equal(svg.headers.get("content-security-policy"), "default-src 'none'; style-src 'unsafe-inline'; sandbox"); | |
| 2782 | - | |
| 2783 | - // Only URLs Diffinery signed: a tampered signature or URL, or one Diffinery never signed, is a 404 that fetches nothing. | |
| 2784 | - const before = seen.length; | |
| 2785 | - const [, , , sig, enc] = src.pixel.split("/"); | |
| 2786 | - const evil = Buffer.from(`${other}/page.html?x`).toString("base64url"); | |
| 2787 | - for (const path of [`/-/camo/${sig}/${evil}`, `/-/camo/${sig.slice(0, -2)}AA/${enc}`, `/-/camo/x/${enc}`, `/-/camo/${sig}`, `/-/camo/${"A".repeat(43)}/${Buffer.from(base).toString("base64url")}`]) { | |
| 2788 | - assert.equal((await get(path)).status, 404, path); | |
| 2789 | - } | |
| 2790 | - assert.equal(seen.length, before); | |
| 2791 | - } finally { | |
| 2792 | - site.close(); | |
| 2793 | - } | |
| 2794 | -}); |