Skip to content

fix(security): close protected billing and identity trust gaps - #589

Closed
seonghobae wants to merge 32 commits into
developfrom
fix/stripe-webhook-trust-develop-488
Closed

fix(security): close protected billing and identity trust gaps#589
seonghobae wants to merge 32 commits into
developfrom
fix/stripe-webhook-trust-develop-488

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Security hotfix

Protected develop@2c328875e00e86537df3e965170be80532571cad still carries three fail-open trust gaps that this branch closes without treating downstream callback data as entitlement authority:

  1. unsigned checkout.session.completed JSON could upgrade orgs.plan to pro;
  2. pending invite bearer tokens were exposed in the organization roster and could be redeemed by a different authenticated identity; and
  3. when OIDC_ISSUER was absent, the built-in mock IdP could be reached outside explicit development mode and mint a session for a caller-selected email.

Exact current identity

  • protected live base: develop@2c328875e00e86537df3e965170be80532571cad;
  • exact contributor head: 03cbe9fe227dc3b8fdddb5013ea9ab804d5946cc;
  • branch: fix/stripe-webhook-trust-develop-488;
  • fresh comparison: ahead-only, 32 commits ahead, zero behind, merge base exactly 2c328875...;
  • Ready/non-Draft and mechanically mergeable at the latest fresh refetch; and
  • no qualifying independent current-head approval exists.

Any contributor-head or protected-base movement invalidates revision-sensitive evidence until the new revisions are freshly reconciled.

Current-head behavior

  • POST /api/stripe/webhook verifies Stripe t + v1 HMAC-SHA-256 over a bounded exact raw body before JSON parsing, rejects unsigned/stale/mutated deliveries, and acknowledges authentic deliveries without treating callback JSON as authority to mutate paid entitlements.
  • Signature verification preserves the literal signed t text from the header while separately validating its numeric timestamp for freshness, matching the provider-signed payload bytes.
  • server/application_routes.mjs is the supported shared security boundary. It installs production OIDC fail-closed, invite identity-binding, and pending-token-redaction guards before mounting the internal implementation graph.
  • The internal core now also removes pending invite bearer tokens at the query boundary, binds invite redemption to the authenticated account email, and keeps the built-in OIDC mock disabled unless SCOPEWEAVE_DEV=1. Missing production OIDC configuration therefore fails closed even if a future internal consumer bypasses the outer shared boundary.
  • Invite identity hints are used only after a persisted JWT token_version still matches, so revoked sessions defer to the authoritative core authentication boundary and cannot become an invite-email oracle.
  • Guard rejections still enter the existing core abuse-control/observability machinery through a non-mutating OPTIONS accounting probe. The probe forwards the current x-forwarded-for input but no body or authorization; a process-local environment symbol carries the attempted HTTP method so structured logs retain the original method while the probe remains non-mutating. A core 429 becomes the public response; otherwise the intended generic 404 is returned.

Test-first repair evidence

Revoked-session invite oracle

  • RED 36c10372a8c8aab5437bc99e24e4ea22bb659201 added a stale-session invite regression.
  • Repair bc50cc99577d95876f834c651bc545daf5fbe267 checks the live persisted JWT token version before an email hint may narrow invite access.

Guard-rejection abuse-control bypass

  • RED ab7c7fe1ed3778c5bc2af3e3e6bb8c27a66c8bcd registered tests/api/security-guard-abuse-controls.test.mjs; hosted Server Tests run 32656577151, job 97236135012, failed at the new assertion because repeated unconfigured-OIDC rejection remained 404 instead of reaching the limiter's 429.
  • Repair 72f6acce5c1ff233a97ea345a27d59364ff7515f routes rejected-guard accounting through the existing core abuse-control/observability middleware without executing the unsafe historical handler.
  • Later review-driven hardening keeps structured audit logs on the attempted request method rather than the synthetic probe method.

Internal trust-boundary hardening

