fix: read public repositories without scoped App token minting

This commit is contained in:
tdurieux committed 2026-09-14 16:50:18 +02:00
1 parent 8b9f79502b
commit 2d153dd353
5 files changed
+97 -70

No files matched your search

+13
View File
@@ -249,6 +249,17 @@ export function octokit(token: string) {
});
if (context) {
oct.hook.before("request", async options => {
if (context.publicRepository) {
const url = new URL(oct.request.endpoint(options).url);
const prefix = `/repos/${context.publicRepository.split("/").map(encodeURIComponent).join("/")}`.toLowerCase();
const path = url.pathname.toLowerCase();
const suffix = path.slice(prefix.length);
const readable = /^(?:\/?|\/branches(?:\/[^/]+)?|\/commits(?:\/[^/]+)?|\/readme|\/pages|\/zipball\/[^/]+|\/git\/(?:trees|blobs)\/[^/]+|\/pulls\/\d+|\/issues\/\d+\/comments)$/.test(suffix);
if (!readable || (options.method !== "GET" && !(options.method === "HEAD" && suffix.startsWith("/zipball/"))) || url.origin !== "https://api.github.com" ||
(path !== prefix && !path.startsWith(prefix + "/"))) {
throw new AnonymousError("github_app_access_required", { httpStatus: 403 });
}
}
options.headers.authorization = `token ${await context.renew()}`;
});
oct.hook.wrap("request", async (request, options) => {
@@ -278,6 +289,8 @@ export { waitForTokenGate };
export async function checkToken(token: string) {
const oct = octokit(token);
try {
const context = githubTokenContext(token);
if (context?.publicRepository) { await context.renew(); return true; }
if (token.startsWith("ghs_")) await oct.request("GET /installation/repositories");
else await oct.users.getAuthenticated();
return true;
+19 -46
View File
@@ -1,5 +1,5 @@
import { registerGitHubToken } from "./github-token-context";
import { createHash, createSign, randomUUID } from "crypto";
import { createSign, randomUUID } from "crypto";
import { readFileSync } from "fs";
import config from "../config";
import AnonymousError from "./AnonymousError";
@@ -180,7 +180,7 @@ export async function appRepositories(ownerId: string) {
const installationTokens = new Map<string, { token: string; expires: number }>();
const minting = new Map<string, Promise<string>>();
export function clearAppTokenCache() { installationTokens.clear(); publicTokens.clear(); }
export function clearAppTokenCache() { installationTokens.clear(); }
async function installationToken(binding: RepositoryAccess, ownerId: string): Promise<string> {
const id = binding.installationId;
let local = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
@@ -219,54 +219,27 @@ async function installationToken(binding: RepositoryAccess, ownerId: string): Pr
try { return await work; } finally { minting.delete(key); }
}
const publicTokens = new Map<string, { token: string; expires: number }>();
const publicMinting = new Map<string, Promise<string>>();
async function scopedPublicToken(ownerId: string, repositoryId: number, sourceName: string, userToken: string, force: boolean) {
const key = `${ownerId}:${repositoryId}:${createHash("sha256").update(userToken).digest("hex")}`;
if (force) publicTokens.delete(key);
const cached = publicTokens.get(key);
if (cached && cached.expires > Date.now() + 60000) return cached.token;
if (publicMinting.has(key)) return publicMinting.get(key)!;
const mint = (async () => {
const basic = Buffer.from(`${config.GITHUB_APP_CLIENT_ID}:${config.GITHUB_APP_CLIENT_SECRET}`).toString("base64");
const issued = await githubRequest<{ token: string; expires_at?: string | null }>(
`/applications/${encodeURIComponent(config.GITHUB_APP_CLIENT_ID)}/token/scoped`, basic, "POST", {
access_token: userToken, target: sourceName.split("/")[0], repository_ids: [repositoryId],
permissions: { metadata: "read", contents: "read", pull_requests: "read", pages: "read" },
}, "Basic");
if (typeof issued.token !== "string" || !issued.token || issued.token === userToken) throw appError("github_app_access_required");
// An omitted expiration does not justify caching beyond the current read.
const expires = issued.expires_at ? Date.parse(issued.expires_at) : 0;
if (Number.isFinite(expires) && expires > Date.now() + 60000) {
if (publicTokens.size >= 1000) publicTokens.clear();
publicTokens.set(key, { token: issued.token, expires });
}
return issued.token;
})();
publicMinting.set(key, mint);
try { return await mint; } finally { publicMinting.delete(key); }
}
export async function boundAppToken(ownerId: string, binding: RepositoryAccess, sourceName?: string, force = false): Promise<string> {
export async function boundAppToken(ownerId: string, binding: RepositoryAccess, sourceName?: string, _force = false): Promise<string> {
if (!Number.isSafeInteger(binding.repositoryId)) throw appError();
if (binding.publicRead === true) {
if (binding.installationId !== undefined || !sourceName || !/^[^/\s]+\/[^/\s]+$/.test(sourceName)) throw appError();
const userToken = await appUserToken(ownerId);
// Source reads use owner/name, so validate that exact name against the
// bound ID. A replacement at a renamed repository's old URL must fail.
const repo = await githubRequest<GitHubRepositoryInfo>(
`/repos/${sourceName.split("/").map(encodeURIComponent).join("/")}`, userToken);
// A public binding must never gain private access, even if the user later
// installs the App on this repository. Reconnect explicitly to do that.
if (repo.id !== binding.repositoryId || repo.private !== false || repo.visibility !== "public") throw appError("github_app_access_required");
const token = await scopedPublicToken(ownerId, binding.repositoryId!, sourceName, userToken, force);
// Each repository gets a distinct bearer token, so concurrent public
// traversals can revalidate their own binding on every request.
registerGitHubToken(token, { quotaKey: `app-user:${ownerId}`,
renew: force => boundAppToken(ownerId, binding, sourceName, force) });
return token;
const renew = async () => {
const userToken = await appUserToken(ownerId);
const repo = await githubRequest<GitHubRepositoryInfo>(
`/repos/${sourceName.split("/").map(encodeURIComponent).join("/")}`, userToken);
// A public binding must not acquire private access or follow a replacement
// repository at an old name, even if the user can read it.
if (repo.id !== binding.repositoryId || repo.private !== false || repo.visibility !== "public") throw appError("github_app_access_required");
return userToken;
};
await renew();
// This process-local handle keeps concurrent repositories independent without
// asking GitHub to mint an installation-scoped token for an uninstalled repo.
const handle = `public-read:${randomUUID()}`;
registerGitHubToken(handle, { quotaKey: `app-user:${ownerId}`, renew, publicRepository: sourceName });
return handle;
}
if (!Number.isSafeInteger(binding.installationId)) throw appError();
const userToken = await appUserToken(ownerId);
// User token checks the intersection of user and App rights on every access.
+6 -2
View File
@@ -1,12 +1,16 @@
import { createHash } from "crypto";
interface TokenContext { quotaKey: string; renew: (force?: boolean) => Promise<string>; }
interface TokenContext { quotaKey: string; renew: (force?: boolean) => Promise<string>; publicRepository?: string; }
const contexts = new Map<string, TokenContext>();
export function registerGitHubToken(token: string, context: TokenContext) {
if (contexts.size >= 2000 && !contexts.has(token)) contexts.delete(contexts.keys().next().value!);
contexts.set(token, context);
}
export function githubTokenContext(token: string) { return contexts.get(token); }
export function githubTokenContext(token: string) {
const context = contexts.get(token);
if (!context && token.startsWith("public-read:")) throw new Error("Public repository access context expired");
return context;
}
export function githubQuotaKey(token: string) {
return contexts.get(token)?.quotaKey || createHash("sha256").update(token).digest("hex").slice(0, 24);
}
+10 -2
View File
@@ -1,3 +1,4 @@
import { githubTokenContext } from "../github-token-context";
import AnonymizedFile from "../AnonymizedFile";
import GitHubBase, {
GitHubBaseData,
@@ -79,10 +80,11 @@ export default class GitHubStream extends GitHubBase {
});
logger.debug("downloading file", { url });
return got.stream(url, {
hooks: { beforeRequest: [async () => { await githubTokenContext(token)?.renew(); }] },
headers: {
"X-GitHub-Api-Version": "2022-11-28",
accept: "application/vnd.github.raw+json",
authorization: `token ${token}`,
...(githubTokenContext(token)?.publicRepository ? {} : { authorization: `token ${token}` }),
},
});
} catch (error) {
@@ -108,7 +110,8 @@ export default class GitHubStream extends GitHubBase {
);
logger.debug("downloading via raw URL (LFS)", { url });
return got.stream(url, {
headers: { authorization: `token ${token}` },
hooks: { beforeRequest: [async () => { await githubTokenContext(token)?.renew(); }] },
headers: githubTokenContext(token)?.publicRepository ? {} : { authorization: `token ${token}` },
followRedirect: true,
});
}
@@ -122,6 +125,11 @@ export default class GitHubStream extends GitHubBase {
sha: string,
filePath: string
): Promise<stream.Readable> {
// Public raw downloads need no bearer token and do not consume the
// unauthenticated REST API quota. GitHub also resolves LFS pointers here.
if (githubTokenContext(token)?.publicRepository) {
return Promise.resolve(this.downloadFileViaRaw(token, filePath));
}
return new Promise<stream.Readable>((resolve) => {
const blobStream = this.downloadFile(token, sha);
let settled = false;