@open-mercato/gateway-stripe 0.6.8-develop.7066.1.6bfa9ff69f → 0.6.8-develop.7067.1.82e9867ea1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { isTrustedWebhookDispatch } from "@open-mercato/shared/lib/queue/dispatchOrigin";
1
2
  import { claimWebhookProcessing, releaseWebhookClaim } from "@open-mercato/core/modules/payment_gateways/lib/webhook-utils";
2
3
  import { mapWebhookEventToStatus, mapStripeStatus } from "../lib/status-map.js";
3
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
@@ -15,6 +16,13 @@ function readSessionIdFromEvent(event) {
15
16
  return null;
16
17
  }
17
18
  async function handle(job, ctx) {
19
+ if (!isTrustedWebhookDispatch(job.payload)) {
20
+ logger.error("Dropping webhook job with missing or untrusted dispatch origin", {
21
+ eventType: job.payload?.event?.eventType,
22
+ transactionId: job.payload?.transactionId ?? null
23
+ });
24
+ return;
25
+ }
18
26
  const em = ctx.resolve("em");
19
27
  const paymentGatewayService = ctx.resolve("paymentGatewayService");
20
28
  const integrationLogService = ctx.resolve("integrationLogService");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/gateway_stripe/workers/webhook-processor.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { WebhookEvent } from '@open-mercato/shared/modules/payment_gateways/types'\nimport type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'\nimport { claimWebhookProcessing, releaseWebhookClaim } from '@open-mercato/core/modules/payment_gateways/lib/webhook-utils'\nimport { mapWebhookEventToStatus, mapStripeStatus } from '../lib/status-map'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('gateway_stripe').child({ component: 'webhook-processor' })\n\ntype WebhookJobPayload = {\n providerKey: string\n event: WebhookEvent\n transactionId?: string | null\n scope?: {\n organizationId: string\n tenantId: string\n } | null\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport const metadata: WorkerMeta = {\n queue: 'stripe-webhook',\n id: 'gateway-stripe:webhook-processor',\n concurrency: 5,\n}\n\nfunction readSessionIdFromEvent(event: WebhookEvent): string | null {\n const id = event.data.id\n if (typeof id === 'string' && id.trim().length > 0) return id.trim()\n const paymentIntent = event.data.payment_intent\n if (typeof paymentIntent === 'string' && paymentIntent.trim().length > 0) return paymentIntent.trim()\n return null\n}\n\nexport default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: HandlerContext): Promise<void> {\n const em = ctx.resolve<EntityManager>('em')\n const paymentGatewayService = ctx.resolve<PaymentGatewayService>('paymentGatewayService')\n const integrationLogService = ctx.resolve<IntegrationLogService>('integrationLogService')\n const event = job.payload.event\n // Scope MUST come from the trusted enqueuer (a signature-verified transaction), never from\n // attacker-controlled `event.data.metadata` \u2014 that fallback was the cross-tenant-write vector.\n const scope = job.payload.scope ?? null\n\n try {\n let transaction = job.payload.transactionId && scope\n ? await paymentGatewayService.findTransaction(job.payload.transactionId, scope)\n : null\n if (!transaction) {\n const sessionId = readSessionIdFromEvent(event)\n if (!sessionId || !scope) return\n transaction = await paymentGatewayService.findTransactionBySessionId(sessionId, scope, 'stripe')\n }\n if (!transaction) return\n\n const transactionScope = { organizationId: transaction.organizationId, tenantId: transaction.tenantId }\n const log = integrationLogService.scoped('gateway_stripe', transactionScope)\n const claimed = await claimWebhookProcessing(em, event.idempotencyKey, 'stripe', transactionScope, event.eventType)\n if (!claimed) {\n await log.info('Duplicate Stripe webhook skipped', {\n eventType: event.eventType,\n idempotencyKey: event.idempotencyKey,\n transactionId: transaction.id,\n })\n return\n }\n\n const eventStatus = mapWebhookEventToStatus(event.eventType)\n const providerStatus = typeof event.data.status === 'string' ? event.data.status : ''\n const unifiedStatus = eventStatus ?? mapStripeStatus(providerStatus)\n\n if (unifiedStatus !== 'unknown') {\n await paymentGatewayService.syncTransactionStatus(transaction.id, {\n unifiedStatus,\n providerStatus: event.eventType,\n providerData: event.data,\n }, transactionScope)\n }\n\n await log.info('Stripe webhook processed', {\n eventType: event.eventType,\n transactionId: transaction.id,\n unifiedStatus,\n })\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : 'Stripe webhook processing failed'\n if (scope) {\n await releaseWebhookClaim(em, event.idempotencyKey, 'stripe', scope)\n await integrationLogService.write({\n integrationId: 'gateway_stripe',\n level: 'error',\n message: 'Stripe webhook processing failed',\n code: 'stripe_webhook_processing_failed',\n payload: {\n error: message,\n eventType: event.eventType,\n transactionId: job.payload.transactionId ?? null,\n },\n }, scope)\n } else {\n logger.error('Stripe webhook processing failed', {\n eventType: event.eventType,\n transactionId: job.payload.transactionId ?? null,\n err: error,\n })\n }\n throw error\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { WebhookEvent } from '@open-mercato/shared/modules/payment_gateways/types'\nimport { isTrustedWebhookDispatch } from '@open-mercato/shared/lib/queue/dispatchOrigin'\nimport type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'\nimport { claimWebhookProcessing, releaseWebhookClaim } from '@open-mercato/core/modules/payment_gateways/lib/webhook-utils'\nimport { mapWebhookEventToStatus, mapStripeStatus } from '../lib/status-map'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('gateway_stripe').child({ component: 'webhook-processor' })\n\ntype WebhookJobPayload = {\n providerKey: string\n event: WebhookEvent\n transactionId?: string | null\n scope?: {\n organizationId: string\n tenantId: string\n } | null\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport const metadata: WorkerMeta = {\n queue: 'stripe-webhook',\n id: 'gateway-stripe:webhook-processor',\n concurrency: 5,\n}\n\nfunction readSessionIdFromEvent(event: WebhookEvent): string | null {\n const id = event.data.id\n if (typeof id === 'string' && id.trim().length > 0) return id.trim()\n const paymentIntent = event.data.payment_intent\n if (typeof paymentIntent === 'string' && paymentIntent.trim().length > 0) return paymentIntent.trim()\n return null\n}\n\nexport default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: HandlerContext): Promise<void> {\n // Fail closed on untrusted dispatch origins (#5213): only jobs enqueued by the\n // inbound webhook route (signature verified against per-tenant credentials) may\n // drive Stripe payment state. Scheduler-dispatched or unmarked jobs are dropped.\n if (!isTrustedWebhookDispatch(job.payload)) {\n logger.error('Dropping webhook job with missing or untrusted dispatch origin', {\n eventType: job.payload?.event?.eventType,\n transactionId: job.payload?.transactionId ?? null,\n })\n return\n }\n\n const em = ctx.resolve<EntityManager>('em')\n const paymentGatewayService = ctx.resolve<PaymentGatewayService>('paymentGatewayService')\n const integrationLogService = ctx.resolve<IntegrationLogService>('integrationLogService')\n const event = job.payload.event\n // Scope MUST come from the trusted enqueuer (a signature-verified transaction), never from\n // attacker-controlled `event.data.metadata` \u2014 that fallback was the cross-tenant-write vector.\n const scope = job.payload.scope ?? null\n\n try {\n let transaction = job.payload.transactionId && scope\n ? await paymentGatewayService.findTransaction(job.payload.transactionId, scope)\n : null\n if (!transaction) {\n const sessionId = readSessionIdFromEvent(event)\n if (!sessionId || !scope) return\n transaction = await paymentGatewayService.findTransactionBySessionId(sessionId, scope, 'stripe')\n }\n if (!transaction) return\n\n const transactionScope = { organizationId: transaction.organizationId, tenantId: transaction.tenantId }\n const log = integrationLogService.scoped('gateway_stripe', transactionScope)\n const claimed = await claimWebhookProcessing(em, event.idempotencyKey, 'stripe', transactionScope, event.eventType)\n if (!claimed) {\n await log.info('Duplicate Stripe webhook skipped', {\n eventType: event.eventType,\n idempotencyKey: event.idempotencyKey,\n transactionId: transaction.id,\n })\n return\n }\n\n const eventStatus = mapWebhookEventToStatus(event.eventType)\n const providerStatus = typeof event.data.status === 'string' ? event.data.status : ''\n const unifiedStatus = eventStatus ?? mapStripeStatus(providerStatus)\n\n if (unifiedStatus !== 'unknown') {\n await paymentGatewayService.syncTransactionStatus(transaction.id, {\n unifiedStatus,\n providerStatus: event.eventType,\n providerData: event.data,\n }, transactionScope)\n }\n\n await log.info('Stripe webhook processed', {\n eventType: event.eventType,\n transactionId: transaction.id,\n unifiedStatus,\n })\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : 'Stripe webhook processing failed'\n if (scope) {\n await releaseWebhookClaim(em, event.idempotencyKey, 'stripe', scope)\n await integrationLogService.write({\n integrationId: 'gateway_stripe',\n level: 'error',\n message: 'Stripe webhook processing failed',\n code: 'stripe_webhook_processing_failed',\n payload: {\n error: message,\n eventType: event.eventType,\n transactionId: job.payload.transactionId ?? null,\n },\n }, scope)\n } else {\n logger.error('Stripe webhook processing failed', {\n eventType: event.eventType,\n transactionId: job.payload.transactionId ?? null,\n err: error,\n })\n }\n throw error\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/gateway-stripe",
3
- "version": "0.6.8-develop.7066.1.6bfa9ff69f",
3
+ "version": "0.6.8-develop.7067.1.82e9867ea1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,21 +62,21 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.6.8-develop.7066.1.6bfa9ff69f",
66
- "@open-mercato/queue": "0.6.8-develop.7066.1.6bfa9ff69f",
67
- "@open-mercato/ui": "0.6.8-develop.7066.1.6bfa9ff69f",
65
+ "@open-mercato/core": "0.6.8-develop.7067.1.82e9867ea1",
66
+ "@open-mercato/queue": "0.6.8-develop.7067.1.82e9867ea1",
67
+ "@open-mercato/ui": "0.6.8-develop.7067.1.82e9867ea1",
68
68
  "@stripe/react-stripe-js": "^6.7.0",
69
69
  "@stripe/stripe-js": "^9.12.1",
70
70
  "stripe": "^22.3.1"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@mikro-orm/postgresql": "^7.0.14",
74
- "@open-mercato/shared": "0.6.8-develop.7066.1.6bfa9ff69f",
74
+ "@open-mercato/shared": "0.6.8-develop.7067.1.82e9867ea1",
75
75
  "react": "^19.0.0",
76
76
  "react-dom": "^19.0.0"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.6.8-develop.7066.1.6bfa9ff69f",
79
+ "@open-mercato/shared": "0.6.8-develop.7067.1.82e9867ea1",
80
80
  "@types/jest": "^30.0.0",
81
81
  "@types/react": "^19.2.17",
82
82
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals'
2
+ import handler from '../workers/webhook-processor'
3
+
4
+ const makeCtx = () => ({
5
+ resolve: jest.fn(),
6
+ jobId: 'job-1',
7
+ attemptNumber: 1,
8
+ })
9
+
10
+ const baseEvent = {
11
+ idempotencyKey: 'evt_1',
12
+ eventType: 'payment_intent.succeeded',
13
+ data: { id: 'cs_test_1' },
14
+ }
15
+
16
+ describe('gateway-stripe webhook processor dispatch-origin enforcement (#5213)', () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks()
19
+ })
20
+
21
+ it('drops scheduler-originated jobs before touching any service', async () => {
22
+ const ctx = makeCtx()
23
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
24
+
25
+ await handler({
26
+ payload: {
27
+ providerKey: 'stripe',
28
+ event: baseEvent,
29
+ transactionId: 'tx-1',
30
+ scope: { organizationId: 'o-1', tenantId: 't-1' },
31
+ _jobOrigin: 'scheduler',
32
+ },
33
+ } as never, ctx as never)
34
+
35
+ expect(ctx.resolve).not.toHaveBeenCalled()
36
+
37
+ consoleErrorSpy.mockRestore()
38
+ })
39
+
40
+ it('drops unmarked jobs (fail closed)', async () => {
41
+ const ctx = makeCtx()
42
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
43
+
44
+ await handler({
45
+ payload: {
46
+ providerKey: 'stripe',
47
+ event: baseEvent,
48
+ transactionId: 'tx-1',
49
+ scope: { organizationId: 'o-1', tenantId: 't-1' },
50
+ },
51
+ } as never, ctx as never)
52
+
53
+ expect(ctx.resolve).not.toHaveBeenCalled()
54
+
55
+ consoleErrorSpy.mockRestore()
56
+ })
57
+ })
@@ -57,7 +57,7 @@ function makeJob(payload: {
57
57
  return {
58
58
  id: 'job_1',
59
59
  createdAt: '2026-01-01T00:00:00.000Z',
60
- payload: { providerKey: 'stripe', ...payload },
60
+ payload: { providerKey: 'stripe', _jobOrigin: 'inbound-webhook', ...payload },
61
61
  } as unknown as WorkerJob
62
62
  }
63
63
 
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
3
3
  import type { WebhookEvent } from '@open-mercato/shared/modules/payment_gateways/types'
4
+ import { isTrustedWebhookDispatch } from '@open-mercato/shared/lib/queue/dispatchOrigin'
4
5
  import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
5
6
  import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
6
7
  import { claimWebhookProcessing, releaseWebhookClaim } from '@open-mercato/core/modules/payment_gateways/lib/webhook-utils'
@@ -38,6 +39,17 @@ function readSessionIdFromEvent(event: WebhookEvent): string | null {
38
39
  }
39
40
 
40
41
  export default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: HandlerContext): Promise<void> {
42
+ // Fail closed on untrusted dispatch origins (#5213): only jobs enqueued by the
43
+ // inbound webhook route (signature verified against per-tenant credentials) may
44
+ // drive Stripe payment state. Scheduler-dispatched or unmarked jobs are dropped.
45
+ if (!isTrustedWebhookDispatch(job.payload)) {
46
+ logger.error('Dropping webhook job with missing or untrusted dispatch origin', {
47
+ eventType: job.payload?.event?.eventType,
48
+ transactionId: job.payload?.transactionId ?? null,
49
+ })
50
+ return
51
+ }
52
+
41
53
  const em = ctx.resolve<EntityManager>('em')
42
54
  const paymentGatewayService = ctx.resolve<PaymentGatewayService>('paymentGatewayService')
43
55
  const integrationLogService = ctx.resolve<IntegrationLogService>('integrationLogService')