From 2442551f0611f79f6353e9d974afed8dd5c9aa4e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 00:49:12 -0700 Subject: [PATCH 1/6] fix(workday): validate tenantUrl to prevent SSRF in SOAP client --- .../core/security/input-validation.test.ts | 273 +++++++++++++++++- .../sim/lib/core/security/input-validation.ts | 147 +++++++++- apps/sim/tools/workday/soap.ts | 7 + 3 files changed, 418 insertions(+), 9 deletions(-) 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/tools/workday/soap.ts b/apps/sim/tools/workday/soap.ts index a1f269008f8..7cfe291c4f8 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,12 +129,18 @@ 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(/\/$/, '') return `${baseUrl}/ccx/service/${tenant}/${svc.name}/${svc.version}?wsdl` From 74bd1b1f6ee6cdb68d58c3295598c68cb2d2b78f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 01:03:34 -0700 Subject: [PATCH 2/6] fix(workday): use validation.sanitized in buildWsdlUrl --- apps/sim/tools/workday/soap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/tools/workday/soap.ts b/apps/sim/tools/workday/soap.ts index 7cfe291c4f8..aeba1391a6c 100644 --- a/apps/sim/tools/workday/soap.ts +++ b/apps/sim/tools/workday/soap.ts @@ -142,7 +142,7 @@ export function buildWsdlUrl( 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` } From d738429d8bef241feaab1740a37d29bafe768d49 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 01:04:39 -0700 Subject: [PATCH 3/6] 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). --- apps/sim/app/(auth)/signup/signup-form.tsx | 13 ++- .../sim/app/(auth)/verify/use-verification.ts | 12 ++- apps/sim/connectors/obsidian/obsidian.ts | 95 ++++++++++++++----- apps/sim/connectors/servicenow/servicenow.ts | 46 ++++++--- apps/sim/lib/knowledge/documents/utils.ts | 47 +++++++++ apps/sim/providers/azure-anthropic/index.ts | 15 ++- apps/sim/providers/azure-openai/index.ts | 15 ++- 7 files changed, 201 insertions(+), 42 deletions(-) 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..db5ddcf632c 100644 --- a/apps/sim/connectors/obsidian/obsidian.ts +++ b/apps/sim/connectors/obsidian/obsidian.ts @@ -1,13 +1,18 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { ObsidianIcon } from '@/components/icons' -import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { + secureFetchWithPinnedIPAndRetry, + VALIDATE_RETRY_OPTIONS, +} from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' 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 +27,31 @@ interface NoteJson { } /** - * Normalizes the vault URL by removing trailing slashes. + * Normalizes the vault URL and resolves its hostname to a concrete IP that + * will be pinned for the lifetime of this request sequence. + * + * The Obsidian Local REST API plugin runs on the user's own machine — there + * is no Obsidian SaaS domain we can allowlist. For hosted Sim deployments the + * user must expose the plugin through a public URL (tunnel, port-forward). + * Because the hostname is fully user-controlled, we resolve DNS once through + * validateUrlWithDNS (which blocks private IPs/localhost in hosted mode, + * allows localhost in self-hosted mode, and rejects dangerous ports) and + * then reuse that IP on every outgoing fetch via secureFetchWithPinnedIP — + * this prevents DNS rebinding attacks where a malicious nameserver would + * otherwise swap in a private IP between validation and the actual request. */ -function normalizeVaultUrl(url: string): string { - return url.trim().replace(/\/+$/, '') +async function resolveVaultEndpoint( + rawUrl: string | undefined +): Promise<{ baseUrl: string; resolvedIP: string }> { + let url = (rawUrl || DEFAULT_VAULT_URL).trim().replace(/\/+$/, '') + if (url && !url.startsWith('https://') && !url.startsWith('http://')) { + url = `https://${url}` + } + const validation = await validateUrlWithDNS(url, 'vaultUrl', { allowHttp: true }) + if (!validation.isValid || !validation.resolvedIP) { + throw new Error(validation.error || 'Invalid vault URL') + } + return { baseUrl: url, resolvedIP: validation.resolvedIP } } /** @@ -34,21 +60,24 @@ function normalizeVaultUrl(url: string): string { */ async function listDirectory( baseUrl: string, + resolvedIP: string, accessToken: string, dirPath: string, - retryOptions?: Parameters[2] + retryOptions?: Parameters[3] ): Promise { const encodedDir = dirPath ? dirPath.split('/').map(encodeURIComponent).join('/') : '' const endpoint = encodedDir ? `${baseUrl}/vault/${encodedDir}/` : `${baseUrl}/vault/` - const response = await fetchWithRetry( + const response = await secureFetchWithPinnedIPAndRetry( endpoint, + resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, + allowHttp: true, }, retryOptions ) @@ -68,9 +97,10 @@ const MAX_RECURSION_DEPTH = 20 async function listVaultFiles( baseUrl: string, + resolvedIP: string, accessToken: string, folderPath?: string, - retryOptions?: Parameters[2], + retryOptions?: Parameters[3], depth = 0 ): Promise { if (depth > MAX_RECURSION_DEPTH) { @@ -79,7 +109,7 @@ async function listVaultFiles( } const rootPath = folderPath || '' - const entries = await listDirectory(baseUrl, accessToken, rootPath, retryOptions) + const entries = await listDirectory(baseUrl, resolvedIP, accessToken, rootPath, retryOptions) const mdFiles: string[] = [] const subDirs: string[] = [] @@ -96,7 +126,14 @@ async function listVaultFiles( for (const dir of subDirs) { try { - const nested = await listVaultFiles(baseUrl, accessToken, dir, retryOptions, depth + 1) + const nested = await listVaultFiles( + baseUrl, + resolvedIP, + accessToken, + dir, + retryOptions, + depth + 1 + ) mdFiles.push(...nested) } catch (error) { logger.warn('Failed to list subdirectory', { @@ -114,18 +151,21 @@ async function listVaultFiles( */ async function fetchNote( baseUrl: string, + resolvedIP: string, accessToken: string, filePath: string, - retryOptions?: Parameters[2] + retryOptions?: Parameters[3] ): Promise { - const response = await fetchWithRetry( + const response = await secureFetchWithPinnedIPAndRetry( `${baseUrl}/vault/${filePath.split('/').map(encodeURIComponent).join('/')}`, + resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.olrapi.note+json', }, + allowHttp: true, }, retryOptions ) @@ -183,15 +223,13 @@ export const obsidianConnector: ConnectorConfig = { cursor?: string, syncContext?: Record ): Promise => { - const baseUrl = normalizeVaultUrl( - (sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124' - ) + const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string) const folderPath = (sourceConfig.folderPath as string) || '' let allFiles = syncContext?.allFiles as string[] | undefined if (!allFiles) { logger.info('Listing all vault files', { baseUrl, folderPath }) - allFiles = await listVaultFiles(baseUrl, accessToken, folderPath || undefined) + allFiles = await listVaultFiles(baseUrl, resolvedIP, accessToken, folderPath || undefined) if (syncContext) { syncContext.allFiles = allFiles } @@ -230,12 +268,10 @@ export const obsidianConnector: ConnectorConfig = { externalId: string, _syncContext?: Record ): Promise => { - const baseUrl = normalizeVaultUrl( - (sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124' - ) + const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string) try { - const note = await fetchNote(baseUrl, accessToken, externalId) + const note = await fetchNote(baseUrl, resolvedIP, accessToken, externalId) const content = note.content || '' return { @@ -275,14 +311,24 @@ export const obsidianConnector: ConnectorConfig = { return { valid: false, error: 'Vault URL is required' } } - const baseUrl = normalizeVaultUrl(rawUrl) + let baseUrl: string + let resolvedIP: string + try { + const endpoint = await resolveVaultEndpoint(rawUrl) + baseUrl = endpoint.baseUrl + resolvedIP = endpoint.resolvedIP + } catch (error) { + return { valid: false, error: toError(error).message } + } try { - const response = await fetchWithRetry( + const response = await secureFetchWithPinnedIPAndRetry( `${baseUrl}/`, + resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, + allowHttp: true, }, VALIDATE_RETRY_OPTIONS ) @@ -302,6 +348,7 @@ export const obsidianConnector: ConnectorConfig = { if (folderPath.trim()) { const entries = await listDirectory( baseUrl, + resolvedIP, accessToken, folderPath.trim(), VALIDATE_RETRY_OPTIONS @@ -313,8 +360,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..dffb955fd92 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,17 @@ 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' + let instanceUrl: string + try { + instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string) + } catch (error) { + logger.warn('Failed to validate ServiceNow instance URL', { + externalId, + error: toError(error).message, + }) + return null + } + try { const { result } = await serviceNowApiGet(instanceUrl, tableName, authHeader, { sysparm_query: `sys_id=${externalId}`, @@ -568,7 +587,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 +610,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/knowledge/documents/utils.ts b/apps/sim/lib/knowledge/documents/utils.ts index bafc5d4cc99..ae539f89c63 100644 --- a/apps/sim/lib/knowledge/documents/utils.ts +++ b/apps/sim/lib/knowledge/documents/utils.ts @@ -1,6 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' +import { + type SecureFetchOptions, + type SecureFetchResponse, + secureFetchWithPinnedIP, +} from '@/lib/core/security/input-validation.server' const logger = createLogger('RetryUtils') @@ -208,3 +213,45 @@ export async function fetchWithRetry( return response }, retryOptions) } + +/** + * Wrapper for secure fetch requests with retry logic and DNS pinning. + * Use when connecting to user-controlled hostnames that cannot be restricted + * to a domain allowlist (e.g. self-hosted plugin endpoints). The DNS lookup + * happens once during validation; this helper reuses that resolved IP so a + * malicious nameserver cannot rebind to a private IP between validation and + * the actual request. + */ +export async function secureFetchWithPinnedIPAndRetry( + url: string, + resolvedIP: string, + options: SecureFetchOptions & { allowHttp?: boolean } = {}, + retryOptions: RetryOptions = {} +): Promise { + return retryWithExponentialBackoff(async () => { + const response = await secureFetchWithPinnedIP(url, resolvedIP, options) + + if (!response.ok && isRetryableError({ status: response.status })) { + const errorText = await response.text() + const error: HTTPError = new Error( + `HTTP ${response.status}: ${response.statusText} - ${errorText}` + ) + error.status = response.status + error.statusText = response.statusText + + const retryAfter = response.headers.get('Retry-After') + if (retryAfter) { + const waitMs = Number.isNaN(Number(retryAfter)) + ? Math.max(0, new Date(retryAfter).getTime() - Date.now()) + : Number(retryAfter) * 1000 + if (waitMs > 0) { + error.retryAfterMs = waitMs + } + } + + throw error + } + + return response + }, retryOptions) +} 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.') From fa5ab281258c90b0c787eb644e5bc289917fa78a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 08:36:20 -0700 Subject: [PATCH 4/6] fix(obsidian): use isomorphic SSRF validation to unblock client build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/sim/connectors/obsidian/obsidian.ts | 87 ++++++++--------------- apps/sim/lib/knowledge/documents/utils.ts | 47 ------------ 2 files changed, 29 insertions(+), 105 deletions(-) diff --git a/apps/sim/connectors/obsidian/obsidian.ts b/apps/sim/connectors/obsidian/obsidian.ts index db5ddcf632c..b76fd34b2e9 100644 --- a/apps/sim/connectors/obsidian/obsidian.ts +++ b/apps/sim/connectors/obsidian/obsidian.ts @@ -1,11 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { ObsidianIcon } from '@/components/icons' -import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' -import { - secureFetchWithPinnedIPAndRetry, - VALIDATE_RETRY_OPTIONS, -} from '@/lib/knowledge/documents/utils' +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' @@ -27,31 +24,28 @@ interface NoteJson { } /** - * Normalizes the vault URL and resolves its hostname to a concrete IP that - * will be pinned for the lifetime of this request sequence. + * Normalizes the vault URL and validates it against SSRF protections. * - * The Obsidian Local REST API plugin runs on the user's own machine — there - * is no Obsidian SaaS domain we can allowlist. For hosted Sim deployments the - * user must expose the plugin through a public URL (tunnel, port-forward). - * Because the hostname is fully user-controlled, we resolve DNS once through - * validateUrlWithDNS (which blocks private IPs/localhost in hosted mode, - * allows localhost in self-hosted mode, and rejects dangerous ports) and - * then reuse that IP on every outgoing fetch via secureFetchWithPinnedIP — - * this prevents DNS rebinding attacks where a malicious nameserver would - * otherwise swap in a private IP between validation and the actual request. + * 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 localhost + HTTP, 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). */ -async function resolveVaultEndpoint( - rawUrl: string | undefined -): Promise<{ baseUrl: string; resolvedIP: string }> { +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 = await validateUrlWithDNS(url, 'vaultUrl', { allowHttp: true }) - if (!validation.isValid || !validation.resolvedIP) { + const validation = validateExternalUrl(url, 'vaultUrl', { allowHttp: true }) + if (!validation.isValid) { throw new Error(validation.error || 'Invalid vault URL') } - return { baseUrl: url, resolvedIP: validation.resolvedIP } + return url } /** @@ -60,24 +54,21 @@ async function resolveVaultEndpoint( */ async function listDirectory( baseUrl: string, - resolvedIP: string, accessToken: string, dirPath: string, - retryOptions?: Parameters[3] + retryOptions?: Parameters[2] ): Promise { const encodedDir = dirPath ? dirPath.split('/').map(encodeURIComponent).join('/') : '' const endpoint = encodedDir ? `${baseUrl}/vault/${encodedDir}/` : `${baseUrl}/vault/` - const response = await secureFetchWithPinnedIPAndRetry( + const response = await fetchWithRetry( endpoint, - resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, - allowHttp: true, }, retryOptions ) @@ -90,17 +81,13 @@ 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( baseUrl: string, - resolvedIP: string, accessToken: string, folderPath?: string, - retryOptions?: Parameters[3], + retryOptions?: Parameters[2], depth = 0 ): Promise { if (depth > MAX_RECURSION_DEPTH) { @@ -109,7 +96,7 @@ async function listVaultFiles( } const rootPath = folderPath || '' - const entries = await listDirectory(baseUrl, resolvedIP, accessToken, rootPath, retryOptions) + const entries = await listDirectory(baseUrl, accessToken, rootPath, retryOptions) const mdFiles: string[] = [] const subDirs: string[] = [] @@ -126,14 +113,7 @@ async function listVaultFiles( for (const dir of subDirs) { try { - const nested = await listVaultFiles( - baseUrl, - resolvedIP, - accessToken, - dir, - retryOptions, - depth + 1 - ) + const nested = await listVaultFiles(baseUrl, accessToken, dir, retryOptions, depth + 1) mdFiles.push(...nested) } catch (error) { logger.warn('Failed to list subdirectory', { @@ -151,21 +131,18 @@ async function listVaultFiles( */ async function fetchNote( baseUrl: string, - resolvedIP: string, accessToken: string, filePath: string, - retryOptions?: Parameters[3] + retryOptions?: Parameters[2] ): Promise { - const response = await secureFetchWithPinnedIPAndRetry( + const response = await fetchWithRetry( `${baseUrl}/vault/${filePath.split('/').map(encodeURIComponent).join('/')}`, - resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.olrapi.note+json', }, - allowHttp: true, }, retryOptions ) @@ -223,13 +200,13 @@ export const obsidianConnector: ConnectorConfig = { cursor?: string, syncContext?: Record ): Promise => { - const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string) + const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string) const folderPath = (sourceConfig.folderPath as string) || '' let allFiles = syncContext?.allFiles as string[] | undefined if (!allFiles) { logger.info('Listing all vault files', { baseUrl, folderPath }) - allFiles = await listVaultFiles(baseUrl, resolvedIP, accessToken, folderPath || undefined) + allFiles = await listVaultFiles(baseUrl, accessToken, folderPath || undefined) if (syncContext) { syncContext.allFiles = allFiles } @@ -268,10 +245,10 @@ export const obsidianConnector: ConnectorConfig = { externalId: string, _syncContext?: Record ): Promise => { - const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string) + const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string) try { - const note = await fetchNote(baseUrl, resolvedIP, accessToken, externalId) + const note = await fetchNote(baseUrl, accessToken, externalId) const content = note.content || '' return { @@ -312,23 +289,18 @@ export const obsidianConnector: ConnectorConfig = { } let baseUrl: string - let resolvedIP: string try { - const endpoint = await resolveVaultEndpoint(rawUrl) - baseUrl = endpoint.baseUrl - resolvedIP = endpoint.resolvedIP + baseUrl = resolveVaultEndpoint(rawUrl) } catch (error) { return { valid: false, error: toError(error).message } } try { - const response = await secureFetchWithPinnedIPAndRetry( + const response = await fetchWithRetry( `${baseUrl}/`, - resolvedIP, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, - allowHttp: true, }, VALIDATE_RETRY_OPTIONS ) @@ -348,7 +320,6 @@ export const obsidianConnector: ConnectorConfig = { if (folderPath.trim()) { const entries = await listDirectory( baseUrl, - resolvedIP, accessToken, folderPath.trim(), VALIDATE_RETRY_OPTIONS diff --git a/apps/sim/lib/knowledge/documents/utils.ts b/apps/sim/lib/knowledge/documents/utils.ts index ae539f89c63..bafc5d4cc99 100644 --- a/apps/sim/lib/knowledge/documents/utils.ts +++ b/apps/sim/lib/knowledge/documents/utils.ts @@ -1,11 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { - type SecureFetchOptions, - type SecureFetchResponse, - secureFetchWithPinnedIP, -} from '@/lib/core/security/input-validation.server' const logger = createLogger('RetryUtils') @@ -213,45 +208,3 @@ export async function fetchWithRetry( return response }, retryOptions) } - -/** - * Wrapper for secure fetch requests with retry logic and DNS pinning. - * Use when connecting to user-controlled hostnames that cannot be restricted - * to a domain allowlist (e.g. self-hosted plugin endpoints). The DNS lookup - * happens once during validation; this helper reuses that resolved IP so a - * malicious nameserver cannot rebind to a private IP between validation and - * the actual request. - */ -export async function secureFetchWithPinnedIPAndRetry( - url: string, - resolvedIP: string, - options: SecureFetchOptions & { allowHttp?: boolean } = {}, - retryOptions: RetryOptions = {} -): Promise { - return retryWithExponentialBackoff(async () => { - const response = await secureFetchWithPinnedIP(url, resolvedIP, options) - - if (!response.ok && isRetryableError({ status: response.status })) { - const errorText = await response.text() - const error: HTTPError = new Error( - `HTTP ${response.status}: ${response.statusText} - ${errorText}` - ) - error.status = response.status - error.statusText = response.statusText - - const retryAfter = response.headers.get('Retry-After') - if (retryAfter) { - const waitMs = Number.isNaN(Number(retryAfter)) - ? Math.max(0, new Date(retryAfter).getTime() - Date.now()) - : Number(retryAfter) * 1000 - if (waitMs > 0) { - error.retryAfterMs = waitMs - } - } - - throw error - } - - return response - }, retryOptions) -} From 5d26e7b6903026e77dfddd5887526e4153fc0fa3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 08:59:48 -0700 Subject: [PATCH 5/6] fix(servicenow): propagate URL validation errors in getDocument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/sim/connectors/servicenow/servicenow.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/sim/connectors/servicenow/servicenow.ts b/apps/sim/connectors/servicenow/servicenow.ts index dffb955fd92..86f6a40617b 100644 --- a/apps/sim/connectors/servicenow/servicenow.ts +++ b/apps/sim/connectors/servicenow/servicenow.ts @@ -522,16 +522,7 @@ 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' - let instanceUrl: string - try { - instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string) - } catch (error) { - logger.warn('Failed to validate ServiceNow instance URL', { - externalId, - error: toError(error).message, - }) - return null - } + const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string) try { const { result } = await serviceNowApiGet(instanceUrl, tableName, authHeader, { From b221b6c55f215d674a70f60d9b3288613358648b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 09:31:16 -0700 Subject: [PATCH 6/6] fix(obsidian): drop allowHttp to restore HTTPS enforcement in hosted mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/sim/connectors/obsidian/obsidian.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sim/connectors/obsidian/obsidian.ts b/apps/sim/connectors/obsidian/obsidian.ts index b76fd34b2e9..bf570b96af1 100644 --- a/apps/sim/connectors/obsidian/obsidian.ts +++ b/apps/sim/connectors/obsidian/obsidian.ts @@ -30,8 +30,9 @@ interface NoteJson { * 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 localhost + HTTP, still blocks non-loopback - * private IPs and dangerous ports (22, 25, 3306, 5432, 6379, 27017, 9200) + * - 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). @@ -41,7 +42,7 @@ function resolveVaultEndpoint(rawUrl: string | undefined): string { if (url && !url.startsWith('https://') && !url.startsWith('http://')) { url = `https://${url}` } - const validation = validateExternalUrl(url, 'vaultUrl', { allowHttp: true }) + const validation = validateExternalUrl(url, 'vaultUrl') if (!validation.isValid) { throw new Error(validation.error || 'Invalid vault URL') }