Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ba7554e
test: reproduce unsigned Stripe webhook plan escalation
seonghobae Aug 23, 2026
042830d
fix: authenticate Stripe webhooks before entitlement handling
seonghobae Aug 23, 2026
cc0c6ed
test(billing): keep Stripe webhook behind abuse controls
seonghobae Aug 23, 2026
1f5c2b2
fix(billing): keep Stripe verification inside app controls
seonghobae Aug 23, 2026
4488ab9
test(ci): align public-route contract with Stripe repair
seonghobae Aug 23, 2026
dce2424
fix(billing): retire unsigned Stripe plan-upgrade handler
seonghobae Aug 23, 2026
5e30072
test(server): prove toast route is unique
seonghobae Aug 23, 2026
4ae65a6
test(a11y): exercise shipped toast asset route
seonghobae Aug 23, 2026
87d34bd
test(security): bind invites to intended identity
seonghobae Aug 23, 2026
eaab3f8
fix(security): bind invite redemption to email
seonghobae Aug 23, 2026
5dc72e7
test(ci): match secured route composition
seonghobae Aug 23, 2026
a1ad3bc
test(security): fail closed when OIDC is unconfigured
seonghobae Aug 23, 2026
7b19b86
fix(security): require explicit development for mock OIDC
seonghobae Aug 23, 2026
2d39860
ci: run owned-production coverage in server gate
seonghobae Aug 23, 2026
77406a9
test(security): require invite safety in protected route graph
seonghobae Aug 23, 2026
d611894
test(security): cover shared OIDC fail-closed boundary
seonghobae Aug 23, 2026
7c1f08c
fix(security): secure shared application boundary
seonghobae Aug 23, 2026
394e3cc
fix(security): align OIDC regression and trust docs
seonghobae Aug 23, 2026
7ef64d2
test(security): expose invite core bypass regression
seonghobae Aug 23, 2026
36c1037
test(security): bind invite guard to live sessions
seonghobae Aug 23, 2026
bc50cc9
fix(security): reject revoked sessions before invite binding
seonghobae Aug 23, 2026
40eb4d1
test(security): keep guard rejections behind abuse controls
seonghobae Aug 23, 2026
ab7c7fe
test(security): register guard abuse-control regression
seonghobae Aug 23, 2026
72f6acc
fix(security): account rejected guards in abuse controls
seonghobae Aug 23, 2026
de0a053
test(stripe): preserve literal signature timestamp
seonghobae Aug 23, 2026
131d79e
fix(stripe): verify literal signed timestamp
seonghobae Aug 23, 2026
cf67f2a
test(security): preserve guard rejection method evidence
seonghobae Aug 23, 2026
177996e
fix(observability): retain original guard request method
seonghobae Aug 23, 2026
04363c0
fix(observability): carry original guard method safely
seonghobae Aug 23, 2026
25b95ed
test(security): require core OIDC fail-closed
seonghobae Aug 23, 2026
5807225
test(security): require core invite fail-closed behavior
seonghobae Aug 23, 2026
03cbe9f
fix(security): harden internal invite and OIDC boundaries
seonghobae Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- Replaced the unsigned `POST /api/stripe/webhook` plan-upgrade stub with a
fail-closed raw-body HMAC-SHA-256 signature boundary. Signed deliveries are
acknowledged only; webhook JSON is not entitlement authority until durable
event reconciliation. Unsigned, stale, or body-mutated signatures fail closed,
and the public app copies protected logging and rate-limit middleware so abuse
controls still wrap the endpoint (Krawczyk et al., 1997; National Institute of
Standards and Technology, 2008).
- 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.
- Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or
unexpanded placeholder values so production deployments fail closed.
Expand Down
75 changes: 75 additions & 0 deletions docs/doctoring/stripe-webhook-trust-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Stripe webhook trust boundary

## Decision

Protected `develop` previously accepted an unauthenticated
`checkout.session.completed` JSON body and upgraded `orgs.plan` to `pro`. That
is a privilege-escalation window: any caller who can POST to
`/api/stripe/webhook` can grant paid entitlements.

The public application now:

1. copies the protected route graph, including logging and rate-limit
middleware, except the historical Stripe handler;
2. registers a fail-closed verifier that HMAC-SHA-256-checks
`timestamp + "." + raw body` against `STRIPE_WEBHOOK_SECRET` before parsing
JSON;
3. acknowledges an authentic delivery with `{ received: true }` and does **not**
mutate `orgs.plan`; and
4. keeps the protected-graph copy of the same route fail-closed so a future
composer that mounts `application_routes` directly cannot restore unsigned
plan upgrades.

Signature validity authenticates the delivery only. Durable event
deduplication, provider-state reconciliation, and entitlement writes remain
follow-up work (stacked billing lifecycle), not this hotfix.

## Standards rationale

HMAC-SHA-256 over the exact signed bytes is the Stripe webhook contract and
matches RFC 2104 / FIPS 198-1 keyed hashing. JSON parsing happens only after
constant-time comparison so semantically equivalent but byte-different bodies
cannot be substituted. Replay is bounded by a five-minute timestamp window.
Missing configuration fails closed with `503 stripe_webhook_not_configured`
rather than accepting unsigned traffic.

OAuth bearer-token rules (RFC 6750; RFC 9700) do not apply to this provider
callback; the webhook secret is a shared HMAC key, not an access token. The
endpoint remains behind the same abuse-control middleware as the rest of the
application so unsigned floods are rate-limited instead of skipping the limiter
through a first-match public route.

## Verification contract

Regression tests must prove:

- unsigned `checkout.session.completed` JSON never upgrades `orgs.plan`;
- a correctly signed delivery is acknowledged and still leaves plan unchanged;
- a stale timestamp or a JSON-equivalent mutated body fails signature checks;
- when `SCOPEWEAVE_RATE_LIMIT_MAX=1`, the second webhook in the window is `429`;
- the public app does not `app.route('/', applicationRoutes)`;
- the public app copies protected routes except `POST /api/stripe/webhook` and
then registers `verifyStripeWebhookRequest`; and
- `application_routes.mjs` no longer contains a `checkout.session.completed`
plan-upgrade path.
Comment thread
seonghobae marked this conversation as resolved.
Resolved

## Officer next action

Rotate `STRIPE_WEBHOOK_SECRET` if it may have been exposed while the unsigned
stub was live. Point the Stripe endpoint at the public `/api/stripe/webhook`
path. Do not treat a `200 { received: true }` as proof that the organization is
Pro until reconciliation writes entitlements from Stripe's retrieved session
state.

## References

Krawczyk, H., Bellare, M., & Canetti, R. (1997). *HMAC: Keyed-hashing for
message authentication* (RFC 2104). Internet Engineering Task Force.
https://doi.org/10.17487/RFC2104

National Institute of Standards and Technology. (2008). *The keyed-hash
message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of
Commerce. https://doi.org/10.6028/NIST.FIPS.198-1

Stripe. (n.d.). *Webhook signatures*. Stripe Docs.
https://docs.stripe.com/webhooks/signatures
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
"check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings",
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node 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",
"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/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/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 && npm run test:api",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/stripe-webhook.test.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",
"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/stripe-webhook-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/application_routes.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.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/stripe-webhook-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",
Expand Down
Loading
Loading