Skip to content

Commit 41fb863

Browse files
authored
fix(webhooks): validate and pin EmailBison apiBaseUrl before outbound requests (#5415)
* fix(webhooks): validate and pin EmailBison apiBaseUrl before outbound requests Route Email Bison webhook create/delete requests through the shared DNS-validated, IP-pinned fetch used by the Teams and Slack webhook providers instead of a raw fetch to the user-configured instance URL. * fix(webhooks): restore NEXT_PUBLIC_APP_URL in emailbison tests, dedupe strict-delete warning log Test env var mutation was never restored, risking cross-file leakage in single-threaded vitest runs. Strict-mode deleteSubscription failures were logged twice (once at the throw site with context, once generically by the outer catch); the outer catch now skips its own log for errors already logged at the throw site.
1 parent a1fbb57 commit 41fb863

2 files changed

Lines changed: 229 additions & 13 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
8+
9+
import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison'
10+
11+
const WEBHOOK_ID = 'webhook-uuid-1234'
12+
const PUBLIC_BASE_URL = 'https://my-instance.emailbison.com'
13+
14+
function makeWebhook(providerConfig: Record<string, unknown>) {
15+
return {
16+
id: WEBHOOK_ID,
17+
path: 'abc',
18+
providerConfig,
19+
} as unknown as Parameters<typeof emailBisonHandler.deleteSubscription>[0]['webhook']
20+
}
21+
22+
function jsonSecureResponse(status: number, body: Record<string, unknown>) {
23+
return {
24+
ok: status >= 200 && status < 300,
25+
status,
26+
statusText: '',
27+
headers: { get: () => null },
28+
body: { cancel: vi.fn() },
29+
json: vi.fn().mockResolvedValue(body),
30+
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
31+
arrayBuffer: vi.fn(),
32+
}
33+
}
34+
35+
describe('emailBisonHandler createSubscription', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com'
39+
})
40+
41+
afterEach(() => {
42+
process.env.NEXT_PUBLIC_APP_URL = undefined
43+
})
44+
45+
it('rejects an apiBaseUrl that resolves to a blocked address before making a request', async () => {
46+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
47+
isValid: false,
48+
error: 'URL resolves to a blocked address',
49+
})
50+
51+
const webhook = makeWebhook({
52+
apiKey: 'test-key',
53+
apiBaseUrl: 'https://169.254.169.254',
54+
triggerId: 'emailbison_email_sent',
55+
})
56+
57+
await expect(
58+
emailBisonHandler.createSubscription({
59+
webhook,
60+
workflow: {} as never,
61+
userId: 'user-1',
62+
requestId: 'req-1',
63+
} as never)
64+
).rejects.toThrow('Email Bison Instance URL could not be validated.')
65+
66+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
67+
})
68+
69+
it('creates the webhook subscription for a valid public instance URL', async () => {
70+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
71+
isValid: true,
72+
resolvedIP: '203.0.113.10',
73+
})
74+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
75+
jsonSecureResponse(200, { data: { id: 42 } })
76+
)
77+
78+
const webhook = makeWebhook({
79+
apiKey: 'test-key',
80+
apiBaseUrl: PUBLIC_BASE_URL,
81+
triggerId: 'emailbison_email_sent',
82+
})
83+
84+
const result = await emailBisonHandler.createSubscription({
85+
webhook,
86+
workflow: {} as never,
87+
userId: 'user-1',
88+
requestId: 'req-1',
89+
} as never)
90+
91+
expect(result).toEqual({ providerConfigUpdates: { externalId: '42' } })
92+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
93+
expect.stringContaining(PUBLIC_BASE_URL),
94+
'203.0.113.10',
95+
expect.objectContaining({ method: 'POST' })
96+
)
97+
})
98+
})
99+
100+
describe('emailBisonHandler deleteSubscription', () => {
101+
beforeEach(() => {
102+
vi.clearAllMocks()
103+
})
104+
105+
it('rejects an apiBaseUrl that resolves to a blocked address before making a request', async () => {
106+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
107+
isValid: false,
108+
error: 'URL resolves to a blocked address',
109+
})
110+
111+
const webhook = makeWebhook({
112+
apiKey: 'test-key',
113+
apiBaseUrl: 'https://127.0.0.1',
114+
externalId: '42',
115+
})
116+
117+
await emailBisonHandler.deleteSubscription({
118+
webhook,
119+
workflow: {} as never,
120+
requestId: 'req-1',
121+
strict: false,
122+
} as never)
123+
124+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
125+
})
126+
127+
it('throws when strict and the apiBaseUrl resolves to a blocked address', async () => {
128+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
129+
isValid: false,
130+
error: 'URL resolves to a blocked address',
131+
})
132+
133+
const webhook = makeWebhook({
134+
apiKey: 'test-key',
135+
apiBaseUrl: 'https://127.0.0.1',
136+
externalId: '42',
137+
})
138+
139+
await expect(
140+
emailBisonHandler.deleteSubscription({
141+
webhook,
142+
workflow: {} as never,
143+
requestId: 'req-1',
144+
strict: true,
145+
} as never)
146+
).rejects.toThrow('Email Bison Instance URL could not be validated.')
147+
148+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
149+
})
150+
151+
it('deletes the webhook subscription for a valid public instance URL', async () => {
152+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
153+
isValid: true,
154+
resolvedIP: '203.0.113.10',
155+
})
156+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
157+
jsonSecureResponse(200, {})
158+
)
159+
160+
const webhook = makeWebhook({
161+
apiKey: 'test-key',
162+
apiBaseUrl: PUBLIC_BASE_URL,
163+
externalId: '42',
164+
})
165+
166+
await emailBisonHandler.deleteSubscription({
167+
webhook,
168+
workflow: {} as never,
169+
requestId: 'req-1',
170+
strict: false,
171+
} as never)
172+
173+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
174+
expect.stringContaining(PUBLIC_BASE_URL),
175+
'203.0.113.10',
176+
expect.objectContaining({ method: 'DELETE' })
177+
)
178+
})
179+
})

