Skip to content

Commit febc36f

Browse files
waleedlatif1claude
andauthored
fix(security): enforce URL validation across connectors, providers, and auth flows (SSRF + open-redirect hardening) (#4236)
* fix(workday): validate tenantUrl to prevent SSRF in SOAP client * fix(workday): use validation.sanitized in buildWsdlUrl * 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). * fix(obsidian): use isomorphic SSRF validation to unblock client build The Obsidian connector is reachable from client bundles via `connectors/registry.ts` (the knowledge UI reads metadata like `.icon`/`.name`). Importing `validateUrlWithDNS` / `secureFetchWithPinnedIP` from `input-validation.server` pulled `dns/promises`, `http`, `https`, `net` into client chunks, breaking the Turbopack build: Module not found: Can't resolve 'dns/promises' ./apps/sim/lib/core/security/input-validation.server.ts [Client Component Browser] ./apps/sim/connectors/obsidian/obsidian.ts [Client Component Browser] ./apps/sim/connectors/registry.ts [Client Component Browser] Once that file polluted a browser context, Turbopack also failed to resolve the Node builtins in its legitimate server-route imports, cascading the error across App Routes and Server Components. Fix: switch the Obsidian connector to the isomorphic `validateExternalUrl` + `fetchWithRetry` helpers, matching the pattern used by every other connector in the registry. This keeps the core SSRF protections: - hosted Sim: blocks localhost, private IPs, HTTP (HTTPS enforced) - self-hosted Sim: allows localhost + HTTP, still blocks non-loopback private IPs and dangerous ports (22, 25, 3306, 5432, 6379, 27017, 9200) Drops the DNS-rebinding defense specifically (the IP-pinned fetch chain). The trade-off is acceptable because the vault URL is entered by the workspace admin — not arbitrary untrusted input — and hosted deployments already force the plugin to be exposed through a public URL (tunnel/port-forward), making rebinding a narrow threat. Also reverts the `secureFetchWithPinnedIPAndRetry` wrapper in `lib/knowledge/documents/utils.ts` (no longer needed, and its `.server` import was the original source of the client-bundle pollution). * fix(servicenow): propagate URL validation errors in getDocument Match listDocuments behavior — invalid instance URL should surface as a configuration error rather than being swallowed into a "document not found" null response during sync. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(obsidian): drop allowHttp to restore HTTPS enforcement in hosted mode allowHttp: true permitted plaintext HTTP for all hosts in all deployment modes, contradicting the documented policy. The default validateExternalUrl behavior already allows http://localhost in self-hosted mode (the actual Obsidian Local REST API use case) via the built-in carve-out, while correctly rejecting HTTP for public hosts in hosted mode — which prevents leaking the Bearer access token over plaintext. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5cf7e8d commit febc36f

File tree

9 files changed

+528
-44
lines changed

9 files changed

+528
-44
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: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { ObsidianIcon } from '@/components/icons'
4+
import { validateExternalUrl } 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 { joinTagArray, parseTagDate } from '@/connectors/utils'
78

89
const logger = createLogger('ObsidianConnector')
910

1011
const DOCS_PER_PAGE = 50
12+
const DEFAULT_VAULT_URL = 'https://127.0.0.1:27124'
1113

