fix: revalidate scoped public tokens throughout repository reads

This commit is contained in:
Thomas Durieux
2026-09-13 15:16:45 +00:00
parent 5e5cc6e0c2
commit dc026e2ee8
3 changed files with 91 additions and 20 deletions
+49 -8
View File
@@ -141,12 +141,14 @@ describeMongo("GitHub App credential and repository integration", function () {
owner = await Users.create({ username: "owner", externalIDs: { github: "10" } });
});
afterEach(() => { globalThis.fetch = previousFetch; });
function mock(handler) {
function mock(handler, scopeHandler) {
globalThis.fetch = async (url, options) => {
if (String(url).startsWith(base)) return previousFetch(url, options);
const body = options?.body ? JSON.parse(options.body) : undefined;
calls.push({ url: String(url), body, options });
const result = await handler(String(url), options, body);
const result = String(url).endsWith("/token/scoped")
? scopeHandler ? await scopeHandler(body, options) : { token: `ghu_scoped_${body.repository_ids[0]}_${body.access_token}`, expires_at: new Date(Date.now() + 3600000).toISOString() }
: await handler(String(url), options, body);
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json" } });
};
}
@@ -208,7 +210,7 @@ describeMongo("GitHub App credential and repository integration", function () {
return { id: 7, private: false, visibility: "public", full_name: "other/public", name: "public", owner: { login: "other" }, default_branch: "main" };
});
const selected = await app.selectRepositoryAccess(owner.id, "other/public", "github-app");
expect(selected.token).to.equal("ghu_access1");
expect(selected.token).to.equal("ghu_scoped_7_ghu_access1");
expect(selected.binding).to.include({ kind: "github-app", publicRead: true, repositoryId: 7 });
expect(selected.binding.installationId).to.equal(undefined);
const Repos = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
@@ -240,7 +242,7 @@ describeMongo("GitHub App credential and repository integration", function () {
await app.saveAppGrant(owner.id, data());
const binding = { kind: "github-app", publicRead: true, repositoryId: 7, revision: "public" };
mock(() => ({ id: 7, private: false, visibility: "public" }));
expect(await app.boundAppToken(owner.id, binding, "other/public")).to.equal("ghu_access1");
expect(await app.boundAppToken(owner.id, binding, "other/public")).to.equal("ghu_scoped_7_ghu_access1");
for (const metadata of [{ id: 7, private: true }, { id: 7, private: false, visibility: "internal" }, { id: 7, private: false }, { status: 404 }, { id: 8, private: false, visibility: "public" }]) {
mock(() => metadata);
await rejects(app.boundAppToken(owner.id, binding, "other/public"), "github_app_access_required");
@@ -253,7 +255,7 @@ describeMongo("GitHub App credential and repository integration", function () {
await app.saveAppGrant(owner.id, data("old", -1));
mock(url => url.includes("/login/oauth/access_token") ? data("new") : { id: 7, private: false, visibility: "public" });
expect(await app.boundAppToken(owner.id, { kind: "github-app", publicRead: true, repositoryId: 7, revision: "public" }, "other/public"))
.to.equal("ghu_accessnew");
.to.equal("ghu_scoped_7_ghu_accessnew");
});
it("renews a public token inside an existing Octokit traversal without changing owner or quota", async () => {
await app.saveAppGrant(owner.id, data("old"));
@@ -273,9 +275,48 @@ describeMongo("GitHub App credential and repository integration", function () {
await oct.git.getTree({ owner: "other", repo: "public", tree_sha: "first" });
await Credentials.updateOne({ ownerId: owner.id, provider: app.APP_PROVIDER }, { $set: { expiresAt: new Date(0) } });
await oct.git.getTree({ owner: "other", repo: "public", tree_sha: "second" });
expect(sent).to.deep.equal(["token ghu_accessold", "token ghu_accessnew"]);
expect(sent).to.deep.equal(["token ghu_scoped_7_ghu_accessold", "token ghu_scoped_7_ghu_accessnew"]);
expect(githubQuotaKey(token)).to.equal(`app-user:${owner.id}`);
expect(githubQuotaKey("ghu_accessnew")).to.equal(githubQuotaKey(token));
expect(githubQuotaKey("ghu_scoped_7_ghu_accessnew")).to.equal(githubQuotaKey(token));
});
it("revalidates each public traversal independently after another repository binds the same user", async () => {
await app.saveAppGrant(owner.id, data());
let firstVisibility = "public";
const sent = [];
mock(url => {
if (url.includes("/git/trees/")) { sent.push(url); return { tree: [] }; }
return { id: url.endsWith("/first") ? 7 : 8, private: false,
visibility: url.endsWith("/first") ? firstVisibility : "public" };
});
const binding = { kind: "github-app", publicRead: true, repositoryId: 7, revision: "public" };
const first = await app.boundAppToken(owner.id, binding, "other/first");
const second = await app.boundAppToken(owner.id, { ...binding, repositoryId: 8 }, "other/second");
expect(first).not.to.equal(second);
const { octokit } = require("../src/core/GitHubUtils");
const firstClient = octokit(first), secondClient = octokit(second);
await firstClient.git.getTree({ owner: "other", repo: "first", tree_sha: "one" });
firstVisibility = "internal";
await rejects(firstClient.git.getTree({ owner: "other", repo: "first", tree_sha: "two" }), "github_app_access_required");
firstVisibility = "private";
await rejects(firstClient.git.getTree({ owner: "other", repo: "first", tree_sha: "three" }), "github_app_access_required");
await secondClient.git.getTree({ owner: "other", repo: "second", tree_sha: "four" });
expect(sent).to.have.length(2);
const scopes = calls.filter(call => call.url.endsWith("/token/scoped"));
expect(scopes.map(call => call.body.repository_ids)).to.deep.equal([[7], [8]]);
for (const request of scopes) {
expect(request.options.headers.Authorization).to.match(/^Basic /);
expect(request.body.target).to.equal("other");
expect(Object.values(request.body.permissions).every(value => value === "read")).to.equal(true);
}
expect(githubQuotaKey(first)).to.equal(githubQuotaKey(second));
});
it("never returns the unrestricted user token when scoping fails", async () => {
await app.saveAppGrant(owner.id, data());
for (const response of [{ status: 403 }, { token: "ghu_access1" }, {}]) {
app.clearAppTokenCache();
mock(() => ({ id: 7, private: false, visibility: "public" }), () => response);
await rejects(app.boundAppToken(owner.id, { kind: "github-app", publicRead: true, repositoryId: 7, revision: "public" }, "other/public"), "github_app_access_required");
}
});
it("rejects a replacement at the original name for repository and PR source reads", async () => {
await app.saveAppGrant(owner.id, data());
@@ -296,7 +337,7 @@ describeMongo("GitHub App credential and repository integration", function () {
for (const resource of resources) await rejects(resource.getToken(), "github_app_access_required");
expect(calls.every(call => call.url.endsWith("/repos/other/original"))).to.equal(true);
mock(() => ({ id: 7, private: false, visibility: "public" }));
for (const resource of resources) expect(await resource.getToken()).to.equal("ghu_access1");
for (const resource of resources) expect(await resource.getToken()).to.equal("ghu_scoped_7_ghu_access1");
});
it("does not reinterpret missing or mixed installation bindings as public access", async () => {
await app.saveAppGrant(owner.id, data());