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>
This commit is contained in:
Waleed
2026-04-20 10:54:03 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 5cf7e8d546
commit febc36ff9c
9 changed files with 528 additions and 44 deletions
+9 -4
View File
@@ -10,6 +10,7 @@ import { usePostHog } from 'posthog-js/react'
import { Input, Label } from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
@@ -102,10 +103,14 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
useEffect(() => {
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
}, [])
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
[searchParams]
)
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
const invalidCallbackRef = useRef(false)
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
}
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
const isInviteFlow = useMemo(
() =>
searchParams.get('invite_flow') === 'true' ||
+10 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
const logger = createLogger('useVerification')
@@ -55,8 +56,11 @@ export function useVerification({
}
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
if (storedRedirectUrl) {
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
setRedirectUrl(storedRedirectUrl)
} else if (storedRedirectUrl) {
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
sessionStorage.removeItem('inviteRedirectUrl')
}
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
@@ -67,7 +71,11 @@ export function useVerification({
const redirectParam = searchParams.get('redirectAfter')
if (redirectParam) {
setRedirectUrl(redirectParam)
if (validateCallbackUrl(redirectParam)) {
setRedirectUrl(redirectParam)
} else {
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
}
}
const inviteFlowParam = searchParams.get('invite_flow')
+36 -15
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { ObsidianIcon } from '@/components/icons'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { joinTagArray, parseTagDate } from '@/connectors/utils'
@@ -8,6 +9,7 @@ import { joinTagArray, parseTagDate } from '@/connectors/utils'
const logger = createLogger('ObsidianConnector')
const DOCS_PER_PAGE = 50
const DEFAULT_VAULT_URL = 'https://127.0.0.1:27124'
interface NoteJson {
content: string
@@ -22,10 +24,29 @@ interface NoteJson {
}
/**
* Normalizes the vault URL by removing trailing slashes.
* Normalizes the vault URL and validates it against SSRF protections.
*
* The Obsidian Local REST API plugin runs on the user's own machine, so there
* is no SaaS domain to allowlist — the vault URL is fully user-controlled. We
* defer to the shared `validateExternalUrl` policy:
* - hosted Sim: blocks localhost, private IPs, HTTP (forces HTTPS)
* - self-hosted Sim: allows http://localhost (built-in carve-out), still
* blocks non-loopback private IPs and dangerous ports (22, 25, 3306,
* 5432, 6379, 27017, 9200)
*
* This does not defend against DNS rebinding; for hosted deployments the user
* must expose the plugin through a public URL (tunnel, port-forward).
*/
function normalizeVaultUrl(url: string): string {
return url.trim().replace(/\/+$/, '')
function resolveVaultEndpoint(rawUrl: string | undefined): string {
let url = (rawUrl || DEFAULT_VAULT_URL).trim().replace(/\/+$/, '')
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
const validation = validateExternalUrl(url, 'vaultUrl')
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid vault URL')
}
return url
}
/**
@@ -61,9 +82,6 @@ async function listDirectory(
return data.files ?? []
}
/**
* Recursively lists all markdown files in the vault or a specific folder.
*/
const MAX_RECURSION_DEPTH = 20
async function listVaultFiles(
@@ -183,9 +201,7 @@ export const obsidianConnector: ConnectorConfig = {
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const baseUrl = normalizeVaultUrl(
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
)
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
const folderPath = (sourceConfig.folderPath as string) || ''
let allFiles = syncContext?.allFiles as string[] | undefined
@@ -230,9 +246,7 @@ export const obsidianConnector: ConnectorConfig = {
externalId: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
const baseUrl = normalizeVaultUrl(
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
)
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
try {
const note = await fetchNote(baseUrl, accessToken, externalId)
@@ -275,7 +289,12 @@ export const obsidianConnector: ConnectorConfig = {
return { valid: false, error: 'Vault URL is required' }
}
const baseUrl = normalizeVaultUrl(rawUrl)
let baseUrl: string
try {
baseUrl = resolveVaultEndpoint(rawUrl)
} catch (error) {
return { valid: false, error: toError(error).message }
}
try {
const response = await fetchWithRetry(
@@ -313,8 +332,10 @@ export const obsidianConnector: ConnectorConfig = {
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect to Obsidian vault'
return { valid: false, error: message }
return {
valid: false,
error: toError(error).message || 'Failed to connect to Obsidian vault',
}
}
},
+26 -11
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { ServiceNowIcon } from '@/components/icons'
import { validateServiceNowInstanceUrl } from '@/lib/core/security/input-validation'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { htmlToPlainText, parseTagDate } from '@/connectors/utils'
@@ -45,15 +46,23 @@ interface Incident extends ServiceNowRecord {
}
/**
* Normalizes the instance URL to ensure it has the correct format.
* Normalizes and validates the ServiceNow instance URL.
*
* Prepends https:// if the scheme is missing, strips trailing slashes, then
* enforces a ServiceNow-owned domain allowlist to prevent SSRF — the instance
* URL is user-controlled and was previously fetched server-side with no
* validation.
*/
function normalizeInstanceUrl(instanceUrl: string): string {
let url = instanceUrl.trim()
url = url.replace(/\/+$/, '')
if (!url.startsWith('https://') && !url.startsWith('http://')) {
function resolveServiceNowInstanceUrl(rawUrl: string): string {
let url = (rawUrl ?? '').trim().replace(/\/+$/, '')
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
return url
const validation = validateServiceNowInstanceUrl(url)
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid instance URL')
}
return validation.sanitized ?? url
}
/**
@@ -430,7 +439,7 @@ export const servicenowConnector: ConnectorConfig = {
cursor?: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const instanceUrl = normalizeInstanceUrl(sourceConfig.instanceUrl as string)
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : DEFAULT_MAX_ITEMS
const authHeader = buildAuthHeader(accessToken, sourceConfig)
@@ -504,7 +513,6 @@ export const servicenowConnector: ConnectorConfig = {
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const instanceUrl = normalizeInstanceUrl(sourceConfig.instanceUrl as string)
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
const authHeader = buildAuthHeader(accessToken, sourceConfig)
const isKB = contentType === 'kb_knowledge'
@@ -514,6 +522,8 @@ export const servicenowConnector: ConnectorConfig = {
? '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'
: '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'
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
try {
const { result } = await serviceNowApiGet(instanceUrl, tableName, authHeader, {
sysparm_query: `sys_id=${externalId}`,
@@ -568,7 +578,13 @@ export const servicenowConnector: ConnectorConfig = {
return { valid: false, error: 'Max items must be a positive number' }
}
const normalizedUrl = normalizeInstanceUrl(instanceUrl)
let normalizedUrl: string
try {
normalizedUrl = resolveServiceNowInstanceUrl(instanceUrl)
} catch (error) {
return { valid: false, error: toError(error).message }
}
const authHeader = buildAuthHeader(accessToken, sourceConfig)
const tableName = contentType === 'kb_knowledge' ? 'kb_knowledge' : 'incident'
@@ -585,8 +601,7 @@ export const servicenowConnector: ConnectorConfig = {
)
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect to ServiceNow'
return { valid: false, error: message }
return { valid: false, error: toError(error).message || 'Failed to connect to ServiceNow' }
}
},
@@ -1,9 +1,10 @@
import { featureFlagsMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
validateAirtableId,
validateAlphanumericId,
validateAwsRegion,
validateCallbackUrl,
validateEnum,
validateExternalUrl,
validateFileExtension,
@@ -21,7 +22,9 @@ import {
validatePathSegment,
validateProxyUrl,
validateS3BucketName,
validateServiceNowInstanceUrl,
validateSupabaseProjectId,
validateWorkdayTenantUrl,
} from '@/lib/core/security/input-validation'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { sanitizeForLogging } from '@/lib/core/security/redaction'
@@ -1837,3 +1840,271 @@ describe('validateMondayColumnId', () => {
})
})
})
describe('validateCallbackUrl', () => {
const ORIGIN = 'https://sim.app'
const originalWindow = (globalThis as { window?: unknown }).window
beforeEach(() => {
;(globalThis as { window?: unknown }).window = {
location: { origin: ORIGIN },
}
})
afterEach(() => {
if (originalWindow === undefined) {
;(globalThis as { window?: unknown }).window = undefined
} else {
;(globalThis as { window?: unknown }).window = originalWindow
}
})
describe('accepts legitimate same-origin URLs', () => {
it.each([
['/workspace'],
['/invite/abc-123'],
['/invite/abc?foo=bar&baz=qux'],
['/workspace#section'],
['/credential-account/456'],
['?reset=true'],
['/'],
['https://sim.app/workspace'],
['https://sim.app/'],
['HTTPS://SIM.APP/foo'],
])('accepts %s', (url) => {
expect(validateCallbackUrl(url)).toBe(true)
})
})
describe('rejects open-redirect payloads', () => {
it.each([
['', 'empty string'],
['//evil.com', 'protocol-relative'],
['/\\evil.com', 'backslash protocol-relative'],
['\\\\evil.com', 'double backslash'],
['/\t/evil.com', 'tab-stripped protocol-relative'],
['/\n/evil.com', 'newline-stripped protocol-relative'],
['/\r/evil.com', 'CR-stripped protocol-relative'],
['https://evil.com', 'cross-origin absolute URL'],
['https://sim.app@evil.com', 'userinfo smuggling'],
['https://sim.app.evil.com', 'subdomain confusion'],
['https://sim.app:3001/foo', 'different port'],
['http://sim.app/foo', 'different protocol'],
['javascript:alert(1)', 'javascript scheme'],
['data:text/html,<script>alert(1)</script>', 'data scheme'],
['vbscript:msgbox', 'vbscript scheme'],
])('rejects %s (%s)', (url) => {
expect(validateCallbackUrl(url)).toBe(false)
})
})
describe('server-side (no window)', () => {
beforeEach(() => {
;(globalThis as { window?: unknown }).window = undefined
})
it('falls back to placeholder origin and still rejects cross-origin URLs', () => {
expect(validateCallbackUrl('/workspace')).toBe(true)
expect(validateCallbackUrl('//evil.com')).toBe(false)
expect(validateCallbackUrl('https://evil.com')).toBe(false)
expect(validateCallbackUrl('javascript:alert(1)')).toBe(false)
})
})
})
describe('validateServiceNowInstanceUrl', () => {
describe('valid ServiceNow instance URLs', () => {
it.concurrent('should accept *.service-now.com', () => {
const result = validateServiceNowInstanceUrl('https://acme.service-now.com')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('https://acme.service-now.com')
})
it.concurrent('should accept *.servicenow.com', () => {
const result = validateServiceNowInstanceUrl('https://acme.servicenow.com')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept *.servicenowservices.com (GovCloud)', () => {
const result = validateServiceNowInstanceUrl('https://acme.servicenowservices.com')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept URLs with paths', () => {
const result = validateServiceNowInstanceUrl('https://acme.service-now.com/api/now/table')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept multi-level subdomains', () => {
const result = validateServiceNowInstanceUrl('https://dev.acme.service-now.com')
expect(result.isValid).toBe(true)
})
})
describe('invalid hosts — allowlist rejection', () => {
it.concurrent('should reject attacker-controlled domains', () => {
const result = validateServiceNowInstanceUrl('https://evil.com')
expect(result.isValid).toBe(false)
expect(result.error).toContain('ServiceNow-hosted domain')
})
it.concurrent('should reject lookalike suffixes', () => {
const result = validateServiceNowInstanceUrl('https://acme.service-now.com.evil.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject embedded substrings', () => {
const result = validateServiceNowInstanceUrl('https://service-now.com.evil.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject vanity CNAME hosts (Custom URL plugin)', () => {
const result = validateServiceNowInstanceUrl('https://support.acme.com')
expect(result.isValid).toBe(false)
expect(result.error).toContain('ServiceNow-hosted domain')
})
it.concurrent('should reject userinfo smuggling', () => {
const result = validateServiceNowInstanceUrl('https://acme.service-now.com@evil.com')
expect(result.isValid).toBe(false)
})
})
describe('invalid URLs — delegated to validateExternalUrl', () => {
it.concurrent('should reject null', () => {
const result = validateServiceNowInstanceUrl(null)
expect(result.isValid).toBe(false)
})
it.concurrent('should reject empty string', () => {
const result = validateServiceNowInstanceUrl('')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject http:// protocol', () => {
const result = validateServiceNowInstanceUrl('http://acme.service-now.com')
expect(result.isValid).toBe(false)
expect(result.error).toContain('https://')
})
it.concurrent('should reject private IPs', () => {
const result = validateServiceNowInstanceUrl('https://192.168.1.1')
expect(result.isValid).toBe(false)
expect(result.error).toContain('private IP')
})
it.concurrent('should reject link-local metadata IP', () => {
const result = validateServiceNowInstanceUrl('https://169.254.169.254')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject blocked ports', () => {
const result = validateServiceNowInstanceUrl('https://acme.service-now.com:22')
expect(result.isValid).toBe(false)
expect(result.error).toContain('blocked port')
})
it.concurrent('should reject malformed URLs', () => {
const result = validateServiceNowInstanceUrl('not-a-url')
expect(result.isValid).toBe(false)
})
})
})
describe('validateWorkdayTenantUrl', () => {
describe('valid Workday tenant URLs', () => {
it.concurrent('should accept *.workday.com implementation tenants', () => {
const result = validateWorkdayTenantUrl('https://wd2-impl-services1.workday.com')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('https://wd2-impl-services1.workday.com')
})
it.concurrent('should accept *.workday.com production tenants', () => {
const result = validateWorkdayTenantUrl('https://wd5-services1.workday.com')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept *.myworkday.com production tenants', () => {
const result = validateWorkdayTenantUrl('https://wd5-services1.myworkday.com')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept URLs with trailing slash', () => {
const result = validateWorkdayTenantUrl('https://wd2-impl-services1.workday.com/')
expect(result.isValid).toBe(true)
})
it.concurrent('should be case-insensitive for hostname', () => {
const result = validateWorkdayTenantUrl('https://WD5-Services1.Workday.com')
expect(result.isValid).toBe(true)
})
})
describe('invalid hosts — allowlist rejection', () => {
it.concurrent('should reject attacker-controlled domains', () => {
const result = validateWorkdayTenantUrl('https://evil.com')
expect(result.isValid).toBe(false)
expect(result.error).toContain('Workday-hosted domain')
})
it.concurrent('should reject lookalike suffixes', () => {
const result = validateWorkdayTenantUrl('https://wd5.workday.com.evil.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject embedded substrings', () => {
const result = validateWorkdayTenantUrl('https://workday.com.evil.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject near-miss domains', () => {
const result = validateWorkdayTenantUrl('https://evilworkday.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject userinfo smuggling', () => {
const result = validateWorkdayTenantUrl('https://wd5.workday.com@evil.com')
expect(result.isValid).toBe(false)
})
})
describe('invalid URLs — delegated to validateExternalUrl', () => {
it.concurrent('should reject null', () => {
const result = validateWorkdayTenantUrl(null)
expect(result.isValid).toBe(false)
})
it.concurrent('should reject empty string', () => {
const result = validateWorkdayTenantUrl('')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject http:// protocol', () => {
const result = validateWorkdayTenantUrl('http://wd2-impl-services1.workday.com')
expect(result.isValid).toBe(false)
expect(result.error).toContain('https://')
})
it.concurrent('should reject private IPs', () => {
const result = validateWorkdayTenantUrl('https://192.168.1.1')
expect(result.isValid).toBe(false)
expect(result.error).toContain('private IP')
})
it.concurrent('should reject link-local metadata IP (SSRF classic)', () => {
const result = validateWorkdayTenantUrl('https://169.254.169.254')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject blocked ports', () => {
const result = validateWorkdayTenantUrl('https://wd2-impl-services1.workday.com:22')
expect(result.isValid).toBe(false)
expect(result.error).toContain('blocked port')
})
it.concurrent('should reject malformed URLs', () => {
const result = validateWorkdayTenantUrl('not-a-url')
expect(result.isValid).toBe(false)
})
})
})
+139 -8
View File
@@ -1155,23 +1155,40 @@ export function validatePaginationCursor(
return { isValid: true, sanitized: value }
}
const CALLBACK_URL_SERVER_BASE = 'https://callback-url-validator.invalid'
/**
* Validates a callback URL to prevent open redirect attacks.
* Accepts relative paths and absolute URLs matching the current origin.
*
* Accepts:
* - Same-origin relative references (e.g. `/workspace`, `/invite/abc?foo=bar`, `?q=1`)
* - Absolute URLs whose origin matches the current origin
*
* Rejects:
* - Cross-origin absolute URLs
* - Protocol-relative URLs (`//evil.com`) and backslash variants (`/\evil.com`,
* `\\evil.com`), which browsers resolve to an external origin
* - Whitespace/control-character bypasses (`/\t/evil.com`, `/\n/evil.com`) — the
* WHATWG URL parser strips these everywhere in the input, collapsing the value
* to a protocol-relative reference
* - Userinfo smuggling (`https://trusted.com@evil.com`)
* - Opaque-origin schemes (`javascript:`, `data:`, `vbscript:`, etc.)
*
* Delegates parsing to `new URL()` so validation matches the browser's resolution
* when the value is later assigned to `window.location.href`. This mirrors the
* reference pattern from next-auth and follows the OWASP Unvalidated Redirects
* cheat sheet guidance to never validate URLs with string operations.
*
* @param url - The callback URL to validate
* @returns true if the URL is safe to redirect to
*/
export function validateCallbackUrl(url: string): boolean {
try {
if (url.startsWith('/')) return true
if (typeof url !== 'string' || url.length === 0) return false
if (typeof window === 'undefined') return false
const currentOrigin = window.location.origin
if (url.startsWith(currentOrigin)) return true
return false
const base = typeof window === 'undefined' ? CALLBACK_URL_SERVER_BASE : window.location.origin
const parsed = new URL(url, base)
return parsed.origin === base
} catch (error) {
logger.error('Error validating callback URL:', { error, url })
return false
@@ -1460,3 +1477,117 @@ export function isMicrosoftContentUrl(url: string): boolean {
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`)
)
}
const SERVICENOW_ALLOWED_HOST_SUFFIXES = [
'.service-now.com',
'.servicenow.com',
'.servicenowservices.com',
] as const
/**
* Validates a ServiceNow instance URL to prevent SSRF attacks.
*
* ServiceNow instances are SaaS endpoints hosted on ServiceNow-owned domains.
* Example valid formats:
* - https://acme.service-now.com (standard commercial instances)
* - https://acme.servicenow.com (newer commercial domain)
* - https://acme.servicenowservices.com (GovCloud/FedRAMP)
*
* This validator ensures the URL:
* - Is a valid HTTPS URL (reuses validateExternalUrl for IP/localhost/port checks)
* - Has a hostname ending in a trusted ServiceNow-owned domain suffix
*
* Note: Customers using the Custom URLs plugin to front their instance with a
* vanity CNAME (e.g. support.acme.com) will be rejected. Point the connector at
* the underlying `*.service-now.com` host instead.
*
* @param url - The instance URL to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateServiceNowInstanceUrl(instanceUrl)
* if (!result.isValid) {
* throw new Error(result.error)
* }
* ```
*/
export function validateServiceNowInstanceUrl(
url: string | null | undefined,
paramName = 'instanceUrl'
): ValidationResult {
const urlResult = validateExternalUrl(url, paramName)
if (!urlResult.isValid) return urlResult
const hostname = new URL(url as string).hostname.toLowerCase()
const isAllowedHost = SERVICENOW_ALLOWED_HOST_SUFFIXES.some(
(suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix)
)
if (!isAllowedHost) {
logger.warn('ServiceNow instance URL hostname not on allowlist', {
paramName,
hostname: hostname.substring(0, 100),
})
return {
isValid: false,
error: `${paramName} must be a ServiceNow-hosted domain (e.g., *.service-now.com, *.servicenow.com, or *.servicenowservices.com)`,
}
}
return { isValid: true, sanitized: url as string }
}
const WORKDAY_ALLOWED_HOST_SUFFIXES = ['.workday.com', '.myworkday.com'] as const
/**
* Validates a Workday tenant URL to prevent SSRF attacks.
*
* Workday tenant URLs are SaaS endpoints hosted on Workday-owned domains.
* Example valid formats:
* - https://wd2-impl-services1.workday.com (implementation/sandbox tenants)
* - https://wd5-services1.workday.com (production)
* - https://wd5-services1.myworkday.com (production, customer-facing endpoint)
*
* This validator ensures the URL:
* - Is a valid HTTPS URL (reuses validateExternalUrl for IP/localhost/port checks)
* - Has a hostname ending in a trusted Workday-owned domain suffix
*
* @param url - The tenant URL to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateWorkdayTenantUrl(tenantUrl)
* if (!result.isValid) {
* throw new Error(result.error)
* }
* ```
*/
export function validateWorkdayTenantUrl(
url: string | null | undefined,
paramName = 'tenantUrl'
): ValidationResult {
const urlResult = validateExternalUrl(url, paramName)
if (!urlResult.isValid) return urlResult
const hostname = new URL(url as string).hostname.toLowerCase()
const isAllowedHost = WORKDAY_ALLOWED_HOST_SUFFIXES.some(
(suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix)
)
if (!isAllowedHost) {
logger.warn('Workday tenant URL hostname not on allowlist', {
paramName,
hostname: hostname.substring(0, 100),
})
return {
isValid: false,
error: `${paramName} must be a Workday-hosted domain (e.g., *.workday.com or *.myworkday.com)`,
}
}
return { isValid: true, sanitized: url as string }
}
+14 -1
View File
@@ -1,6 +1,7 @@
import Anthropic from '@anthropic-ai/sdk'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import type { StreamingExecution } from '@/executor/types'
import { executeAnthropicProviderRequest } from '@/providers/anthropic/core'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
@@ -19,13 +20,25 @@ export const azureAnthropicProvider: ProviderConfig = {
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const azureEndpoint = request.azureEndpoint || env.AZURE_ANTHROPIC_ENDPOINT
const userProvidedEndpoint = request.azureEndpoint
const azureEndpoint = userProvidedEndpoint || env.AZURE_ANTHROPIC_ENDPOINT
if (!azureEndpoint) {
throw new Error(
'Azure endpoint is required for Azure Anthropic. Please provide it via the azureEndpoint parameter or AZURE_ANTHROPIC_ENDPOINT environment variable.'
)
}
if (userProvidedEndpoint) {
const validation = await validateUrlWithDNS(userProvidedEndpoint, 'azureEndpoint')
if (!validation.isValid) {
logger.warn('Blocked SSRF attempt via azureEndpoint', {
endpoint: userProvidedEndpoint,
error: validation.error,
})
throw new Error(`Invalid Azure Anthropic endpoint: ${validation.error}`)
}
}
const apiKey = request.apiKey
if (!apiKey) {
throw new Error('API key is required for Azure Anthropic.')
+14 -1
View File
@@ -11,6 +11,7 @@ import type {
} from 'openai/resources/chat/completions'
import type { ReasoningEffort } from 'openai/resources/shared'
import { env } from '@/lib/core/config/env'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import {
@@ -616,7 +617,8 @@ export const azureOpenAIProvider: ProviderConfig = {
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const azureEndpoint = request.azureEndpoint || env.AZURE_OPENAI_ENDPOINT
const userProvidedEndpoint = request.azureEndpoint
const azureEndpoint = userProvidedEndpoint || env.AZURE_OPENAI_ENDPOINT
if (!azureEndpoint) {
throw new Error(
@@ -624,6 +626,17 @@ export const azureOpenAIProvider: ProviderConfig = {
)
}
if (userProvidedEndpoint) {
const validation = await validateUrlWithDNS(userProvidedEndpoint, 'azureEndpoint')
if (!validation.isValid) {
logger.warn('Blocked SSRF attempt via azureEndpoint', {
endpoint: userProvidedEndpoint,
error: validation.error,
})
throw new Error(`Invalid Azure OpenAI endpoint: ${validation.error}`)
}
}
const apiKey = request.apiKey
if (!apiKey) {
throw new Error('API key is required for Azure OpenAI.')
+8 -1
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import * as soap from 'soap'
import { validateWorkdayTenantUrl } from '@/lib/core/security/input-validation'
const logger = createLogger('WorkdaySoapClient')
@@ -128,14 +129,20 @@ export interface WorkdayClient extends soap.Client {
/**
* Builds the WSDL URL for a Workday SOAP service.
* Pattern: {tenantUrl}/ccx/service/{tenant}/{serviceName}/{version}?wsdl
*
* @throws Error if tenantUrl is not a trusted Workday-hosted URL (SSRF guard)
*/
export function buildWsdlUrl(
tenantUrl: string,
tenant: string,
service: WorkdayServiceKey
): string {
const validation = validateWorkdayTenantUrl(tenantUrl)
if (!validation.isValid) {
throw new Error(validation.error ?? 'Invalid tenantUrl')
}
const svc = WORKDAY_SERVICES[service]
const baseUrl = tenantUrl.replace(/\/$/, '')
const baseUrl = (validation.sanitized ?? tenantUrl).replace(/\/$/, '')
return `${baseUrl}/ccx/service/${tenant}/${svc.name}/${svc.version}?wsdl`
}