From 6361b3102e0197c373e02f954034f2a59be02cda Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 22 Jul 2026 15:29:33 -0700 Subject: [PATCH 1/4] feat(api): add proxyUrl for residential/custom proxy egress on the API block The HTTP/API block egresses from the app runtime's fixed datacenter IPs via secureFetchWithPinnedIP, so targets behind Cloudflare/WAF that block datacenter IPs (e.g. state .gov license portals) return 403/429 even when the identical request works from a browser. There was no way to route a request through a residential/custom proxy. Add an optional `proxyUrl` field (Advanced) to the API block. When set, the request routes through the given http:// proxy so it egresses from that proxy's IP. Security: - validateAndPinProxyUrl resolves the proxy host's DNS and blocks private/reserved/loopback IPs (same SSRF guard as target URLs), then pins the connection by rewriting the host to the resolved IP (creds/port preserved), closing the DNS-rebinding window. - Restricted to the http: proxy scheme (https/socks rejected) so host pinning is safe without breaking TLS-to-proxy SNI. - Target-IP pinning is intentionally bypassed when a proxy is active (the proxy resolves the target); target URL validation still runs. Threaded block field -> http tool param -> formatRequestParams -> executeToolRequest (validate + pin) -> secureFetchWithPinnedIP, which swaps its pinned Node agent for HttpsProxyAgent/HttpProxyAgent (keyed off target protocol) when proxyUrl is set. --- apps/sim/blocks/blocks/api.ts | 13 +++ .../core/security/input-validation.server.ts | 85 +++++++++++++++++-- .../core/security/input-validation.test.ts | 60 +++++++++++++ apps/sim/package.json | 2 + apps/sim/tools/http/request.ts | 5 ++ apps/sim/tools/http/types.ts | 1 + apps/sim/tools/index.test.ts | 71 ++++++++++++++++ apps/sim/tools/index.ts | 11 +++ apps/sim/tools/utils.test.ts | 11 +++ apps/sim/tools/utils.ts | 8 +- bun.lock | 2 + .../src/mocks/input-validation.mock.ts | 2 + 12 files changed, 265 insertions(+), 6 deletions(-) diff --git a/apps/sim/blocks/blocks/api.ts b/apps/sim/blocks/blocks/api.ts index cecd661e4c7..347cac15afd 100644 --- a/apps/sim/blocks/blocks/api.ts +++ b/apps/sim/blocks/blocks/api.ts @@ -123,6 +123,15 @@ Example: description: 'Allow retries for POST/PATCH requests (may create duplicate requests)', mode: 'advanced', }, + { + id: 'proxyUrl', + title: 'Proxy URL', + type: 'short-input', + placeholder: 'http://user:pass@proxy.host:port', + description: + 'Optional. Route this request through an http:// proxy (e.g. a residential proxy). Must be http://; the proxy host must be publicly reachable.', + mode: 'advanced', + }, ], tools: { access: ['http_request'], @@ -144,6 +153,10 @@ Example: type: 'boolean', description: 'Allow retries for non-idempotent methods like POST/PATCH', }, + proxyUrl: { + type: 'string', + description: 'Optional http:// proxy URL to route the request through', + }, }, outputs: { data: { type: 'json', description: 'API response data (JSON, text, or other formats)' }, diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 09e758fa008..37414a8ade1 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -5,6 +5,8 @@ import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' +import { HttpProxyAgent } from 'http-proxy-agent' +import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' @@ -148,6 +150,65 @@ export async function validateUrlWithDNS( } } +/** + * Result of validating a user-supplied HTTP proxy URL. + */ +export interface ProxyValidationResult { + isValid: boolean + /** Proxy URL with hostname rewritten to the resolved IP (creds/port preserved) to pin the proxy connection. */ + pinnedProxyUrl?: string + error?: string +} + +/** + * Validates a user-supplied HTTP proxy URL and returns an IP-pinned form. + * + * When a request routes through a proxy, the TCP connection targets the proxy + * host (the proxy resolves the destination), so target-IP pinning no longer + * governs egress and the proxy URL becomes the SSRF surface. This function: + * 1. Enforces the `http:` scheme (raw TCP to the proxy, no TLS-to-proxy SNI to + * reconcile, so the host can be safely rewritten to an IP). + * 2. Resolves the proxy host's DNS and blocks private/reserved/loopback IPs via + * {@link validateUrlWithDNS}. + * 3. Pins the connection by rewriting the hostname to the resolved IP while + * preserving credentials/port, closing the DNS-rebinding (TOCTOU) window. + * + * @param proxyUrl - The proxy URL (e.g. `http://user:pass@host:port`) + */ +export async function validateAndPinProxyUrl( + proxyUrl: string | null | undefined +): Promise { + if (!proxyUrl || typeof proxyUrl !== 'string') { + return { isValid: false, error: 'proxyUrl must be a string' } + } + + let parsed: URL + try { + parsed = new URL(proxyUrl) + } catch { + return { isValid: false, error: 'proxyUrl must be a valid URL' } + } + + if (parsed.protocol !== 'http:') { + return { + isValid: false, + error: 'proxyUrl must use http:// (https/socks proxies are not supported)', + } + } + + const validation = await validateUrlWithDNS(proxyUrl, 'proxyUrl', { allowHttp: true }) + if (!validation.isValid) { + return { isValid: false, error: validation.error } + } + + // Bracket IPv6 literals: assigning an unbracketed IPv6 address to + // URL.hostname is a no-op (the WHATWG parser rejects it), which would leave + // the original DNS hostname in place and reopen the rebinding window. + const resolvedIP = validation.resolvedIP! + parsed.hostname = resolvedIP.includes(':') ? `[${resolvedIP}]` : resolvedIP + return { isValid: true, pinnedProxyUrl: parsed.toString() } +} + /** * Validates a database hostname by resolving DNS and checking the resolved IP * against private/reserved ranges to prevent SSRF via database connections. @@ -343,6 +404,12 @@ export interface SecureFetchOptions { signal?: AbortSignal /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */ stripAuthOnRedirect?: boolean + /** + * Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}). + * When set, the connection routes through this proxy and target-IP pinning is + * bypassed (the proxy resolves the target). + */ + proxyUrl?: string } export class SecureFetchHeaders { @@ -678,11 +745,19 @@ export async function secureFetchWithPinnedIP( const defaultPort = isHttps ? 443 : 80 const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort - const lookup = createPinnedLookup(resolvedIP) - - const agentOptions: http.AgentOptions = { lookup } - - const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) + let agent: http.Agent + if (options.proxyUrl) { + // The proxy connection is already IP-pinned by validateAndPinProxyUrl; routing + // through the proxy intentionally bypasses target-IP pinning (the proxy + // resolves the target). Agent choice keys off the target protocol: an + // https target tunnels via CONNECT, an http target uses absolute-URI + // forwarding - both over the plain-http proxy connection. + agent = isHttps ? new HttpsProxyAgent(options.proxyUrl) : new HttpProxyAgent(options.proxyUrl) + } else { + const lookup = createPinnedLookup(resolvedIP) + const agentOptions: http.AgentOptions = { lookup } + agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) + } const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {} diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index b4c87e09374..0da28ede451 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -28,6 +28,7 @@ import { } from '@/lib/core/security/input-validation' import { isPrivateOrReservedIP, + validateAndPinProxyUrl, validateDatabaseHost, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' @@ -843,6 +844,65 @@ describe('validateDatabaseHost', () => { }) }) +describe('validateAndPinProxyUrl', () => { + it('should reject a null/empty proxy URL', async () => { + expect((await validateAndPinProxyUrl(null)).isValid).toBe(false) + expect((await validateAndPinProxyUrl('')).isValid).toBe(false) + }) + + it('should reject a malformed URL', async () => { + const result = await validateAndPinProxyUrl('not a url') + expect(result.isValid).toBe(false) + expect(result.error).toContain('valid URL') + }) + + it('should reject an https:// proxy scheme', async () => { + const result = await validateAndPinProxyUrl('https://proxy.example.com:8080') + expect(result.isValid).toBe(false) + expect(result.error).toContain('http://') + }) + + it('should reject a socks5:// proxy scheme', async () => { + const result = await validateAndPinProxyUrl('socks5://proxy.example.com:1080') + expect(result.isValid).toBe(false) + expect(result.error).toContain('http://') + }) + + it('should reject a proxy host that is a private IP', async () => { + const result = await validateAndPinProxyUrl('http://user:pass@192.168.1.1:8080') + expect(result.isValid).toBe(false) + expect(result.error).toMatch(/private IP|blocked IP/) + }) + + it('should reject a proxy host that is the metadata IP', async () => { + const result = await validateAndPinProxyUrl('http://169.254.169.254:80') + expect(result.isValid).toBe(false) + expect(result.error).toMatch(/private IP|blocked IP/) + }) + + it('should accept a public proxy host and pin the hostname to the resolved IP, preserving creds/port', async () => { + const result = await validateAndPinProxyUrl('http://user:pass@8.8.8.8:8080') + expect(result.isValid).toBe(true) + const pinned = new URL(result.pinnedProxyUrl!) + expect(pinned.protocol).toBe('http:') + expect(pinned.hostname).toBe('8.8.8.8') + expect(pinned.username).toBe('user') + expect(pinned.password).toBe('pass') + expect(pinned.port).toBe('8080') + }) + + it('should bracket an IPv6 resolved address so the pinned host is the IP, not the original name', async () => { + const result = await validateAndPinProxyUrl('http://user:pass@[2606:4700:4700::1111]:8080') + expect(result.isValid).toBe(true) + const pinned = new URL(result.pinnedProxyUrl!) + // Must be pinned to the bracketed IPv6 literal, never left as a DNS name. + expect(pinned.hostname).toBe('[2606:4700:4700::1111]') + expect(pinned.username).toBe('user') + expect(pinned.password).toBe('pass') + expect(pinned.port).toBe('8080') + }) +}) + describe('validateInteger', () => { describe('valid integers', () => { it.concurrent('should accept positive integers', () => { diff --git a/apps/sim/package.json b/apps/sim/package.json index 8e13a844bd9..9b2b9b1413b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -155,6 +155,8 @@ "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", "html-to-text": "^9.0.5", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", "idb-keyval": "6.2.2", "image-size": "1.2.1", "imapflow": "1.2.4", diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index c4a94db75aa..130a28a3fa6 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -51,6 +51,11 @@ export const requestTool: ToolConfig = { visibility: 'user-or-llm', description: 'Form data to send (will set appropriate Content-Type)', }, + proxyUrl: { + type: 'string', + visibility: 'user-only', + description: 'Route the request through an http:// proxy (e.g. http://user:pass@host:port).', + }, timeout: { type: 'number', visibility: 'user-only', diff --git a/apps/sim/tools/http/types.ts b/apps/sim/tools/http/types.ts index 7d2e95aa4bd..8ca91e1b102 100644 --- a/apps/sim/tools/http/types.ts +++ b/apps/sim/tools/http/types.ts @@ -8,6 +8,7 @@ export interface RequestParams { params?: TableRow[] | string pathParams?: Record formData?: Record + proxyUrl?: string timeout?: number retries?: number retryDelayMs?: number diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index f16e5927eeb..5df9984cf10 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -958,6 +958,77 @@ describe('Automatic Internal Route Detection', () => { Object.assign(tools, originalTools) }) + it('should validate + pin a proxyUrl param and pass it to secureFetchWithPinnedIP', async () => { + inputValidationMockFns.mockValidateAndPinProxyUrl.mockResolvedValue({ + isValid: true, + pinnedProxyUrl: 'http://user:pass@1.2.3.4:8080/', + }) + + const mockTool = { + id: 'test_external_proxy', + name: 'Test External Proxy Tool', + description: 'A test tool that routes through a proxy', + version: '1.0.0', + params: {}, + request: { + url: 'https://api.example.com/endpoint', + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + + const originalTools = { ...tools } + ;(tools as any).test_external_proxy = mockTool + + await executeTool('test_external_proxy', { proxyUrl: 'http://user:pass@proxy.host:8080' }) + + expect(inputValidationMockFns.mockValidateAndPinProxyUrl).toHaveBeenCalledWith( + 'http://user:pass@proxy.host:8080' + ) + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + 'https://api.example.com/endpoint', + '93.184.216.34', + expect.objectContaining({ proxyUrl: 'http://user:pass@1.2.3.4:8080/' }) + ) + + Object.assign(tools, originalTools) + }) + + it('should throw when the proxyUrl param fails validation', async () => { + inputValidationMockFns.mockValidateAndPinProxyUrl.mockResolvedValue({ + isValid: false, + error: 'proxyUrl must use http:// (https/socks proxies are not supported)', + }) + + const mockTool = { + id: 'test_external_bad_proxy', + name: 'Test External Bad Proxy Tool', + description: 'A test tool with an invalid proxy', + version: '1.0.0', + params: {}, + request: { + url: 'https://api.example.com/endpoint', + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + + const originalTools = { ...tools } + ;(tools as any).test_external_bad_proxy = mockTool + + const result = await executeTool('test_external_bad_proxy', { + proxyUrl: 'https://proxy.host:8080', + }) + + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid proxy URL') + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + + Object.assign(tools, originalTools) + }) + it('should handle dynamic URLs that resolve to internal routes', async () => { const mockTool = { id: 'test_dynamic_internal', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3ca1fbe1e15..c4fb4775447 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -14,6 +14,7 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS, getMaxExecutionTimeout } from '@/lib/core import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter' import { secureFetchWithPinnedIP, + validateAndPinProxyUrl, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { PlatformEvents } from '@/lib/core/telemetry' @@ -1809,6 +1810,15 @@ async function executeToolRequest( throw new Error(`Invalid tool URL: ${urlValidation.error}`) } + let proxyOption: string | undefined + if (requestParams.proxyUrl) { + const proxyValidation = await validateAndPinProxyUrl(requestParams.proxyUrl) + if (!proxyValidation.isValid) { + throw new Error(`Invalid proxy URL: ${proxyValidation.error}`) + } + proxyOption = proxyValidation.pinnedProxyUrl + } + const secureResponse = await secureFetchWithPinnedIP(fullUrl, urlValidation.resolvedIP!, { method: requestParams.method, headers: headersRecord, @@ -1816,6 +1826,7 @@ async function executeToolRequest( timeout: requestParams.timeout, maxResponseBytes: MAX_TOOL_RESPONSE_BODY_BYTES, signal, + proxyUrl: proxyOption, }) const responseHeaders = new Headers(secureResponse.headers.toRecord()) diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index e5e88ff20e6..17f390c9661 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -209,6 +209,17 @@ describe('formatRequestParams', () => { expect(result.body).toBe('{"prompt": "Hello"}\n{"prompt": "World"}') }) + + it.concurrent('should pass through a non-empty proxyUrl (trimmed)', () => { + const result = formatRequestParams(mockTool, { proxyUrl: ' http://user:pass@host:8080 ' }) + expect(result.proxyUrl).toBe('http://user:pass@host:8080') + }) + + it.concurrent('should omit proxyUrl when blank, whitespace, or absent', () => { + expect(formatRequestParams(mockTool, {}).proxyUrl).toBeUndefined() + expect(formatRequestParams(mockTool, { proxyUrl: '' }).proxyUrl).toBeUndefined() + expect(formatRequestParams(mockTool, { proxyUrl: ' ' }).proxyUrl).toBeUndefined() + }) }) describe('validateRequiredParametersAfterMerge', () => { diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 177928e64e1..229a5830795 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -82,6 +82,7 @@ export interface RequestParams { headers: Record body?: string timeout?: number + proxyUrl?: string } /** @@ -137,7 +138,12 @@ export function formatRequestParams(tool: ToolConfig, params: Record Date: Wed, 22 Jul 2026 16:03:03 -0700 Subject: [PATCH 2/4] docs(api): document the Proxy URL advanced field and steer proxy credentials to env vars --- apps/docs/content/docs/en/workflows/blocks/api.mdx | 1 + apps/sim/blocks/blocks/api.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/api.mdx b/apps/docs/content/docs/en/workflows/blocks/api.mdx index 3d8fd7c22e3..8a0354ae5cb 100644 --- a/apps/docs/content/docs/en/workflows/blocks/api.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/api.mdx @@ -40,6 +40,7 @@ The request payload for POST, PUT, and PATCH, sent as JSON. Type it directly, or - **Retries.** Number of retry attempts on timeouts, `429` responses, and `5xx` errors. Defaults to 0. - **Retry delay / Max retry delay (ms).** The exponential-backoff bounds used between retries. - **Retry non-idempotent methods.** Off by default, so POST and PATCH are not retried, which avoids duplicate writes. Turn it on only when a repeated request is safe. +- **Proxy URL.** Optional `http://` proxy the request egresses through, such as a residential proxy for targets that block datacenter IPs. The proxy host must be publicly reachable, and only the `http://` scheme is supported (an `https://` target still tunnels through it securely). Keep proxy credentials in an environment variable and reference it as `{{PROXY_URL}}` rather than typing them into the field. ## Outputs diff --git a/apps/sim/blocks/blocks/api.ts b/apps/sim/blocks/blocks/api.ts index 347cac15afd..72450921163 100644 --- a/apps/sim/blocks/blocks/api.ts +++ b/apps/sim/blocks/blocks/api.ts @@ -127,9 +127,9 @@ Example: id: 'proxyUrl', title: 'Proxy URL', type: 'short-input', - placeholder: 'http://user:pass@proxy.host:port', + placeholder: 'http://user:pass@proxy.host:port or {{PROXY_URL}}', description: - 'Optional. Route this request through an http:// proxy (e.g. a residential proxy). Must be http://; the proxy host must be publicly reachable.', + 'Optional. Route this request through an http:// proxy (e.g. a residential proxy). Must be http://; the proxy host must be publicly reachable. Keep credentials in an environment variable and reference it like {{PROXY_URL}}.', mode: 'advanced', }, ], From d9b9440fd271a9a4389e38a01b649a774ba16216 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 22 Jul 2026 16:13:05 -0700 Subject: [PATCH 3/4] fix(api): reject loopback/private proxy hosts unconditionally, closing the self-hosted rebinding gap --- apps/sim/lib/core/security/input-validation.server.ts | 11 ++++++++++- apps/sim/lib/core/security/input-validation.test.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 37414a8ade1..7dda9ed443f 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -201,10 +201,19 @@ export async function validateAndPinProxyUrl( return { isValid: false, error: validation.error } } + const resolvedIP = validation.resolvedIP! + + // validateUrlWithDNS carves out loopback for self-hosted dev targets, but a proxy + // must be publicly reachable unconditionally: once it governs egress, a loopback + // forward proxy plus a rebinding target could reach internal addresses that + // target-IP pinning otherwise blocks. + if (isPrivateOrReservedIP(resolvedIP)) { + return { isValid: false, error: 'proxyUrl resolves to a blocked IP address' } + } + // Bracket IPv6 literals: assigning an unbracketed IPv6 address to // URL.hostname is a no-op (the WHATWG parser rejects it), which would leave // the original DNS hostname in place and reopen the rebinding window. - const resolvedIP = validation.resolvedIP! parsed.hostname = resolvedIP.includes(':') ? `[${resolvedIP}]` : resolvedIP return { isValid: true, pinnedProxyUrl: parsed.toString() } } diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 0da28ede451..bdf98ea53f5 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -874,6 +874,15 @@ describe('validateAndPinProxyUrl', () => { expect(result.error).toMatch(/private IP|blocked IP/) }) + it('should reject a loopback proxy host even off the hosted platform', async () => { + const localhost = await validateAndPinProxyUrl('http://localhost:3128') + expect(localhost.isValid).toBe(false) + expect(localhost.error).toContain('blocked IP') + const loopback = await validateAndPinProxyUrl('http://127.0.0.1:3128') + expect(loopback.isValid).toBe(false) + expect(loopback.error).toContain('blocked IP') + }) + it('should reject a proxy host that is the metadata IP', async () => { const result = await validateAndPinProxyUrl('http://169.254.169.254:80') expect(result.isValid).toBe(false) From ec633dc804e7bbee40bc5286facbaf99e178d54d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 22 Jul 2026 16:18:30 -0700 Subject: [PATCH 4/4] chore(api): tighten proxy-path inline comments --- .../core/security/input-validation.server.ts | 19 +++++++------------ .../core/security/input-validation.test.ts | 1 - 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 7dda9ed443f..17de7aa63c8 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -203,17 +203,14 @@ export async function validateAndPinProxyUrl( const resolvedIP = validation.resolvedIP! - // validateUrlWithDNS carves out loopback for self-hosted dev targets, but a proxy - // must be publicly reachable unconditionally: once it governs egress, a loopback - // forward proxy plus a rebinding target could reach internal addresses that - // target-IP pinning otherwise blocks. + // validateUrlWithDNS permits loopback for self-hosted dev targets; a proxy governs + // egress, so loopback/private proxy hosts stay blocked unconditionally. if (isPrivateOrReservedIP(resolvedIP)) { return { isValid: false, error: 'proxyUrl resolves to a blocked IP address' } } - // Bracket IPv6 literals: assigning an unbracketed IPv6 address to - // URL.hostname is a no-op (the WHATWG parser rejects it), which would leave - // the original DNS hostname in place and reopen the rebinding window. + // Bracket IPv6 literals: assigning an unbracketed IPv6 address to URL.hostname + // is a no-op, which would leave the DNS hostname in place and reopen rebinding. parsed.hostname = resolvedIP.includes(':') ? `[${resolvedIP}]` : resolvedIP return { isValid: true, pinnedProxyUrl: parsed.toString() } } @@ -756,11 +753,9 @@ export async function secureFetchWithPinnedIP( let agent: http.Agent if (options.proxyUrl) { - // The proxy connection is already IP-pinned by validateAndPinProxyUrl; routing - // through the proxy intentionally bypasses target-IP pinning (the proxy - // resolves the target). Agent choice keys off the target protocol: an - // https target tunnels via CONNECT, an http target uses absolute-URI - // forwarding - both over the plain-http proxy connection. + // Proxy connection is already IP-pinned by validateAndPinProxyUrl; target-IP + // pinning is intentionally bypassed (the proxy resolves the target). https + // targets tunnel via CONNECT, http targets use absolute-URI forwarding. agent = isHttps ? new HttpsProxyAgent(options.proxyUrl) : new HttpProxyAgent(options.proxyUrl) } else { const lookup = createPinnedLookup(resolvedIP) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index bdf98ea53f5..eb44eb55939 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -904,7 +904,6 @@ describe('validateAndPinProxyUrl', () => { const result = await validateAndPinProxyUrl('http://user:pass@[2606:4700:4700::1111]:8080') expect(result.isValid).toBe(true) const pinned = new URL(result.pinnedProxyUrl!) - // Must be pinned to the bracketed IPv6 literal, never left as a DNS name. expect(pinned.hostname).toBe('[2606:4700:4700::1111]') expect(pinned.username).toBe('user') expect(pinned.password).toBe('pass')