From df8df0d53b39bfd6a5d8d69fabade19bd668e0a5 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:15:13 -0500 Subject: [PATCH] fix(server): bound Bitbucket API requests with 30s timeout (#14140) --- .../src/sourceControl/BitbucketApi.test.ts | 87 ++++++++++++++++++- .../src/server/BitbucketApi.ts | 12 +++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 22abb952990b16687301975d4cd3969793d22ec1..cc23ae1c7938319646ad4546ea49393717d49f85 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -65,13 +66,16 @@ function makeLayer(input: { readonly requestFailure?: ( request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; + readonly stall?: boolean; readonly git?: Partial; readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => - input.requestFailure - ? Effect.fail(input.requestFailure(request)) - : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + input.stall + ? Effect.never + : input.requestFailure + ? Effect.fail(input.requestFailure(request)) + : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { readConfigValue: vi.fn(() => @@ -1099,3 +1103,80 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); + +const stalledResponseBody = (status: number): Response => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + // Deliberately never close: headers arrive before the body stalls. + }, + }), + { status, headers: { "content-type": "application/json" } }, + ); + +it.effect.each([200, 503])( + "times out a stalled Bitbucket JSON body after HTTP %s headers", + (status) => { + const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const fiber = yield* Effect.forkChild( + Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), + ); + while (execute.mock.calls.length === 0) yield* Effect.yieldNow; + yield* TestClock.adjust("31 seconds"); + const error = yield* Fiber.join(fiber); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "getPullRequest"); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect.each([200, 503])( + "times out a stalled Bitbucket raw body after HTTP %s headers", + (status) => { + const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const fiber = yield* Effect.forkChild( + Effect.flip(api.request({ method: "GET", url: "/user" })), + ); + while (execute.mock.calls.length === 0) yield* Effect.yieldNow; + yield* TestClock.adjust("31 seconds"); + const error = yield* Fiber.join(fiber); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "request"); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("times out before JSON headers arrive", () => { + const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const fiber = yield* Effect.forkChild( + Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), + ); + while (execute.mock.calls.length === 0) yield* Effect.yieldNow; + yield* TestClock.adjust("31 seconds"); + const error = yield* Fiber.join(fiber); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "getPullRequest"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("times out before raw response headers arrive", () => { + const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const fiber = yield* Effect.forkChild( + Effect.flip(api.request({ method: "GET", url: "/user" })), + ); + while (execute.mock.calls.length === 0) yield* Effect.yieldNow; + yield* TestClock.adjust("31 seconds"); + const error = yield* Fiber.join(fiber); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); + assert.strictEqual(error.operation, "request"); + }).pipe(Effect.provide(layer)); +}); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index d9d68144f1fc58a149bcde5edfb8a9246cad9a11..741dcbeb08a30325864839f147d69fe8b5be9405 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -39,6 +39,8 @@ const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ const MAX_REDIRECTS = 3; +/** A single API operation must bound both header delivery and body consumption. */ +const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds"; const BitbucketApiEnvConfig = Config.all({ baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( @@ -732,6 +734,10 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((response) => decodeResponse(operation, schema, response)), + Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail(new BitbucketRequestError({ operation, cause })), + }), ); const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { @@ -968,6 +974,12 @@ export const make = Effect.gen(function* () { orElse: (failed) => responseError("request", failed), })(response), ), + // This also caps redirect hops: no request can finish headers then stall its body forever. + Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new BitbucketRequestError({ operation: "request", cause })), + }), ); return BitbucketApi.of({ -- 2.54.0