From b2668d571373ac2f39e02f2bb0ebaefc766db533 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 21:59:59 +0000 Subject: [PATCH] fix(security): sanitize provider HTTP errors Preserve provider error status without allowing HTTP-shaped messages or details to bypass repository credential redaction. Co-Authored-By: Paperclip --- .../repository-provider-plugin-seam.test.ts | 28 +++++++++++++++++++ .../repository-providers/connector-guard.ts | 10 +++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/repository-provider-plugin-seam.test.ts b/server/src/__tests__/repository-provider-plugin-seam.test.ts index 5ec4e695c30..63e2ee4b014 100644 --- a/server/src/__tests__/repository-provider-plugin-seam.test.ts +++ b/server/src/__tests__/repository-provider-plugin-seam.test.ts @@ -518,6 +518,34 @@ describe("bridge connector", () => { }); }); + it("sanitizes HTTP-shaped provider errors while preserving their status", async () => { + const connector = createPluginRepositoryProviderConnector({ + pluginId: PLUGIN_ID, + pluginKey: PLUGIN_KEY, + declaration, + workerManager: fakeWorkerManager({ + handlers: { + repositoryProviderSync: () => { + const error = new Error("provider failed token=ghs_supersecret at https://user:pw@github.example.com/x") as Error & { + status: number; + details: unknown; + }; + error.status = 409; + error.details = { access_token: "ghs_supersecret" }; + throw error; + }, + }, + }), + }); + + await connector.sync({ connection: connection(), cursor: null }).catch((error: Error & { status?: number; details?: unknown }) => { + expect(error.status).toBe(409); + expect(error.message).not.toContain("ghs_supersecret"); + expect(error.message).not.toContain("user:pw@"); + expect(error.details).toBeUndefined(); + }); + }); + it("rebuilds the credential audit from host-known values and marks it no-store", async () => { const conn = connection(); const connector = createPluginRepositoryProviderConnector({ diff --git a/server/src/services/repository-providers/connector-guard.ts b/server/src/services/repository-providers/connector-guard.ts index cd7ecb36388..a00017e3b89 100644 --- a/server/src/services/repository-providers/connector-guard.ts +++ b/server/src/services/repository-providers/connector-guard.ts @@ -2,7 +2,7 @@ import { REPOSITORY_PROVIDER_ANY_HOST, normalizeRepositoryProviderHost, } from "@paperclipai/shared"; -import { unprocessable } from "../../errors.js"; +import { HttpError, unprocessable } from "../../errors.js"; import { sanitizeRepositoryProviderError, sanitizeRepositoryProviderMetadata, @@ -160,11 +160,11 @@ async function guarded(operation: () => Promise): Promise { return await operation(); } catch (error) { // Providers surface HTTP-shaped errors deliberately (422 for bad state, - // 409 for conflicts); those pass through with their status intact. Anything - // else becomes a sanitized 422 so a raw provider message — which may embed a - // token or an internal URL — never reaches a caller or a log line. + // 409 for conflicts). Preserve only their status; their message and details + // are still untrusted provider output and must not bypass sanitization. if (error && typeof error === "object" && typeof (error as { status?: unknown }).status === "number") { - throw error; + const status = (error as { status: number }).status; + throw new HttpError(status, sanitizeRepositoryProviderError(error)); } throw unprocessable(sanitizeRepositoryProviderError(error)); }