Current exact head 03cbe9fe227dc3b8fdddb5013ea9ab804d5946cc is fix(security): harden internal invite and OIDC boundaries. It removes token from the core pending-invite roster query, repeats invite-email binding in core, corrects the core Stripe composition comment, restricts oidcMock to explicit development mode, and makes the core OIDC start path return 404 {"error":"sso not configured"} when production configuration is absent. These changes make the outer guards defense-in-depth rather than the sole protection against those failure modes.

Current workflow state

All six repository workflow runs associated with exact contributor head 03cbe9fe227dc3b8fdddb5013ea9ab804d5946cc are terminal GitHub-success:

  • Server Tests 32667073472 — success;
  • Fuzz 32667073515 — success;
  • SAST Semgrep 32667073476 — success;
  • OSV Scanner 32667073761 — success;
  • Dependency Review 32667073482 — success; and
  • Security Scan 32667073487 — success.

These labels are not immutable contributor-head merge authority under the known checkout defect. The current Server Tests run checked out synthetic PR merge SHA 59475c5466f4dd447e34ea9d3df3566e5fa7099e, not contributor SHA 03cbe9f.... ScopeWeave #523 owns repository-native exact-head Server Tests/coverage integrity; ContextualWisdomLab/.github#1222 owns reusable required SAST/Security exact-head checkout integrity. Predecessor, synthetic-only, status-only, model-only, pending, skipped, absent, or stale evidence is never promoted to passing.

Review state

No formal APPROVED review exists on the current contributor head. Current Devin/CodeRabbit formal reviews are COMMENTED observations, not qualifying independent approval.

Verified defects around webhook middleware bypass, invite token exposure/identity binding, OIDC fail-open behavior, broken coverage-contract assertions, guard rejection method logging, stale composition comments, revoked-session narrowing, and literal Stripe timestamp handling are repaired in current source. Remaining unresolved inline observations are informational or belong to an already-separated owner lane: bounded raw-body handling; minor members-response rebuild overhead; the intentional invited-email equality constraint; declared-versus-streamed length mismatch with HMAC still covering the actual bounded bytes; invite guard DB work occurring before the current core limiter; dependence on core delegation for abuse accounting; and confirmation that literal timestamp HMAC handling is correct. They remain visible rather than being mass-resolved for status cleanliness.

Separate owning lanes

Billing lifecycle boundary

Durable Stripe event deduplication, provider-state reconciliation, and trusted entitlement activation remain separate billing-lifecycle work. This hotfix deliberately fails closed rather than granting paid rights directly from webhook JSON. A legitimate paid conversion therefore still requires the separately owned trusted reconciliation path; this PR does not claim paid-lifecycle completion.

Merge boundary

Do not merge or enable auto-merge until the unchanged exact contributor head remains reconciled to protected develop, every applicable functional/browser/owned-coverage/docstring/CodeQL/SAST/security/dependency/supply-chain/package/provenance/required-workflow gate is substantively terminal-passing under corrected revision-evidence contracts, valid unresolved current-head defects are zero, and a qualifying independent current-head/last-push approval genuinely exists.

Pending, queued, skipped-required, cancelled, absent, neutral-required, failed, stale, predecessor, synthetic-only, status-only, author-only, model-only, rate-limited, or infrastructure-only evidence is non-passing. Do not self-approve, manufacture approval, dismiss a valid current review, weaken protection, or transfer predecessor evidence.

Refs #488, #592, #593.

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Stripe 웹훅을 원시 본문 HMAC-SHA-256 검증 기반의 fail-closed 흐름으로 교체했습니다. SaaS 라우트를 공통 코어로 분리하고, 검증된 웹훅도 내구성 있는 조정 전에는 entitlement를 변경하지 않도록 했습니다.

Changes

웹훅 보안 및 애플리케이션 라우팅

