Skip to content

Commit badfeae

Browse files
authored
fix(security): SSRF fixes (#4548)
* 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 * 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 <token> (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 * 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 * 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. * 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.
1 parent ae680af commit badfeae

4 files changed

Lines changed: 78 additions & 11 deletions

File tree

apps/sim/app/api/auth/oauth2/shopify/store/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { account } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6-
import { shopifyStoreCookieSchema } from '@/lib/api/contracts/oauth-connections'
6+
import {
7+
shopifyShopDomainSchema,
8+
shopifyStoreCookieSchema,
9+
} from '@/lib/api/contracts/oauth-connections'
710
import { getSession } from '@/lib/auth'
811
import { getBaseUrl } from '@/lib/core/utils/urls'
912
import { isSameOrigin } from '@/lib/core/utils/validation'
@@ -38,6 +41,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3841
}
3942
const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data
4043

44+
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
45+
logger.error('Invalid shop domain format in cookie', { shopDomain })
46+
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`)
47+
}
48+
4149
const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, {
4250
headers: {
4351
'X-Shopify-Access-Token': accessToken,

apps/sim/app/api/tools/tts/unified/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,13 @@ async function synthesizeWithAzure(
659659
throw new Error('text and apiKey are required for Azure TTS')
660660
}
661661

662+
const AZURE_REGION_RE = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/
663+
if (!AZURE_REGION_RE.test(region)) {
664+
throw new Error(
665+
'Invalid Azure region: must match /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ (e.g. eastus, westeurope)'
666+
)
667+
}
668+
662669
let ssml = `<speak version='1.0' xml:lang='en-US' xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts"><voice name='${voiceId}'>`
663670

664671
if (style) {

apps/sim/lib/api/contracts/tools/media/tts.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ export const ttsUnifiedToolBodySchema = z
5454
volumeGainDb: z.coerce.number().optional(),
5555
sampleRateHertz: z.coerce.number().optional(),
5656
effectsProfileId: z.array(z.string()).optional(),
57-
region: z.string().optional(),
57+
region: z
58+
.string()
59+
.regex(
60+
/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/,
61+
'region must be a valid Azure region identifier (e.g. eastus, westeurope)'
62+
)
63+
.optional(),
5864
rate: z.string().optional(),
5965
styleDegree: z.coerce.number().optional(),
6066
role: z.string().optional(),

apps/sim/lib/auth/auth.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'crypto'
12
import { cache } from 'react'
23
import { sso } from '@better-auth/sso'
34
import { stripe } from '@better-auth/stripe'
@@ -1611,27 +1612,71 @@ export const auth = betterAuth({
16111612
clientSecret: env.WEALTHBOX_CLIENT_SECRET as string,
16121613
authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize',
16131614
tokenUrl: 'https://app.crmworkspace.com/oauth/token',
1614-
userInfoUrl: 'https://dummy-not-used.wealthbox.com', // Dummy URL since no user info endpoint exists
1615+
userInfoUrl: 'https://api.crmworkspace.com/v1/me',
16151616
scopes: getCanonicalScopesForProvider('wealthbox'),
16161617
responseType: 'code',
16171618
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`,
1618-
getUserInfo: async (_tokens) => {
1619+
getUserInfo: async (tokens) => {
16191620
try {
1620-
logger.info('Creating Wealthbox user profile from token data')
1621+
logger.info('Fetching Wealthbox user profile')
1622+
1623+
const response = await fetch('https://api.crmworkspace.com/v1/me', {
1624+
headers: {
1625+
Authorization: `Bearer ${tokens.accessToken}`,
1626+
},
1627+
})
16211628

1622-
const uniqueId = 'wealthbox-user'
16231629
const now = new Date()
16241630

1631+
if (response.ok) {
1632+
const data = await response.json()
1633+
const userId = data.id?.toString()
1634+
if (!userId) {
1635+
return null
1636+
}
1637+
const email =
1638+
data.email && typeof data.email === 'string'
1639+
? data.email
1640+
: `wealthbox-${userId}@wealthbox.user`
1641+
const name = data.name || data.full_name || data.username || 'Wealthbox User'
1642+
1643+
return {
1644+
id: `wealthbox-${userId}-${generateId()}`,
1645+
name,
1646+
email,
1647+
emailVerified: false,
1648+
createdAt: now,
1649+
updatedAt: now,
1650+
}
1651+
}
1652+
1653+
// Fallback: derive a stable identifier from the refresh token (long-lived)
1654+
// rather than the access token (rotates every ~2 hours) to avoid creating
1655+
// duplicate accounts on token refresh.
1656+
logger.warn(
1657+
'Wealthbox user info fetch failed, falling back to token-derived identity',
1658+
{
1659+
status: response.status,
1660+
}
1661+
)
1662+
const stableToken = tokens.refreshToken ?? tokens.accessToken
1663+
if (!stableToken) {
1664+
logger.error('Wealthbox fallback identity: no refresh or access token available')
1665+
return null
1666+
}
1667+
const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24)
16251668
return {
1626-
id: `${uniqueId}-${generateId()}`,
1669+
id: `wealthbox-${tokenHash}-${generateId()}`,
16271670
name: 'Wealthbox User',
1628-
email: `${uniqueId}@wealthbox.user`,
1671+
email: `wealthbox-${tokenHash}@wealthbox.user`,
16291672
emailVerified: false,
16301673
createdAt: now,
16311674
updatedAt: now,
16321675
}
16331676
} catch (error) {
1634-
logger.error('Error creating Wealthbox user profile:', { error })
1677+
logger.error('Error creating Wealthbox user profile:', {
1678+
error: toError(error).message,
1679+
})
16351680
return null
16361681
}
16371682
},
@@ -1730,11 +1775,12 @@ export const auth = betterAuth({
17301775
}
17311776

17321777
logger.info('HubSpot token metadata response:', {
1778+
hubId: data.hub_id,
1779+
hubDomain: data.hub_domain,
1780+
userId: data.user_id,
17331781
hasScopes: !!data.scopes,
17341782
scopesType: typeof data.scopes,
17351783
scopesIsArray: Array.isArray(data.scopes),
1736-
scopesValue: data.scopes,
1737-
fullResponse: data,
17381784
})
17391785

17401786
return {

0 commit comments

Comments
 (0)