Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions services/git-token-service/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
45 changes: 32 additions & 13 deletions services/git-token-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 /<workspace>/<repo>.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
Expand All @@ -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 /<workspace>/<repo>.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
// /<workspace>/<repo>.git/...; the API surface is the REST API under
// /2.0/repositories/<workspace>/<repo>/...
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(
Expand Down Expand Up @@ -1248,6 +1261,12 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint<CloudflareEnv> {
return { success: false, reason: 'source_unavailable' };
}
}
if (upstream.authSurface === 'api') {
return {
success: true,
headers: { authorization: `Bearer ${subject.token}` },
};
}
return {
success: true,
headers: {
Expand Down