Skip to content

Commit d738429

Browse files
committed
fix(security): enforce URL validation across connectors, providers, auth
- Azure OpenAI/Anthropic: validate user-supplied azureEndpoint with validateUrlWithDNS to block SSRF to private IPs, localhost (in hosted mode), and dangerous ports. - ServiceNow connector: enforce ServiceNow domain allowlist via validateServiceNowInstanceUrl before calling the instance URL. - Obsidian connector: validate vaultUrl with validateUrlWithDNS and reuse the resolved IP via secureFetchWithPinnedIPAndRetry to block DNS rebinding between validation and request. - Signup + verify flows: pass redirect/callbackUrl/redirectAfter and stored inviteRedirectUrl through validateCallbackUrl; drop unsafe values and log a warning. - lib/knowledge/documents/utils.ts: add secureFetchWithPinnedIPAndRetry wrapper around secureFetchWithPinnedIP (used by Obsidian).
1 parent 74bd1b1 commit d738429

File tree

7 files changed

+201
-42
lines changed

7 files changed

+201
-42
lines changed

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { usePostHog } from 'posthog-js/react'
1010
import { Input, Label } from '@/components/emcn'
1111
import { client, useSession } from '@/lib/auth/auth-client'
1212
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
13+
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
1314
import { cn } from '@/lib/core/utils/cn'
1415
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1516
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
@@ -102,10 +103,14 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
102103
useEffect(() => {
103104
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
104105
}, [])
105-
const redirectUrl = useMemo(
106-
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
107-
[searchParams]
108-
)
106+
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
107+
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
108+
const invalidCallbackRef = useRef(false)
109+
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
110+
invalidCallbackRef.current = true
111+
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
112+
}
113+
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
109114
const isInviteFlow = useMemo(
110115
() =>
111116
searchParams.get('invite_flow') === 'true' ||

apps/sim/app/(auth)/verify/use-verification.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { useRouter, useSearchParams } from 'next/navigation'
66
import { client, useSession } from '@/lib/auth/auth-client'
7+
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
78

89
const logger = createLogger('useVerification')
910

@@ -55,8 +56,11 @@ export function useVerification({
5556
}
5657

5758
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
58-
if (storedRedirectUrl) {
59+
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
5960
setRedirectUrl(storedRedirectUrl)
61+
} else if (storedRedirectUrl) {
62+
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
63+
sessionStorage.removeItem('inviteRedirectUrl')
6064
}
6165

6266
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
@@ -67,7 +71,11 @@ export function useVerification({
6771

6872
const redirectParam = searchParams.get('redirectAfter')
6973
if (redirectParam) {
70-
setRedirectUrl(redirectParam)
74+
if (validateCallbackUrl(redirectParam)) {
75+
setRedirectUrl(redirectParam)
76+
} else {
77+
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
78+
}
7179
}
7280

7381
const inviteFlowParam = searchParams.get('invite_flow')

apps/sim/connectors/obsidian/obsidian.ts

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { ObsidianIcon } from '@/components/icons'
4-
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
4+
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
5+
import {
6+
secureFetchWithPinnedIPAndRetry,
7+
VALIDATE_RETRY_OPTIONS,
8+
} from '@/lib/knowledge/documents/utils'
59
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
610
import { joinTagArray, parseTagDate } from '@/connectors/utils'
711

812
const logger = createLogger('ObsidianConnector')
913

1014
const DOCS_PER_PAGE = 50
15+
const DEFAULT_VAULT_URL = 'https://127.0.0.1:27124'
1116

1217
interface NoteJson {
1318
content: string
@@ -22,10 +27,31 @@ interface NoteJson {
2227
}
2328

2429
/**
25-
* Normalizes the vault URL by removing trailing slashes.
30+
* Normalizes the vault URL and resolves its hostname to a concrete IP that
31+
* will be pinned for the lifetime of this request sequence.
32+
*
33+
* The Obsidian Local REST API plugin runs on the user's own machine — there
34+
* is no Obsidian SaaS domain we can allowlist. For hosted Sim deployments the
35+
* user must expose the plugin through a public URL (tunnel, port-forward).
36+
* Because the hostname is fully user-controlled, we resolve DNS once through
37+
* validateUrlWithDNS (which blocks private IPs/localhost in hosted mode,
38+
* allows localhost in self-hosted mode, and rejects dangerous ports) and
39+
* then reuse that IP on every outgoing fetch via secureFetchWithPinnedIP —
40+
* this prevents DNS rebinding attacks where a malicious nameserver would
41+
* otherwise swap in a private IP between validation and the actual request.
2642
*/
27-
function normalizeVaultUrl(url: string): string {
28-
return url.trim().replace(/\/+$/, '')
43+
async function resolveVaultEndpoint(
44+
rawUrl: string | undefined
45+
): Promise<{ baseUrl: string; resolvedIP: string }> {
46+
let url = (rawUrl || DEFAULT_VAULT_URL).trim().replace(/\/+$/, '')
47+
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
48+
url = `https://${url}`
49+
}
50+
const validation = await validateUrlWithDNS(url, 'vaultUrl', { allowHttp: true })
51+
if (!validation.isValid || !validation.resolvedIP) {
52+
throw new Error(validation.error || 'Invalid vault URL')
53+
}
54+
return { baseUrl: url, resolvedIP: validation.resolvedIP }
2955
}
3056

3157
/**
@@ -34,21 +60,24 @@ function normalizeVaultUrl(url: string): string {
3460
*/
3561
async function listDirectory(
3662
baseUrl: string,
63+
resolvedIP: string,
3764
accessToken: string,
3865
dirPath: string,
39-
retryOptions?: Parameters<typeof fetchWithRetry>[2]
66+
retryOptions?: Parameters<typeof secureFetchWithPinnedIPAndRetry>[3]
4067
): Promise<string[]> {
4168
const encodedDir = dirPath ? dirPath.split('/').map(encodeURIComponent).join('/') : ''
4269
const endpoint = encodedDir ? `${baseUrl}/vault/${encodedDir}/` : `${baseUrl}/vault/`
4370

44-
const response = await fetchWithRetry(
71+
const response = await secureFetchWithPinnedIPAndRetry(
4572
endpoint,
73+
resolvedIP,
4674
{
4775
method: 'GET',
4876
headers: {
4977
Authorization: `Bearer ${accessToken}`,
5078
Accept: 'application/json',
5179
},
80+
allowHttp: true,
5281
},
5382
retryOptions
5483
)
@@ -68,9 +97,10 @@ const MAX_RECURSION_DEPTH = 20
6897

6998
async function listVaultFiles(
7099
baseUrl: string,
100+
resolvedIP: string,
71101
accessToken: string,
72102
folderPath?: string,
73-
retryOptions?: Parameters<typeof fetchWithRetry>[2],
103+
retryOptions?: Parameters<typeof secureFetchWithPinnedIPAndRetry>[3],
74104
depth = 0
75105
): Promise<string[]> {
76106
if (depth > MAX_RECURSION_DEPTH) {
@@ -79,7 +109,7 @@ async function listVaultFiles(
79109
}
80110

81111
const rootPath = folderPath || ''
82-
const entries = await listDirectory(baseUrl, accessToken, rootPath, retryOptions)
112+
const entries = await listDirectory(baseUrl, resolvedIP, accessToken, rootPath, retryOptions)
83113

84114
const mdFiles: string[] = []
85115
const subDirs: string[] = []
@@ -96,7 +126,14 @@ async function listVaultFiles(
96126

97127
for (const dir of subDirs) {
98128
try {
99-
const nested = await listVaultFiles(baseUrl, accessToken, dir, retryOptions, depth + 1)
129+
const nested = await listVaultFiles(
130+
baseUrl,
131+
resolvedIP,
132+
accessToken,
133+
dir,
134+
retryOptions,
135+
depth + 1
136+
)
100137
mdFiles.push(...nested)
101138
} catch (error) {
102139
logger.warn('Failed to list subdirectory', {
@@ -114,18 +151,21 @@ async function listVaultFiles(
114151
*/
115152
async function fetchNote(
116153
baseUrl: string,
154+
resolvedIP: string,
117155
accessToken: string,
118156
filePath: string,
119-
retryOptions?: Parameters<typeof fetchWithRetry>[2]
157+
retryOptions?: Parameters<typeof secureFetchWithPinnedIPAndRetry>[3]
120158
): Promise<NoteJson> {
121-
const response = await fetchWithRetry(
159+
const response = await secureFetchWithPinnedIPAndRetry(
122160
`${baseUrl}/vault/${filePath.split('/').map(encodeURIComponent).join('/')}`,
161+
resolvedIP,
123162
{
124163
method: 'GET',
125164
headers: {
126165
Authorization: `Bearer ${accessToken}`,
127166
Accept: 'application/vnd.olrapi.note+json',
128167
},
168+
allowHttp: true,
129169
},
130170
retryOptions
131171
)
@@ -183,15 +223,13 @@ export const obsidianConnector: ConnectorConfig = {
183223
cursor?: string,
184224
syncContext?: Record<string, unknown>
185225
): Promise<ExternalDocumentList> => {
186-
const baseUrl = normalizeVaultUrl(
187-
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
188-
)
226+
const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string)
189227
const folderPath = (sourceConfig.folderPath as string) || ''
190228

191229
let allFiles = syncContext?.allFiles as string[] | undefined
192230
if (!allFiles) {
193231
logger.info('Listing all vault files', { baseUrl, folderPath })
194-
allFiles = await listVaultFiles(baseUrl, accessToken, folderPath || undefined)
232+
allFiles = await listVaultFiles(baseUrl, resolvedIP, accessToken, folderPath || undefined)
195233
if (syncContext) {
196234
syncContext.allFiles = allFiles
197235
}
@@ -230,12 +268,10 @@ export const obsidianConnector: ConnectorConfig = {
230268
externalId: string,
231269
_syncContext?: Record<string, unknown>
232270
): Promise<ExternalDocument | null> => {
233-
const baseUrl = normalizeVaultUrl(
234-
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
235-
)
271+
const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string)
236272

237273
try {
238-
const note = await fetchNote(baseUrl, accessToken, externalId)
274+
const note = await fetchNote(baseUrl, resolvedIP, accessToken, externalId)
239275
const content = note.content || ''
240276

241277
return {
@@ -275,14 +311,24 @@ export const obsidianConnector: ConnectorConfig = {
275311
return { valid: false, error: 'Vault URL is required' }
276312
}
277313

278-
const baseUrl = normalizeVaultUrl(rawUrl)
314+
let baseUrl: string
315+
let resolvedIP: string
316+
try {
317+
const endpoint = await resolveVaultEndpoint(rawUrl)
318+
baseUrl = endpoint.baseUrl
319+
resolvedIP = endpoint.resolvedIP
320+
} catch (error) {
321+
return { valid: false, error: toError(error).message }
322+
}
279323

280324
try {
281-
const response = await fetchWithRetry(
325+
const response = await secureFetchWithPinnedIPAndRetry(
282326
`${baseUrl}/`,
327+
resolvedIP,
283328
{
284329
method: 'GET',
285330
headers: { Authorization: `Bearer ${accessToken}` },
331+
allowHttp: true,
286332
},
287333
VALIDATE_RETRY_OPTIONS
288334
)
@@ -302,6 +348,7 @@ export const obsidianConnector: ConnectorConfig = {
302348
if (folderPath.trim()) {
303349
const entries = await listDirectory(
304350
baseUrl,
351+
resolvedIP,
305352
accessToken,
306353
folderPath.trim(),
307354
VALIDATE_RETRY_OPTIONS
@@ -313,8 +360,10 @@ export const obsidianConnector: ConnectorConfig = {
313360

314361
return { valid: true }
315362
} catch (error) {
316-
const message = error instanceof Error ? error.message : 'Failed to connect to Obsidian vault'
317-
return { valid: false, error: message }
363+
return {
364+
valid: false,
365+
error: toError(error).message || 'Failed to connect to Obsidian vault',
366+
}
318367
}
319368
},
320369

apps/sim/connectors/servicenow/servicenow.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { ServiceNowIcon } from '@/components/icons'
4+
import { validateServiceNowInstanceUrl } from '@/lib/core/security/input-validation'
45
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
67
import { htmlToPlainText, parseTagDate } from '@/connectors/utils'
@@ -45,15 +46,23 @@ interface Incident extends ServiceNowRecord {
4546
}
4647

4748
/**
48-
* Normalizes the instance URL to ensure it has the correct format.
49+
* Normalizes and validates the ServiceNow instance URL.
50+
*
51+
* Prepends https:// if the scheme is missing, strips trailing slashes, then
52+
* enforces a ServiceNow-owned domain allowlist to prevent SSRF — the instance
53+
* URL is user-controlled and was previously fetched server-side with no
54+
* validation.
4955
*/
50-
function normalizeInstanceUrl(instanceUrl: string): string {
51-
let url = instanceUrl.trim()
52-
url = url.replace(/\/+$/, '')
53-
if (!url.startsWith('https://') && !url.startsWith('http://')) {
56+
function resolveServiceNowInstanceUrl(rawUrl: string): string {
57+
let url = (rawUrl ?? '').trim().replace(/\/+$/, '')
58+
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
5459
url = `https://${url}`
5560
}
56-
return url
61+
const validation = validateServiceNowInstanceUrl(url)
62+
if (!validation.isValid) {
63+
throw new Error(validation.error || 'Invalid instance URL')
64+
}
65+
return validation.sanitized ?? url
5766
}
5867

5968
/**
@@ -430,7 +439,7 @@ export const servicenowConnector: ConnectorConfig = {
430439
cursor?: string,
431440
_syncContext?: Record<string, unknown>
432441
): Promise<ExternalDocumentList> => {
433-
const instanceUrl = normalizeInstanceUrl(sourceConfig.instanceUrl as string)
442+
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
434443
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
435444
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : DEFAULT_MAX_ITEMS
436445
const authHeader = buildAuthHeader(accessToken, sourceConfig)
@@ -504,7 +513,6 @@ export const servicenowConnector: ConnectorConfig = {
504513
sourceConfig: Record<string, unknown>,
505514
externalId: string
506515
): Promise<ExternalDocument | null> => {
507-
const instanceUrl = normalizeInstanceUrl(sourceConfig.instanceUrl as string)
508516
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
509517
const authHeader = buildAuthHeader(accessToken, sourceConfig)
510518
const isKB = contentType === 'kb_knowledge'
@@ -514,6 +522,17 @@ export const servicenowConnector: ConnectorConfig = {
514522
? 'sys_id,short_description,text,wiki,workflow_state,kb_category,kb_knowledge_base,number,author,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
515523
: 'sys_id,number,short_description,description,state,priority,category,assigned_to,opened_by,close_notes,resolution_notes,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
516524

525+
let instanceUrl: string
526+
try {
527+
instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
528+
} catch (error) {
529+
logger.warn('Failed to validate ServiceNow instance URL', {
530+
externalId,
531+
error: toError(error).message,
532+
})
533+
return null
534+
}
535+
517536
try {
518537
const { result } = await serviceNowApiGet(instanceUrl, tableName, authHeader, {
519538
sysparm_query: `sys_id=${externalId}`,
@@ -568,7 +587,13 @@ export const servicenowConnector: ConnectorConfig = {
568587
return { valid: false, error: 'Max items must be a positive number' }
569588
}
570589

571-
const normalizedUrl = normalizeInstanceUrl(instanceUrl)
590+
let normalizedUrl: string
591+
try {
592+
normalizedUrl = resolveServiceNowInstanceUrl(instanceUrl)
593+
} catch (error) {
594+
return { valid: false, error: toError(error).message }
595+
}
596+
572597
const authHeader = buildAuthHeader(accessToken, sourceConfig)
573598
const tableName = contentType === 'kb_knowledge' ? 'kb_knowledge' : 'incident'
574599

@@ -585,8 +610,7 @@ export const servicenowConnector: ConnectorConfig = {
585610
)
586611
return { valid: true }
587612
} catch (error) {
588-
const message = error instanceof Error ? error.message : 'Failed to connect to ServiceNow'
589-
return { valid: false, error: message }
613+
return { valid: false, error: toError(error).message || 'Failed to connect to ServiceNow' }
590614
}
591615
},
592616

0 commit comments

Comments
 (0)