Skip to content

Unauthenticated Stripe pass-through routes #2

Description

@nathanclevenger

Code-read on 2026-09-10 against f303cc8 (local) / b23b7bc (origin/main); the finding is the same on both. No requests were sent to the deployed worker. Full note: dot-do/apis docs/research/payments-do-auth-check.md.

Finding

Every REST route in src/index.ts except POST /webhooks calls getStripe().<resource>.<op>() with no check of who is calling. fetch (src/index.ts:623-652) goes matchRoute → handler → Stripe; there is no middleware, header inspection, or allowlist in between. The only 401 the worker can emit is Stripe rejecting the platform key (:116-124). src/env.d.ts:16-34 declares no caller-side secret or JWKS, so there is no auth mechanism even in configuration. The worker is routed publicly at payments.do/* (wrangler.jsonc:12-17), and the header comment's intent ("REST API for service binding consumers", :4) is not enforced.

getConnectOptions (:144-158) forwards Stripe-Account from the request header, or stripeAccount from the body, verbatim — so an unauthenticated caller can act as the platform on any connected account. The StripeDO rate limiter and method whitelist in src/stripe.ts are not wired (no durable_objects binding, never imported).

Routes

route auth Stripe op money-moving? reads customer data? public?
POST /customers (:251) none customers.create no writes PII yes
GET /customers/:id (:258) none customers.retrieve no yes yes
PATCH /customers/:id (:264) none customers.update (body pass-through) indirectly writes PII yes
POST /subscriptions (:273) none subscriptions.create yes — yes
GET /subscriptions/:id (:280) none subscriptions.retrieve no yes yes
DELETE /subscriptions/:id (:286) none subscriptions.cancel yes — yes
PATCH /subscriptions/:id (:292) none subscriptions.update (body pass-through) yes — yes
POST /subscriptions/:id/pause / resume (:300, :313) none subscriptions.update yes — yes
POST /charges (:327) none charges.create yes — yes
GET /charges/:id (:341) none charges.retrieve no yes (card last4, billing details) yes
POST /invoices (:349) none invoices.create yes — yes
GET /invoices/:id (:356) none invoices.retrieve no yes yes
POST /invoices/:id/finalize / void (:362, :368) none finalizeInvoice / voidInvoice yes — yes
POST /products, PATCH /products/:id (:376, :389) none products.create/update catalog write — yes
POST /prices (:398) none prices.create catalog write — yes
GET /products/:id, GET /prices/:id (:383, :405) none retrieve no no yes
POST /refunds (:413) none refunds.create yes (outbound) — yes
POST /import (:422-469) none lists ALL customers, subscriptions, products, prices, invoices on the platform account; emits to EVENTS under a caller-chosen ns (:424) no yes, in bulk yes
GET /checkout (:477) none (public buyer front by design; amount never from the query string) checkout.sessions.create session only no yes, intended
POST /webhooks (:520) Stripe-Signature via constructEvent (:521-538) event processing no no yes, intended
unmatched paths (:641-650) none intended capnweb RPC over the whole Stripe SDK; dead under rpc.do@0.2.4 (RPC() is a client factory, .fetch → transport.call on the Stripe object → TypeError → 500) — — returns 500

Recommended fix

Preferred (ax ADR 0023 §2): require a short-lived id.org.ai-issued JWT with aud=payments.do (RFC 8707) on every route except /, /checkout and /webhooks, verified against id.org.ai's JWKS pinned in deploy config. Derive tenant and the permitted Stripe-Account from the token's claims, never from the request header or body.

Smallest safe interim change: a PUBLIC_PATHS = ['/', '/checkout', '/webhooks'] allowlist at src/index.ts:629 (before matchRoute) that answers 404 for every other path unless the request arrived over a service binding; delete POST /import and the RPC fallback (:81-89, :641-650); drop the Stripe-Account/stripeAccount pivot from unauthenticated input. Service-binding-only exposure of the pass-through routes matches the stated intent (CLAUDE.md:21-26); /checkout and /webhooks must stay on the public hostname, so the route itself cannot simply be removed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions