diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 5d1b2d25ff6..c721a07291b 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -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' || diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 3ef05231a4e..ec95836c98a 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -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') diff --git a/apps/sim/connectors/obsidian/obsidian.ts b/apps/sim/connectors/obsidian/obsidian.ts index f8c469dc86a..bf570b96af1 100644 --- a/apps/sim/connectors/obsidian/obsidian.ts +++ b/apps/sim/connectors/obsidian/obsidian.ts @@ -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 ): Promise => { - 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 ): Promise => { - 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', + } } }, diff --git a/apps/sim/connectors/servicenow/servicenow.ts b/apps/sim/connectors/servicenow/servicenow.ts index be55c08ae98..86f6a40617b 100644 --- a/apps/sim/connectors/servicenow/servicenow.ts +++ b/apps/sim/connectors/servicenow/servicenow.ts @@ -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 ): Promise => { - 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, externalId: string ): Promise => { - 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' } } }, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 4086ee5341f..fb8dbf35d34 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -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,', '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) + }) + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 42cb49070e3..355bab8c28f 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -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 window === 'undefined') return false + if (typeof url !== 'string' || url.length === 0) 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 } +} diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index 710d1ba90e8..999dc0938f8 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -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 => { - 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.') diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 0d8566be602..d60354c77af 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -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 => { - 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.') diff --git a/apps/sim/tools/workday/soap.ts b/apps/sim/tools/workday/soap.ts index a1f269008f8..aeba1391a6c 100644 --- a/apps/sim/tools/workday/soap.ts +++ b/apps/sim/tools/workday/soap.ts @@ -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` }