Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .github/workflows/server-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ jobs:
run: npm run test:unit
- name: API tests (auth · tenancy · RBAC · billing · webhooks · rate limit)
run: npm run test:api
- name: Owned production coverage evidence
run: npm run test:coverage
- name: app.js stays eval-safe (no top-level import/export)
run: node -e "new Function(require('fs').readFileSync('app.js','utf8')); console.log('eval-safe OK')"

Expand Down
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
85 changes: 85 additions & 0 deletions docs/doctoring/stripe-webhook-trust-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 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 supported HTTP composition now has one explicit security boundary:

1. `server/app.mjs` re-exports the supported shared application from
`server/application_routes.mjs`;
2. that shared boundary installs the production OIDC fail-closed guard, invite
identity binding, and pending-invite-token redaction before mounting the
internal implementation graph from `server/application_routes_core.mjs`;
3. the internal graph retains the existing request logging, metrics, and
rate-limit middleware and contains the single `POST /api/stripe/webhook`
route; and
4. that route HMAC-SHA-256-checks the exact bounded raw body using the Stripe
`t` and `v1` signature values before parsing JSON, acknowledges an authentic
delivery with `{ received: true }`, and does **not** mutate `orgs.plan`.

`server/application_routes_core.mjs` is an internal implementation module, not
a supported application entry point. Consumers must import
`server/application_routes.mjs` or `server/app.mjs`; bypassing the supported
boundary would also bypass its OIDC and invitation controls.

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 inside the implementation graph's abuse-control and
observability middleware so unsigned floods remain subject to the same rate
limit and request accounting as the surrounding API.

## 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`;
- `server/app.mjs` re-exports the supported shared application boundary rather
than maintaining a second route graph;
- the shared boundary installs OIDC and invitation guards before
`app.route('/', coreRoutes)`;
- both the public app and the supported shared route graph fail closed when
production OIDC is unconfigured;
- the internal core Stripe route invokes `verifyStripeWebhookRequest`; and
- the core graph contains no `checkout.session.completed` path that treats
callback JSON as entitlement authority.

## 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
10 changes: 5 additions & 5 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/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/security-guard-abuse-controls.test.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/application_routes_core.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 All @@ -31,4 +31,4 @@
"c8": "12.0.0",
"fast-check": "4.9.0"
}
}
}
Loading
Loading