fix(security): suppress raw response body from user-facing billing errors
billing.ts (startCheckout, openBillingPortal): replace raw res.text() in thrown Error with a safe status-only message. The response body from /cp/billing/* routes can contain Stripe API error detail (invalid key, card decline message, raw Stripe envelope) that should not reach clients. orgs/page.tsx (createOrg): same fix — raw body → safe message. Full body is logged server-side for debugging. Closes: #91 (CWE-209 — Stripe key echoed in error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
beba599250
commit
acf03cd057
@ -352,7 +352,8 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`${res.status}: ${body}`);
|
||||
console.error(`[orgs] create ${res.status}: ${body}`);
|
||||
throw new Error(`Failed to create organization (${res.status})`);
|
||||
}
|
||||
onCreated(slug);
|
||||
} catch (e) {
|
||||
|
||||
@ -70,7 +70,7 @@ describe("startCheckout", () => {
|
||||
expect(body.cancel_url).toContain("checkout=cancel");
|
||||
});
|
||||
|
||||
it("throws with the body text on non-2xx so the UI can surface it", async () => {
|
||||
it("throws with status code on non-2xx; body is logged not surfaced", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 402,
|
||||
@ -78,8 +78,11 @@ describe("startCheckout", () => {
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
// Status code must appear so callers know what happened.
|
||||
await expect(startCheckout("starter", "acme")).rejects.toThrow(/402/);
|
||||
await expect(startCheckout("starter", "acme")).rejects.toThrow(/payment required/);
|
||||
// Body text must NOT appear — it may contain Stripe API detail.
|
||||
await expect(startCheckout("starter", "acme")).rejects.toThrow(/checkout failed/);
|
||||
await expect(startCheckout("starter", "acme")).rejects.not.toThrow(/payment required/);
|
||||
});
|
||||
|
||||
it("sends users to /orgs on success, back to current page on cancel", async () => {
|
||||
|
||||
@ -120,8 +120,12 @@ export async function startCheckout(
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`checkout: ${res.status} ${text}`);
|
||||
// Never embed res.text() in the thrown error — the response body
|
||||
// may contain Stripe API error detail (e.g. invalid key, card decline
|
||||
// message, raw Stripe envelope) that should not reach the client.
|
||||
const detail = await res.text();
|
||||
console.error(`[billing] checkout ${res.status}: ${detail}`);
|
||||
throw new Error(`checkout failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@ -141,8 +145,12 @@ export async function openBillingPortal(orgSlug: string): Promise<string> {
|
||||
body: JSON.stringify({ org_slug: orgSlug, return_url: returnUrl }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`portal: ${res.status} ${text}`);
|
||||
// Never embed res.text() in the thrown error — the response body
|
||||
// may contain Stripe API error detail (e.g. invalid key, card decline
|
||||
// message, raw Stripe envelope) that should not reach the client.
|
||||
const detail = await res.text();
|
||||
console.error(`[billing] portal ${res.status}: ${detail}`);
|
||||
throw new Error(`portal failed (${res.status})`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
return data.url;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user