1214
interface NoteJson {
1315
content: string
@@ -22,10 +24,29 @@ interface NoteJson {
2224
}
2325

2426
/**
25-
* Normalizes the vault URL by removing trailing slashes.
27+
* Normalizes the vault URL and validates it against SSRF protections.
28+
*
29+
* The Obsidian Local REST API plugin runs on the user's own machine, so there
30+
* is no SaaS domain to allowlist — the vault URL is fully user-controlled. We
31+
* defer to the shared `validateExternalUrl` policy:
32+
* - hosted Sim: blocks localhost, private IPs, HTTP (forces HTTPS)
33+
* - self-hosted Sim: allows http://localhost (built-in carve-out), still
34+
* blocks non-loopback private IPs and dangerous ports (22, 25, 3306,
35+
* 5432, 6379, 27017, 9200)
36+
*
37+
* This does not defend against DNS rebinding; for hosted deployments the user
38+
* must expose the plugin through a public URL (tunnel, port-forward).
2639
*/
27-
function normalizeVaultUrl(url: string): string {
28-
return url.trim().replace(/\/+$/, '')
40+
function resolveVaultEndpoint(rawUrl: string | undefined): string {
41+
let url = (rawUrl || DEFAULT_VAULT_URL).trim().replace(/\/+$/, '')
42+
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
43+
url = `https://${url}`
44+
}
45+
const validation = validateExternalUrl(url, 'vaultUrl')
46+
if (!validation.isValid) {
47+
throw new Error(validation.error || 'Invalid vault URL')
48+
}
49+
return url
2950
}
3051

3152
/**
@@ -61,9 +82,6 @@ async function listDirectory(
6182
return data.files ?? []
6283
}
6384

64-
/**
65-
* Recursively lists all markdown files in the vault or a specific folder.
66-
*/
6785
const MAX_RECURSION_DEPTH = 20
6886

6987
async function listVaultFiles(
@@ -183,9 +201,7 @@ export const obsidianConnector: ConnectorConfig = {
183201
cursor?: string,
184202
syncContext?: Record<string, unknown>
185203
): Promise<ExternalDocumentList> => {
186-
const baseUrl = normalizeVaultUrl(
187-
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
188-
)
204+
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
189205
const folderPath = (sourceConfig.folderPath as string) || ''
190206

191207
let allFiles = syncContext?.allFiles as string[] | undefined
@@ -230,9 +246,7 @@ export const obsidianConnector: ConnectorConfig = {
230246
externalId: string,
231247
_syncContext?: Record<string, unknown>
232248
): Promise<ExternalDocument | null> => {
233-
const baseUrl = normalizeVaultUrl(
234-
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
235-
)
249+
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
236250

237251
try {
238252
const note = await fetchNote(baseUrl, accessToken, externalId)
@@ -275,7 +289,12 @@ export const obsidianConnector: ConnectorConfig = {
275289
return { valid: false, error: 'Vault URL is required' }
276290
}
277291

278-
const baseUrl = normalizeVaultUrl(rawUrl)
292+
let baseUrl: string
293+
try {
294+
baseUrl = resolveVaultEndpoint(rawUrl)
295+
} catch (error) {
296+
return { valid: false, error: toError(error).message }
297+
}
279298

280299
try {
281300
const response = await fetchWithRetry(
@@ -313,8 +332,10 @@ export const obsidianConnector: ConnectorConfig = {
313332

314333
return { valid: true }
315334
} catch (error) {
316-
const message = error instanceof Error ? error.message : 'Failed to connect to Obsidian vault'
317-
return { valid: false, error: message }
335+
return {
336+
valid: false,
337+
error: toError(error).message || 'Failed to connect to Obsidian vault',
338+
}
318339
}
319340
},
320341

apps/sim/connectors/servicenow/servicenow.ts

Lines changed: 26 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,8 @@ 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+
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
526+
517527
try {
518528
const { result } = await serviceNowApiGet(instanceUrl, tableName, authHeader, {
519529
sysparm_query: `sys_id=${externalId}`,
@@ -568,7 +578,13 @@ export const servicenowConnector: ConnectorConfig = {
568578
return { valid: false, error: 'Max items must be a positive number' }
569579
}
570580

571-
const normalizedUrl = normalizeInstanceUrl(instanceUrl)
581+
let normalizedUrl: string
582+
try {
583+
normalizedUrl = resolveServiceNowInstanceUrl(instanceUrl)
584+
} catch (error) {
585+
return { valid: false, error: toError(error).message }
586+
}
587+
572588
const authHeader = buildAuthHeader(accessToken, sourceConfig)
573589
const tableName = contentType === 'kb_knowledge' ? 'kb_knowledge' : 'incident'
574590

@@ -585,8 +601,7 @@ export const servicenowConnector: ConnectorConfig = {
585601
)
586602
return { valid: true }
587603
} catch (error) {
588-
const message = error instanceof Error ? error.message : 'Failed to connect to ServiceNow'
589-
return { valid: false, error: message }
604+
return { valid: false, error: toError(error).message || 'Failed to connect to ServiceNow' }
590605
}
591606
},
592607

0 commit comments

Comments
 (0)