Skip to content

IDOR in generateReceiptUrl #28395

@PanAchy

Description

@PanAchy

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions