Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { describe, expect, it } from '@jest/globals';
import type { ReviewActor } from '@kilocode/app-shared/provider-review';
import {
createBitbucketInteractiveClient,
type BitbucketInteractiveBrokerRequest,
type BitbucketInteractiveMetadata,
type BitbucketInteractiveRequest,
type BitbucketInteractiveServiceSuccess,
Expand Down Expand Up @@ -76,6 +77,111 @@ const json = (body: unknown, status = 200, headers = {}) =>
});

describe('server-only Bitbucket interactive broker client', () => {
const sourceRequest = {
operation: 'file',
source: {
pullRequestId: 7,
workspaceUuid: '123e4567-e89b-12d3-a456-426614174098',
repositoryUuid: '123e4567-e89b-12d3-a456-426614174099',
},
params: {
path: {
workspace: 'acme',
repo_slug: 'widgets',
commit: '0123456789abcdef0123456789abcdef01234567',
path: 'src/file.ts',
},
},
} satisfies BitbucketInteractiveBrokerRequest<'file'>;

it.each(['file', 'fileMetadata'] as const)(
'forwards the narrow source %s contract while retaining destination authorization',
async operation => {
const sent: unknown[] = [];
const data =
operation === 'file'
? 'fork content'
: {
type: 'commit_file',
path: 'src/file.ts',
size: 12,
commit: { hash: sourceRequest.params.path.commit },
attributes: [],
};
const result = await createBitbucketInteractiveClient({
...options,
fetch: async (_url, init) => {
if (new Headers(init?.headers).get('authorization') !== 'Bearer internal-token-fixture')
return json({}, 403);
sent.push(JSON.parse(String(init?.body)));
return json({ success: true, result: { status: 200, data }, metadata });
},
}).execute({ ...sourceRequest, operation });
expect(sent).toEqual([
{
...options.workspace,
...options.repository,
request: { ...sourceRequest, operation },
},
]);
expect(result).toEqual({ status: 200, data, metadata });
expect(JSON.stringify({ sent, result })).not.toContain('internal-token-fixture');
}
);

it.each([
'invalid_request',
'not_connected',
'integration_mismatch',
'workspace_mismatch',
'repository_mismatch',
'conflict',
'insufficient_permissions',
'not_found',
'rate_limited',
'temporarily_unavailable',
'provider_unavailable',
'authentication_rejected',
] as const)('retains sanitized source failure %s without automatic retries', async reason => {
let requests = 0;
const error = await createBitbucketInteractiveClient({
...options,
fetch: async () => {
requests += 1;
return json({ success: false, reason }, 200, { authorization: 'provider-token-fixture' });
},
})
.execute(sourceRequest)
.catch(error => error);
expect(error).toMatchObject({ code: reason, message: reason });
expect(requests).toBe(1);
expect(`${String(error)} ${JSON.stringify(error)} ${error.stack}`).not.toContain(
'provider-token-fixture'
);
expect(error).not.toHaveProperty('request');
expect(error).not.toHaveProperty('response');
expect(error).not.toHaveProperty('cause');
});

it('does not expose an unrecognized source failure or its credential-bearing cause', async () => {
const error = await createBitbucketInteractiveClient({
...options,
fetch: async () =>
json({
success: false,
reason: 'provider-token-fixture',
cause: { token: 'provider-token-fixture' },
}),
})
.execute(sourceRequest)
.catch(error => error);
expect(error).toMatchObject({ code: 'invalid_response' });
expect(`${String(error)} ${JSON.stringify(error)} ${error.stack}`).not.toContain(
'provider-token-fixture'
);
expect(error).not.toHaveProperty('cause');
});

it('sends exact identity and exposes workspace-token facts without credential objects', async () => {
const sent: { url: string; body: unknown; redirect?: RequestRedirect }[] = [];
const result = await createBitbucketInteractiveClient({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { GIT_TOKEN_SERVICE_API_URL } from '@/lib/config.server';
import { generateInternalServiceToken, TOKEN_EXPIRY } from '@/lib/tokens';
import {
BitbucketInteractiveMetadataSchema,
type BitbucketInteractiveBrokerRequest,
type BitbucketInteractiveSourceSelector,
type BitbucketInteractiveData,
type BitbucketInteractiveMetadata,
type BitbucketInteractiveOperation,
Expand All @@ -24,6 +26,8 @@ import type {
} from './token-service-client';

export type {
BitbucketInteractiveBrokerRequest,
BitbucketInteractiveSourceSelector,
BitbucketInteractiveMetadata,
BitbucketInteractiveRequest,
BitbucketInteractiveResponse,
Expand Down Expand Up @@ -95,7 +99,7 @@ export function createBitbucketInteractiveClient(options: {
}) {
return {
async execute<K extends BitbucketInteractiveOperation>(
request: BitbucketInteractiveRequest<K>
request: BitbucketInteractiveBrokerRequest<K>
): Promise<BitbucketInteractiveResponse<BitbucketInteractiveData<K>>> {
if (!options.organizationId || !options.actorUserId)
throw new BitbucketInteractiveClientError('invalid_request');
Expand Down
26 changes: 26 additions & 0 deletions packages/worker-utils/src/internal-service-token-audiences.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { signKiloToken, verifyKiloToken } from './kilo-token.js';
import { BITBUCKET_INTERACTIVE_AUDIENCE } from './internal-service-token-audiences.js';
import { GITLAB_CREDENTIAL_BROKER_AUDIENCE as RootGitLabCredentialBrokerAudience } from './index.js';
import {
BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE,
Expand All @@ -10,6 +12,30 @@ import {
} from './internal-service-token-audiences.js';

describe('internal service token audiences', () => {
it('prevents interactive assertions from authorizing legacy endpoints', async () => {
const secret = 'test-secret-that-is-at-least-32-characters';
const { token } = await signKiloToken({
userId: 'actor',
pepper: null,
secret,
expiresInSeconds: 60,
audience: BITBUCKET_INTERACTIVE_AUDIENCE,
});
await expect(
verifyKiloToken(token, secret, { audience: BITBUCKET_INTERACTIVE_AUDIENCE })
).resolves.toMatchObject({ kiloUserId: 'actor' });
for (const audience of [
undefined,
BITBUCKET_REPOSITORY_LIST_AUDIENCE,
BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE,
BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE,
BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE,
GITLAB_CREDENTIAL_BROKER_AUDIENCE,
]) {
await expect(verifyKiloToken(token, secret, { audience })).rejects.toThrow();
}
});

it('keeps Bitbucket operations purpose-bound and mutually distinct', () => {
const audiences = [
BITBUCKET_REPOSITORY_LIST_AUDIENCE,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const BITBUCKET_REPOSITORY_LIST_AUDIENCE = 'git-token-service:bitbucket-repositories';
export const BITBUCKET_INTERACTIVE_AUDIENCE = 'git-token-service:bitbucket-interactive-review';
export const BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE =
'git-token-service:bitbucket-code-review:pull-request';
export const BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE =
Expand Down
183 changes: 183 additions & 0 deletions services/git-token-service/src/bitbucket-interactive-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,42 @@ describe('Bitbucket generated SDK boundary', () => {
expect(effects).toBe(0);
});

it.each(['file', 'createComment', 'deleteBranch'] as const)(
'never resolves a broker source selector inside the exact SDK scope: %s',
async operation => {
let effects = 0;
const operationRequest =
operation === 'file'
? {
operation,
params: {
path: { ...branches.params.path, commit: 'a'.repeat(40), path: 'file.ts' },
},
}
: operation === 'createComment'
? comment
: {
operation,
params: { path: { ...branches.params.path, name: 'feature' } },
};
const request = {
...operationRequest,
source: {
pullRequestId: 7,
workspaceUuid: '123e4567-e89b-12d3-a456-426614174098',
repositoryUuid: '123e4567-e89b-12d3-a456-426614174099',
},
};
await expect(
api(async () => {
effects += 1;
return json({ id: 91 });
}).execute(request as BitbucketInteractiveRequest)
).rejects.toMatchObject({ code: 'invalid_request' });
expect(effects).toBe(0);
}
);

it.each([
{ ...comment, body: null },
{ ...comment, body: undefined },
Expand Down Expand Up @@ -559,6 +595,153 @@ describe('Bitbucket generated SDK boundary', () => {
});
});

describe('UUID-addressed merge task locations', () => {
const scope = {
kind: 'repository' as const,
workspace: '{123e4567-e89b-12d3-a456-426614174031}',
repository: '{123e4567-e89b-12d3-a456-426614174032}',
};
const options = {
scope,
accessToken: token,
canonicalTaskRepository: { workspace: 'acme', repository: 'widgets' },
};
const request = {
...merge,
params: {
path: { workspace: scope.workspace, repo_slug: scope.repository, pull_request_id: 7 },
},
};
const mergeUrl =
'https://api.bitbucket.org/2.0/repositories/%7B123e4567-e89b-12d3-a456-426614174031%7D/%7B123e4567-e89b-12d3-a456-426614174032%7D/pullrequests/7/merge';
const uuidTaskUrl = `${mergeUrl}/task-status/task-1`;

it.each([
['canonical header', taskUrl, null, true],
['canonical body', taskUrl, { task_status_url: taskUrl }, true],
[
'canonical UUID task',
`${prUrl}/merge/task-status/%7B123e4567-e89b-12d3-a456-426614174099%7D`,
null,
true,
],
['UUID with mapping', uuidTaskUrl, null, true],
['legacy UUID without mapping', uuidTaskUrl, null, false],
] as const)(
'retains the %s after a UUID-addressed merge',
async (_name, location, data, mapped) => {
const result = await createBitbucketInteractiveApi({
...options,
canonicalTaskRepository: mapped ? options.canonicalTaskRepository : undefined,
fetch: async (url, init) => {
if (url !== mergeUrl || init?.method !== 'POST' || init.redirect !== 'manual')
return json({}, 404);
return data === null
? new Response(null, { status: 202, headers: { location } })
: json(data, 202, { location });
},
}).execute(request);
expect(result).toEqual({ status: 202, location, data });
expect(JSON.stringify(result)).not.toContain(token);
}
);

it('rejects a canonical task without a verified alias mapping', async () => {
await expect(
createBitbucketInteractiveApi({
scope,
accessToken: token,
fetch: async () => new Response(null, { status: 202, headers: { location: taskUrl } }),
}).execute(request)
).rejects.toMatchObject({ code: 'invalid_response' });
});

it.each([
taskUrl.replace('/acme/', '/foreign/'),
taskUrl.replace('/widgets/', '/other/'),
uuidTaskUrl.replace('426614174032', '426614174099'),
uuidTaskUrl.replace('426614174031', '426614174098'),
taskUrl.replace('/widgets/', `/%7B123e4567-e89b-12d3-a456-426614174032%7D/`),
taskUrl.replace('/7/', '/8/'),
taskUrl.replace('task-1', ''),
`${taskUrl}/child`,
taskUrl.replace('task-1', 'task%2Fother'),
taskUrl.replace('task-1', 'task%252Fother'),
taskUrl.replace('task-1', '%2e%2e'),
taskUrl.replace('task-1', 'task%5Cother'),
taskUrl.replace('task-1', '%ZZ'),
taskUrl.replace('task-1', 'x'.repeat(256)),
taskUrl.replace('/acme/', '/%61cme/'),
taskUrl.replace('/widgets/', '/%77idgets/'),
taskUrl.replace('/widgets/', '/widgets/../widgets/'),
taskUrl.replace('api.bitbucket.org', 'api.bitbucket.org.evil.example'),
taskUrl.replace('https:', 'http:'),
taskUrl.replace('api.bitbucket.org', 'api.bitbucket.org:444'),
taskUrl.replace('https://', 'https://user:private-provider-token@'),
`${taskUrl}?page=2`,
`${taskUrl}?access_token=${token}`,
`${taskUrl}#fragment`,
])('rejects an unbound or unsafe task location %s', async location => {
const error = await createBitbucketInteractiveApi({
...options,
fetch: async () => new Response(null, { status: 202, headers: { location } }),
})
.execute(request)
.catch(error => error);
expect(error).toMatchObject({ code: 'invalid_response' });
expect(`${String(error)} ${JSON.stringify(error)} ${error.stack}`).not.toContain(token);
});

it.each([
{ workspace: '../acme', repository: 'widgets' },
{ workspace: 'acme', repository: 'widgets/other' },
])('rejects an unsafe server alias %#', canonicalTaskRepository => {
expect(() => createBitbucketInteractiveApi({ ...options, canonicalTaskRepository })).toThrow(
'invalid_request'
);
});

it.each([
{ kind: 'workspace', workspace: scope.workspace },
{ kind: 'repository', workspace: 'acme', repository: 'widgets' },
] as const)('requires an immutable repository scope for aliases %#', scope => {
expect(() => createBitbucketInteractiveApi({ ...options, scope })).toThrow('invalid_request');
});

it('rejects a request-selected alias before dispatch', async () => {
let effects = 0;
await expect(
createBitbucketInteractiveApi({
...options,
fetch: async () => {
effects += 1;
return new Response(null, { status: 202, headers: { location: taskUrl } });
},
}).execute({
...request,
canonicalTaskRepository: options.canonicalTaskRepository,
} as BitbucketInteractiveRequest<'merge'>)
).rejects.toMatchObject({ code: 'invalid_request' });
expect(effects).toBe(0);
});

it('does not extend the alias mapping to pagination', async () => {
await expect(
createBitbucketInteractiveApi({
...options,
fetch: async () =>
json({
values: [],
next: 'https://api.bitbucket.org/2.0/repositories/acme/widgets/refs/branches?pagelen=50&page=2',
}),
}).execute({
operation: 'branches',
params: { path: { workspace: scope.workspace, repo_slug: scope.repository } },
})
).rejects.toMatchObject({ code: 'invalid_pagination' });
});
});

describe('SDK credential boundary', () => {
it.each(['access_token', 'oauth_token', 'Authorization', 'callback'])(
'rejects credential or unknown query key %s before dispatch',
Expand Down
Loading
Loading