@open-mercato/webhooks 0.5.1-develop.2912.8d7b1fef24 → 0.5.1-develop.2924.d13908516e
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.
- package/dist/modules/webhooks/lib/delivery.js +60 -32
- package/dist/modules/webhooks/lib/delivery.js.map +2 -2
- package/dist/modules/webhooks/lib/url-safety.js +14 -2
- package/dist/modules/webhooks/lib/url-safety.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/webhooks/lib/__tests__/url-safety.test.ts +52 -0
- package/src/modules/webhooks/lib/delivery.ts +79 -35
- package/src/modules/webhooks/lib/url-safety.ts +29 -0
|
@@ -4,7 +4,11 @@ import { WebhookDeliveryEntity, WebhookEntity } from "../data/entities.js";
|
|
|
4
4
|
import { emitWebhooksEvent } from "../events.js";
|
|
5
5
|
import { enqueueWebhookDelivery } from "./queue.js";
|
|
6
6
|
import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from "./integration-state.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
assertSafeWebhookDeliveryUrl,
|
|
9
|
+
safeWebhookFetch,
|
|
10
|
+
UnsafeWebhookUrlError
|
|
11
|
+
} from "./url-safety.js";
|
|
8
12
|
async function createWebhookDelivery(input) {
|
|
9
13
|
const bodyPayload = {
|
|
10
14
|
type: input.eventId,
|
|
@@ -84,26 +88,12 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
|
|
|
84
88
|
try {
|
|
85
89
|
await assertSafeWebhookDeliveryUrl(webhook.url);
|
|
86
90
|
} catch (error) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
delivery.attemptNumber += 1;
|
|
93
|
-
webhook.consecutiveFailures += 1;
|
|
94
|
-
webhook.lastFailureAt = /* @__PURE__ */ new Date();
|
|
95
|
-
await em.flush();
|
|
96
|
-
await emitWebhooksEvent("webhooks.delivery.failed", {
|
|
97
|
-
deliveryId: delivery.id,
|
|
98
|
-
webhookId: webhook.id,
|
|
99
|
-
eventType: delivery.eventType,
|
|
100
|
-
errorMessage: message,
|
|
101
|
-
durationMs: 0,
|
|
102
|
-
organizationId: delivery.organizationId,
|
|
103
|
-
tenantId: delivery.tenantId,
|
|
104
|
-
willRetry: false
|
|
91
|
+
return await recordWebhookSafetyFailure({
|
|
92
|
+
em,
|
|
93
|
+
delivery,
|
|
94
|
+
webhook,
|
|
95
|
+
error
|
|
105
96
|
});
|
|
106
|
-
return { status: delivery.status, deliveryId: delivery.id };
|
|
107
97
|
}
|
|
108
98
|
const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload);
|
|
109
99
|
delivery.payload = bodyPayload;
|
|
@@ -124,18 +114,22 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
|
|
|
124
114
|
try {
|
|
125
115
|
const controller = new AbortController();
|
|
126
116
|
const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
117
|
+
let response;
|
|
118
|
+
try {
|
|
119
|
+
response = await safeWebhookFetch(webhook.url, {
|
|
120
|
+
method: webhook.httpMethod,
|
|
121
|
+
redirect: "manual",
|
|
122
|
+
headers: {
|
|
123
|
+
"content-type": "application/json",
|
|
124
|
+
...headers,
|
|
125
|
+
...webhook.customHeaders ?? {}
|
|
126
|
+
},
|
|
127
|
+
body,
|
|
128
|
+
signal: controller.signal
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
clearTimeout(timeoutId);
|
|
132
|
+
}
|
|
139
133
|
delivery.attemptNumber += 1;
|
|
140
134
|
delivery.responseStatus = response.status;
|
|
141
135
|
delivery.responseBody = (await response.text()).slice(0, 4096);
|
|
@@ -179,6 +173,17 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
|
|
|
179
173
|
});
|
|
180
174
|
return { status: delivery.status, deliveryId: delivery.id };
|
|
181
175
|
} catch (error) {
|
|
176
|
+
if (error instanceof UnsafeWebhookUrlError) {
|
|
177
|
+
const durationMs = Date.now() - delivery.enqueuedAt.getTime();
|
|
178
|
+
delivery.durationMs = durationMs;
|
|
179
|
+
return await recordWebhookSafetyFailure({
|
|
180
|
+
em,
|
|
181
|
+
delivery,
|
|
182
|
+
webhook,
|
|
183
|
+
error,
|
|
184
|
+
durationMs
|
|
185
|
+
});
|
|
186
|
+
}
|
|
182
187
|
delivery.attemptNumber += 1;
|
|
183
188
|
delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime();
|
|
184
189
|
delivery.lastAttemptAt = /* @__PURE__ */ new Date();
|
|
@@ -206,6 +211,29 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
|
|
|
206
211
|
return { status: delivery.status, deliveryId: delivery.id };
|
|
207
212
|
}
|
|
208
213
|
}
|
|
214
|
+
async function recordWebhookSafetyFailure(input) {
|
|
215
|
+
const { em, delivery, webhook, error } = input;
|
|
216
|
+
const message = error instanceof UnsafeWebhookUrlError ? error.message : "Webhook URL rejected by safety check";
|
|
217
|
+
delivery.status = "failed";
|
|
218
|
+
delivery.errorMessage = message;
|
|
219
|
+
delivery.nextRetryAt = null;
|
|
220
|
+
delivery.lastAttemptAt = /* @__PURE__ */ new Date();
|
|
221
|
+
delivery.attemptNumber += 1;
|
|
222
|
+
webhook.consecutiveFailures += 1;
|
|
223
|
+
webhook.lastFailureAt = /* @__PURE__ */ new Date();
|
|
224
|
+
await em.flush();
|
|
225
|
+
await emitWebhooksEvent("webhooks.delivery.failed", {
|
|
226
|
+
deliveryId: delivery.id,
|
|
227
|
+
webhookId: webhook.id,
|
|
228
|
+
eventType: delivery.eventType,
|
|
229
|
+
errorMessage: message,
|
|
230
|
+
durationMs: input.durationMs ?? 0,
|
|
231
|
+
organizationId: delivery.organizationId,
|
|
232
|
+
tenantId: delivery.tenantId,
|
|
233
|
+
willRetry: false
|
|
234
|
+
});
|
|
235
|
+
return { status: delivery.status, deliveryId: delivery.id };
|
|
236
|
+
}
|
|
209
237
|
async function handleFailedDelivery(input) {
|
|
210
238
|
const { delivery, webhook } = input;
|
|
211
239
|
const retriesRemaining = delivery.attemptNumber < Math.max(delivery.maxAttempts, 1);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/webhooks/lib/delivery.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { buildWebhookHeaders, generateMessageId } from '@open-mercato/shared/lib/webhooks'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { emitWebhooksEvent } from '../events'\nimport { enqueueWebhookDelivery } from './queue'\nimport { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from './integration-state'\nimport { assertSafeWebhookDeliveryUrl, UnsafeWebhookUrlError } from './url-safety'\n\nexport interface WebhookDeliveryJob {\n deliveryId: string\n tenantId: string\n organizationId: string\n}\n\nexport interface CreateWebhookDeliveryInput {\n em: EntityManager\n webhook: WebhookEntity\n eventId: string\n payload: Record<string, unknown>\n}\n\ntype ProcessWebhookDeliveryOptions = {\n scheduleRetries?: boolean\n}\n\ntype WebhookBody = {\n type: string\n timestamp: string\n data: Record<string, unknown>\n}\n\nexport async function createWebhookDelivery(input: CreateWebhookDeliveryInput): Promise<WebhookDeliveryEntity> {\n const bodyPayload: WebhookBody = {\n type: input.eventId,\n timestamp: new Date().toISOString(),\n data: input.payload,\n }\n const now = new Date()\n\n const delivery = input.em.create(WebhookDeliveryEntity, {\n webhookId: input.webhook.id,\n eventType: input.eventId,\n messageId: generateMessageId(),\n payload: bodyPayload,\n status: 'pending',\n attemptNumber: 0,\n maxAttempts: input.webhook.maxRetries,\n targetUrl: input.webhook.url,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n enqueuedAt: now,\n createdAt: now,\n updatedAt: now,\n })\n\n await input.em.flush()\n await emitWebhooksEvent('webhooks.delivery.enqueued', {\n deliveryId: delivery.id,\n webhookId: input.webhook.id,\n eventType: input.eventId,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n })\n return delivery\n}\n\nexport async function processWebhookDeliveryJob(\n em: EntityManager,\n job: WebhookDeliveryJob,\n options: ProcessWebhookDeliveryOptions = {},\n): Promise<{ status: string; deliveryId: string } | null> {\n const delivery = await em.findOne(WebhookDeliveryEntity, {\n id: job.deliveryId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n })\n\n if (!delivery) return null\n\n const webhook = await findOneWithDecryption(\n em,\n WebhookEntity,\n {\n id: delivery.webhookId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n deletedAt: null,\n },\n {},\n { tenantId: job.tenantId, organizationId: job.organizationId },\n )\n\n if (!webhook) {\n delivery.status = 'failed'\n delivery.errorMessage = 'Webhook not found'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n if (!webhook.isActive) {\n delivery.status = 'expired'\n delivery.errorMessage = 'Webhook is inactive'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n const integrationEnabled = await isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n\n if (!integrationEnabled) {\n delivery.status = 'expired'\n delivery.errorMessage = WEBHOOK_INTEGRATION_DISABLED_MESSAGE\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n try {\n await assertSafeWebhookDeliveryUrl(webhook.url)\n } catch (error) {\n const message = error instanceof UnsafeWebhookUrlError\n ? error.message\n : 'Webhook URL rejected by safety check'\n delivery.status = 'failed'\n delivery.errorMessage = message\n delivery.nextRetryAt = null\n delivery.lastAttemptAt = new Date()\n delivery.attemptNumber += 1\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n await em.flush()\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: message,\n durationMs: 0,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: false,\n })\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload)\n delivery.payload = bodyPayload\n delivery.status = 'sending'\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = null\n await em.flush()\n\n const body = JSON.stringify(bodyPayload)\n const timestamp = Math.floor(Date.now() / 1000)\n const headers = buildWebhookHeaders(\n delivery.messageId,\n timestamp,\n body,\n webhook.secret,\n webhook.previousSecret,\n )\n\n const attemptStartedAt = Date.now()\n\n try {\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)\n\n const response = await fetch(webhook.url, {\n method: webhook.httpMethod,\n redirect: 'manual',\n headers: {\n 'content-type': 'application/json',\n ...headers,\n ...(webhook.customHeaders ?? {}),\n },\n body,\n signal: controller.signal,\n })\n\n clearTimeout(timeoutId)\n\n delivery.attemptNumber += 1\n delivery.responseStatus = response.status\n delivery.responseBody = (await response.text()).slice(0, 4096)\n delivery.responseHeaders = Object.fromEntries(response.headers.entries())\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n\n if (response.ok) {\n delivery.status = 'delivered'\n delivery.deliveredAt = new Date()\n delivery.nextRetryAt = null\n webhook.consecutiveFailures = 0\n webhook.lastSuccessAt = new Date()\n\n await emitWebhooksEvent('webhooks.delivery.succeeded', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n })\n\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: shouldRetryStatus(response.status),\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: `HTTP ${response.status}`,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n responseStatus: response.status,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n } catch (error) {\n delivery.attemptNumber += 1\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = error instanceof Error ? error.message : 'Unknown delivery error'\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: true,\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: delivery.errorMessage,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: delivery.errorMessage,\n durationMs: Date.now() - attemptStartedAt,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n }\n}\n\ntype HandleFailedDeliveryInput = {\n em: EntityManager\n webhook: WebhookEntity\n delivery: WebhookDeliveryEntity\n canRetry: boolean\n scheduleRetries: boolean\n fallbackMessage: string\n}\n\nasync function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<void> {\n const { delivery, webhook } = input\n const retriesRemaining = delivery.attemptNumber < Math.max(delivery.maxAttempts, 1)\n const shouldRetry = input.canRetry && retriesRemaining\n\n if (shouldRetry) {\n const nextRetryAt = calculateNextRetry(delivery.attemptNumber)\n delivery.status = 'pending'\n delivery.nextRetryAt = nextRetryAt\n\n await input.em.flush()\n\n if (input.scheduleRetries) {\n try {\n await enqueueWebhookDelivery(\n {\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n },\n Math.max(nextRetryAt.getTime() - Date.now(), 0),\n )\n } catch (error) {\n delivery.status = 'failed'\n delivery.nextRetryAt = null\n delivery.errorMessage = error instanceof Error\n ? `Retry scheduling failed: ${error.message}`\n : 'Retry scheduling failed'\n }\n }\n } else {\n delivery.status = 'expired'\n delivery.nextRetryAt = null\n\n await emitWebhooksEvent('webhooks.delivery.exhausted', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n errorMessage: delivery.errorMessage ?? input.fallbackMessage,\n })\n }\n\n if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {\n webhook.isActive = false\n await emitWebhooksEvent('webhooks.webhook.disabled', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n consecutiveFailures: webhook.consecutiveFailures,\n })\n }\n\n await input.em.flush()\n}\n\nfunction normalizeWebhookBody(eventType: string, payload: Record<string, unknown>): WebhookBody {\n const candidateType = typeof payload.type === 'string' ? payload.type : null\n const candidateTimestamp = typeof payload.timestamp === 'string' ? payload.timestamp : null\n const candidateData = isRecord(payload.data) ? payload.data : null\n\n if (candidateType && candidateTimestamp && candidateData) {\n return {\n type: candidateType,\n timestamp: candidateTimestamp,\n data: candidateData,\n }\n }\n\n return {\n type: eventType,\n timestamp: new Date().toISOString(),\n data: payload,\n }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction shouldRetryStatus(status: number): boolean {\n if (status >= 200 && status < 300) return false\n if (status === 408 || status === 429) return true\n return status >= 500\n}\n\nfunction calculateNextRetry(attemptNumber: number): Date {\n const baseDelayMs = 1000\n const jitterMs = Math.floor(Math.random() * 1000)\n const delayMs = baseDelayMs * Math.pow(2, Math.max(attemptNumber - 1, 0)) + jitterMs\n return new Date(Date.now() + delayMs)\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,qBAAqB,yBAAyB;AACvD,SAAS,6BAA6B;AACtC,SAAS,uBAAuB,qBAAqB;AACrD,SAAS,yBAAyB;AAClC,SAAS,8BAA8B;AACvC,SAAS,6BAA6B,4CAA4C;AAClF,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { buildWebhookHeaders, generateMessageId } from '@open-mercato/shared/lib/webhooks'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { emitWebhooksEvent } from '../events'\nimport { enqueueWebhookDelivery } from './queue'\nimport { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from './integration-state'\nimport {\n assertSafeWebhookDeliveryUrl,\n safeWebhookFetch,\n UnsafeWebhookUrlError,\n} from './url-safety'\n\nexport interface WebhookDeliveryJob {\n deliveryId: string\n tenantId: string\n organizationId: string\n}\n\nexport interface CreateWebhookDeliveryInput {\n em: EntityManager\n webhook: WebhookEntity\n eventId: string\n payload: Record<string, unknown>\n}\n\ntype ProcessWebhookDeliveryOptions = {\n scheduleRetries?: boolean\n}\n\ntype WebhookBody = {\n type: string\n timestamp: string\n data: Record<string, unknown>\n}\n\nexport async function createWebhookDelivery(input: CreateWebhookDeliveryInput): Promise<WebhookDeliveryEntity> {\n const bodyPayload: WebhookBody = {\n type: input.eventId,\n timestamp: new Date().toISOString(),\n data: input.payload,\n }\n const now = new Date()\n\n const delivery = input.em.create(WebhookDeliveryEntity, {\n webhookId: input.webhook.id,\n eventType: input.eventId,\n messageId: generateMessageId(),\n payload: bodyPayload,\n status: 'pending',\n attemptNumber: 0,\n maxAttempts: input.webhook.maxRetries,\n targetUrl: input.webhook.url,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n enqueuedAt: now,\n createdAt: now,\n updatedAt: now,\n })\n\n await input.em.flush()\n await emitWebhooksEvent('webhooks.delivery.enqueued', {\n deliveryId: delivery.id,\n webhookId: input.webhook.id,\n eventType: input.eventId,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n })\n return delivery\n}\n\nexport async function processWebhookDeliveryJob(\n em: EntityManager,\n job: WebhookDeliveryJob,\n options: ProcessWebhookDeliveryOptions = {},\n): Promise<{ status: string; deliveryId: string } | null> {\n const delivery = await em.findOne(WebhookDeliveryEntity, {\n id: job.deliveryId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n })\n\n if (!delivery) return null\n\n const webhook = await findOneWithDecryption(\n em,\n WebhookEntity,\n {\n id: delivery.webhookId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n deletedAt: null,\n },\n {},\n { tenantId: job.tenantId, organizationId: job.organizationId },\n )\n\n if (!webhook) {\n delivery.status = 'failed'\n delivery.errorMessage = 'Webhook not found'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n if (!webhook.isActive) {\n delivery.status = 'expired'\n delivery.errorMessage = 'Webhook is inactive'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n const integrationEnabled = await isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n\n if (!integrationEnabled) {\n delivery.status = 'expired'\n delivery.errorMessage = WEBHOOK_INTEGRATION_DISABLED_MESSAGE\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n try {\n await assertSafeWebhookDeliveryUrl(webhook.url)\n } catch (error) {\n return await recordWebhookSafetyFailure({\n em,\n delivery,\n webhook,\n error,\n })\n }\n\n const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload)\n delivery.payload = bodyPayload\n delivery.status = 'sending'\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = null\n await em.flush()\n\n const body = JSON.stringify(bodyPayload)\n const timestamp = Math.floor(Date.now() / 1000)\n const headers = buildWebhookHeaders(\n delivery.messageId,\n timestamp,\n body,\n webhook.secret,\n webhook.previousSecret,\n )\n\n const attemptStartedAt = Date.now()\n\n try {\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)\n\n let response: Response\n try {\n response = await safeWebhookFetch(webhook.url, {\n method: webhook.httpMethod,\n redirect: 'manual',\n headers: {\n 'content-type': 'application/json',\n ...headers,\n ...(webhook.customHeaders ?? {}),\n },\n body,\n signal: controller.signal,\n })\n } finally {\n clearTimeout(timeoutId)\n }\n\n delivery.attemptNumber += 1\n delivery.responseStatus = response.status\n delivery.responseBody = (await response.text()).slice(0, 4096)\n delivery.responseHeaders = Object.fromEntries(response.headers.entries())\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n\n if (response.ok) {\n delivery.status = 'delivered'\n delivery.deliveredAt = new Date()\n delivery.nextRetryAt = null\n webhook.consecutiveFailures = 0\n webhook.lastSuccessAt = new Date()\n\n await emitWebhooksEvent('webhooks.delivery.succeeded', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n })\n\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: shouldRetryStatus(response.status),\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: `HTTP ${response.status}`,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n responseStatus: response.status,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n } catch (error) {\n if (error instanceof UnsafeWebhookUrlError) {\n // DNS rebinding caught between the upfront safety check and the pinned connect:\n // the same attacker-controlled DNS would defeat retries too, so fail terminally.\n const durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.durationMs = durationMs\n return await recordWebhookSafetyFailure({\n em,\n delivery,\n webhook,\n error,\n durationMs,\n })\n }\n\n delivery.attemptNumber += 1\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = error instanceof Error ? error.message : 'Unknown delivery error'\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: true,\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: delivery.errorMessage,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: delivery.errorMessage,\n durationMs: Date.now() - attemptStartedAt,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n }\n}\n\ntype RecordWebhookSafetyFailureInput = {\n em: EntityManager\n delivery: WebhookDeliveryEntity\n webhook: WebhookEntity\n error: unknown\n durationMs?: number | null\n}\n\nasync function recordWebhookSafetyFailure(\n input: RecordWebhookSafetyFailureInput,\n): Promise<{ status: string; deliveryId: string }> {\n const { em, delivery, webhook, error } = input\n const message = error instanceof UnsafeWebhookUrlError\n ? error.message\n : 'Webhook URL rejected by safety check'\n\n delivery.status = 'failed'\n delivery.errorMessage = message\n delivery.nextRetryAt = null\n delivery.lastAttemptAt = new Date()\n delivery.attemptNumber += 1\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n await em.flush()\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: message,\n durationMs: input.durationMs ?? 0,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: false,\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n}\n\ntype HandleFailedDeliveryInput = {\n em: EntityManager\n webhook: WebhookEntity\n delivery: WebhookDeliveryEntity\n canRetry: boolean\n scheduleRetries: boolean\n fallbackMessage: string\n}\n\nasync function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<void> {\n const { delivery, webhook } = input\n const retriesRemaining = delivery.attemptNumber < Math.max(delivery.maxAttempts, 1)\n const shouldRetry = input.canRetry && retriesRemaining\n\n if (shouldRetry) {\n const nextRetryAt = calculateNextRetry(delivery.attemptNumber)\n delivery.status = 'pending'\n delivery.nextRetryAt = nextRetryAt\n\n await input.em.flush()\n\n if (input.scheduleRetries) {\n try {\n await enqueueWebhookDelivery(\n {\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n },\n Math.max(nextRetryAt.getTime() - Date.now(), 0),\n )\n } catch (error) {\n delivery.status = 'failed'\n delivery.nextRetryAt = null\n delivery.errorMessage = error instanceof Error\n ? `Retry scheduling failed: ${error.message}`\n : 'Retry scheduling failed'\n }\n }\n } else {\n delivery.status = 'expired'\n delivery.nextRetryAt = null\n\n await emitWebhooksEvent('webhooks.delivery.exhausted', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n errorMessage: delivery.errorMessage ?? input.fallbackMessage,\n })\n }\n\n if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {\n webhook.isActive = false\n await emitWebhooksEvent('webhooks.webhook.disabled', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n consecutiveFailures: webhook.consecutiveFailures,\n })\n }\n\n await input.em.flush()\n}\n\nfunction normalizeWebhookBody(eventType: string, payload: Record<string, unknown>): WebhookBody {\n const candidateType = typeof payload.type === 'string' ? payload.type : null\n const candidateTimestamp = typeof payload.timestamp === 'string' ? payload.timestamp : null\n const candidateData = isRecord(payload.data) ? payload.data : null\n\n if (candidateType && candidateTimestamp && candidateData) {\n return {\n type: candidateType,\n timestamp: candidateTimestamp,\n data: candidateData,\n }\n }\n\n return {\n type: eventType,\n timestamp: new Date().toISOString(),\n data: payload,\n }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction shouldRetryStatus(status: number): boolean {\n if (status >= 200 && status < 300) return false\n if (status === 408 || status === 429) return true\n return status >= 500\n}\n\nfunction calculateNextRetry(attemptNumber: number): Date {\n const baseDelayMs = 1000\n const jitterMs = Math.floor(Math.random() * 1000)\n const delayMs = baseDelayMs * Math.pow(2, Math.max(attemptNumber - 1, 0)) + jitterMs\n return new Date(Date.now() + delayMs)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertSafeOutboundUrl,
|
|
3
3
|
assertStaticallySafeOutboundUrl,
|
|
4
|
-
parseOutboundUrl
|
|
4
|
+
parseOutboundUrl,
|
|
5
|
+
safeOutboundFetch
|
|
5
6
|
} from "@open-mercato/shared/lib/url-safety";
|
|
6
7
|
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
7
8
|
import { isPrivateIpAddress } from "@open-mercato/shared/lib/network";
|
|
@@ -37,12 +38,23 @@ async function assertSafeWebhookDeliveryUrl(rawUrl, deps = {}) {
|
|
|
37
38
|
lookupHost: deps.lookupHost
|
|
38
39
|
});
|
|
39
40
|
}
|
|
41
|
+
async function safeWebhookFetch(rawUrl, init = {}, deps = {}) {
|
|
42
|
+
const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled();
|
|
43
|
+
return safeOutboundFetch(rawUrl, init, {
|
|
44
|
+
errorFactory: webhookErrorFactory,
|
|
45
|
+
subject: SUBJECT,
|
|
46
|
+
allowPrivate,
|
|
47
|
+
lookupHost: deps.lookupHost,
|
|
48
|
+
fetchImpl: deps.fetchImpl
|
|
49
|
+
});
|
|
50
|
+
}
|
|
40
51
|
export {
|
|
41
52
|
UnsafeWebhookUrlError,
|
|
42
53
|
assertSafeWebhookDeliveryUrl,
|
|
43
54
|
assertStaticallySafeWebhookUrl,
|
|
44
55
|
isAllowPrivateWebhookUrlsEnabled,
|
|
45
56
|
isPrivateIpAddress,
|
|
46
|
-
parseWebhookUrl
|
|
57
|
+
parseWebhookUrl,
|
|
58
|
+
safeWebhookFetch
|
|
47
59
|
};
|
|
48
60
|
//# sourceMappingURL=url-safety.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/webhooks/lib/url-safety.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n assertSafeOutboundUrl,\n assertStaticallySafeOutboundUrl,\n parseOutboundUrl,\n type HostLookup,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport { isPrivateIpAddress } from '@open-mercato/shared/lib/network'\n\nconst SUBJECT = 'Webhook URL'\n\n// reason: string (not UrlSafetyReason) preserves BC per BACKWARD_COMPATIBILITY.md \u00A72\nexport class UnsafeWebhookUrlError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `Webhook URL rejected: ${reason}`)\n this.name = 'UnsafeWebhookUrlError'\n this.reason = reason\n }\n}\n\nconst webhookErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeWebhookUrlError(reason, message)\n\ntype ParsedWebhookUrl = {\n url: URL\n hostname: string\n}\n\nexport function parseWebhookUrl(rawUrl: string): ParsedWebhookUrl {\n return parseOutboundUrl(rawUrl, { errorFactory: webhookErrorFactory, subject: SUBJECT })\n}\n\nexport type AssertStaticWebhookUrlDeps = {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeWebhookUrl(\n rawUrl: string,\n deps: AssertStaticWebhookUrlDeps = {},\n): void {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n assertStaticallySafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nexport function isAllowPrivateWebhookUrlsEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS, false)\n}\n\nexport type AssertSafeWebhookDeliveryDeps = {\n lookupHost?: HostLookup\n allowPrivate?: boolean\n}\n\nexport async function assertSafeWebhookDeliveryUrl(\n rawUrl: string,\n deps: AssertSafeWebhookDeliveryDeps = {},\n): Promise<void> {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n await assertSafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n })\n}\n"],
|
|
5
|
-
"mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,
|
|
4
|
+
"sourcesContent": ["import {\n assertSafeOutboundUrl,\n assertStaticallySafeOutboundUrl,\n parseOutboundUrl,\n safeOutboundFetch,\n type HostLookup,\n type SafeOutboundFetchOptions,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport { isPrivateIpAddress } from '@open-mercato/shared/lib/network'\n\nconst SUBJECT = 'Webhook URL'\n\n// reason: string (not UrlSafetyReason) preserves BC per BACKWARD_COMPATIBILITY.md \u00A72\nexport class UnsafeWebhookUrlError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `Webhook URL rejected: ${reason}`)\n this.name = 'UnsafeWebhookUrlError'\n this.reason = reason\n }\n}\n\nconst webhookErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeWebhookUrlError(reason, message)\n\ntype ParsedWebhookUrl = {\n url: URL\n hostname: string\n}\n\nexport function parseWebhookUrl(rawUrl: string): ParsedWebhookUrl {\n return parseOutboundUrl(rawUrl, { errorFactory: webhookErrorFactory, subject: SUBJECT })\n}\n\nexport type AssertStaticWebhookUrlDeps = {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeWebhookUrl(\n rawUrl: string,\n deps: AssertStaticWebhookUrlDeps = {},\n): void {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n assertStaticallySafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nexport function isAllowPrivateWebhookUrlsEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS, false)\n}\n\nexport type AssertSafeWebhookDeliveryDeps = {\n lookupHost?: HostLookup\n allowPrivate?: boolean\n}\n\nexport async function assertSafeWebhookDeliveryUrl(\n rawUrl: string,\n deps: AssertSafeWebhookDeliveryDeps = {},\n): Promise<void> {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n await assertSafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n })\n}\n\nexport type SafeWebhookFetchDeps = {\n lookupHost?: HostLookup\n allowPrivate?: boolean\n fetchImpl?: SafeOutboundFetchOptions['fetchImpl']\n}\n\n/**\n * Validates the webhook URL and performs a `fetch()` with the connection pinned to a\n * pre-validated address \u2014 defeats DNS rebinding by ensuring the validation lookup and\n * the connect lookup return the same IP. Always sets `redirect: 'manual'` unless the\n * caller overrides it.\n */\nexport async function safeWebhookFetch(\n rawUrl: string,\n init: RequestInit = {},\n deps: SafeWebhookFetchDeps = {},\n): Promise<Response> {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n return safeOutboundFetch(rawUrl, init, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n fetchImpl: deps.fetchImpl,\n })\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,+BAA+B;AAExC,SAAS,0BAA0B;AAEnC,MAAM,UAAU;AAGT,MAAM,8BAA8B,MAAM;AAAA,EAG/C,YAAY,QAAgB,SAAkB;AAC5C,UAAM,WAAW,yBAAyB,MAAM,EAAE;AAClD,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,MAAM,sBAAsB,CAAC,QAAyB,YACpD,IAAI,sBAAsB,QAAQ,OAAO;AAOpC,SAAS,gBAAgB,QAAkC;AAChE,SAAO,iBAAiB,QAAQ,EAAE,cAAc,qBAAqB,SAAS,QAAQ,CAAC;AACzF;AAMO,SAAS,+BACd,QACA,OAAmC,CAAC,GAC9B;AACN,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,kCAAgC,QAAQ;AAAA,IACtC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,EACF,CAAC;AACH;AAEO,SAAS,mCAA4C;AAC1D,SAAO,wBAAwB,QAAQ,IAAI,gCAAgC,KAAK;AAClF;AAOA,eAAsB,6BACpB,QACA,OAAsC,CAAC,GACxB;AACf,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,QAAM,sBAAsB,QAAQ;AAAA,IAClC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,IACA,YAAY,KAAK;AAAA,EACnB,CAAC;AACH;AAcA,eAAsB,iBACpB,QACA,OAAoB,CAAC,GACrB,OAA6B,CAAC,GACX;AACnB,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,SAAO,kBAAkB,QAAQ,MAAM;AAAA,IACrC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,IACA,YAAY,KAAK;AAAA,IACjB,WAAW,KAAK;AAAA,EAClB,CAAC;AACH;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/webhooks",
|
|
3
|
-
"version": "0.5.1-develop.
|
|
3
|
+
"version": "0.5.1-develop.2924.d13908516e",
|
|
4
4
|
"description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -69,18 +69,18 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@open-mercato/core": "0.5.1-develop.
|
|
73
|
-
"@open-mercato/queue": "0.5.1-develop.
|
|
74
|
-
"@open-mercato/ui": "0.5.1-develop.
|
|
72
|
+
"@open-mercato/core": "0.5.1-develop.2924.d13908516e",
|
|
73
|
+
"@open-mercato/queue": "0.5.1-develop.2924.d13908516e",
|
|
74
|
+
"@open-mercato/ui": "0.5.1-develop.2924.d13908516e"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@mikro-orm/postgresql": "^7.0.10",
|
|
78
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
78
|
+
"@open-mercato/shared": "0.5.1-develop.2924.d13908516e",
|
|
79
79
|
"react": "^19.0.0",
|
|
80
80
|
"react-dom": "^19.0.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
83
|
+
"@open-mercato/shared": "0.5.1-develop.2924.d13908516e",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"esbuild": "^0.28.0",
|
|
86
86
|
"glob": "^13.0.6",
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
assertStaticallySafeWebhookUrl,
|
|
4
4
|
isPrivateIpAddress,
|
|
5
5
|
parseWebhookUrl,
|
|
6
|
+
safeWebhookFetch,
|
|
6
7
|
UnsafeWebhookUrlError,
|
|
7
8
|
} from '../url-safety'
|
|
8
9
|
|
|
@@ -191,3 +192,54 @@ describe('url-safety — assertSafeWebhookDeliveryUrl (DNS rebinding guard)', ()
|
|
|
191
192
|
).rejects.toMatchObject({ reason: 'forbidden_protocol' })
|
|
192
193
|
})
|
|
193
194
|
})
|
|
195
|
+
|
|
196
|
+
describe('url-safety — safeWebhookFetch', () => {
|
|
197
|
+
it('rejects DNS-rebinding hosts before any fetch attempt', async () => {
|
|
198
|
+
const fetchImpl = jest.fn() as unknown as typeof fetch
|
|
199
|
+
const lookupHost = async () => [{ address: '10.0.0.5', family: 4 }]
|
|
200
|
+
await expect(
|
|
201
|
+
safeWebhookFetch('https://rebind.evil.example/', {}, {
|
|
202
|
+
fetchImpl,
|
|
203
|
+
lookupHost,
|
|
204
|
+
allowPrivate: false,
|
|
205
|
+
}),
|
|
206
|
+
).rejects.toBeInstanceOf(UnsafeWebhookUrlError)
|
|
207
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('reports the private resolved address in the error reason', async () => {
|
|
211
|
+
const fetchImpl = jest.fn() as unknown as typeof fetch
|
|
212
|
+
const lookupHost = async () => [{ address: '10.0.0.5', family: 4 }]
|
|
213
|
+
try {
|
|
214
|
+
await safeWebhookFetch('https://rebind.evil.example/', {}, {
|
|
215
|
+
fetchImpl,
|
|
216
|
+
lookupHost,
|
|
217
|
+
allowPrivate: false,
|
|
218
|
+
})
|
|
219
|
+
throw new Error('expected throw')
|
|
220
|
+
} catch (error) {
|
|
221
|
+
expect(error).toBeInstanceOf(UnsafeWebhookUrlError)
|
|
222
|
+
expect((error as UnsafeWebhookUrlError).reason).toBe('private_ip_resolved')
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('forwards init through fetchImpl with redirect:"manual"', async () => {
|
|
227
|
+
const fetchImpl = jest.fn(async () => new Response('ok', { status: 200 })) as unknown as typeof fetch
|
|
228
|
+
const lookupHost = async () => [{ address: '93.184.216.34', family: 4 }]
|
|
229
|
+
const response = await safeWebhookFetch(
|
|
230
|
+
'https://hooks.example.com/in',
|
|
231
|
+
{ method: 'POST', body: 'payload' },
|
|
232
|
+
{ fetchImpl, lookupHost, allowPrivate: false },
|
|
233
|
+
)
|
|
234
|
+
expect(response.status).toBe(200)
|
|
235
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
236
|
+
const [, init] = (fetchImpl as unknown as jest.Mock).mock.calls[0]
|
|
237
|
+
expect(init).toEqual(
|
|
238
|
+
expect.objectContaining({
|
|
239
|
+
method: 'POST',
|
|
240
|
+
body: 'payload',
|
|
241
|
+
redirect: 'manual',
|
|
242
|
+
}),
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -5,7 +5,11 @@ import { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'
|
|
|
5
5
|
import { emitWebhooksEvent } from '../events'
|
|
6
6
|
import { enqueueWebhookDelivery } from './queue'
|
|
7
7
|
import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from './integration-state'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
assertSafeWebhookDeliveryUrl,
|
|
10
|
+
safeWebhookFetch,
|
|
11
|
+
UnsafeWebhookUrlError,
|
|
12
|
+
} from './url-safety'
|
|
9
13
|
|
|
10
14
|
export interface WebhookDeliveryJob {
|
|
11
15
|
deliveryId: string
|
|
@@ -123,28 +127,12 @@ export async function processWebhookDeliveryJob(
|
|
|
123
127
|
try {
|
|
124
128
|
await assertSafeWebhookDeliveryUrl(webhook.url)
|
|
125
129
|
} catch (error) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
delivery.nextRetryAt = null
|
|
132
|
-
delivery.lastAttemptAt = new Date()
|
|
133
|
-
delivery.attemptNumber += 1
|
|
134
|
-
webhook.consecutiveFailures += 1
|
|
135
|
-
webhook.lastFailureAt = new Date()
|
|
136
|
-
await em.flush()
|
|
137
|
-
await emitWebhooksEvent('webhooks.delivery.failed', {
|
|
138
|
-
deliveryId: delivery.id,
|
|
139
|
-
webhookId: webhook.id,
|
|
140
|
-
eventType: delivery.eventType,
|
|
141
|
-
errorMessage: message,
|
|
142
|
-
durationMs: 0,
|
|
143
|
-
organizationId: delivery.organizationId,
|
|
144
|
-
tenantId: delivery.tenantId,
|
|
145
|
-
willRetry: false,
|
|
130
|
+
return await recordWebhookSafetyFailure({
|
|
131
|
+
em,
|
|
132
|
+
delivery,
|
|
133
|
+
webhook,
|
|
134
|
+
error,
|
|
146
135
|
})
|
|
147
|
-
return { status: delivery.status, deliveryId: delivery.id }
|
|
148
136
|
}
|
|
149
137
|
|
|
150
138
|
const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload)
|
|
@@ -170,19 +158,22 @@ export async function processWebhookDeliveryJob(
|
|
|
170
158
|
const controller = new AbortController()
|
|
171
159
|
const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)
|
|
172
160
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
161
|
+
let response: Response
|
|
162
|
+
try {
|
|
163
|
+
response = await safeWebhookFetch(webhook.url, {
|
|
164
|
+
method: webhook.httpMethod,
|
|
165
|
+
redirect: 'manual',
|
|
166
|
+
headers: {
|
|
167
|
+
'content-type': 'application/json',
|
|
168
|
+
...headers,
|
|
169
|
+
...(webhook.customHeaders ?? {}),
|
|
170
|
+
},
|
|
171
|
+
body,
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
})
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timeoutId)
|
|
176
|
+
}
|
|
186
177
|
|
|
187
178
|
delivery.attemptNumber += 1
|
|
188
179
|
delivery.responseStatus = response.status
|
|
@@ -234,6 +225,20 @@ export async function processWebhookDeliveryJob(
|
|
|
234
225
|
|
|
235
226
|
return { status: delivery.status, deliveryId: delivery.id }
|
|
236
227
|
} catch (error) {
|
|
228
|
+
if (error instanceof UnsafeWebhookUrlError) {
|
|
229
|
+
// DNS rebinding caught between the upfront safety check and the pinned connect:
|
|
230
|
+
// the same attacker-controlled DNS would defeat retries too, so fail terminally.
|
|
231
|
+
const durationMs = Date.now() - delivery.enqueuedAt.getTime()
|
|
232
|
+
delivery.durationMs = durationMs
|
|
233
|
+
return await recordWebhookSafetyFailure({
|
|
234
|
+
em,
|
|
235
|
+
delivery,
|
|
236
|
+
webhook,
|
|
237
|
+
error,
|
|
238
|
+
durationMs,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
237
242
|
delivery.attemptNumber += 1
|
|
238
243
|
delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()
|
|
239
244
|
delivery.lastAttemptAt = new Date()
|
|
@@ -266,6 +271,45 @@ export async function processWebhookDeliveryJob(
|
|
|
266
271
|
}
|
|
267
272
|
}
|
|
268
273
|
|
|
274
|
+
type RecordWebhookSafetyFailureInput = {
|
|
275
|
+
em: EntityManager
|
|
276
|
+
delivery: WebhookDeliveryEntity
|
|
277
|
+
webhook: WebhookEntity
|
|
278
|
+
error: unknown
|
|
279
|
+
durationMs?: number | null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function recordWebhookSafetyFailure(
|
|
283
|
+
input: RecordWebhookSafetyFailureInput,
|
|
284
|
+
): Promise<{ status: string; deliveryId: string }> {
|
|
285
|
+
const { em, delivery, webhook, error } = input
|
|
286
|
+
const message = error instanceof UnsafeWebhookUrlError
|
|
287
|
+
? error.message
|
|
288
|
+
: 'Webhook URL rejected by safety check'
|
|
289
|
+
|
|
290
|
+
delivery.status = 'failed'
|
|
291
|
+
delivery.errorMessage = message
|
|
292
|
+
delivery.nextRetryAt = null
|
|
293
|
+
delivery.lastAttemptAt = new Date()
|
|
294
|
+
delivery.attemptNumber += 1
|
|
295
|
+
webhook.consecutiveFailures += 1
|
|
296
|
+
webhook.lastFailureAt = new Date()
|
|
297
|
+
await em.flush()
|
|
298
|
+
|
|
299
|
+
await emitWebhooksEvent('webhooks.delivery.failed', {
|
|
300
|
+
deliveryId: delivery.id,
|
|
301
|
+
webhookId: webhook.id,
|
|
302
|
+
eventType: delivery.eventType,
|
|
303
|
+
errorMessage: message,
|
|
304
|
+
durationMs: input.durationMs ?? 0,
|
|
305
|
+
organizationId: delivery.organizationId,
|
|
306
|
+
tenantId: delivery.tenantId,
|
|
307
|
+
willRetry: false,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
return { status: delivery.status, deliveryId: delivery.id }
|
|
311
|
+
}
|
|
312
|
+
|
|
269
313
|
type HandleFailedDeliveryInput = {
|
|
270
314
|
em: EntityManager
|
|
271
315
|
webhook: WebhookEntity
|
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
assertSafeOutboundUrl,
|
|
3
3
|
assertStaticallySafeOutboundUrl,
|
|
4
4
|
parseOutboundUrl,
|
|
5
|
+
safeOutboundFetch,
|
|
5
6
|
type HostLookup,
|
|
7
|
+
type SafeOutboundFetchOptions,
|
|
6
8
|
type UrlSafetyReason,
|
|
7
9
|
} from '@open-mercato/shared/lib/url-safety'
|
|
8
10
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
@@ -71,3 +73,30 @@ export async function assertSafeWebhookDeliveryUrl(
|
|
|
71
73
|
lookupHost: deps.lookupHost,
|
|
72
74
|
})
|
|
73
75
|
}
|
|
76
|
+
|
|
77
|
+
export type SafeWebhookFetchDeps = {
|
|
78
|
+
lookupHost?: HostLookup
|
|
79
|
+
allowPrivate?: boolean
|
|
80
|
+
fetchImpl?: SafeOutboundFetchOptions['fetchImpl']
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validates the webhook URL and performs a `fetch()` with the connection pinned to a
|
|
85
|
+
* pre-validated address — defeats DNS rebinding by ensuring the validation lookup and
|
|
86
|
+
* the connect lookup return the same IP. Always sets `redirect: 'manual'` unless the
|
|
87
|
+
* caller overrides it.
|
|
88
|
+
*/
|
|
89
|
+
export async function safeWebhookFetch(
|
|
90
|
+
rawUrl: string,
|
|
91
|
+
init: RequestInit = {},
|
|
92
|
+
deps: SafeWebhookFetchDeps = {},
|
|
93
|
+
): Promise<Response> {
|
|
94
|
+
const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()
|
|
95
|
+
return safeOutboundFetch(rawUrl, init, {
|
|
96
|
+
errorFactory: webhookErrorFactory,
|
|
97
|
+
subject: SUBJECT,
|
|
98
|
+
allowPrivate,
|
|
99
|
+
lookupHost: deps.lookupHost,
|
|
100
|
+
fetchImpl: deps.fetchImpl,
|
|
101
|
+
})
|
|
102
|
+
}
|