From 85db3b2a1d26d15e72ec5eb177f7362a97e0b1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:46:50 +0000 Subject: [PATCH 1/5] fix(git-token-service): define the GitHub session capability request surface - Bound GitHub capabilities carry their repository's request surface: REST under /repos//, the repository's Git smart-HTTP and LFS endpoints, release assets under that repository, and registry reads in the owner's package scope. - Share the path, endpoint, and decode checks between the legacy and bound validators. - GraphQL requests are answered with the repository-scoped installation credential. - Identity reads (GET/HEAD /user) remain available, and Git requests for other public repositories are forwarded anonymously. - Clone URLs work with or without the .git suffix. --- .../src/sandbox-outbound.test.ts | 29 ++ .../cloud-agent-next/src/sandbox-outbound.ts | 2 +- services/cloud-agent-next/src/types.ts | 3 +- services/git-token-service/src/index.test.ts | 173 +++++++++- services/git-token-service/src/index.ts | 304 ++++++++++++++---- 5 files changed, 441 insertions(+), 70 deletions(-) diff --git a/services/cloud-agent-next/src/sandbox-outbound.test.ts b/services/cloud-agent-next/src/sandbox-outbound.test.ts index b9f3a2c0dd6..f07f6e04a99 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.test.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -377,6 +377,35 @@ describe('handleManagedScmOutbound', () => { expect(logs).not.toContain('upstream-token'); }); + it('forwards a request that needs no credential', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: null, + installationId: 'installation-123', + source: 'user', + appType: 'standard', + }); + const fetchMock = vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })); + vi.stubGlobal('fetch', fetchMock); + + const response = await handleOutbound( + new Request('https://github.com/other/project.git/info/refs?service=git-upload-pack', { + headers: { + Authorization: basicCredential(CAPABILITY), + 'User-Agent': 'git/2.43.0', + }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(response.status).toBe(404); + expect(redeemGitHubSessionCapability).toHaveBeenCalledOnce(); + const forwarded = fetchMock.mock.calls[0]?.[0]; + if (!(forwarded instanceof Request)) throw new Error('Expected a forwarded request'); + expect(forwarded.headers.get('Authorization')).toBeNull(); + expect(forwarded.headers.get('User-Agent')).toBe('git/2.43.0'); + }); + it('keeps forwarding and logs null correlation fields for an older redemption response', async () => { const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ success: true, diff --git a/services/cloud-agent-next/src/sandbox-outbound.ts b/services/cloud-agent-next/src/sandbox-outbound.ts index 659018cf254..0cb8a22487b 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -572,7 +572,7 @@ async function handleManagedGitHubOutbound( return new Response('GitHub authorization unavailable', { status: 502 }); } - const headersToApply = { authorization: result.authorization }; + const headersToApply = { authorization: result.authorization ?? undefined }; let response: Response; try { if (!isGitHubGitUploadRetryableRequest(request)) { diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index cfaa915b36f..244c070ccd2 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -267,7 +267,8 @@ type IssueGitHubSessionCapabilityResult = type RedeemGitHubSessionCapabilityResult = | { success: true; - authorization: string; + /** Null means the request is forwarded anonymously. */ + authorization: string | null; installationId?: string; source?: 'user' | 'installation'; appType?: 'standard' | 'lite'; diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 9a7c84af7b4..fed04885a58 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1810,16 +1810,21 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); it.each([ - ['POST', 'https://api.github.com/graphql'], - ['GET', 'https://api.github.com/user/repos'], - ['DELETE', 'https://api.github.com/repos/acme/other/issues/42/comments/1'], + ['GET', 'https://api.github.com/repos/acme/repo/pulls/42'], + ['POST', 'https://api.github.com/repos/acme/repo/issues/42/comments'], + ['PUT', 'https://api.github.com/repos/acme/repo/pulls/42/merge'], + ['DELETE', 'https://api.github.com/repos/acme/repo/issues/42/labels/bug'], ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], + ['GET', 'https://api.github.com/user'], + ['POST', 'https://api.github.com/graphql'], + ['POST', 'https://uploads.github.com/repos/acme/repo/releases/1/assets?name=asset.zip'], ['GET', 'https://npm.pkg.github.com/@acme%2fdesign'], ['GET', 'https://npm.pkg.github.com/download/@acme/design/1.0.0/package.tgz'], - ['POST', 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip'], - ['POST', 'https://github.com/acme/other.git/info/lfs/objects/batch'], + ['GET', 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack'], + ['GET', 'https://github.com/acme/repo/info/refs?service=git-upload-pack'], + ['POST', 'https://github.com/acme/repo.git/git-receive-pack'], ] as const)( - 'redeems an installation-pinned capability for unrestricted GitHub request %s %s', + 'redeems an installation-pinned capability for its own repository request %s %s', async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1850,6 +1855,45 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { } ); + it.each([ + ['GET', 'https://api.github.com/repos/acme/other/pulls/42', 'repository_mismatch'], + ['GET', 'https://api.github.com/user/repos', 'repository_mismatch'], + ['GET', 'https://api.github.com/graphql', 'invalid_upstream_request'], + ['OPTIONS', 'https://api.github.com/repos/acme/repo/pulls/42', 'invalid_upstream_request'], + [ + 'POST', + 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip', + 'repository_mismatch', + ], + ['GET', 'https://npm.pkg.github.com/@other%2fdesign', 'repository_mismatch'], + ['POST', 'https://npm.pkg.github.com/@acme%2fdesign', 'invalid_upstream_request'], + ['GET', 'https://github.com/acme/other/issues', 'repository_mismatch'], + ['GET', 'https://github.com/acme/other.git/archive/main.tar.gz', 'repository_mismatch'], + ['GET', 'https://api.github.com/repos/acme/repo/files/..%2f..%2fother', 'invalid_upstream_url'], + ] as const)( + 'applies the recorded repository scope to installation-pinned request %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + it.each([ ['POST', 'https://api.github.com/repos/acme/repo/issues/42/comments'], ['PATCH', 'https://api.github.com/repos/acme/repo/issues/comments/123'], @@ -1921,16 +1965,18 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); it.each([ - ['POST', 'https://api.github.com/graphql'], - ['GET', 'https://api.github.com/user/repos'], - ['DELETE', 'https://api.github.com/repos/acme/other/issues/42/comments/1'], + ['GET', 'https://api.github.com/repos/acme/repo/pulls/42'], + ['PATCH', 'https://api.github.com/repos/acme/repo/pulls/42'], + ['DELETE', 'https://api.github.com/repos/acme/repo/issues/42/labels/bug'], ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], + ['GET', 'https://api.github.com/user'], + ['POST', 'https://uploads.github.com/repos/acme/repo/releases/1/assets?name=asset.zip'], ['GET', 'https://npm.pkg.github.com/@acme%2fdesign'], ['GET', 'https://npm.pkg.github.com/download/@acme/design/1.0.0/package.tgz'], - ['POST', 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip'], - ['GET', 'https://github.com/acme/other.git/info/refs?service=git-upload-pack'], + ['GET', 'https://github.com/acme/repo/info/refs?service=git-upload-pack'], + ['POST', 'https://github.com/acme/repo.git/info/lfs/objects/batch'], ] as const)( - 'redeems a selected-user capability for unrestricted GitHub request %s %s', + 'redeems a selected-user capability for its own repository request %s %s', async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1963,6 +2009,109 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { } ); + it('redeems GraphQL for a selected-user capability with the repository installation token', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer installation-token', + installationId: '123', + source: 'installation', + appType: 'standard', + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + }); + + it('forwards a Git request for another repository anonymously', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/other.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ + success: true, + authorization: null, + installationId: '123', + source: 'user', + appType: 'standard', + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it.each([ + ['GET', 'https://api.github.com/repos/acme/other/pulls/42', 'repository_mismatch'], + ['GET', 'https://api.github.com/user/repos', 'repository_mismatch'], + [ + 'DELETE', + 'https://api.github.com/repos/acme/other/issues/42/comments/1', + 'repository_mismatch', + ], + [ + 'POST', + 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip', + 'repository_mismatch', + ], + ['GET', 'https://npm.pkg.github.com/@other%2fdesign', 'repository_mismatch'], + ['OPTIONS', 'https://api.github.com/repos/acme/repo/pulls/42', 'invalid_upstream_request'], + ['GET', 'https://github.com/acme/repo2/archive/main.tar.gz', 'repository_mismatch'], + ['GET', 'https://github.com/acme/other/issues', 'repository_mismatch'], + ] as const)( + 'applies the recorded repository scope to selected-user request %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.source).toBe('user'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + it.each([ [ 'GET', diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 3c928c55b98..53f94f93416 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -192,7 +192,8 @@ export type RedeemGitHubSessionCapabilityParams = { }; export type RedeemGitHubSessionCapabilitySuccess = { success: true; - authorization: string; + /** Null means the request is forwarded anonymously. */ + authorization: string | null; installationId: string; source: 'user' | 'installation'; appType: GitHubAppType; @@ -397,26 +398,196 @@ async function readBoundedInternalJsonRequest(request: Request): Promise` (metadata, tarball, and download paths). + */ +function isOwnerPackageScopePath(path: string, owner: string): boolean { + const scope = `@${owner}`; + const segments = path.split('/'); + return ( + (segments[1] === scope && segments.length >= 3) || + (segments[1] === 'download' && segments[2] === scope && segments.length >= 4) + ); +} + +function parseGitHubCapabilityRequest( + requestMethod: string, requestUrl: string -): RedeemGitHubSessionCapabilityFailureReason | null { +): + | { failure: RedeemGitHubSessionCapabilityFailureReason; request?: never } + | { + failure: null; + request: { url: URL; method: string; path: string }; + } { let url: URL; try { url = new URL(requestUrl); } catch { - return 'invalid_upstream_url'; + return { failure: 'invalid_upstream_url' }; } - if (url.protocol !== 'https:') return 'invalid_upstream_url'; - if (url.username || url.password || url.hash) return 'invalid_upstream_url'; - if (url.port !== '') return 'upstream_host_not_allowed'; - if ( - !['api.github.com', 'uploads.github.com', 'github.com', 'npm.pkg.github.com'].includes( - url.hostname - ) - ) { - return 'upstream_host_not_allowed'; + if (url.protocol !== 'https:') return { failure: 'invalid_upstream_url' }; + if (url.username || url.password || url.hash) return { failure: 'invalid_upstream_url' }; + if (url.port !== '') return { failure: 'upstream_host_not_allowed' }; + const path = decodePathnameIteratively(url.pathname); + if (path === null || path.includes('\\') || /(?:^|\/)\.{1,2}(?:\/|$)/.test(path)) { + return { failure: 'invalid_upstream_url' }; } - return null; + return { + failure: null, + request: { url, method: requestMethod.toUpperCase(), path: path.toLowerCase() }, + }; +} + +function validateGitHubCapabilityUpstream( + requestMethod: string, + requestUrl: string, + repository: { owner: string; repo: string } +): GitHubCapabilityUpstream { + const parsed = parseGitHubCapabilityRequest(requestMethod, requestUrl); + if (parsed.failure) return { failure: parsed.failure }; + const { url, method, path } = parsed.request; + + if (url.hostname === 'api.github.com') { + if (path === '/user') { + return GITHUB_IDENTITY_METHODS.includes(method) + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (path === '/graphql') { + // gh resolves pull-request views and checks through GraphQL, which is not + // addressed by repository path. Answer it with the repository-scoped + // installation credential. + return method === 'POST' + ? { failure: null, credential: 'installation' } + : { failure: 'invalid_upstream_request' }; + } + if (!isRepositoryApiPath(path, repository)) return { failure: 'repository_mismatch' }; + return GITHUB_REPOSITORY_API_METHODS.includes(method) + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (url.hostname === 'uploads.github.com') { + if (!isRepositoryApiPath(path, repository)) return { failure: 'repository_mismatch' }; + return GITHUB_UPLOAD_METHODS.includes(method) + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (url.hostname === 'npm.pkg.github.com') { + if (!GITHUB_REGISTRY_METHODS.includes(method)) return { failure: 'invalid_upstream_request' }; + return isOwnerPackageScopePath(path, repository.owner) + ? { failure: null, credential: 'pinned' } + : { failure: 'repository_mismatch' }; + } + if (url.hostname === 'github.com') { + if (matchesRepositoryGitRequest(method, url, path, repository)) { + return { failure: null, credential: 'pinned' }; + } + if (matchesAnonymousGitHubGitRequest(method, url, path)) { + return { failure: null, credential: 'none' }; + } + return isRepositoryRootPath(path, repository) + ? { failure: 'invalid_upstream_request' } + : { failure: 'repository_mismatch' }; + } + return { failure: 'upstream_host_not_allowed' }; } function validateLegacyGitHubCapabilityUpstream( @@ -427,25 +598,19 @@ function validateLegacyGitHubCapabilityUpstream( if (/%2f|%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { return 'invalid_upstream_url'; } - let url: URL; - try { - url = new URL(requestUrl); - } catch { - return 'invalid_upstream_url'; - } - if (url.protocol !== 'https:') return 'invalid_upstream_url'; - if (url.username || url.password || url.hash) return 'invalid_upstream_url'; - const method = requestMethod.toUpperCase(); - if (url.hostname === 'api.github.com' && url.port === '') { + const parsed = parseGitHubCapabilityRequest(requestMethod, requestUrl); + if (parsed.failure) return parsed.failure; + const { url, method, path } = parsed.request; + + if (url.hostname === 'api.github.com') { if (!['GET', 'POST', 'PATCH', 'HEAD'].includes(method)) { return 'invalid_upstream_request'; } - const repositoryApiPath = `/repos/${repository.owner}/${repository.repo}`; - const path = url.pathname.toLowerCase(); - if (path !== repositoryApiPath && !path.startsWith(`${repositoryApiPath}/`)) { + const repositoryApiPrefix = repositoryApiPath(repository); + if (!isRepositoryApiPath(path, repository)) { return 'repository_mismatch'; } - const relativePath = path.slice(repositoryApiPath.length); + const relativePath = path.slice(repositoryApiPrefix.length); if ( ['GET', 'HEAD'].includes(method) && (/^\/pulls\/[1-9]\d*$/.test(relativePath) || @@ -459,30 +624,11 @@ function validateLegacyGitHubCapabilityUpstream( if (method === 'POST' && /^\/pulls\/[1-9]\d*\/reviews$/.test(relativePath)) return null; return 'invalid_upstream_request'; } - if (url.hostname !== 'github.com' || url.port !== '') return 'upstream_host_not_allowed'; - - const repositoryPath = `/${repository.owner}/${repository.repo}.git`; - const path = url.pathname.toLowerCase(); - if (!path.startsWith(`/${repository.owner}/${repository.repo}`)) return 'repository_mismatch'; - - if (method === 'GET' && path === `${repositoryPath}/info/refs`) { - const entries = [...url.searchParams.entries()]; - const service = url.searchParams.get('service'); - if (entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack')) { - return null; - } - } - if ( - method === 'POST' && - url.search === '' && - (path === `${repositoryPath}/git-upload-pack` || - path === `${repositoryPath}/git-receive-pack` || - path === `${repositoryPath}/info/lfs/objects/batch` || - path === `${repositoryPath}/info/lfs/locks/verify`) - ) { - return null; - } - return 'invalid_upstream_request'; + if (url.hostname !== 'github.com') return 'upstream_host_not_allowed'; + if (!isRepositoryRootPath(path, repository)) return 'repository_mismatch'; + return matchesRepositoryGitRequest(method, url, path, repository) + ? null + : 'invalid_upstream_request'; } function isGitLabGitAuthPath(pathname: string): boolean { @@ -1015,11 +1161,18 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: false, reason: 'container_mismatch' }; } - const upstreamFailure = + const upstream = claims.version === 2 - ? validateGitHubCapabilityUpstream(params.requestUrl) - : validateLegacyGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims); - if (upstreamFailure) return { success: false, reason: upstreamFailure }; + ? validateGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims) + : { + failure: validateLegacyGitHubCapabilityUpstream( + params.requestMethod, + params.requestUrl, + claims + ), + credential: 'pinned' as const, + }; + if (upstream.failure) return { success: false, reason: upstream.failure }; const authParams = { userId: claims.userId, @@ -1030,6 +1183,45 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { : {}), githubRepo: `${claims.owner}/${claims.repo}`, }; + if (upstream.credential === 'none') { + return { + success: true, + authorization: null, + installationId: claims.identity.installationId, + source: claims.source, + appType: claims.identity.appType, + }; + } + const installationAuth = async (): Promise => { + let auth: GetCloudAgentAuthForRepoResult; + try { + auth = await this.getCloudAgentAuthForRepo(authParams); + } catch { + return { success: false, reason: 'source_unavailable' }; + } + if (!auth.success) { + return { + success: false, + reason: + auth.reason === 'integration_mismatch' ? 'integration_mismatch' : 'source_unavailable', + }; + } + const identityMatches = + claims.source === 'installation' + ? this.matchesSessionIdentity(claims.identity, auth) + : auth.installationId === claims.identity.installationId && + auth.appType === claims.identity.appType; + if (!identityMatches) return { success: false, reason: 'identity_mismatch' }; + return { + success: true, + authorization: this.formatUpstreamAuthorization(params.requestUrl, auth.githubToken), + installationId: auth.installationId, + source: auth.source, + appType: auth.appType, + }; + }; + if (upstream.credential === 'installation') return installationAuth(); + let auth: GetCloudAgentAuthForRepoResult | null; if (claims.source === 'user') { auth = await this.redeemPinnedUserAuthorization(authParams); From 929dcc67a11ff68f4c416c6e9c64f41dfcea15ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:11:27 +0000 Subject: [PATCH 2/5] fix(git-token-service): match LFS endpoints by the path after the repository root `git lfs lock`, `git lfs locks`, and `git lfs unlock` use the repository's /info/lfs/locks endpoints. Matching the endpoint by the path after the repository root keeps every Git endpoint handled consistently. --- services/git-token-service/src/index.test.ts | 4 ++ services/git-token-service/src/index.ts | 57 ++++++++++---------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index fed04885a58..779753e9af4 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1782,6 +1782,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { it.each([ 'https://github.com/acme/repo.git/info/lfs/objects/batch', 'https://github.com/acme/repo.git/info/lfs/locks/verify', + 'https://github.com/acme/repo.git/info/lfs/locks', + 'https://github.com/acme/repo.git/info/lfs/locks/42/unlock', ])('redeems an installation-pinned capability for exact LFS control URL %s', async requestUrl => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1975,6 +1977,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { ['GET', 'https://npm.pkg.github.com/download/@acme/design/1.0.0/package.tgz'], ['GET', 'https://github.com/acme/repo/info/refs?service=git-upload-pack'], ['POST', 'https://github.com/acme/repo.git/info/lfs/objects/batch'], + ['GET', 'https://github.com/acme/repo.git/info/lfs/locks?path=asset.bin&limit=100'], + ['POST', 'https://github.com/acme/repo.git/info/lfs/locks'], ] as const)( 'redeems a selected-user capability for its own repository request %s %s', async (requestMethod, requestUrl) => { diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 53f94f93416..801356e0e61 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -432,32 +432,39 @@ function isRepositoryRootPath(path: string, repository: { owner: string; repo: s } /** - * Matches one Git smart-HTTP or LFS control request for `repositoryPath` - * (for example `/acme/widgets.git`). + * Matches a Git smart-HTTP or LFS endpoint by the path after the repository + * root, for example `/info/refs` or `/git-receive-pack`. */ -function matchesGitHubGitRequest( - method: string, - url: URL, - path: string, - repositoryPath: string -): boolean { - if (method === 'GET' && path === `${repositoryPath}/info/refs`) { +function matchesGitRequestSuffix(method: string, url: URL, suffix: string): boolean { + if (method === 'GET' && suffix === '/info/refs') { const entries = [...url.searchParams.entries()]; const service = url.searchParams.get('service'); return ( entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack') ); } + if (method === 'GET' && suffix === '/info/lfs/locks') return true; + if (method !== 'POST' || url.search !== '') return false; return ( - method === 'POST' && - url.search === '' && - (path === `${repositoryPath}/git-upload-pack` || - path === `${repositoryPath}/git-receive-pack` || - path === `${repositoryPath}/info/lfs/objects/batch` || - path === `${repositoryPath}/info/lfs/locks/verify`) + suffix === '/git-upload-pack' || + suffix === '/git-receive-pack' || + suffix === '/info/lfs/objects/batch' || + suffix === '/info/lfs/locks' || + suffix === '/info/lfs/locks/verify' || + /^\/info\/lfs\/locks\/[^/]+\/unlock$/.test(suffix) ); } +function matchesGitHubGitRequest( + method: string, + url: URL, + path: string, + repositoryPath: string +): boolean { + if (!path.startsWith(repositoryPath)) return false; + return matchesGitRequestSuffix(method, url, path.slice(repositoryPath.length)); +} + /** * A clone URL may omit the `.git` suffix, and GitHub answers it with a redirect to * the suffixed form, so both spellings belong to the same repository. @@ -476,23 +483,13 @@ function matchesRepositoryGitRequest( } /** - * True for a Git smart-HTTP or LFS control request shaped URL for any repository. - * Requests for another repository are forwarded anonymously. + * Matches the Git endpoints that need no authorization header, such as a public + * repository's clone and LFS requests. */ function matchesAnonymousGitHubGitRequest(method: string, url: URL, path: string): boolean { - const match = - /^\/[^/]+\/[^/]+?(?:\.git)?(\/info\/refs|\/git-upload-pack|\/git-receive-pack|\/info\/lfs\/objects\/batch|\/info\/lfs\/locks\/verify)$/.exec( - path - ); - if (!match) return false; - if (method === 'GET' && match[1] === '/info/refs') { - const entries = [...url.searchParams.entries()]; - const service = url.searchParams.get('service'); - return ( - entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack') - ); - } - return method === 'POST' && url.search === ''; + const segments = path.split('/'); + if (segments.length < 3) return false; + return matchesGitRequestSuffix(method, url, `/${segments.slice(3).join('/')}`); } /** From d9d8bb81df4f9ce610a8bf367340b8e05b650f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:22:11 +0000 Subject: [PATCH 3/5] fix(git-token-service): require the session user pin for GraphQL redemption A bound capability recorded with a user source now resolves that pin before GraphQL redemption answers with the repository-scoped installation credential. A pin that no longer resolves, or whose attribution changed, returns source_unavailable or identity_mismatch and no upstream request is made. --- services/git-token-service/src/index.test.ts | 57 +++++++++++++++++++- services/git-token-service/src/index.ts | 25 ++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 779753e9af4..56d6c8a3231 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -2039,10 +2039,65 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { source: 'installation', appType: 'standard', }); - expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); }); + it('refuses GraphQL for a selected-user capability whose pin no longer resolves', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: false, + reason: 'no_user_authorization', + }); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('refuses GraphQL for a selected-user capability when the installation integration no longer matches', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ + success: false, + reason: 'integration_mismatch', + }); + serviceMocks.findRefreshCandidates.mockResolvedValueOnce({ success: true, candidates: [] }); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + it('forwards a Git request for another repository anonymously', async () => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 801356e0e61..967d015eda7 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -1217,7 +1217,30 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { appType: auth.appType, }; }; - if (upstream.credential === 'installation') return installationAuth(); + if (upstream.credential === 'installation') { + // GraphQL is not addressed by repository path, so it is answered with the + // repository-scoped installation credential. A capability pinned to a user + // still requires that pin to resolve, so the session cannot outlive it. + if (claims.source === 'user') { + const pinned = await this.redeemPinnedUserAuthorization(authParams); + if (pinned && !pinned.success) { + return { + success: false, + reason: + pinned.reason === 'integration_mismatch' + ? 'integration_mismatch' + : 'source_unavailable', + }; + } + if (!pinned || pinned.source !== claims.source) { + return { success: false, reason: 'source_unavailable' }; + } + if (!this.matchesSessionIdentity(claims.identity, pinned)) { + return { success: false, reason: 'identity_mismatch' }; + } + } + return installationAuth(); + } let auth: GetCloudAgentAuthForRepoResult | null; if (claims.source === 'user') { From ef3940354f7307faf0283cc8040e34f28e5d7a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:55:53 +0000 Subject: [PATCH 4/5] fix(git-token-service): answer GraphQL with the capability's own credential A user-source bound capability now sends the recorded user's token on GraphQL after its pin and identity checks, so GraphQL mutations are authored by the session user instead of the app. Installation-source capabilities keep the repository-scoped installation token. The repository confinement of the other paths is unchanged. --- services/git-token-service/src/index.test.ts | 35 ++++++++++- services/git-token-service/src/index.ts | 63 ++------------------ 2 files changed, 39 insertions(+), 59 deletions(-) diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 56d6c8a3231..800784abc3f 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1972,6 +1972,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { ['DELETE', 'https://api.github.com/repos/acme/repo/issues/42/labels/bug'], ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], ['GET', 'https://api.github.com/user'], + ['POST', 'https://api.github.com/graphql'], ['POST', 'https://uploads.github.com/repos/acme/repo/releases/1/assets?name=asset.zip'], ['GET', 'https://npm.pkg.github.com/@acme%2fdesign'], ['GET', 'https://npm.pkg.github.com/download/@acme/design/1.0.0/package.tgz'], @@ -2013,7 +2014,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { } ); - it('redeems GraphQL for a selected-user capability with the repository installation token', async () => { + it('redeems GraphQL for a selected-user capability with the user token', async () => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', @@ -2025,6 +2026,36 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { serviceMocks.selectUserAuthorization.mockClear(); serviceMocks.getTokenForRepo.mockClear(); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer user-token', + installationId: '123', + source: 'user', + appType: 'standard', + }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('redeems GraphQL for an installation-source capability with the installation token', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.source).toBe('installation'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, @@ -2039,8 +2070,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { source: 'installation', appType: 'standard', }); - expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); }); it('refuses GraphQL for a selected-user capability whose pin no longer resolves', async () => { diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 967d015eda7..6d42896ad45 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -398,7 +398,7 @@ async function readBoundedInternalJsonRequest(request: Request): Promise { appType: claims.identity.appType, }; } - const installationAuth = async (): Promise => { - let auth: GetCloudAgentAuthForRepoResult; - try { - auth = await this.getCloudAgentAuthForRepo(authParams); - } catch { - return { success: false, reason: 'source_unavailable' }; - } - if (!auth.success) { - return { - success: false, - reason: - auth.reason === 'integration_mismatch' ? 'integration_mismatch' : 'source_unavailable', - }; - } - const identityMatches = - claims.source === 'installation' - ? this.matchesSessionIdentity(claims.identity, auth) - : auth.installationId === claims.identity.installationId && - auth.appType === claims.identity.appType; - if (!identityMatches) return { success: false, reason: 'identity_mismatch' }; - return { - success: true, - authorization: this.formatUpstreamAuthorization(params.requestUrl, auth.githubToken), - installationId: auth.installationId, - source: auth.source, - appType: auth.appType, - }; - }; - if (upstream.credential === 'installation') { - // GraphQL is not addressed by repository path, so it is answered with the - // repository-scoped installation credential. A capability pinned to a user - // still requires that pin to resolve, so the session cannot outlive it. - if (claims.source === 'user') { - const pinned = await this.redeemPinnedUserAuthorization(authParams); - if (pinned && !pinned.success) { - return { - success: false, - reason: - pinned.reason === 'integration_mismatch' - ? 'integration_mismatch' - : 'source_unavailable', - }; - } - if (!pinned || pinned.source !== claims.source) { - return { success: false, reason: 'source_unavailable' }; - } - if (!this.matchesSessionIdentity(claims.identity, pinned)) { - return { success: false, reason: 'identity_mismatch' }; - } - } - return installationAuth(); - } let auth: GetCloudAgentAuthForRepoResult | null; if (claims.source === 'user') { From 5a1d2912d1cdee1a6c75fbe16af949b1dc856e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:02:06 +0000 Subject: [PATCH 5/5] docs(git-token-service): describe the GraphQL endpoint in the validator The comment records that GraphQL is not addressed by repository path and that the capability's own credential answers it for each source. --- services/git-token-service/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 6d42896ad45..afc24af459b 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -549,10 +549,10 @@ function validateGitHubCapabilityUpstream( : { failure: 'invalid_upstream_request' }; } if (path === '/graphql') { - // gh resolves pull-request views and mutations through GraphQL, which is not - // addressed by repository path, so the capability's own credential answers it: - // the recorded user's token for a user source, the repository-scoped - // installation token for an installation source. + // gh resolves pull-request views and mutations through GraphQL. The endpoint is + // not addressed by repository path, so the capability's own credential answers it: + // the recorded user's token for a user source, the repository-scoped installation + // token for an installation source. return method === 'POST' ? { failure: null, credential: 'pinned' } : { failure: 'invalid_upstream_request' };