apps/sim/lib/webhooks/providers/emailbison.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { isRecordLike } from '@sim/utils/object'
4+
import {
5+
type SecureFetchResponse,
6+
secureFetchWithPinnedIP,
7+
validateUrlWithDNS,
8+
} from '@/lib/core/security/input-validation.server'
49
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
510
import type {
611
DeleteSubscriptionContext,
@@ -147,7 +152,14 @@ export const emailBisonHandler: WebhookProviderHandler = {
147152
webhookId: webhook.id,
148153
})
149154

150-
const response = await fetch(emailBisonUrl('/api/webhook-url', {}, apiBaseUrl), {
155+
const targetUrl = emailBisonUrl('/api/webhook-url', {}, apiBaseUrl)
156+
const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl')
157+
if (!urlValidation.isValid) {
158+
logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`)
159+
throw new Error('Email Bison Instance URL could not be validated.')
160+
}
161+
162+
const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, {
151163
method: 'POST',
152164
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
153165
body: JSON.stringify({
@@ -207,25 +219,39 @@ export const emailBisonHandler: WebhookProviderHandler = {
207219
hasApiBaseUrl: Boolean(apiBaseUrl),
208220
hasExternalId: Boolean(externalId),
209221
})
210-
if (ctx.strict) throw new Error('Missing Email Bison webhook cleanup configuration')
222+
if (ctx.strict)
223+
throw new AlreadyLoggedError('Missing Email Bison webhook cleanup configuration')
211224
return
212225
}
213226

214-
const response = await fetch(
215-
emailBisonUrl(`/api/webhook-url/${encodeURIComponent(externalId)}`, {}, apiBaseUrl),
216-
{
217-
method: 'DELETE',
218-
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
219-
}
227+
const targetUrl = emailBisonUrl(
228+
`/api/webhook-url/${encodeURIComponent(externalId)}`,
229+
{},
230+
apiBaseUrl
220231
)
232+
const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl')
233+
if (!urlValidation.isValid) {
234+
logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`, {
235+
webhookId: webhook.id,
236+
})
237+
if (ctx.strict)
238+
throw new AlreadyLoggedError('Email Bison Instance URL could not be validated.')
239+
return
240+
}
241+
242+
const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, {
243+
method: 'DELETE',
244+
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
245+
})
221246

222247
if (!response.ok && response.status !== 404) {
223248
const responseBody = await parseJsonResponse(response)
224249
logger.warn(`[${requestId}] Failed to delete Email Bison webhook`, {
225250
status: response.status,
226251
response: responseBody,
227252
})
228-
if (ctx.strict) throw new Error(`Failed to delete Email Bison webhook: ${response.status}`)
253+
if (ctx.strict)
254+
throw new AlreadyLoggedError(`Failed to delete Email Bison webhook: ${response.status}`)
229255
return
230256
}
231257

@@ -235,15 +261,26 @@ export const emailBisonHandler: WebhookProviderHandler = {
235261
webhookId: webhook.id,
236262
})
237263
} catch (error) {
238-
logger.warn(`[${requestId}] Error deleting Email Bison webhook`, {
239-
message: toError(error).message,
240-
})
264+
if (!(error instanceof AlreadyLoggedError)) {
265+
logger.warn(`[${requestId}] Error deleting Email Bison webhook`, {
266+
message: toError(error).message,
267+
})
268+
}
241269
if (ctx.strict) throw error
242270
}
243271
},
244272
}
245273

246-
async function parseJsonResponse(response: Response): Promise<Record<string, unknown> | null> {
274+
/**
275+
* Marks an error whose failure reason has already been logged with full context
276+
* at the throw site, so the outer catch in `deleteSubscription` does not emit
277+
* a second, redundant warning for the same failure.
278+
*/
279+
class AlreadyLoggedError extends Error {}
280+
281+
async function parseJsonResponse(
282+
response: SecureFetchResponse
283+
): Promise<Record<string, unknown> | null> {
247284
try {
248285
const body: unknown = await response.json()
249286
return isRecordLike(body) ? body : null

0 commit comments

Comments
 (0)