File
Blob: test/git-acl.worker.test.ts
| 1 | import { beforeAll, describe, expect, it } from "vitest"; |
| 2 | import { env, exports as workerExports } from "cloudflare:workers"; |
| 3 | |
| 4 | import { newPrefixedId } from "@/worker/common"; |
| 5 | import { createDb } from "@/worker/db/d1/client"; |
| 6 | import { generatePatPlaintext, hashPatPlaintext } from "@/worker/auth/pat"; |
| 7 | import { insertPatWithGrants } from "@/worker/db/d1/dal"; |
| 8 | |
| 9 | import { ensureD1Migrations } from "./util/d1Setup"; |
| 10 | import { seedRepo, type SeededRepo } from "./util/repoSeed"; |
| 11 | |
| 12 | beforeAll(async () => { |
| 13 | await ensureD1Migrations(env); |
| 14 | }); |
| 15 | |
| 16 | function basicAuth(user: string, pass: string): string { |
| 17 | return `Basic ${btoa(`${user}:${pass}`)}`; |
| 18 | } |
| 19 | |
| 20 | async function seedPat(args: { |
| 21 | userId: string; |
| 22 | level: "pull" | "push"; |
| 23 | scope: { kind: "repo"; repoId: string } | { kind: "namespace"; namespaceId: string }; |
| 24 | revokedAt?: number; |
| 25 | expiresAt?: number; |
| 26 | }): Promise<string> { |
| 27 | const db = createDb(env.DB); |
| 28 | const generated = generatePatPlaintext(); |
| 29 | const hash = await hashPatPlaintext(generated.plaintext); |
| 30 | const patId = newPrefixedId("pat"); |
| 31 | await insertPatWithGrants(db, { |
| 32 | pat: { |
| 33 | id: patId, |
| 34 | userId: args.userId, |
| 35 | name: "ci", |
| 36 | prefix: generated.publicPrefix, |
| 37 | hash, |
| 38 | createdAt: Date.now(), |
| 39 | expiresAt: args.expiresAt ?? null, |
| 40 | revokedAt: args.revokedAt ?? null, |
| 41 | lastUsedAt: null, |
| 42 | }, |
| 43 | namespaceGrants: |
| 44 | args.scope.kind === "namespace" |
| 45 | ? [{ patId, namespaceId: args.scope.namespaceId, level: args.level }] |
| 46 | : [], |
| 47 | repoGrants: |
| 48 | args.scope.kind === "repo" ? [{ patId, repoId: args.scope.repoId, level: args.level }] : [], |
| 49 | }); |
| 50 | return generated.plaintext; |
| 51 | } |
| 52 | |
| 53 | function infoRefs(seed: SeededRepo, service: "git-upload-pack" | "git-receive-pack"): string { |
| 54 | return `https://example.com/${seed.namespaceSlug}/${seed.repoSlug}/info/refs?service=${service}`; |
| 55 | } |
| 56 | |
| 57 | function uploadPackUrl(seed: SeededRepo): string { |
| 58 | return `https://example.com/${seed.namespaceSlug}/${seed.repoSlug}/git-upload-pack`; |
| 59 | } |
| 60 | |
| 61 | function receivePackUrl(seed: SeededRepo): string { |
| 62 | return `https://example.com/${seed.namespaceSlug}/${seed.repoSlug}/git-receive-pack`; |
| 63 | } |
| 64 | |
| 65 | const FETCH_BODY = new TextEncoder().encode(""); |
| 66 | |
| 67 | describe("Git ACL: read paths", () => { |
| 68 | it("public + anonymous + info-refs upload-pack -> 200", async () => { |
| 69 | const seed = await seedRepo(env, { |
| 70 | namespaceSlug: `acl-pub-${Math.random().toString(36).slice(2, 8)}`, |
| 71 | repoSlug: "site", |
| 72 | visibility: "public", |
| 73 | }); |
| 74 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack")); |
| 75 | expect(res.status).toBe(200); |
| 76 | expect(res.headers.get("Content-Type")).toContain("git-upload-pack-advertisement"); |
| 77 | }); |
| 78 | |
| 79 | it("public + anonymous + missing route cache -> 404", async () => { |
| 80 | const seed = await seedRepo(env, { |
| 81 | namespaceSlug: `acl-pub-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 82 | repoSlug: "site", |
| 83 | visibility: "public", |
| 84 | skipRouteCache: true, |
| 85 | }); |
| 86 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack")); |
| 87 | expect(res.status).toBe(404); |
| 88 | }); |
| 89 | |
| 90 | it("public + valid PAT + missing route cache -> 200", async () => { |
| 91 | const seed = await seedRepo(env, { |
| 92 | namespaceSlug: `acl-pat-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 93 | repoSlug: "site", |
| 94 | visibility: "public", |
| 95 | skipRouteCache: true, |
| 96 | }); |
| 97 | const plaintext = await seedPat({ |
| 98 | userId: seed.userId, |
| 99 | level: "pull", |
| 100 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 101 | }); |
| 102 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), { |
| 103 | headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) }, |
| 104 | }); |
| 105 | expect(res.status).toBe(200); |
| 106 | }); |
| 107 | |
| 108 | it("public + malformed PAT + missing route cache -> 401", async () => { |
| 109 | const seed = await seedRepo(env, { |
| 110 | namespaceSlug: `acl-badpat-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 111 | repoSlug: "site", |
| 112 | visibility: "public", |
| 113 | skipRouteCache: true, |
| 114 | }); |
| 115 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), { |
| 116 | headers: { Authorization: basicAuth(seed.namespaceSlug, "not-a-pat") }, |
| 117 | }); |
| 118 | expect(res.status).toBe(401); |
| 119 | }); |
| 120 | |
| 121 | it("private + anonymous + info-refs upload-pack -> 404 (non-disclosure)", async () => { |
| 122 | const seed = await seedRepo(env, { |
| 123 | namespaceSlug: `acl-priv-${Math.random().toString(36).slice(2, 8)}`, |
| 124 | repoSlug: "site", |
| 125 | visibility: "private", |
| 126 | }); |
| 127 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack")); |
| 128 | expect(res.status).toBe(404); |
| 129 | }); |
| 130 | |
| 131 | it("private + valid PAT (pull) + info-refs upload-pack -> 200", async () => { |
| 132 | const seed = await seedRepo(env, { |
| 133 | namespaceSlug: `acl-pat-pull-${Math.random().toString(36).slice(2, 8)}`, |
| 134 | repoSlug: "site", |
| 135 | visibility: "private", |
| 136 | }); |
| 137 | const plaintext = await seedPat({ |
| 138 | userId: seed.userId, |
| 139 | level: "pull", |
| 140 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 141 | }); |
| 142 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), { |
| 143 | headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) }, |
| 144 | }); |
| 145 | expect(res.status).toBe(200); |
| 146 | }); |
| 147 | |
| 148 | it("private + valid PAT + missing route cache -> 200", async () => { |
| 149 | const seed = await seedRepo(env, { |
| 150 | namespaceSlug: `acl-priv-pat-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 151 | repoSlug: "site", |
| 152 | visibility: "private", |
| 153 | skipRouteCache: true, |
| 154 | }); |
| 155 | const plaintext = await seedPat({ |
| 156 | userId: seed.userId, |
| 157 | level: "pull", |
| 158 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 159 | }); |
| 160 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), { |
| 161 | headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) }, |
| 162 | }); |
| 163 | expect(res.status).toBe(200); |
| 164 | }); |
| 165 | |
| 166 | it("private + valid PAT (push) + info-refs upload-pack -> 200", async () => { |
| 167 | const seed = await seedRepo(env, { |
| 168 | namespaceSlug: `acl-pat-push-r-${Math.random().toString(36).slice(2, 8)}`, |
| 169 | repoSlug: "site", |
| 170 | visibility: "private", |
| 171 | }); |
| 172 | const plaintext = await seedPat({ |
| 173 | userId: seed.userId, |
| 174 | level: "push", |
| 175 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 176 | }); |
| 177 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), { |
| 178 | headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) }, |
| 179 | }); |
| 180 | expect(res.status).toBe(200); |
| 181 | }); |
| 182 | |
| 183 | it("private + Basic username mismatch -> 401", async () => { |
| 184 | const seed = await seedRepo(env, { |
| 185 | namespaceSlug: `acl-mismatch-${Math.random().toString(36).slice(2, 8)}`, |
| 186 | repoSlug: "site", |
| 187 | visibility: "private", |
| 188 | }); |
| 189 | const plaintext = await seedPat({ |
| 190 | userId: seed.userId, |
| 191 | level: "pull", |
| 192 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 193 | }); |
| 194 | const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), { |
| 195 | headers: { Authorization: basicAuth("not-the-namespace", plaintext) }, |
| 196 | }); |
| 197 | expect(res.status).toBe(401); |
| 198 | }); |
| 199 | |
| 200 | it("private + git-upload-pack POST without creds -> 404", async () => { |
| 201 | const seed = await seedRepo(env, { |
| 202 | namespaceSlug: `acl-priv-post-${Math.random().toString(36).slice(2, 8)}`, |
| 203 | repoSlug: "site", |
| 204 | visibility: "private", |
| 205 | }); |
| 206 | const res = await workerExports.default.fetch(uploadPackUrl(seed), { |
| 207 | method: "POST", |
| 208 | headers: { |
| 209 | "Content-Type": "application/x-git-upload-pack-request", |
| 210 | "Git-Protocol": "version=2", |
| 211 | }, |
| 212 | body: FETCH_BODY, |
| 213 | }); |
| 214 | expect(res.status).toBe(404); |
| 215 | }); |
| 216 | }); |
| 217 | |
| 218 | describe("Git ACL: push paths", () => { |
| 219 | it("private + anonymous + info-refs receive-pack -> 401 challenge", async () => { |
| 220 | const seed = await seedRepo(env, { |
| 221 | namespaceSlug: `acl-priv-recv-${Math.random().toString(36).slice(2, 8)}`, |
| 222 | repoSlug: "site", |
| 223 | visibility: "private", |
| 224 | }); |
| 225 | const res = await workerExports.default.fetch(infoRefs(seed, "git-receive-pack")); |
| 226 | expect(res.status).toBe(401); |
| 227 | expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/); |
| 228 | }); |
| 229 | |
| 230 | it("private + anonymous + missing route cache + info-refs receive-pack -> 401 challenge", async () => { |
| 231 | const seed = await seedRepo(env, { |
| 232 | namespaceSlug: `acl-priv-recv-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 233 | repoSlug: "site", |
| 234 | visibility: "private", |
| 235 | skipRouteCache: true, |
| 236 | }); |
| 237 | const res = await workerExports.default.fetch(infoRefs(seed, "git-receive-pack")); |
| 238 | expect(res.status).toBe(401); |
| 239 | expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/); |
| 240 | }); |
| 241 | |
| 242 | it("public + anonymous + missing route cache + info-refs receive-pack -> 401 challenge", async () => { |
| 243 | const seed = await seedRepo(env, { |
| 244 | namespaceSlug: `acl-pub-recv-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 245 | repoSlug: "site", |
| 246 | visibility: "public", |
| 247 | skipRouteCache: true, |
| 248 | }); |
| 249 | const res = await workerExports.default.fetch(infoRefs(seed, "git-receive-pack")); |
| 250 | expect(res.status).toBe(401); |
| 251 | expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/); |
| 252 | }); |
| 253 | |
| 254 | it("private + anonymous + missing route cache + receive-pack POST -> 401 challenge", async () => { |
| 255 | const seed = await seedRepo(env, { |
| 256 | namespaceSlug: `acl-priv-post-miss-${Math.random().toString(36).slice(2, 8)}`, |
| 257 | repoSlug: "site", |
| 258 | visibility: "private", |
| 259 | skipRouteCache: true, |
| 260 | }); |
| 261 | const res = await workerExports.default.fetch(receivePackUrl(seed), { |
| 262 | method: "POST", |
| 263 | headers: { |
| 264 | "Content-Type": "application/x-git-receive-pack-request", |
| 265 | }, |
| 266 | body: new Uint8Array(), |
| 267 | }); |
| 268 | expect(res.status).toBe(401); |
| 269 | expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/); |
| 270 | }); |
| 271 | |
| 272 | it("private + PAT pull-only + receive-pack POST -> 403", async () => { |
| 273 | const seed = await seedRepo(env, { |
| 274 | namespaceSlug: `acl-pull-push-${Math.random().toString(36).slice(2, 8)}`, |
| 275 | repoSlug: "site", |
| 276 | visibility: "private", |
| 277 | }); |
| 278 | const plaintext = await seedPat({ |
| 279 | userId: seed.userId, |
| 280 | level: "pull", |
| 281 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 282 | }); |
| 283 | const res = await workerExports.default.fetch(receivePackUrl(seed), { |
| 284 | method: "POST", |
| 285 | headers: { |
| 286 | "Content-Type": "application/x-git-receive-pack-request", |
| 287 | Authorization: basicAuth(seed.namespaceSlug, plaintext), |
| 288 | }, |
| 289 | body: new Uint8Array(), |
| 290 | }); |
| 291 | expect(res.status).toBe(403); |
| 292 | }); |
| 293 | |
| 294 | it("private + revoked PAT + receive-pack POST -> 401 challenge", async () => { |
| 295 | const seed = await seedRepo(env, { |
| 296 | namespaceSlug: `acl-revoked-${Math.random().toString(36).slice(2, 8)}`, |
| 297 | repoSlug: "site", |
| 298 | visibility: "private", |
| 299 | }); |
| 300 | const plaintext = await seedPat({ |
| 301 | userId: seed.userId, |
| 302 | level: "push", |
| 303 | scope: { kind: "repo", repoId: seed.repositoryId }, |
| 304 | revokedAt: Date.now() - 1, |
| 305 | }); |
| 306 | const res = await workerExports.default.fetch(receivePackUrl(seed), { |
| 307 | method: "POST", |
| 308 | headers: { |
| 309 | "Content-Type": "application/x-git-receive-pack-request", |
| 310 | Authorization: basicAuth(seed.namespaceSlug, plaintext), |
| 311 | }, |
| 312 | body: new Uint8Array(), |
| 313 | }); |
| 314 | expect(res.status).toBe(401); |
| 315 | }); |
| 316 | }); |