From d0debd790594655ce2100bb3c61b041b740f12d0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:02:59 -0700 Subject: [PATCH] fix(server): sanitize Bitbucket HTTP errors Co-authored-by: codex --- .../src/sourceControl/BitbucketApi.test.ts | 62 +++++++++++++++++++ apps/server/src/sourceControl/BitbucketApi.ts | 18 ++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e4a7649e74a..84dc8f9c849 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -544,6 +544,68 @@ it.effect("preserves the HTTP client failure without deriving the domain message }).pipe(Effect.provide(layer)); }); +it.effect("keeps Bitbucket HTTP response bodies out of diagnostics", () => { + const secretBody = '{"error":{"message":"credential=secret-value"}}'; + const { layer } = makeLayer({ + response: () => new Response(secretBody, { status: 403 }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.getPullRequest({ + cwd: "/repo", + reference: "42", + }), + ); + + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 403); + assert.strictEqual(error.responseBodyLength, secretBody.length); + assert.strictEqual(error.detail, "Bitbucket returned HTTP 403."); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports Bitbucket response-body read failures with stable diagnostics", () => { + const bodyReadCause = new Error("credential=secret-value"); + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(bodyReadCause); + }, + }), + { status: 502 }, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.getPullRequest({ + cwd: "/repo", + reference: "42", + }), + ); + + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 502); + assert.strictEqual(error.responseBodyLength, undefined); + assert.strictEqual(error.detail, "Failed to read the Bitbucket error response body."); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Failed to read the Bitbucket error response body.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 9a678ab44dc..51bfd4b7071 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -42,6 +43,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass { return response.text.pipe( - Effect.orElseSucceed(() => ""), + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation, + status: response.status, + detail: "Failed to read the Bitbucket error response body.", + cause, + }), + ), Effect.flatMap((body) => Effect.fail( new BitbucketApiError({ operation, status: response.status, - detail: - body.trim().length > 0 - ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` - : `Bitbucket returned HTTP ${response.status}.`, + responseBodyLength: body.length, + detail: `Bitbucket returned HTTP ${response.status}.`, }), ), ),