From 171a4c4ee8ca995976a793e39d953e594442a7f2 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 13:39:06 -0700 Subject: [PATCH 1/5] fix: address SSRF and token-leakage security vulnerabilities - Azure TTS SSRF: validate region against /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ in both the contract (tts.ts) and runtime guard in synthesizeWithAzure, preventing user-supplied region from redirecting requests to arbitrary hosts - HubSpot token in logs: remove fullResponse from logger.info call; log only non-sensitive metadata (hub_id, hub_domain, user_id) instead of the full introspection response which included the access token - Wealthbox account takeover: replace hardcoded email with per-user identity by fetching /v1/users/me; fall back to token-derived stable identifier so distinct Wealthbox users no longer share the same email address - Shopify SSRF: apply shopifyShopDomainSchema (.myshopify.com allowlist) to shopDomain from cookie before using it to build the fetch URL --- .../api/auth/oauth2/shopify/store/route.ts | 10 +++- apps/sim/app/api/tools/tts/unified/route.ts | 7 +++ apps/sim/lib/api/contracts/tools/media/tts.ts | 8 ++- apps/sim/lib/auth/auth.ts | 49 ++++++++++++++++--- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index dfa59fa77c6..c4a26459ecb 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -3,7 +3,10 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { shopifyStoreCookieSchema } from '@/lib/api/contracts/oauth-connections' +import { + shopifyShopDomainSchema, + shopifyStoreCookieSchema, +} from '@/lib/api/contracts/oauth-connections' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' @@ -38,6 +41,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data + if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { + logger.error('Invalid shop domain format in cookie', { shopDomain }) + return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) + } + const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, { headers: { 'X-Shopify-Access-Token': accessToken, diff --git a/apps/sim/app/api/tools/tts/unified/route.ts b/apps/sim/app/api/tools/tts/unified/route.ts index 10c069b0607..ca7186e9b99 100644 --- a/apps/sim/app/api/tools/tts/unified/route.ts +++ b/apps/sim/app/api/tools/tts/unified/route.ts @@ -659,6 +659,13 @@ async function synthesizeWithAzure( throw new Error('text and apiKey are required for Azure TTS') } + const AZURE_REGION_RE = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ + if (!AZURE_REGION_RE.test(region)) { + throw new Error( + 'Invalid Azure region: must match /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ (e.g. eastus, westeurope)' + ) + } + let ssml = `` if (style) { diff --git a/apps/sim/lib/api/contracts/tools/media/tts.ts b/apps/sim/lib/api/contracts/tools/media/tts.ts index 7fbc5643f99..3a5b68e50a8 100644 --- a/apps/sim/lib/api/contracts/tools/media/tts.ts +++ b/apps/sim/lib/api/contracts/tools/media/tts.ts @@ -54,7 +54,13 @@ export const ttsUnifiedToolBodySchema = z volumeGainDb: z.coerce.number().optional(), sampleRateHertz: z.coerce.number().optional(), effectsProfileId: z.array(z.string()).optional(), - region: z.string().optional(), + region: z + .string() + .regex( + /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/, + 'region must be a valid Azure region identifier (e.g. eastus, westeurope)' + ) + .optional(), rate: z.string().optional(), styleDegree: z.coerce.number().optional(), role: z.string().optional(), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index b446fff9727..248c0ca091c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1615,17 +1615,51 @@ export const auth = betterAuth({ scopes: getCanonicalScopesForProvider('wealthbox'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, - getUserInfo: async (_tokens) => { + getUserInfo: async (tokens) => { try { - logger.info('Creating Wealthbox user profile from token data') + logger.info('Fetching Wealthbox user profile') + + const response = await fetch('https://api.crmworkspace.com/v1/users/me', { + headers: { + ACCESS_TOKEN: tokens.accessToken, + }, + }) - const uniqueId = 'wealthbox-user' const now = new Date() + if (response.ok) { + const data = await response.json() + const userId = data.id?.toString() + const email = + data.email && typeof data.email === 'string' + ? data.email + : `wealthbox-${userId}@wealthbox.user` + const name = data.name || data.full_name || data.username || 'Wealthbox User' + + return { + id: `wealthbox-${userId}-${generateId()}`, + name, + email, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } + + // Fallback: derive a stable per-token identifier from the access token + // so that each Wealthbox user gets a unique account rather than all + // sharing the same hardcoded email. + logger.warn( + 'Wealthbox user info fetch failed, falling back to token-derived identity', + { + status: response.status, + } + ) + const tokenHash = Buffer.from(tokens.accessToken).toString('base64').slice(0, 24) return { - id: `${uniqueId}-${generateId()}`, + id: `wealthbox-${tokenHash}-${generateId()}`, name: 'Wealthbox User', - email: `${uniqueId}@wealthbox.user`, + email: `wealthbox-${tokenHash}@wealthbox.user`, emailVerified: false, createdAt: now, updatedAt: now, @@ -1730,11 +1764,12 @@ export const auth = betterAuth({ } logger.info('HubSpot token metadata response:', { + hubId: data.hub_id, + hubDomain: data.hub_domain, + userId: data.user_id, hasScopes: !!data.scopes, scopesType: typeof data.scopes, scopesIsArray: Array.isArray(data.scopes), - scopesValue: data.scopes, - fullResponse: data, }) return { From d3bd32120857edcdba4e18134255b8fca4d97209 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 13:56:48 -0700 Subject: [PATCH 2/5] fix(wealthbox): correct getUserInfo endpoint, auth header, and stable identity - Bug 1: Change API endpoint from /v1/users/me to /v1/me (correct Wealthbox API path) - Bug 2: Replace ACCESS_TOKEN header with Authorization: Bearer (standard OAuth 2.0) - Bug 3: Remove generateId() from returned id (was non-deterministic, caused duplicate accounts); use refresh token (stable, long-lived) instead of access token (rotates every ~2 hours) as the hash source for the fallback identity; return null if no token is available --- apps/sim/lib/auth/auth.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 248c0ca091c..90ca7fa1153 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1619,9 +1619,9 @@ export const auth = betterAuth({ try { logger.info('Fetching Wealthbox user profile') - const response = await fetch('https://api.crmworkspace.com/v1/users/me', { + const response = await fetch('https://api.crmworkspace.com/v1/me', { headers: { - ACCESS_TOKEN: tokens.accessToken, + Authorization: `Bearer ${tokens.accessToken}`, }, }) @@ -1637,7 +1637,7 @@ export const auth = betterAuth({ const name = data.name || data.full_name || data.username || 'Wealthbox User' return { - id: `wealthbox-${userId}-${generateId()}`, + id: `wealthbox-${userId}`, name, email, emailVerified: false, @@ -1646,18 +1646,23 @@ export const auth = betterAuth({ } } - // Fallback: derive a stable per-token identifier from the access token - // so that each Wealthbox user gets a unique account rather than all - // sharing the same hardcoded email. + // Fallback: derive a stable identifier from the refresh token (long-lived) + // rather than the access token (rotates every ~2 hours) to avoid creating + // duplicate accounts on token refresh. logger.warn( 'Wealthbox user info fetch failed, falling back to token-derived identity', { status: response.status, } ) - const tokenHash = Buffer.from(tokens.accessToken).toString('base64').slice(0, 24) + const stableToken = tokens.refreshToken ?? tokens.accessToken + if (!stableToken) { + logger.error('Wealthbox fallback identity: no refresh or access token available') + return null + } + const tokenHash = Buffer.from(stableToken).toString('base64').slice(0, 24) return { - id: `wealthbox-${tokenHash}-${generateId()}`, + id: `wealthbox-${tokenHash}`, name: 'Wealthbox User', email: `wealthbox-${tokenHash}@wealthbox.user`, emailVerified: false, @@ -1665,7 +1670,9 @@ export const auth = betterAuth({ updatedAt: now, } } catch (error) { - logger.error('Error creating Wealthbox user profile:', { error }) + logger.error('Error creating Wealthbox user profile:', { + error: toError(error).message, + }) return null } }, From b2e9f57cf1db93a2585d278460821f2970f4f72b Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:16:39 -0700 Subject: [PATCH 3/5] fix(security): hash wealthbox fallback token identity, guard undefined userId - Replace base64 encoding with SHA-256 hash for fallback token-derived identity so raw token bytes are never stored in the DB - Return null early when Wealthbox API response lacks an id field to prevent all such users colliding on the wealthbox-undefined account --- apps/sim/lib/auth/auth.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 90ca7fa1153..9e20084b669 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { cache } from 'react' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' @@ -1630,6 +1631,9 @@ export const auth = betterAuth({ if (response.ok) { const data = await response.json() const userId = data.id?.toString() + if (!userId) { + return null + } const email = data.email && typeof data.email === 'string' ? data.email @@ -1660,7 +1664,7 @@ export const auth = betterAuth({ logger.error('Wealthbox fallback identity: no refresh or access token available') return null } - const tokenHash = Buffer.from(stableToken).toString('base64').slice(0, 24) + const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24) return { id: `wealthbox-${tokenHash}`, name: 'Wealthbox User', From c90c3f29edfe3400f85675fad99a320b8b3bd902 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:43:24 -0700 Subject: [PATCH 4/5] fix(auth): replace stale wealthbox userInfoUrl placeholder with actual endpoint The dummy URL comment was rendered obsolete when getUserInfo was updated to fetch from api.crmworkspace.com/v1/me. Align userInfoUrl with the real endpoint used in the getUserInfo implementation. --- apps/sim/lib/auth/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 9e20084b669..428852853da 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1612,7 +1612,7 @@ export const auth = betterAuth({ clientSecret: env.WEALTHBOX_CLIENT_SECRET as string, authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize', tokenUrl: 'https://app.crmworkspace.com/oauth/token', - userInfoUrl: 'https://dummy-not-used.wealthbox.com', // Dummy URL since no user info endpoint exists + userInfoUrl: 'https://api.crmworkspace.com/v1/me', scopes: getCanonicalScopesForProvider('wealthbox'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, From 13275e998eb942303ef4baae4e9a537b930ff35c Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 19:51:45 -0700 Subject: [PATCH 5/5] fix(auth): append generateId() suffix to Wealthbox account IDs to match codebase pattern All other providers use `${stableId}-${generateId()}` so the account.create.after hook can strip the UUID suffix, find stale sibling rows, and migrate credential FKs. Without the suffix the migration logic is skipped and reconnections would hit duplicate key conflicts instead of gracefully updating credentials. --- apps/sim/lib/auth/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 428852853da..d0c1bc5422b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1641,7 +1641,7 @@ export const auth = betterAuth({ const name = data.name || data.full_name || data.username || 'Wealthbox User' return { - id: `wealthbox-${userId}`, + id: `wealthbox-${userId}-${generateId()}`, name, email, emailVerified: false, @@ -1666,7 +1666,7 @@ export const auth = betterAuth({ } const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24) return { - id: `wealthbox-${tokenHash}`, + id: `wealthbox-${tokenHash}-${generateId()}`, name: 'Wealthbox User', email: `wealthbox-${tokenHash}@wealthbox.user`, emailVerified: false,