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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 4613b36f58
commit b2668d5713
2 files changed
+33 -5

No files matched your search

@@ -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({
@@ -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<T>(operation: () => Promise<T>): Promise<T> {
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));
}