mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Claude Opus 4.7
parent
5cf7e8d546
commit
febc36ff9c
@@ -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' ||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user