diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index e6eab9ca99..04ea5e398b 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -667,6 +667,140 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { }) ).resolves.toEqual({ success: false, reason: 'invalid_upstream_url' }); }); + + it('redeems a REST API request into a Bearer header', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'GET', + requestUrl: 'https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42', + }) + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer ATCT-runtime-token' }, + }); + }); + + it('redeems a REST API sub-resource request into a Bearer header', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'POST', + requestUrl: + 'https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42/comments', + }) + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer ATCT-runtime-token' }, + }); + }); + + it.each([ + [ + 'a different repository', + 'https://api.bitbucket.org/2.0/repositories/acme/other/pullrequests/42', + ], + [ + 'a different workspace', + 'https://api.bitbucket.org/2.0/repositories/other/widgets/pullrequests/42', + ], + [ + 'a repository name that is only a prefix of a path segment', + 'https://api.bitbucket.org/2.0/repositories/acme/widgetsextra/pullrequests/42', + ], + ] as const)('rejects a REST API request for %s', async (_description, requestUrl) => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'GET', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + }); + + it.each([ + ['http scheme', 'http://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42'], + [ + 'credentials in URL', + 'https://user:pass@api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42', + ], + ['fragment', 'https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42#frag'], + [ + 'path traversal', + 'https://api.bitbucket.org/2.0/repositories/acme/widgets/../other/pullrequests/42', + ], + ['encoded slash', 'https://api.bitbucket.org/2.0/repositories/acme%2fwidgets/pullrequests/42'], + [ + 'encoded traversal', + 'https://api.bitbucket.org/2.0/repositories/acme/widgets/%2e%2e/pullrequests/42', + ], + ] as const)('rejects a malformed REST API request URL (%s)', async (_description, requestUrl) => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'GET', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'invalid_upstream_url' }); + }); + + it('rejects a REST API request to an unapproved origin', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'GET', + requestUrl: 'https://evil.example.com/2.0/repositories/acme/widgets/pullrequests/42', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_origin_not_allowed' }); + }); + + it('still redeems a git smart-HTTP request into Basic auth', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'GET', + requestUrl: 'https://bitbucket.org/acme/widgets.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ + success: true, + headers: { + authorization: `Basic ${Buffer.from('x-token-auth:ATCT-runtime-token').toString('base64')}`, + }, + }); + }); + + it('allows an encoded slash in a REST API query string', async () => { + const capability = await issueCapability(); + // Mirrors bb pr current: the branch name lives in the q= filter, so a branch + // with a slash arrives percent-encoded in the query, never in the path. + const params = new URLSearchParams({ + q: 'source.branch.name = "feature/widgets" AND state = "OPEN"', + pagelen: '50', + }); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'GET', + requestUrl: `https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests?${params.toString()}`, + }) + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer ATCT-runtime-token' }, + }); + }); }); describe('GitTokenRPCEntrypoint Bitbucket runtime authorization', () => { diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 533ab924cc..b6b56ab0cf 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -316,6 +316,8 @@ const BITBUCKET_REPOSITORIES_PATH = '/internal/bitbucket/repositories'; const BITBUCKET_CODE_REVIEW_PULL_REQUEST_PATH = '/internal/bitbucket/code-review/pull-request'; const BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_PATH = '/internal/bitbucket/code-review/webhooks/ensure'; const BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_PATH = '/internal/bitbucket/code-review/webhooks/delete'; +const BITBUCKET_GIT_ORIGIN = 'https://bitbucket.org'; +const BITBUCKET_API_ORIGIN = 'https://api.bitbucket.org'; const GITLAB_CREDENTIAL_BROKER_PATH = '/internal/gitlab/credentials'; const INTERNAL_REQUEST_MAX_BYTES = 16_000; @@ -566,27 +568,33 @@ function validateGitLabCapabilityUpstream( function validateBitbucketCapabilityUpstream( requestUrl: string, repositoryFullName: string -): { failure: RedeemBitbucketSessionCapabilityFailureReason | null } { +): { failure: RedeemBitbucketSessionCapabilityFailureReason | null; authSurface: 'git' | 'api' } { // Bitbucket smart-HTTP repo paths are //.git with literal // slashes and never carry an encoded slash, so reject %2f outright. This guard // deliberately differs from validateGitLabCapabilityUpstream, which must allow // %2f because GitLab addresses projects by encoded path (e.g. // /api/v4/projects/group%2Fproject); do not "reconcile" the two. - if (/%2f|%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { - return { failure: 'invalid_upstream_url' }; + // Only the path is scanned: a query string may legitimately carry %2f data, such + // as a branch name with a slash in `bb pr current`'s q= filter, and it cannot + // change how the path resolves against the repository prefix checked below. + const queryStart = requestUrl.search(/[?#]/); + const requestPath = queryStart === -1 ? requestUrl : requestUrl.slice(0, queryStart); + if (/%2f|%5c/i.test(requestPath) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestPath)) { + return { failure: 'invalid_upstream_url', authSurface: 'git' }; } let url: URL; try { url = new URL(requestUrl); } catch { - return { failure: 'invalid_upstream_url' }; + return { failure: 'invalid_upstream_url', authSurface: 'git' }; } if (url.protocol !== 'https:' || url.username || url.password || url.hash) { - return { failure: 'invalid_upstream_url' }; + return { failure: 'invalid_upstream_url', authSurface: 'git' }; } - if (url.origin !== 'https://bitbucket.org') { - return { failure: 'upstream_origin_not_allowed' }; + if (url.origin !== BITBUCKET_GIT_ORIGIN && url.origin !== BITBUCKET_API_ORIGIN) { + return { failure: 'upstream_origin_not_allowed', authSurface: 'git' }; } + const authSurface = url.origin === BITBUCKET_API_ORIGIN ? 'api' : 'git'; // Defense in depth against nested percent-encoding: the raw check above only // catches single-encoded traversal (%2e/%2f). Decode the pathname iteratively // and re-check, so sequences like %252e%252e%252f — which survive the raw pass @@ -597,15 +605,20 @@ function validateBitbucketCapabilityUpstream( decodedPathname.includes('\\') || /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodedPathname) ) { - return { failure: 'invalid_upstream_url' }; + return { failure: 'invalid_upstream_url', authSurface }; } - // Bitbucket smart-HTTP paths live under //.git/... The full - // name was validated (single slash, no traversal) when the capability decoded. - const repoPath = `/${repositoryFullName}.git`; + // The full repository name was validated (single slash, no traversal) when the + // capability decoded. The git surface is Bitbucket smart-HTTP under + // //.git/...; the API surface is the REST API under + // /2.0/repositories///... + const repoPath = + authSurface === 'api' + ? `/2.0/repositories/${repositoryFullName}` + : `/${repositoryFullName}.git`; if (url.pathname !== repoPath && !url.pathname.startsWith(`${repoPath}/`)) { - return { failure: 'repository_mismatch' }; + return { failure: 'repository_mismatch', authSurface }; } - return { failure: null }; + return { failure: null, authSurface }; } function validateLegacyGitLabCapabilityUpstream( @@ -1248,6 +1261,12 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: false, reason: 'source_unavailable' }; } } + if (upstream.authSurface === 'api') { + return { + success: true, + headers: { authorization: `Bearer ${subject.token}` }, + }; + } return { success: true, headers: {