From f58bc354471620b0b16c107094542017ac59fec7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 21:22:40 -0700 Subject: [PATCH 1/2] fix(webhooks): resolve env var references before deploy-triggered subscription creation Provider config fields like an API key can reference an environment variable via {{VAR_NAME}}. The interactive trigger-save route already resolved these before calling a provider's createSubscription, but the async deployment-outbox path (workflow deploy -> saveTriggerWebhooksForDeploy -> createExternalWebhookSubscription) did not, so the literal unresolved {{VAR_NAME}} string was sent to the provider as the credential and rejected. Resolve env vars in createExternalWebhookSubscription itself so both callers behave the same; the persisted providerConfig keeps storing the unresolved template, only the outbound call gets the resolved value. --- .../webhooks/provider-subscriptions.test.ts | 98 +++++++++++++++++++ .../lib/webhooks/provider-subscriptions.ts | 17 +++- 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/webhooks/provider-subscriptions.test.ts diff --git a/apps/sim/lib/webhooks/provider-subscriptions.test.ts b/apps/sim/lib/webhooks/provider-subscriptions.test.ts new file mode 100644 index 00000000000..c38602e115b --- /dev/null +++ b/apps/sim/lib/webhooks/provider-subscriptions.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetEffectiveDecryptedEnv, mockGetProviderHandler } = vi.hoisted(() => ({ + mockGetEffectiveDecryptedEnv: vi.fn(), + mockGetProviderHandler: vi.fn(), +})) + +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, +})) + +vi.mock('@/lib/webhooks/providers', () => ({ + getProviderHandler: mockGetProviderHandler, +})) + +import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions' + +describe('createExternalWebhookSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetEffectiveDecryptedEnv.mockResolvedValue({ ASHBY_API_KEY: 'real-secret-key' }) + }) + + it('resolves {{ENV_VAR}} references in providerConfig before calling the provider', async () => { + const createSubscription = vi.fn().mockResolvedValue({ + providerConfigUpdates: { externalId: 'ext-1' }, + }) + mockGetProviderHandler.mockReturnValue({ createSubscription }) + + const webhookData = { + provider: 'ashby', + providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' }, + } + const workflow = { id: 'wf-1', workspaceId: 'ws-1' } + + await createExternalWebhookSubscription( + {} as NextRequest, + webhookData, + workflow, + 'user-1', + 'req-1' + ) + + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'ws-1') + const passedWebhook = createSubscription.mock.calls[0][0].webhook + expect(passedWebhook.providerConfig.apiKey).toBe('real-secret-key') + }) + + it('persists the unresolved providerConfig, not the resolved one, back to the caller', async () => { + const createSubscription = vi.fn().mockResolvedValue({ + providerConfigUpdates: { externalId: 'ext-1' }, + }) + mockGetProviderHandler.mockReturnValue({ createSubscription }) + + const webhookData = { + provider: 'ashby', + providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' }, + } + const workflow = { id: 'wf-1', workspaceId: 'ws-1' } + + const result = await createExternalWebhookSubscription( + {} as NextRequest, + webhookData, + workflow, + 'user-1', + 'req-1' + ) + + expect(result.updatedProviderConfig.apiKey).toBe('{{ASHBY_API_KEY}}') + expect(result.updatedProviderConfig.externalId).toBe('ext-1') + }) + + it('skips resolution and provider call entirely when the provider has no createSubscription', async () => { + mockGetProviderHandler.mockReturnValue({}) + + const webhookData = { + provider: 'slack', + providerConfig: { token: '{{SLACK_TOKEN}}' }, + } + const workflow = { id: 'wf-1', workspaceId: 'ws-1' } + + const result = await createExternalWebhookSubscription( + {} as NextRequest, + webhookData, + workflow, + 'user-1', + 'req-1' + ) + + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(result.externalSubscriptionCreated).toBe(false) + expect(result.updatedProviderConfig.token).toBe('{{SLACK_TOKEN}}') + }) +}) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index ae8b160279e..e8a04370460 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { NextRequest } from 'next/server' +import { resolveWebhookProviderConfig } from '@/lib/webhooks/env-resolver' import { getProviderHandler } from '@/lib/webhooks/providers' const logger = createLogger('WebhookProviderSubscriptions') @@ -88,6 +89,14 @@ export function shouldRecreateExternalWebhookSubscription({ * Ask the provider handler to create an external webhook subscription, if that * provider supports automatic registration. * + * `providerConfig` may contain unresolved `{{ENV_VAR}}` references (e.g. an + * API key field backed by an environment variable) — these are resolved here + * before the provider call so deploy-triggered registration (this function is + * also called from the async deployment outbox, not just the interactive + * webhook-save route) behaves the same as a manual save. The persisted + * `providerConfig` returned to the caller stays unresolved; only the + * provider-managed fields from `result.providerConfigUpdates` get merged in. + * * The returned provider-managed fields are merged back into `providerConfig` * by the caller. */ @@ -106,8 +115,14 @@ export async function createExternalWebhookSubscription( return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } } + const resolvedProviderConfig = await resolveWebhookProviderConfig( + providerConfig, + userId, + workflow.workspaceId as string | undefined + ) + const result = await handler.createSubscription({ - webhook: webhookData, + webhook: { ...webhookData, providerConfig: resolvedProviderConfig }, workflow, userId, requestId, From 69bcb971a465213bcdece056c3bc2b779420727b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 21:29:50 -0700 Subject: [PATCH 2/2] fix(webhooks): guard against a non-string workspaceId when resolving env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow.workspaceId as string | undefined was an unchecked cast on a Record — if a caller ever passed a workflow-like object where workspaceId isn't actually a string, workspace-scoped {{VAR}} references would silently stay unresolved and the provider would receive the literal template as the credential, reproducing the exact class of bug this change exists to fix. Replaced with a runtime typeof check that falls back to undefined (personal-env-only resolution) instead of forwarding an unvalidated value. --- .../webhooks/provider-subscriptions.test.ts | 23 +++++++++++++++++++ .../lib/webhooks/provider-subscriptions.ts | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.test.ts b/apps/sim/lib/webhooks/provider-subscriptions.test.ts index c38602e115b..beef3d4a62d 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.test.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.test.ts @@ -74,6 +74,29 @@ describe('createExternalWebhookSubscription', () => { expect(result.updatedProviderConfig.externalId).toBe('ext-1') }) + it('falls back to personal-only env resolution when workspaceId is not a string', async () => { + const createSubscription = vi.fn().mockResolvedValue({ + providerConfigUpdates: { externalId: 'ext-1' }, + }) + mockGetProviderHandler.mockReturnValue({ createSubscription }) + + const webhookData = { + provider: 'ashby', + providerConfig: { apiKey: '{{ASHBY_API_KEY}}', triggerId: 'ashby_application_submit' }, + } + const workflow = { id: 'wf-1', workspaceId: null } + + await createExternalWebhookSubscription( + {} as NextRequest, + webhookData, + workflow, + 'user-1', + 'req-1' + ) + + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', undefined) + }) + it('skips resolution and provider call entirely when the provider has no createSubscription', async () => { mockGetProviderHandler.mockReturnValue({}) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index e8a04370460..afd9a926648 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -115,10 +115,12 @@ export async function createExternalWebhookSubscription( return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } } + const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined + const resolvedProviderConfig = await resolveWebhookProviderConfig( providerConfig, userId, - workflow.workspaceId as string | undefined + workspaceId ) const result = await handler.createSubscription({