Layer / File(s) Summary
공유 애플리케이션 경계
server/app.mjs, server/application_routes.mjs
공개 앱이 공통 라우트 그래프를 재사용합니다. OIDC 미설정 차단, 초대 이메일 검증, 초대 토큰 비공개, rate limit 및 관측 미들웨어 순서를 적용합니다.
핵심 SaaS 라우트 그래프
server/application_routes_core.mjs
인증, 멀티테넌시, 프로젝트, 댓글, 리비전, SSE, 조직 멤버십, 결제, PAT, 감사, OIDC, 첨부, 공유 링크, 계정 수명주기 및 정적 파일 라우트를 구현합니다.
Stripe 웹훅 보안 경계
server/stripe_webhook.mjs, server/application_routes_core.mjs
원시 본문 크기와 UTF-8을 검증합니다. 5분 타임스탬프 창과 constant-time HMAC-SHA-256 비교를 적용합니다. 실패는 분류된 오류로 처리하고, 성공 시 { received: true }를 반환합니다. 웹훅은 조직 entitlement를 직접 변경하지 않습니다.
보안 계약 및 실행 검증
tests/api/*, tests/unit/*, package.json, .github/workflows/server-tests.yml, docs/doctoring/stripe-webhook-trust-boundary.md, CHANGELOG.md
웹훅 서명, 본문 변조, 만료, 크기 초과, 잘못된 페이로드, rate limit, 초대 보안, OIDC 차단 및 정적 파일 라우트를 검증합니다. 커버리지 실행과 운영 문서를 갱신합니다.

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🟡 Moderate · up to cf67f

The PR closes the targeted billing, invite, and production OIDC trust gaps, but it is not yet merge-ready because rejected requests are recorded as OPTIONS instead of their original method, making security audit data inaccurate, while invite-token removal and mock-identity protection still depend on the shared route boundary rather than being enforced in the core graph. These bounded issues should be fixed or explicitly accepted before merge.

Sequence Diagram(s)

sequenceDiagram
  participant Stripe
  participant PublicApp
  participant WebhookVerifier
  participant OrganizationState
  Stripe->>PublicApp: POST /api/stripe/webhook with raw body and signature
  PublicApp->>WebhookVerifier: verifyStripeWebhookRequest(request, secret)
  WebhookVerifier-->>PublicApp: validated event or validation error
  PublicApp-->>Stripe: 200 { received: true } or 400/413/503 error
  PublicApp->>OrganizationState: no direct entitlement update
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 보안 변경의 핵심인 결제 및 사용자 신원 신뢰 경계 수정을 간결하고 명확하게 설명합니다.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stripe-webhook-trust-develop-488

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@seonghobae
seonghobae marked this pull request as ready for review August 23, 2026 13:22
devin-ai-integration[bot]

This comment was marked as resolved.

Keep the public copy-and-replace composition and make the protected-graph
Stripe route fail closed on HMAC verification so a direct mount cannot
upgrade orgs.plan from unsigned JSON. Record the trust boundary in
CHANGELOG and doctoring.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread server/app.mjs Outdated
Comment thread server/stripe_webhook.mjs

Copy link
Copy Markdown
Contributor Author

Strix job 97204514255 (run 32643656525) on exact head dce2424b45833fa6a942fae1edb5d16f0d687bdb produced empty SARIF and a static penetration report with no product findings. The last attempt log is infrastructure: model=openai-direct/gpt-5.6-luna then litellm.BadRequestError: LLM Provider NOT provided after NVIDIA NIM catalog 404s.

That is not a Stripe HMAC finding and does not restore unsigned checkout.session.completed plan upgrades. The required-workflow SHA is protected .github main; stacked #1213 cannot become that SHA. Bounded main-line rewrite: ContextualWisdomLab/.github#1262 (101d80e). Independent Noema/OpenCode/Strix reviews remain required. Do not self-approve.

Replace the source-text serve-path assertion with a runtime Hono route-graph regression that fails while the public facade registers /toast-state.css twice. Remove the redundant facade route so the protected route graph remains the single SaaS implementation.
coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Add a realistic API regression for the protected-develop invite bearer-token leak and cross-identity redemption path. The test requires roster responses to omit tokens, rejects a mismatched authenticated redeemer without membership side effects, preserves case-insensitive intended redemption, and retains one-time use.
Preserve the protected route/middleware order while wrapping the existing invite endpoints at their original route positions. Pending roster state no longer exposes bearer tokens, and the final invite handler rejects an authenticated identity whose canonical email does not match the pending invitation before any membership or acceptance mutation.
devin-ai-integration[bot]

This comment was marked as resolved.

Add a production-mode API regression proving that an absent OIDC issuer cannot silently activate the built-in mock identity provider. The start and mock-authorize paths must return the same stable not-configured response without redirecting or minting callback authority.
@seonghobae seonghobae changed the title fix(billing): fail closed on unsigned Stripe webhooks fix(security): close protected billing and identity trust gaps Aug 23, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment thread server/application_routes_core.mjs Outdated
Comment thread server/application_routes.mjs
Comment thread server/application_routes.mjs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review

Comment thread server/application_routes.mjs
Comment thread server/application_routes.mjs
Comment thread server/application_routes_core.mjs
Comment thread server/stripe_webhook.mjs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +53 to +70
async function bindInviteToAuthenticatedIdentity(c, next) {
// This guard only narrows access after confirming that the presented
// credential is still live. The core requireAuth middleware remains the
// authoritative authentication/RBAC boundary and repeats that validation
// before any invite mutation.
const identity = authenticatedIdentityHint(c);
if (!identity) return next();

const invite = db.prepare('SELECT email, accepted_at FROM invites WHERE token = ?')
.get(c.req.param('token'));
if (!invite || invite.accepted_at) return next();

const invitedEmail = normalizeIdentityEmail(invite.email);
if (!invitedEmail || !identity.email || invitedEmail !== identity.email) {
return guardRejectionThroughCoreAbuseControls(c, { error: 'invalid or used invite' });
}
return next();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Invite guard queries the DB before rate limiting

bindInviteToAuthenticatedIdentity runs an identity lookup and an invite lookup before any rate-limit accounting, which happens only afterward via the OPTIONS probe or core middleware. An authenticated caller can drive DB work on the invite-accept path ahead of the limiter. Rate-limit policy is stated to be owned by a separate PR.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Owner-path repair is now active rather than merely deferred. I freshly reconciled exact #589 head 03cbe9fe227dc3b8fdddb5013ea9ab804d5946cc into the trusted-rate-limit owner lane #587, whose current head is e8ef4fdb00c9dfe55ee28c7e68a8d0cd799a12bf with #589 as a direct parent. That integration preserves #589's application_routes.mjs identity/invite guards but wraps the public route graph in #587's transport-peer-aware outer limiter before route-specific guard/database work; app_routes.mjs is now only a compatibility shim into the guarded shared boundary while the historical core limiter initializes disabled for the public composition. This is the causal owning-boundary repair for the pre-limiter invite DB lookups. Exact integrated #587 CI was freshly dispatched and is still queued/non-passing, so I am deliberately leaving this thread unresolved until the integrated head proves focused/full GREEN and is revalidated against its live base.

coderabbitai[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread server/application_routes.mjs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread tests/api/oidc-production-boundary.test.mjs
Comment thread server/application_routes_core.mjs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread server/application_routes.mjs
Comment thread server/stripe_webhook.mjs

Copy link
Copy Markdown
Contributor Author

Fresh supersession proof: exact #589 head 03cbe9fe227dc3b8fdddb5013ea9ab804d5946cc is the exact merge base and strict ancestor of current #587 head 0811a4fd92b97c12df0cfd89b778ebd7bec3ede7 (ahead_by=38, behind_by=0). The descendant delta does not modify server/application_routes_core.mjs or server/stripe_webhook.mjs, so #589's Stripe/invite/OIDC trust-boundary repairs are preserved intact. The descendant server/application_routes.mjs additionally applies the trusted transport-peer-aware limiter before the invite/OIDC guards and their DB work, which is the owning-boundary repair for #589's sole remaining limiter-order observation.

#589 is therefore technically superseded by #587 rather than an independent merge candidate. Closing this PR loses no unique commits or repaired behavior; #587 remains the integration root. This does not transfer or manufacture merge authority: #587 still requires its own unchanged exact-head gates, current review state, live-base reconciliation, and qualifying independent approval before any merge.

@seonghobae seonghobae closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant