diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index b57c8fdb77e..9c9666c44c9 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -26,6 +26,14 @@ vi.mock('@/lib/execution/e2b', () => ({ executeInE2B: mockExecuteInE2B, })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + isHosted: false, + isE2bEnabled: false, + isProd: false, + isDev: false, + isTest: true, +})) + import { validateProxyUrl } from '@/lib/core/security/input-validation' import { POST } from '@/app/api/function/execute/route' diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 7da902e13be..88b23d72340 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -236,7 +236,13 @@ export class VariableResolver { } if (typeof resolved === 'string') { - const escaped = resolved.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + const escaped = resolved + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029') return `'${escaped}'` } if (typeof resolved === 'object' && resolved !== null) { diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 7ed391a3abb..95458150b7d 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -4,6 +4,7 @@ import https from 'https' import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import * as ipaddr from 'ipaddr.js' +import { isHosted } from '@/lib/core/config/feature-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' const logger = createLogger('InputValidation') @@ -89,10 +90,7 @@ export async function validateUrlWithDNS( return ip === '127.0.0.1' || ip === '::1' })() - if ( - isPrivateOrReservedIP(address) && - !(isLocalhost && resolvedIsLoopback && !options.allowHttp) - ) { + if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { logger.warn('URL resolves to blocked IP address', { paramName, hostname, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 3098c7294fd..46d7c7c0903 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -23,6 +23,9 @@ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/core/config/feature-flags', () => ({ + isHosted: false, +})) describe('validatePathSegment', () => { describe('valid inputs', () => { @@ -569,25 +572,25 @@ describe('validateUrlWithDNS', () => { expect(result.error).toContain('https://') }) - it('should accept https localhost URLs', async () => { + it('should accept https localhost URLs (self-hosted)', async () => { const result = await validateUrlWithDNS('https://localhost/api') expect(result.isValid).toBe(true) expect(result.resolvedIP).toBeDefined() }) - it('should accept http localhost URLs', async () => { + it('should accept http localhost URLs (self-hosted)', async () => { const result = await validateUrlWithDNS('http://localhost/api') expect(result.isValid).toBe(true) expect(result.resolvedIP).toBeDefined() }) - it('should accept IPv4 loopback URLs', async () => { + it('should accept IPv4 loopback URLs (self-hosted)', async () => { const result = await validateUrlWithDNS('http://127.0.0.1/api') expect(result.isValid).toBe(true) expect(result.resolvedIP).toBeDefined() }) - it('should accept IPv6 loopback URLs', async () => { + it('should accept IPv6 loopback URLs (self-hosted)', async () => { const result = await validateUrlWithDNS('http://[::1]/api') expect(result.isValid).toBe(true) expect(result.resolvedIP).toBeDefined() @@ -918,7 +921,7 @@ describe('validateExternalUrl', () => { }) }) - describe('localhost and loopback addresses', () => { + describe('localhost and loopback addresses (self-hosted)', () => { it.concurrent('should accept https localhost', () => { const result = validateExternalUrl('https://localhost/api') expect(result.isValid).toBe(true) @@ -1027,7 +1030,7 @@ describe('validateImageUrl', () => { expect(result.isValid).toBe(true) }) - it.concurrent('should accept localhost URLs', () => { + it.concurrent('should accept localhost URLs (self-hosted)', () => { const result = validateImageUrl('https://localhost/image.png') expect(result.isValid).toBe(true) }) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index ce803fdef53..52c4dde288a 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import * as ipaddr from 'ipaddr.js' +import { isHosted } from '@/lib/core/config/feature-flags' const logger = createLogger('InputValidation') @@ -710,6 +711,13 @@ export function validateExternalUrl( } } + if (isLocalhost && isHosted) { + return { + isValid: false, + error: `${paramName} cannot point to localhost`, + } + } + if (options.allowHttp) { if (protocol !== 'https:' && protocol !== 'http:') { return { @@ -717,13 +725,7 @@ export function validateExternalUrl( error: `${paramName} must use http:// or https:// protocol`, } } - if (isLocalhost) { - return { - isValid: false, - error: `${paramName} cannot point to localhost`, - } - } - } else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) { + } else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost && !isHosted)) { return { isValid: false, error: `${paramName} must use https:// protocol`, diff --git a/apps/sim/lib/guardrails/validate_regex.ts b/apps/sim/lib/guardrails/validate_regex.ts index 16bd78ebf2d..40cfb0a0405 100644 --- a/apps/sim/lib/guardrails/validate_regex.ts +++ b/apps/sim/lib/guardrails/validate_regex.ts @@ -1,3 +1,5 @@ +import safe from 'safe-regex2' + /** * Validate if input matches regex pattern */ @@ -7,15 +9,23 @@ export interface ValidationResult { } export function validateRegex(inputStr: string, pattern: string): ValidationResult { + let regex: RegExp try { - const regex = new RegExp(pattern) - const match = regex.test(inputStr) - - if (match) { - return { passed: true } - } - return { passed: false, error: 'Input does not match regex pattern' } + regex = new RegExp(pattern) } catch (error: any) { return { passed: false, error: `Invalid regex pattern: ${error.message}` } } + + if (!safe(pattern)) { + return { + passed: false, + error: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)', + } + } + + const match = regex.test(inputStr) + if (match) { + return { passed: true } + } + return { passed: false, error: 'Input does not match regex pattern' } } diff --git a/apps/sim/package.json b/apps/sim/package.json index c8cc4530338..055a455b6c7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -116,8 +116,8 @@ "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", - "free-email-domains": "1.2.25", "framer-motion": "^12.5.0", + "free-email-domains": "1.2.25", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", @@ -174,6 +174,7 @@ "remark-gfm": "4.0.1", "resend": "^4.1.2", "rss-parser": "3.13.0", + "safe-regex2": "5.1.0", "sharp": "0.34.3", "soap": "1.8.0", "socket.io": "^4.8.1", diff --git a/bun.lock b/bun.lock index 42c3776ac3c..848126625b1 100644 --- a/bun.lock +++ b/bun.lock @@ -193,6 +193,7 @@ "remark-gfm": "4.0.1", "resend": "^4.1.2", "rss-parser": "3.13.0", + "safe-regex2": "5.1.0", "sharp": "0.34.3", "soap": "1.8.0", "socket.io": "^4.8.1", @@ -3320,6 +3321,8 @@ "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -3346,6 +3349,8 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],