Skip to content

Commit a802d71

Browse files
fix(billing): validate priceId against allowlist and sanitize quantity at checkout endpoints (#2222)
- Add VALID_STRIPE_PLAN_PRICE_IDS and isValidStripePlanPriceId helper in @cap/utils - Validate priceId in guest-checkout, subscribe, and desktop subscribe endpoints - Sanitize quantity to positive bounded integers - Add unit tests verifying arbitrary priceId rejection and valid checkout creation Closes #2222
1 parent e018b68 commit a802d71

6 files changed

Lines changed: 208 additions & 12 deletions

File tree

apps/web/__tests__/unit/mobile-checkout.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock("@cap/utils", () => ({
2222
sessions: { create: checkoutMocks.create },
2323
},
2424
}),
25+
isValidStripePlanPriceId: (id: string) => id === "price_pro",
2526
}));
2627

2728
vi.mock("@/lib/server-analytics", () => ({
@@ -79,6 +80,19 @@ describe("checkout redirects", () => {
7980
});
8081
});
8182

83+
it("rejects non-allowlisted arbitrary priceId with 400", async () => {
84+
const response = await startGuestCheckout(
85+
makeGuestCheckoutRequest({
86+
priceId: "price_arbitrary_attacker_id",
87+
quantity: 1,
88+
}),
89+
);
90+
91+
expect(response.status).toBe(400);
92+
expect(await response.json()).toEqual({ error: "Invalid priceId" });
93+
expect(checkoutMocks.create).not.toHaveBeenCalled();
94+
});
95+
8296
it("sends mobile checkout results through the HTTPS completion route", () => {
8397
expect(getCheckoutRedirectUrls("mobile", "https://cap.so/")).toEqual({
8498
successUrl: "https://cap.so/mobile/checkout/complete?checkout=success",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { isValidStripePlanPriceId, STRIPE_PLAN_IDS } from "@cap/utils";
3+
import { POST as subscribe } from "@/app/api/settings/billing/subscribe/route";
4+
5+
const checkoutMocks = vi.hoisted(() => ({
6+
create: vi.fn(),
7+
track: vi.fn(() => Promise.resolve()),
8+
getCurrentUser: vi.fn(),
9+
dbUpdate: vi.fn(),
10+
}));
11+
12+
vi.mock("@cap/env", () => ({
13+
buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
14+
serverEnv: () => ({ WEB_URL: "https://cap.so" }),
15+
}));
16+
17+
vi.mock("@cap/database/auth/session", () => ({
18+
getCurrentUser: checkoutMocks.getCurrentUser,
19+
}));
20+
21+
vi.mock("@cap/database", () => ({
22+
db: () => ({
23+
update: () => ({
24+
set: () => ({
25+
where: checkoutMocks.dbUpdate,
26+
}),
27+
}),
28+
}),
29+
}));
30+
31+
vi.mock("@cap/database/schema", () => ({
32+
users: { id: "id" },
33+
}));
34+
35+
vi.mock("@cap/utils", async (importOriginal) => {
36+
const actual = await importOriginal<typeof import("@cap/utils")>();
37+
return {
38+
...actual,
39+
stripe: () => ({
40+
customers: {
41+
list: vi.fn().mockResolvedValue({ data: [] }),
42+
create: vi.fn().mockResolvedValue({ id: "cus_new" }),
43+
update: vi.fn().mockResolvedValue({ id: "cus_new" }),
44+
},
45+
checkout: {
46+
sessions: { create: checkoutMocks.create },
47+
},
48+
}),
49+
userIsPro: vi.fn().mockReturnValue(false),
50+
};
51+
});
52+
53+
vi.mock("@/lib/server-analytics", () => ({
54+
trackServerEvent: checkoutMocks.track,
55+
}));
56+
57+
const makeSubscribeRequest = (body: Record<string, unknown>) =>
58+
new Request("https://cap.so/api/settings/billing/subscribe", {
59+
method: "POST",
60+
headers: { "Content-Type": "application/json" },
61+
body: JSON.stringify(body),
62+
}) as unknown as import("next/server").NextRequest;
63+
64+
describe("Stripe plan allowlist", () => {
65+
it("accepts only legitimate Pro plan price IDs", () => {
66+
expect(isValidStripePlanPriceId(STRIPE_PLAN_IDS.development.yearly)).toBe(
67+
true,
68+
);
69+
expect(isValidStripePlanPriceId(STRIPE_PLAN_IDS.development.monthly)).toBe(
70+
true,
71+
);
72+
expect(isValidStripePlanPriceId(STRIPE_PLAN_IDS.production.yearly)).toBe(
73+
true,
74+
);
75+
expect(isValidStripePlanPriceId(STRIPE_PLAN_IDS.production.monthly)).toBe(
76+
true,
77+
);
78+
79+
expect(isValidStripePlanPriceId("price_arbitrary_attacker_id")).toBe(false);
80+
expect(isValidStripePlanPriceId("price_free_tier")).toBe(false);
81+
expect(isValidStripePlanPriceId("")).toBe(false);
82+
});
83+
});
84+
85+
describe("POST /api/settings/billing/subscribe", () => {
86+
beforeEach(() => {
87+
vi.clearAllMocks();
88+
checkoutMocks.getCurrentUser.mockResolvedValue({
89+
id: "user_123",
90+
email: "user@example.test",
91+
stripeCustomerId: "cus_123",
92+
});
93+
checkoutMocks.create.mockResolvedValue({
94+
id: "cs_test",
95+
url: "https://pay.cap.so/session",
96+
});
97+
});
98+
99+
it("rejects arbitrary price IDs with 400", async () => {
100+
const response = await subscribe(
101+
makeSubscribeRequest({
102+
priceId: "price_arbitrary_malicious",
103+
quantity: 1,
104+
}),
105+
);
106+
107+
expect(response.status).toBe(400);
108+
expect(await response.json()).toEqual({
109+
error: true,
110+
message: "Invalid priceId",
111+
});
112+
expect(checkoutMocks.create).not.toHaveBeenCalled();
113+
});
114+
115+
it("accepts valid Pro plan price ID and creates checkout session", async () => {
116+
const response = await subscribe(
117+
makeSubscribeRequest({
118+
priceId: STRIPE_PLAN_IDS.development.monthly,
119+
quantity: 2,
120+
}),
121+
);
122+
123+
expect(response.status).toBe(200);
124+
expect(checkoutMocks.create).toHaveBeenCalledWith(
125+
expect.objectContaining({
126+
customer: "cus_123",
127+
line_items: [
128+
{ price: STRIPE_PLAN_IDS.development.monthly, quantity: 2 },
129+
],
130+
mode: "subscription",
131+
}),
132+
);
133+
});
134+
});

apps/web/app/api/desktop/[...route]/root.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@cap/database/schema";
1111
import { serverEnv } from "@cap/env";
1212
import {
13+
isValidStripePlanPriceId,
1314
isProSubscription,
1415
STRIPE_AVAILABLE,
1516
stripe,
@@ -705,7 +706,9 @@ app.post(
705706
zValidator(
706707
"json",
707708
z.object({
708-
priceId: z.string(),
709+
priceId: z.string().refine((id) => isValidStripePlanPriceId(id), {
710+
message: "Invalid priceId",
711+
}),
709712
platform: z.literal("mobile").optional(),
710713
}),
711714
),

apps/web/app/api/settings/billing/guest-checkout/route.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { serverEnv } from "@cap/env";
2-
import { stripe } from "@cap/utils";
2+
import { isValidStripePlanPriceId, stripe } from "@cap/utils";
33
import type { NextRequest } from "next/server";
44
import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout";
55
import { trackServerEvent } from "@/lib/server-analytics";
@@ -11,19 +11,31 @@ export async function POST(request: NextRequest) {
1111

1212
console.log("Received guest checkout request:", { priceId, quantity });
1313

14-
if (!priceId) {
15-
console.error("Missing required priceId");
16-
return Response.json({ error: "priceId is required" }, { status: 400 });
14+
if (
15+
!priceId ||
16+
typeof priceId !== "string" ||
17+
!isValidStripePlanPriceId(priceId)
18+
) {
19+
console.error("Invalid or missing priceId");
20+
return Response.json({ error: "Invalid priceId" }, { status: 400 });
1721
}
1822

23+
const safeQuantity =
24+
typeof quantity === "number" &&
25+
Number.isInteger(quantity) &&
26+
quantity >= 1 &&
27+
quantity <= 1000
28+
? quantity
29+
: 1;
30+
1931
try {
2032
console.log("Creating guest checkout session");
2133
const redirects = getCheckoutRedirectUrls(
2234
checkoutPlatform,
2335
serverEnv().WEB_URL,
2436
);
2537
const checkoutSession = await stripe().checkout.sessions.create({
26-
line_items: [{ price: priceId, quantity: quantity || 1 }],
38+
line_items: [{ price: priceId, quantity: safeQuantity }],
2739
mode: "subscription",
2840
success_url: redirects.successUrl,
2941
cancel_url: redirects.cancelUrl,

apps/web/app/api/settings/billing/subscribe/route.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from "@cap/database";
22
import { getCurrentUser } from "@cap/database/auth/session";
33
import { users } from "@cap/database/schema";
44
import { serverEnv } from "@cap/env";
5-
import { stripe, userIsPro } from "@cap/utils";
5+
import { isValidStripePlanPriceId, stripe, userIsPro } from "@cap/utils";
66
import { eq } from "drizzle-orm";
77
import type { NextRequest } from "next/server";
88
import type Stripe from "stripe";
@@ -13,11 +13,26 @@ export async function POST(request: NextRequest) {
1313
let customerId = user?.stripeCustomerId;
1414
const { priceId, quantity, isOnBoarding } = await request.json();
1515

16-
if (!priceId) {
17-
console.error("Price ID not found");
18-
return Response.json({ error: true }, { status: 400 });
16+
if (
17+
!priceId ||
18+
typeof priceId !== "string" ||
19+
!isValidStripePlanPriceId(priceId)
20+
) {
21+
console.error("Invalid or missing priceId");
22+
return Response.json(
23+
{ error: true, message: "Invalid priceId" },
24+
{ status: 400 },
25+
);
1926
}
2027

28+
const safeQuantity =
29+
typeof quantity === "number" &&
30+
Number.isInteger(quantity) &&
31+
quantity >= 1 &&
32+
quantity <= 1000
33+
? quantity
34+
: 1;
35+
2136
if (!user) {
2237
console.error("User not found");
2338
return Response.json({ error: true, auth: false }, { status: 401 });
@@ -65,7 +80,7 @@ export async function POST(request: NextRequest) {
6580

6681
const checkoutSession = await stripe().checkout.sessions.create({
6782
customer: customerId as string,
68-
line_items: [{ price: priceId, quantity: quantity }],
83+
line_items: [{ price: priceId, quantity: safeQuantity }],
6984
mode: "subscription",
7085
success_url: isOnBoarding
7186
? `${serverEnv().WEB_URL}/dashboard/settings/organization?upgrade=true&session_id={CHECKOUT_SESSION_ID}`
@@ -84,7 +99,7 @@ export async function POST(request: NextRequest) {
8499
if (checkoutSession.url) {
85100
trackServerEvent(user.id, "checkout_started", {
86101
price_id: priceId,
87-
quantity: quantity,
102+
quantity: safeQuantity,
88103
platform: "web",
89104
});
90105

packages/utils/src/constants/plans.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ export const STRIPE_PLAN_IDS = {
1616
},
1717
};
1818

19+
export const VALID_STRIPE_PLAN_PRICE_IDS = new Set<string>([
20+
STRIPE_PLAN_IDS.development.yearly,
21+
STRIPE_PLAN_IDS.development.monthly,
22+
STRIPE_PLAN_IDS.production.yearly,
23+
STRIPE_PLAN_IDS.production.monthly,
24+
]);
25+
26+
export const isValidStripePlanPriceId = (
27+
priceId: string,
28+
environment?: "development" | "production",
29+
): boolean => {
30+
if (environment) {
31+
const envPlans = STRIPE_PLAN_IDS[environment];
32+
return priceId === envPlans.yearly || priceId === envPlans.monthly;
33+
}
34+
return VALID_STRIPE_PLAN_PRICE_IDS.has(priceId);
35+
};
36+
1937
export const STRIPE_SIGNED_BAA_PRICE_IDS: Record<string, string> = {
2038
development: "price_1U5xKIFJxA1XpeSsdg4Q8H3Z",
2139
production: "price_1U6C99FJxA1XpeSsUg1rXHo2",

0 commit comments

Comments
 (0)