From b5b980578c8e57105f662a0a8171a3d867e3f4fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 17:07:45 +0200 Subject: [PATCH] Backend handoff failure names the status, never the app's answer Same class as Soul pass 4's F1: deliverBackendHandoff put the receiving app's whole response body into its error message. The handoff carries tokens and an app's error page can quote it, and the message reaches whatever catches the error. It now names the status alone and discards the body unread. Co-Authored-By: Claude Opus 5.5 --- src/backend-handoff.ts | 8 ++++---- tests/app.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/backend-handoff.ts b/src/backend-handoff.ts index e6cdacc..1f624e9 100644 --- a/src/backend-handoff.ts +++ b/src/backend-handoff.ts @@ -44,10 +44,10 @@ export async function deliverBackendHandoff(callbackUrl: string, payload: Backen body: JSON.stringify(payload), }); + // The status alone: the app's answer body can quote the handoff, which + // carries tokens, and whatever catches this may record or print it. if (!response.ok) { - const body = await response.text(); - throw new Error( - `Backend handoff delivery failed (status ${response.status}): ${body || "empty response"}` - ); + await response.body?.cancel(); + throw new Error(`Backend handoff delivery failed (status ${response.status}).`); } } diff --git a/tests/app.test.ts b/tests/app.test.ts index 374a3f4..2550d94 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; +import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js"; @@ -1588,3 +1589,28 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () => expect(failed?.eventPayloadJson.providerStatus).toBeNull(); }); }); + +describe("a backend handoff the app refuses", () => { + it("fails by status alone, never with the app's answer body", async () => { + globalThis.fetch = async () => new Response('{"error":"rejected","echo":"CANARYhandoff"}', { status: 500 }); + const failure = await deliverBackendHandoff("https://bifrost.gamecult.org/auth/heimdall/callback", { + source: "heimdall", + kind: "oauth_result", + handoffKind: "backend_callback", + attemptId: "attempt-1", + status: "error", + provider: "discord", + appSlug: "bifrost", + returnTo: "https://bifrost.gamecult.org/", + connection: null, + error: "oauth_callback_failed", + } as BackendHandoffPayload).then( + () => undefined, + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe("Backend handoff delivery failed (status 500)."); + expect(JSON.stringify(failure) + String(failure)).not.toContain("CANARY"); + }); +});