diff --git a/CHANGELOG.md b/CHANGELOG.md index be8862d0..1aed0d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. +- Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry + attempt with a 1 MiB response ceiling before JSON parsing until durable + idempotency exists; validated returned destinations as exact HTTPS + `checkout.stripe.com` URLs without credentials or non-standard ports while + preserving Stripe-issued client fragments; and mapped provider failures to + sanitized no-store 502 responses. - Bound Stripe Checkout success/cancel redirects to an operator-configured canonical public origin instead of request authority, rejected partial or ambiguous billing configuration at startup, and confined successful mock diff --git a/docs/billing-production.md b/docs/billing-production.md index d5c8abb6..ff6dff15 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -50,17 +50,72 @@ For local integration tests, `SCOPEWEAVE_DEV=1` plus a valid loopback the configured origin and a percent-encoded organization identifier; a different request host cannot replace that origin. -## Current slice boundary - -This document describes only the trusted-configuration and redirect-authority -slice of issue #488. It does **not** declare the Stripe lifecycle production -complete. Before production billing can be release-approved, ScopeWeave still -needs the remaining #488 controls, including durable checkout attempts and stable -idempotency keys, a packaged/pinned provider SDK and bounded provider transport, -validated returned Checkout destinations, raw-body webhook verification and -size limits, durable event deduplication, out-of-order reconciliation, normalized -subscription/payment/entitlement state, rollback/recovery procedures, and -end-to-end operational acceptance evidence. +## Provider trust boundary + +> Active PR state: this section describes the stacked provider-boundary work in +> PR #507. It is not protected-`develop` shipped truth until its parent PR #505 +> and this PR are independently approved and integrated. + +The live hosted-Checkout adapter performs one direct server-side HTTPS request to +Stripe until a later lifecycle slice introduces durable checkout-attempt and +idempotency state: + +- endpoint: exact constant `https://api.stripe.com/v1/checkout/sessions`; +- method/body: one POST with `application/x-www-form-urlencoded` fields; +- authentication: `Authorization: Bearer ` sent only to the + constant Stripe API authority; +- total request budget: 15,000 ms using an abort signal; +- redirect policy: provider HTTP redirects are rejected; +- automatic application retries: none; +- successful response budget: at most 1 MiB before UTF-8 decoding and JSON + parsing; invalid, negative, or oversized `Content-Length` declarations are + rejected, and a streamed body that crosses the ceiling is cancelled; +- network, abort, or non-2xx provider failures: stable HTTP 502 + `billing_provider_unavailable` with `Cache-Control: no-store`; +- successful non-JSON, bodyless, unreadable, malformed JSON, or oversized + responses: stable HTTP 502 `billing_provider_invalid_response` with + `Cache-Control: no-store`; +- malformed or untrusted Checkout destinations: stable HTTP 502 + `billing_provider_invalid_response` with `Cache-Control: no-store`; +- browser destination: parsed with `URL` and accepted only for HTTPS, exact + hostname `checkout.stripe.com`, default HTTPS port, and no URL credentials. + +This exact-host check deliberately rejects suffix-confusion names such as +`checkout.stripe.com.evil.example`. Stripe's current Checkout Session API +reference shows a standard hosted `checkout.stripe.com` URL containing an opaque +`#fidk...` fragment. ScopeWeave therefore preserves provider-issued fragments +verbatim after the authority checks instead of treating a fragment as an origin +or hostname decision. + +Stripe Checkout custom domains are not silently trusted. Supporting one requires +a separate operator-owned allowlist or canonical-domain configuration contract +and its own regression evidence. + +Provider exception text, network addresses, non-2xx response bodies, stream-read +errors, and credentials are never copied into the browser error payload. The +customer receives a retry/diagnostic next action rather than downstream internals. + +The provider boundary intentionally uses the documented Stripe HTTPS API instead +of dynamically importing an undeclared runtime SDK. A clean deployment therefore +does not depend on a hidden `stripe` package merely to create the hosted Session. +Package provenance remains part of the normal application supply-chain gate, but +there is no Stripe SDK package gate for this direct adapter. + +## Current lifecycle boundary + +The trusted-configuration and provider-trust slices do **not** declare the Stripe +subscription lifecycle production complete. Before production billing can be +release-approved, ScopeWeave still needs the remaining #488 controls, including: + +a durable checkout-attempt UUID and stable idempotency key; raw-body webhook +signature verification and streaming size limits; durable event deduplication; +out-of-order reconciliation; normalized customer/subscription/payment/entitlement +state; transactional reversible entitlement changes; migration and restore +evidence; privacy/incident runbooks; and provider smoke plus release acceptance. + +No automatic provider retry should be enabled before durable idempotency exists. +No custom Checkout domain should be accepted before an operator-owned trust +configuration exists. ## Operator verification @@ -73,10 +128,26 @@ Before a billing-enabled rollout: 3. Send a checkout request through the same reverse proxy used in production while varying the request authority; success/cancel URLs must still use only `SCOPEWEAVE_PUBLIC_ORIGIN`. -4. Keep the rollout blocked until the remaining #488 lifecycle controls are +4. Capture the canary's outbound request destination and verify exactly one POST + goes to `api.stripe.com/v1/checkout/sessions`, redirects are not followed, and + the request aborts within the configured 15-second total budget. +5. Exercise network failure, non-2xx response, non-JSON success, malformed JSON, + bodyless success, invalid/oversized declared response length, streamed + response overflow, stream-read failure, and provider timeout handling. + Confirm response bodies above 1 MiB are not buffered/parsed and callers + receive only the stable no-store 502 contract without provider body, network, + stream, or credential detail. +6. Reject null, malformed, plaintext, credential-bearing, non-standard-port, + and hostname-confusion Checkout destinations; accept and preserve the exact + standard `https://checkout.stripe.com/...#...` hosted destination, including + its provider-issued fragment. +7. Keep the rollout blocked until the remaining #488 lifecycle controls are implemented and their exact-head security, coverage, review, rollback, and recovery gates pass together. -Rollback for this slice is configuration-neutral: revert the validation module, -checkout authority change, and tests together. No database migration or -persisted billing state is introduced here. +Rollback for the trusted-configuration/provider-boundary stack is data-neutral: +revert the validation and provider-boundary source, tests, documentation, and +CHANGELOG entries together. No database migration or persisted billing state is +introduced by these slices. If billing must be disabled while investigating a +provider outage, remove the complete live provider tuple and restart; never +substitute a production mock. diff --git a/docs/doctoring/stripe-checkout-provider-boundary.md b/docs/doctoring/stripe-checkout-provider-boundary.md new file mode 100644 index 00000000..7f842dab --- /dev/null +++ b/docs/doctoring/stripe-checkout-provider-boundary.md @@ -0,0 +1,187 @@ +# Stripe Checkout provider trust boundary + +Status: **active stacked PR design/implementation evidence; not protected-`develop` +shipped truth until its parent and this slice are independently approved and +integrated.** + +## Buyer-visible failure being closed + +The trusted-origin billing slice prevents request authority from controlling +success/cancel redirects, but a provider boundary also has to constrain how long +Checkout can block a request, how many provider bytes ScopeWeave will accept, +whether an unsafe automatic retry can duplicate a side effect, what +provider-returned URL the browser may follow, and what failure detail can leave +the server. + +Without those controls a buyer cannot distinguish a controlled billing outage +from an indefinite provider wait, and a compromised or malformed provider can +turn a small Checkout call into unbounded process memory consumption or browser +redirect authority. + +## Decision + +ScopeWeave uses a small, explicit HTTPS adapter for this bounded Checkout call +instead of an undeclared runtime SDK. The adapter follows Stripe's published REST +contract and keeps the production dependency surface auditable. + +For hosted Stripe Checkout, this slice applies these controls: + +1. The server sends exactly one `POST https://api.stripe.com/v1/checkout/sessions` + attempt using `application/x-www-form-urlencoded` request data. The request has + a 15,000 ms total abort budget and redirects are rejected by the fetch layer. +2. ScopeWeave implements no provider retry loop until durable checkout-attempt + identifiers and idempotency state exist. A later lifecycle slice may introduce + retries only together with that durable reconciliation model. +3. A non-2xx provider response or network/abort failure becomes stable HTTP 502 + `billing_provider_unavailable`. The provider body is not exposed to callers. +4. A successful response must declare JSON and is consumed as a byte stream with + a hard 1 MiB ceiling before UTF-8 decoding and JSON parsing. A present + `Content-Length` must be a non-negative safe integer no greater than that + ceiling; an invalid or oversized declaration fails before parsing. When an + actual streamed body crosses the ceiling, its reader is cancelled and the + response fails as `billing_provider_invalid_response` rather than buffering + the remaining bytes. +5. Empty, unreadable, malformed-JSON, unsupported-media, invalid-size, and + oversized successful responses become stable HTTP 502 + `billing_provider_invalid_response`. Downstream stream/read errors remain + sanitized and do not expose provider details. +6. The returned Checkout Session `url` is parsed with the platform `URL` parser + and accepted only when it uses HTTPS, has exact hostname + `checkout.stripe.com`, uses the default HTTPS port, and contains no URL + credentials. Provider-issued fragments are preserved verbatim because Stripe's + Checkout Session examples include opaque client fragments; fragments do not + participate in HTTPS authority selection. +7. All provider-facing buyer errors use `Cache-Control: no-store` and omit + downstream exception text, network addresses, response bodies, and secrets. + +The validation uses exact parsed authority fields rather than string-prefix or +suffix matching, so `checkout.stripe.com.evil.example` is not trusted. + +## Authentication, response consumption, and wire contract + +Stripe's API requires HTTPS and authenticates secret API calls with an API key. +Its API reference permits bearer authorization for HTTP clients. ScopeWeave sends +`Authorization: Bearer ` only to the constant Stripe API +origin and never derives that origin from request input. Checkout parameters are +form encoded using the same field names documented by Stripe, including +`line_items[0][price]`, `line_items[0][quantity]`, `success_url`, `cancel_url`, +`client_reference_id`, and organization metadata. + +Stripe documents conventional HTTP status semantics: 2xx is successful while +4xx/5xx responses represent provider/request failures. ScopeWeave therefore does +not parse a non-2xx response as a successful Checkout Session and does not reflect +provider error text to the browser. + +The WHATWG Fetch Standard models a response body as a readable stream and its +body-consuming algorithms operate on the received byte sequence. ScopeWeave uses +that streaming boundary directly instead of calling `Response.json()`, because +`Response.json()` would consume the complete body before the application can +enforce its own byte ceiling. The 1 MiB application limit is deliberately far +above the fields ScopeWeave needs from a Checkout Session while still bounding a +malicious or malfunctioning provider response. + +## Compatibility boundary + +Stripe documents hosted Checkout Session URLs as nullable and present only while +the Session is active. Without a configured Checkout custom domain, Stripe uses +`checkout.stripe.com`; configured custom domains use the merchant's subdomain. +The current Stripe API reference returns an example hosted Checkout URL with an +opaque fragment after the session path, so rejecting all fragments would reject +a documented provider response. ScopeWeave therefore validates the URL authority +and preserves the provider-issued URL, including its fragment, without parsing +or rewriting that fragment. + +ScopeWeave does **not** silently trust arbitrary custom domains in this slice. +Supporting one requires a future operator-owned allowlist/configuration contract +and tests proving the configured authority cannot be replaced by provider or +request input. + +This slice does not add durable checkout attempts, webhook verification, +subscription/payment/entitlement persistence, or reconciliation. Those remain +explicit lifecycle work rather than being silently implied by this +provider-boundary slice. + +## TDD and branch-reconciliation evidence + +The original test-only commit `9373ac2719600d0e159b22557733a4c75def8744` +specified provider timing/authority controls before production changes. A later +parent-head review found that the parent branch dynamically imported an +undeclared `stripe` package, so a real default live request could fail with +`ERR_MODULE_NOT_FOUND` despite injected-factory tests passing. + +Parent commit `ad81eb52a6f0e3448e6a17f0e500e1f140993c92` added a realistic +regression that invokes the default live path and requires a direct HTTPS Stripe +request. Parent commit `0b5e1d9a25a986546efa79d6b0a62d7b1e8395fe` implemented that +transport without adding an undeclared runtime dependency. + +This stacked branch then received regression-only commit +`357ad04523f20415ca996b965943000c414c8809`, which changed the provider tests to +exercise the real default transport, non-2xx handling, invalid successful media, +malformed JSON, exact hosted-URL authority, and sanitized network failure. The +pre-repair stacked source still depended on the absent SDK and therefore could +not satisfy the default-transport contract. Commit +`9f137ef4aa5486b9b3cafe6ec1f986191bc560d0` reconciled the production provider +boundary with the parent's direct transport while preserving the stricter URL +and failure validation. Commit `bfd8718452946c514ca17009894097ce5dcf937e` +preserved the parent's default-transport regression on the child branch. + +Merge commit `e71d9f41893d57b69297d70c7d6a42af1763b297` records the exact +parent `0b5e1d9...` as ancestry without force-pushing or rebasing. The stacked +comparison is therefore ahead-only from the current parent and cannot silently +discard the parent's transport repair. + +Response-size regression commit `e6ebaa70b40046fe3b80d244603f66aac8850d86` +was written before the byte-bound implementation. Its declared-oversize case is +deterministically RED against that predecessor source because the predecessor +called `Response.json()` directly and would accept the small valid JSON body +regardless of the oversized declaration; the streamed-overflow case also +requires cancellation that the predecessor never performed. That queued hosted +run was superseded by the work-conserving production push rather than being +promoted as passing evidence. Production commit +`3e65a73a5b3c82b57eb9cee6fa1e7b1aad179d59` replaced unbounded JSON consumption +with the 1 MiB streamed boundary. Commit +`f1b403c9faa1ae4c8d603a3500113017ebca3732` expanded realistic regressions across +valid, malformed, negative, and oversized size declarations, bodyless success, +stream overflow/cancellation, and downstream stream failure so statement/branch +coverage cannot hide an untested failure path. + +The focused provider regression remains registered under the canonical c8 +coverage producer; no test or deterministic gate is removed to obtain GREEN. +Hosted exact-head CI remains authoritative after every source or documentation +movement. + +## Rollback + +Revert the provider-boundary source change, focused tests, documentation, and +CHANGELOG entry together. No database schema or persisted billing state is +introduced, so rollback has no data migration. If billing must be disabled while +investigating a provider outage, remove the complete live provider tuple and +restart so the existing fail-closed 503 configuration path applies; do not +replace the provider error with a production mock. + +## Traceability + +- Issue: #488 +- Parent trusted-configuration slice: PR #505 +- Provider-boundary slice: PR #507 +- Owned production: `server/billing.mjs` +- Parent regression: `tests/unit/billing-checkout.test.mjs` +- Provider regression: `tests/unit/billing-provider-boundary.test.mjs` +- Operator contract: `docs/billing-production.md` + +## References + +Stripe, Inc. (n.d.). *Authentication*. Stripe API Reference. Retrieved August 15, +2026, from https://docs.stripe.com/api/authentication + +Stripe, Inc. (n.d.). *Create a Checkout Session*. Stripe API Reference. Retrieved +August 15, 2026, from https://docs.stripe.com/api/checkout/sessions/create + +Stripe, Inc. (n.d.). *Errors*. Stripe API Reference. Retrieved August 15, 2026, +from https://docs.stripe.com/api/errors + +Stripe, Inc. (n.d.). *The Checkout Session object*. Stripe API Reference. +Retrieved August 15, 2026, from https://docs.stripe.com/api/checkout/sessions/object + +WHATWG. (n.d.). *Fetch standard*. Retrieved August 16, 2026, from +https://fetch.spec.whatwg.org/ diff --git a/package.json b/package.json index 2a685bb2..1db0fc3e 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/server/billing.mjs b/server/billing.mjs index 4cd63af7..9df39904 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -7,6 +7,7 @@ import { validateBillingStartupConfiguration } from './billing_configuration.mjs const billingConfiguration = validateBillingStartupConfiguration(); const STRIPE_CHECKOUT_ENDPOINT = 'https://api.stripe.com/v1/checkout/sessions'; const STRIPE_REQUEST_TIMEOUT_MS = 15_000; +const STRIPE_RESPONSE_MAX_BYTES = 1024 * 1024; export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -32,22 +33,9 @@ export function wouldExceed(db, org, kind) { return orgUsage(db, org.id)[kind] >= limit; } -function billingUnavailableResponse() { - return new Response(JSON.stringify({ - error: 'billing_not_configured', - action: 'Configure the complete Stripe billing settings and SCOPEWEAVE_PUBLIC_ORIGIN, then restart ScopeWeave.', - }), { - status: 503, - headers: { 'content-type': 'application/json; charset=UTF-8' }, - }); -} - -function billingProviderUnavailableResponse() { - return new Response(JSON.stringify({ - error: 'billing_provider_unavailable', - action: 'Checkout could not be started. Retry later; if the problem persists, contact your ScopeWeave operator.', - }), { - status: 502, +function jsonErrorResponse(status, error, action) { + return new Response(JSON.stringify({ error, action }), { + status, headers: { 'cache-control': 'no-store', 'content-type': 'application/json; charset=UTF-8', @@ -55,8 +43,32 @@ function billingProviderUnavailableResponse() { }); } -function billingProviderUnavailable() { - return new HTTPException(502, { res: billingProviderUnavailableResponse() }); +function billingUnavailableResponse() { + return jsonErrorResponse( + 503, + 'billing_not_configured', + 'Configure the complete Stripe billing settings and SCOPEWEAVE_PUBLIC_ORIGIN, then restart ScopeWeave.', + ); +} + +function providerFailure(code, action) { + return new HTTPException(502, { + res: jsonErrorResponse(502, code, action), + }); +} + +function providerUnavailableFailure() { + return providerFailure( + 'billing_provider_unavailable', + 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + ); +} + +function providerInvalidResponseFailure() { + return providerFailure( + 'billing_provider_invalid_response', + 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + ); } function stripeCheckoutForm(payload) { @@ -71,63 +83,127 @@ function stripeCheckoutForm(payload) { ]); } -function validateCheckoutSessionUrl(session) { - if (!session || typeof session.url !== 'string' || !session.url.trim()) { - throw billingProviderUnavailable(); +async function readBoundedProviderJson(response) { + const declaredLengthHeader = response.headers.get('content-length'); + if (declaredLengthHeader !== null) { + const declaredLength = Number(declaredLengthHeader); + if (!Number.isSafeInteger(declaredLength) + || declaredLength < 0 + || declaredLength > STRIPE_RESPONSE_MAX_BYTES) { + try { + await response.body?.cancel(); + } finally { + throw providerInvalidResponseFailure(); + } + } + } + + if (!response.body) { + throw providerInvalidResponseFailure(); + } + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + + while (true) { + let readResult; + try { + readResult = await reader.read(); + } catch { + throw providerInvalidResponseFailure(); + } + if (readResult.done) break; + + totalBytes += readResult.value.byteLength; + if (totalBytes > STRIPE_RESPONSE_MAX_BYTES) { + try { + await reader.cancel(); + } finally { + throw providerInvalidResponseFailure(); + } + } + chunks.push(readResult.value); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw providerInvalidResponseFailure(); + } +} + +async function cancelUnreadProviderBody(response) { + try { + await response.body?.cancel(); + } catch { + // Cleanup failure must never replace the stable provider failure returned below. + } +} + +async function createStripeSessionWithFetch(secretKey, payload) { + let response; + try { + response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), + headers: { + authorization: `Bearer ${secretKey}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: stripeCheckoutForm(payload).toString(), + }); + } catch { + throw providerUnavailableFailure(); + } + + if (!response.ok) { + await cancelUnreadProviderBody(response); + throw providerUnavailableFailure(); + } + + const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); + if (mediaType !== 'application/json') { + await cancelUnreadProviderBody(response); + throw providerInvalidResponseFailure(); + } + + return readBoundedProviderJson(response); +} + +function validateHostedCheckoutUrl(rawUrl) { + if (typeof rawUrl !== 'string' || rawUrl.length === 0) { + throw providerInvalidResponseFailure(); } let checkoutUrl; try { - checkoutUrl = new URL(session.url); + checkoutUrl = new URL(rawUrl); } catch { - throw billingProviderUnavailable(); + throw providerInvalidResponseFailure(); } - if ( - checkoutUrl.protocol !== 'https:' + const untrustedDestination = checkoutUrl.protocol !== 'https:' || checkoutUrl.hostname !== 'checkout.stripe.com' || checkoutUrl.port !== '' - || checkoutUrl.username - || checkoutUrl.password - ) { - throw billingProviderUnavailable(); + || checkoutUrl.username !== '' + || checkoutUrl.password !== ''; + if (untrustedDestination) { + throw providerInvalidResponseFailure(); } - return session.url; -} - -async function defaultStripeClientFactory(secretKey) { - return { - checkout: { - sessions: { - async create(payload) { - let response; - try { - response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { - method: 'POST', - redirect: 'error', - signal: AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), - headers: { - authorization: `Bearer ${secretKey}`, - 'content-type': 'application/x-www-form-urlencoded', - }, - body: stripeCheckoutForm(payload).toString(), - }); - } catch { - throw billingProviderUnavailable(); - } - - if (!response.ok) throw billingProviderUnavailable(); - - try { - return await response.json(); - } catch { - throw billingProviderUnavailable(); - } - }, - }, - }, - }; + // Stripe's documented hosted Checkout URLs can include an opaque client-side + // fragment. It does not participate in HTTPS authority selection and must be + // preserved verbatim so the browser receives the provider-issued URL intact. + return rawUrl; } /** @@ -137,23 +213,25 @@ async function defaultStripeClientFactory(secretKey) { * URLs always derive from the canonical operator-configured public origin. The * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. - * Provider transport/status/payload failures return a stable HTTP 502 without - * leaking Stripe response details to the caller. + * Live provider calls use one direct HTTPS attempt with a 15-second total budget + * and a 1 MiB response-body ceiling until durable checkout-attempt idempotency + * state exists. The hosted destination must use Stripe's standard HTTPS authority; + * provider-issued client fragments are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. * @param {(secretKey: string) => Promise} [options.stripeClientFactory] - * Stripe-compatible provider factory; injectable for deterministic contract tests. + * Optional Stripe-compatible test seam. Production uses the direct HTTPS transport. * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. - * @throws {HTTPException} HTTP 503 when billing is unconfigured or HTTP 502 when - * the live provider cannot produce a valid hosted Checkout Session URL. + * @throws {HTTPException} HTTP 503 when production billing is not configured; + * HTTP 502 when the provider call fails or returns an untrusted destination. */ export async function createCheckout({ orgId, configuration = billingConfiguration, - stripeClientFactory = defaultStripeClientFactory, + stripeClientFactory, }) { const { mode, publicOrigin } = configuration; if (mode === 'disabled' || !publicOrigin) { @@ -163,21 +241,31 @@ export async function createCheckout({ if (mode === 'live') { const secretKey = String(process.env.STRIPE_SECRET_KEY || '').trim(); const priceId = String(process.env.STRIPE_PRICE_ID || '').trim(); + const payload = { + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${publicOrigin}/?billing=success`, + cancel_url: `${publicOrigin}/?billing=cancel`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }; + let session; - try { - const stripe = await stripeClientFactory(secretKey); - session = await stripe.checkout.sessions.create({ - mode: 'subscription', - line_items: [{ price: priceId, quantity: 1 }], - success_url: `${publicOrigin}/?billing=success`, - cancel_url: `${publicOrigin}/?billing=cancel`, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }); - } catch { - throw billingProviderUnavailable(); + if (stripeClientFactory) { + try { + const stripe = await stripeClientFactory(secretKey); + session = await stripe.checkout.sessions.create(payload); + } catch { + throw providerUnavailableFailure(); + } + } else { + session = await createStripeSessionWithFetch(secretKey, payload); } - return { url: validateCheckoutSessionUrl(session), live: true }; + + return { + url: validateHostedCheckoutUrl(session?.url), + live: true, + }; } return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index 1cecd5b5..56be040e 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -6,6 +6,16 @@ import { createCheckout } from '../../server/billing.mjs'; const disabledConfiguration = { mode: 'disabled', publicOrigin: null }; const mockConfiguration = { mode: 'mock', publicOrigin: 'http://127.0.0.1:8787' }; const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; +const providerFailurePayloads = Object.freeze({ + billing_provider_unavailable: { + error: 'billing_provider_unavailable', + action: 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + }, + billing_provider_invalid_response: { + error: 'billing_provider_invalid_response', + action: 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + }, +}); async function withDefaultStripeTransport(responseFactory, assertion) { const previousSecret = process.env.STRIPE_SECRET_KEY; @@ -26,7 +36,7 @@ async function withDefaultStripeTransport(responseFactory, assertion) { } } -async function assertProviderFailure(runCheckout) { +async function assertProviderFailure(runCheckout, expectedCode = 'billing_provider_unavailable') { let rejectedError; await assert.rejects( runCheckout(), @@ -43,15 +53,18 @@ async function assertProviderFailure(runCheckout) { assert.equal(response.headers.get('cache-control'), 'no-store'); assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); const payload = await response.json(); - assert.deepEqual(payload, { - error: 'billing_provider_unavailable', - action: 'Checkout could not be started. Retry later; if the problem persists, contact your ScopeWeave operator.', - }); + assert.deepEqual(payload, providerFailurePayloads[expectedCode]); } -async function expectSafeProviderFailure(responseFactory) { +async function expectSafeProviderFailure( + responseFactory, + expectedCode = 'billing_provider_unavailable', +) { await withDefaultStripeTransport(responseFactory, async () => { - await assertProviderFailure(() => createCheckout({ orgId: 91, configuration: liveConfiguration })); + await assertProviderFailure( + () => createCheckout({ orgId: 91, configuration: liveConfiguration }), + expectedCode, + ); }); } @@ -243,27 +256,36 @@ test('default live provider transport rejects network failures without leaking p }); test('default live provider transport rejects malformed successful session payloads', async () => { - await expectSafeProviderFailure(async () => new Response(JSON.stringify({ - id: 'cs_test_missing_url', - object: 'checkout.session', - }), { - status: 200, - headers: { 'content-type': 'application/json; charset=utf-8' }, - })); + await expectSafeProviderFailure( + async () => new Response(JSON.stringify({ + id: 'cs_test_missing_url', + object: 'checkout.session', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }), + 'billing_provider_invalid_response', + ); - await expectSafeProviderFailure(async () => new Response('{not-json', { - status: 200, - headers: { 'content-type': 'application/json; charset=utf-8' }, - })); + await expectSafeProviderFailure( + async () => new Response('{not-json', { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }), + 'billing_provider_invalid_response', + ); }); test('live checkout rejects absent and blank provider redirect shapes', async () => { for (const session of [null, {}, { url: null }, { url: '' }, { url: ' ' }]) { - await assertProviderFailure(() => createCheckout({ - orgId: 92, - configuration: liveConfiguration, - stripeClientFactory: fixedSessionFactory(session), - })); + await assertProviderFailure( + () => createCheckout({ + orgId: 92, + configuration: liveConfiguration, + stripeClientFactory: fixedSessionFactory(session), + }), + 'billing_provider_invalid_response', + ); } }); @@ -277,11 +299,14 @@ test('live checkout rejects unsafe or malformed provider redirect URLs', async ( 'https://attacker.example/c/pay/cs_test_foreign_host', 'not a URL', ]) { - await assertProviderFailure(() => createCheckout({ - orgId: 92, - configuration: liveConfiguration, - stripeClientFactory: fixedSessionFactory({ url }), - })); + await assertProviderFailure( + () => createCheckout({ + orgId: 92, + configuration: liveConfiguration, + stripeClientFactory: fixedSessionFactory({ url }), + }), + 'billing_provider_invalid_response', + ); } }); diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs new file mode 100644 index 00000000..0d739ca6 --- /dev/null +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -0,0 +1,281 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createCheckout } from '../../server/billing.mjs'; + +const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; +const hostedCheckoutUrl = 'https://checkout.stripe.com/c/pay/cs_test_boundary#fidkdWxOYHwnPyd1blpx'; +const providerResponseLimitBytes = 1024 * 1024; + +async function withStripeEnv(run) { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_provider_boundary'; + process.env.STRIPE_PRICE_ID = 'price_provider_boundary'; + try { + await run(); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +} + +async function expectProviderError(run, expectedCode) { + let rejectedError; + await assert.rejects(run, (error) => { + rejectedError = error; + assert.equal(error.status, 502); + assert.equal(typeof error.getResponse, 'function'); + return true; + }); + const response = rejectedError.getResponse(); + assert.equal(response.status, 502); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const payload = await response.json(); + assert.equal(payload.error, expectedCode); + assert.equal(typeof payload.action, 'string'); + assert.ok(payload.action.length > 0); + return JSON.stringify(payload); +} + +test('live Checkout uses one bounded direct Stripe HTTPS request and preserves the hosted URL', async () => { + await withStripeEnv(async () => { + const observed = []; + globalThis.fetch = async (url, options) => { + observed.push({ url, options }); + const payload = JSON.stringify({ url: hostedCheckoutUrl }); + return new Response(payload, { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(Buffer.byteLength(payload)), + }, + }); + }; + + const result = await createCheckout({ + orgId: 73, + configuration: liveConfiguration, + }); + + assert.equal(result.url, hostedCheckoutUrl, 'Stripe-hosted client fragment is preserved verbatim'); + assert.equal(observed.length, 1, 'checkout transport performs exactly one provider attempt'); + assert.equal(observed[0].url, 'https://api.stripe.com/v1/checkout/sessions'); + assert.equal(observed[0].options.method, 'POST'); + assert.equal(observed[0].options.redirect, 'error'); + assert.ok(observed[0].options.signal instanceof AbortSignal); + assert.equal(observed[0].options.headers.authorization, 'Bearer sk_test_provider_boundary'); + assert.equal(observed[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + + const form = new URLSearchParams(observed[0].options.body); + assert.equal(form.get('mode'), 'subscription'); + assert.equal(form.get('line_items[0][price]'), 'price_provider_boundary'); + assert.equal(form.get('line_items[0][quantity]'), '1'); + assert.equal(form.get('success_url'), 'https://planner.example.com/?billing=success'); + assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); + assert.equal(form.get('client_reference_id'), '73'); + assert.equal(form.get('metadata[orgId]'), '73'); + }); +}); + +test('live Checkout rejects malformed or untrusted provider authorities', async () => { + const invalidUrls = [ + null, + '', + 'not a URL', + 'http://checkout.stripe.com/c/pay/cs_test_plaintext', + 'https://user:pass@checkout.stripe.com/c/pay/cs_test_credentials', + 'https://checkout.stripe.com.evil.example/c/pay/cs_test_suffix', + 'https://checkout.stripe.com:444/c/pay/cs_test_port', + ]; + + await withStripeEnv(async () => { + for (const url of invalidUrls) { + globalThis.fetch = async () => new Response(JSON.stringify({ url }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + } + }); +}); + +test('provider transport failures become a stable sanitized buyer-facing error', async () => { + await withStripeEnv(async () => { + globalThis.fetch = async () => { + throw new Error('dial tcp 10.7.0.12:443 with sk_live_should_not_escape'); + }; + + const payload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(payload, /10\.7\.0\.12|sk_live_should_not_escape/); + }); +}); + +test('provider HTTP and malformed-success responses fail with stable categories', async () => { + await withStripeEnv(async () => { + globalThis.fetch = async () => new Response('provider secret body', { + status: 503, + headers: { 'content-type': 'text/plain' }, + }); + const unavailablePayload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(unavailablePayload, /provider secret body/); + + globalThis.fetch = async () => new Response('not json', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + + globalThis.fetch = async () => new Response('{malformed', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + + globalThis.fetch = async () => new Response(null, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + }); +}); + +test('rejected Stripe responses cancel unread bodies before returning sanitized failures', async () => { + await withStripeEnv(async () => { + for (const scenario of [ + { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable' }, + { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response' }, + ]) { + let cancelled = false; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider bytes that must not remain leased')); + }, + cancel() { + cancelled = true; + }, + }); + globalThis.fetch = async () => new Response(unreadBody, { + status: scenario.status, + headers: { 'content-type': scenario.contentType }, + }); + + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + scenario.expectedCode, + ); + assert.equal(cancelled, true, `${scenario.expectedCode} cancels its unread response body`); + } + }); +}); + +test('response-body cleanup failure never replaces the stable provider failure', async () => { + await withStripeEnv(async () => { + let cancelCalls = 0; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider body')); + }, + cancel() { + cancelCalls += 1; + throw new Error('cleanup secret must not escape'); + }, + }); + globalThis.fetch = async () => new Response(unreadBody, { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + + const payload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_unavailable', + ); + assert.equal(cancelCalls, 1); + assert.doesNotMatch(payload, /cleanup secret/); + }); +}); + +test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { + await withStripeEnv(async () => { + for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { + globalThis.fetch = async () => new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': declaredLength, + }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + } + + let cancelled = false; + let pullCount = 0; + const oversizedBody = new ReadableStream({ + pull(controller) { + pullCount += 1; + if (pullCount === 1) { + controller.enqueue(new Uint8Array(providerResponseLimitBytes)); + } else { + controller.enqueue(Uint8Array.of(0x20)); + } + }, + cancel() { + cancelled = true; + }, + }); + globalThis.fetch = async () => new Response(oversizedBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + assert.equal(cancelled, true, 'oversized streamed provider bodies are cancelled at the byte boundary'); + }); +}); + +test('provider stream read failures remain sanitized invalid responses', async () => { + await withStripeEnv(async () => { + const failingBody = new ReadableStream({ + pull(controller) { + controller.error(new Error('provider stream secret 10.9.0.7')); + }, + }); + globalThis.fetch = async () => new Response(failingBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + const payload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + assert.doesNotMatch(payload, /provider stream secret|10\.9\.0\.7/); + }); +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..c5b58fa1 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -39,6 +39,11 @@ assert.match( /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-provider-boundary\.test\.mjs/, + 'the Stripe provider trust and transport regression executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/,