diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index bef679c734a..cea63fe4024 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -255,6 +255,7 @@ Configure at least one. Every configured provider stays active and is tried in o | `EGRESS_ALLOWED_HOSTS` | unset | Comma-separated hostnames outbound requests may reach on a private network. Leading wildcard allowed, e.g. `host.docker.internal,*.svc.cluster.local`. Not honored for URLs harvested from content or a third-party response, nor for an HTTP block's `proxyUrl` | | `EGRESS_ALLOWED_IP_RANGES` | unset | Comma-separated CIDRs or IPs outbound requests may reach on a private network, e.g. `10.0.0.0/8`. Same exclusions | | `ALLOW_PRIVATE_DATABASE_HOSTS` | unset | **Deprecated.** Vouches for the entire private address space, for database, cache, and mail connector hosts only. Replace it with the two settings above naming specific destinations | +| `DOMAIN_VERIFICATION_NAMESERVERS` | `1.1.1.1,8.8.8.8` | Comma-separated nameserver IPs that SSO domain verification sends its TXT lookup to. Set it when the network only allows DNS to an internal resolver. Entries that are not IP addresses are ignored | A malformed entry in either allowlist stops the app at startup with a message naming the setting. See [the SSRF boundary](/platform/self-hosting/security#the-ssrf-boundary) for the accepted syntax and for what the allowlist does and does not reach. diff --git a/apps/docs/content/docs/platform/self-hosting/networking.mdx b/apps/docs/content/docs/platform/self-hosting/networking.mdx index 5912e1a4910..1a995ac67c5 100644 --- a/apps/docs/content/docs/platform/self-hosting/networking.mdx +++ b/apps/docs/content/docs/platform/self-hosting/networking.mdx @@ -265,7 +265,7 @@ Most `/api` requests also pass through Next.js Proxy. The current app configurat The app makes outbound calls to model providers, integration APIs, your email provider, object storage, and your telemetry backend. Whether `HTTP_PROXY` / `HTTPS_PROXY` apply depends on which of those paths a call takes — there is no single answer, and no global setting that covers all of them. -The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The rest either build their own HTTP agent or speak a non-HTTP protocol. +The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The SSRF guard reads the same three variables, with the same rules, for every request it sends. The rest either build their own HTTP agent or speak a non-HTTP protocol. This depends on the runtime. The published images run Bun. If you build the standalone output and run it under Node instead, Node ignores these variables unless started with `NODE_USE_ENV_PROXY=1` (Node 22.21+ / 24.5+), and the `fetch`-based "Yes" rows stop being proxied — model providers, Resend, Gmail sending, the desktop update feed, and the telemetry relay. The Azure Blob, GCS and Azure Communication Services rows still hold: those SDKs read the proxy variables through their own agents rather than through `fetch`. See [Bun fetch](https://bun.sh/docs/runtime/networking/fetch) and [Node proxy configuration](https://nodejs.org/learn/http/enterprise-network-configuration). @@ -275,20 +275,31 @@ This depends on the runtime. The published images run Bun. If you build the stan | Email via Resend, Azure Communication Services, and Gmail sending | Yes | | The desktop update feed's calls to GitHub | Yes | | Object storage — Azure Blob and GCS | Yes — their SDK pipelines read the proxy variables | -| Everything through the SSRF guard — the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers | No | -| Azure OpenAI, Azure Anthropic, vLLM | Only when the endpoint comes from `AZURE_OPENAI_ENDPOINT`, `AZURE_ANTHROPIC_ENDPOINT`, or `VLLM_BASE_URL`. An endpoint typed into the block is validated and pinned to its resolved IP, which bypasses the proxy | -| Amazon Bedrock, and object storage on S3 | No — the AWS SDK uses its own request handler | -| Email via SMTP | No — Nodemailer opens a raw TCP connection | -| Email via Amazon SES | No — the AWS SDK transport, over HTTPS | -| OTLP export from the server SDK | No | +| Everything through the SSRF guard — SSO OIDC discovery, the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers, and model endpoints typed into a block | Yes — see [Proxying guarded requests](#proxying-guarded-requests) | +| SSO sign-in — the token, JWKS, and userinfo calls made while a user signs in | Yes — they use the default `fetch` | +| SSO domain verification (TXT lookup) | No — DNS, sent to `1.1.1.1` and `8.8.8.8` unless `DOMAIN_VERIFICATION_NAMESERVERS` names your resolvers | +| Amazon Bedrock, object storage on S3, and every AWS integration (DynamoDB, SQS, Lambda, Textract, and the rest) | Yes — each AWS client gets a proxy-aware agent while a proxy is set. Bedrock then uses HTTP/1.1 instead of HTTP/2 | +| Email via SMTP, and the SMTP block | No — SMTP is not HTTP. Use a mail relay the app can reach directly, or Resend or SES | +| Email via Amazon SES | Yes — the AWS SDK client above | +| OTLP export from the server SDK | Yes — the exporters get the same proxy-aware agent | | The `/api/telemetry` relay that forwards browser events | Yes — it uses the default `fetch` | | Postgres and Redis | No — raw TCP | -The practical consequence: a mandatory-egress-proxy environment can route most LLM traffic, Resend mail, and Azure/GCS storage through the proxy, but guarded integration calls, S3, Bedrock, SMTP, telemetry, and datastore traffic still need a transparent proxy or NAT-based egress. +The practical consequence: a mandatory-egress-proxy environment can route LLM traffic, SSO, integration and workflow HTTP calls, Resend mail, Azure/GCS storage, AWS services, SES, and OTLP export through the proxy. SMTP and datastore traffic (Postgres, Redis, and the database blocks) still need a transparent proxy or NAT-based egress. + +### Proxying guarded requests + +A request through the SSRF guard keeps its checks when it is proxied. Before each request, and again for each redirect hop, the app resolves the destination's hostname with its own DNS and refuses it if any policy in [Security](/platform/self-hosting/security#the-ssrf-boundary) blocks the address. It then hands the hostname, not the address, to the proxy, so a proxy that allowlists by domain sees the name it expects. + +- **DNS must still resolve inside the container.** A destination that the app cannot resolve is refused before the proxy is asked. +- **The proxy resolves the name again.** The app cannot pin the address across a proxy, so the proxy's own egress policy is the last check. +- **`https` destinations are tunneled** with `CONNECT host:443`. **`http` destinations are forwarded** to the proxy as an absolute URL, as curl and Bun do. +- **Proxy credentials** go in the URL, e.g. `http://user:pass@proxy.internal:3128`. They are sent only to the proxy. An `https://` proxy URL also works; SOCKS proxies are not supported. +- **The HTTP block's `proxyUrl`** still takes precedence for that one request. ### Internal destinations and `NO_PROXY` -Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over the same default `fetch`, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference. +Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over the same default `fetch`, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference. AWS clients and OTLP export follow the same rule, so also add an internal S3-compatible store such as MinIO, AWS VPC endpoints, and an in-cluster OpenTelemetry collector. Set it in the application environment, not your shell — under `app.env` on Helm, or the service's `environment:` on Compose. On Helm the suffixes alone are not enough: the chart wires `SOCKET_SERVER_URL`, `PII_URL`, and `OLLAMA_URL` to bare Service names, which no domain suffix matches. Add those names too — `helm template` prints the rendered ones, and the prefix is the release name unless it already contains `sim`, in which case it is the release name alone: diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index ccf71fe631c..276871980dd 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -176,6 +176,8 @@ async function initializeOpenTelemetry() { const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_DEPLOYMENT_ENVIRONMENT } = await import( '@opentelemetry/semantic-conventions/incubating' ) + const { otlpExporterProxyOptions } = await import('@/lib/core/network/proxy-agent.server') + const exporterProxyOptions = otlpExporterProxyOptions() const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http') const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http') const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics') @@ -236,6 +238,7 @@ async function initializeOpenTelemetry() { headers: otlpHeaders, timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), keepAlive: false, + ...exporterProxyOptions, }) // Surface export failures (BatchSpanProcessor swallows them otherwise). @@ -273,6 +276,7 @@ async function initializeOpenTelemetry() { headers: otlpHeaders, timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), keepAlive: false, + ...exporterProxyOptions, }), exportIntervalMillis: 60000, }) @@ -289,6 +293,7 @@ async function initializeOpenTelemetry() { headers: otlpHeaders, timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), keepAlive: false, + ...exporterProxyOptions, }), }) diff --git a/apps/sim/lib/auth/sso/domain-verification.ts b/apps/sim/lib/auth/sso/domain-verification.ts index dd9bb039e1f..65613421629 100644 --- a/apps/sim/lib/auth/sso/domain-verification.ts +++ b/apps/sim/lib/auth/sso/domain-verification.ts @@ -1,8 +1,10 @@ import { Resolver } from 'node:dns/promises' +import { isIP } from 'node:net' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import type { OrganizationDomain } from '@/lib/api/contracts/organization' +import { env } from '@/lib/core/config/env' const logger = createLogger('SSODomainVerification') @@ -54,7 +56,24 @@ const TXT_VALUE_PREFIX = 'sim-domain-verification=' /** Public nameservers used for the challenge lookup, so verification does not * depend on (or get poisoned by) the host's local resolver/split-horizon DNS. */ -const VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8'] +const DEFAULT_VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8'] + +/** + * `DOMAIN_VERIFICATION_NAMESERVERS` replaces the public resolvers for deployments whose + * outbound DNS only reaches an internal resolver. An entry that is not an IP address is + * skipped rather than failing every verification. + */ +function resolveVerificationNameservers(): string[] { + const configured = (env.DOMAIN_VERIFICATION_NAMESERVERS ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const usable = configured.filter((entry) => isIP(entry) !== 0) + if (usable.length < configured.length) { + logger.error('Ignoring DOMAIN_VERIFICATION_NAMESERVERS entries that are not IP addresses') + } + return usable.length > 0 ? usable : DEFAULT_VERIFICATION_NAMESERVERS +} /** * Per-attempt timeout. c-ares multiplies this across servers and retries by @@ -74,12 +93,12 @@ const DNS_TIMEOUT_MS = 2000 const RECORD_ABSENT_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'NXDOMAIN']) /** - * Shared resolver pinned to the public nameservers. Its config is fully static + * Shared resolver pinned to the verification nameservers. Its config is fully static * and `resolveTxt` is safe to call concurrently, so a single module-scope * instance avoids re-allocating one per verification. */ const verificationResolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 }) -verificationResolver.setServers(VERIFICATION_NAMESERVERS) +verificationResolver.setServers(resolveVerificationNameservers()) /** The fully-qualified host an org must create the TXT record on. */ export function buildChallengeHost(domain: string): string { diff --git a/apps/sim/lib/billing/aws-marketplace/client.ts b/apps/sim/lib/billing/aws-marketplace/client.ts index 44734f79345..1e3ec4ee87d 100644 --- a/apps/sim/lib/billing/aws-marketplace/client.ts +++ b/apps/sim/lib/billing/aws-marketplace/client.ts @@ -4,6 +4,7 @@ import { SQSClient } from '@aws-sdk/client-sqs' import { fromHttp } from '@aws-sdk/credential-providers' import { getAwsCredentials } from '@/lib/core/config/aws' import { env } from '@/lib/core/config/env' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' /** * The Entitlement Service exists only here. Metering is multi-region, but a registration token is @@ -38,7 +39,7 @@ function marketplaceClientConfig() { const fullUri = env.AWS_CONTAINER_CREDENTIALS_FULL_URI return { region: MARKETPLACE_REGION, - requestHandler: REQUEST_HANDLER, + requestHandler: awsRequestHandler(REQUEST_HANDLER), credentials: relativeUri || fullUri ? fromHttp({ diff --git a/apps/sim/lib/core/config/appconfig.ts b/apps/sim/lib/core/config/appconfig.ts index da35e356bb6..d885f7a7d9b 100644 --- a/apps/sim/lib/core/config/appconfig.ts +++ b/apps/sim/lib/core/config/appconfig.ts @@ -10,6 +10,7 @@ import { toRecordOrNull } from '@sim/utils/object' import { LRUCache } from 'lru-cache' import { getAwsCredentials } from '@/lib/core/config/aws' import { env } from '@/lib/core/config/env' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' const logger = createLogger('AppConfig') @@ -122,6 +123,7 @@ function getClient(): AppConfigDataClient { client = new AppConfigDataClient({ region: env.AWS_REGION, credentials: getAwsCredentials(), + requestHandler: awsRequestHandler(), }) } return client diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 01b51480d1a..99ce75e3603 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -134,6 +134,7 @@ export const env = createEnv({ ALLOW_PRIVATE_DATABASE_HOSTS: z.boolean().optional(), // Deprecated alias for the egress allowlist, kept so existing self-hosted deployments keep working. Equivalent to allowing every private, reserved, and loopback destination. Prefer EGRESS_ALLOWED_HOSTS / EGRESS_ALLOWED_IP_RANGES, which name specific destinations. EGRESS_ALLOWED_HOSTS: z.string().optional(), // Comma-separated hostnames outbound requests may reach on a private network, leading wildcard allowed (e.g. "host.docker.internal,*.svc.cluster.local"). Self-hosted only; ignored on the hosted platform. Replaces ALLOW_PRIVATE_DATABASE_HOSTS. EGRESS_ALLOWED_IP_RANGES: z.string().optional(), // Comma-separated CIDRs or IPs outbound requests may reach on a private network (e.g. "10.0.0.0/8,192.168.65.254/32"). Self-hosted only; never lifts the cloud-metadata block. + DOMAIN_VERIFICATION_NAMESERVERS: z.string().optional(), // Comma-separated nameserver IPs for the domain-verification TXT lookup (e.g. "10.0.0.2"). Defaults to 1.1.1.1 and 8.8.8.8; set it where outbound DNS is restricted to an internal resolver. ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com") diff --git a/apps/sim/lib/core/network/environment-proxy.server.test.ts b/apps/sim/lib/core/network/environment-proxy.server.test.ts new file mode 100644 index 00000000000..a4441a9671f --- /dev/null +++ b/apps/sim/lib/core/network/environment-proxy.server.test.ts @@ -0,0 +1,361 @@ +import { execFile } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createServer as httpServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { createServer as httpsServer } from 'node:https' +import { type AddressInfo, connect, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Duplex } from 'node:stream' +import { getCACertificates, setDefaultCACertificates } from 'node:tls' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { ListBucketsCommand, S3Client } from '@aws-sdk/client-s3' +import { networkConfigMock } from '@sim/testing/mocks/network-config.mock' +import { NodeHttpHandler } from '@smithy/node-http-handler' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/network/config.server', () => networkConfigMock) +vi.mock('@sim/security/dns', async (importOriginal) => { + const actual = await importOriginal() + const records: Record = { + 'origin.invalid': '1.1.1.1', + 'internal.invalid': '10.0.0.5', + } + return { + ...actual, + resolveHostAddresses: async (host: string) => { + const address = records[host] + if (!address) throw new Error(`No addresses for ${host}`) + return { addresses: [address], preferred: address } + }, + } +}) + +import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' +import { + createPinnedFetchWithDispatcher, + createSsrfGuardedFetchWithDispatcher, + secureFetchWithPinnedIP, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' + +const certificateDirectory = mkdtempSync(join(tmpdir(), 'environment-proxy-tls-')) +const certificatePath = join(certificateDirectory, 'cert.pem') +const keyPath = join(certificateDirectory, 'key.pem') +const sockets = new Set() +const tunnels: Array<{ authority: string | undefined; authorization: string | undefined }> = [] +const forwarded: Array<{ url: string | undefined; authorization: string | undefined }> = [] +const proxiedPorts = new Set() +let originHeaders: Record = {} + +const secureOrigin = httpsServer((req, res) => { + originHeaders = req.headers + res.setHeader('x-via-proxy', proxiedPorts.has(req.socket.remotePort ?? 0) ? 'yes' : 'no') + if (req.url === '/to-internal') { + res.writeHead(302, { location: 'https://internal.invalid/' }) + res.end() + return + } + if (String(req.headers.authorization).startsWith('AWS4-HMAC-SHA256')) { + res.setHeader('content-type', 'application/xml') + res.end('') + return + } + res.end('tls reached') +}) + +/** A forward proxy: CONNECT tunnels reach the TLS origin, absolute-form requests are answered here. */ +function forwardRequest(req: IncomingMessage, res: ServerResponse) { + forwarded.push({ url: req.url, authorization: req.headers['proxy-authorization'] }) + res.end('forwarded') +} +function tunnel(req: IncomingMessage, socket: Duplex, head: Buffer) { + socket.on('error', () => {}) + tunnels.push({ authority: req.url, authorization: req.headers['proxy-authorization'] }) + const upstream = connect(securePort, '127.0.0.1', () => { + if (upstream.localPort) proxiedPorts.add(upstream.localPort) + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (head.length) upstream.write(head) + socket.pipe(upstream).pipe(socket) + }) + upstream.on('error', () => socket.destroy()) + socket.on('close', () => upstream.destroy()) + sockets.add(upstream) + upstream.on('close', () => sockets.delete(upstream)) +} +const proxy = httpServer(forwardRequest) +proxy.on('connect', tunnel) +const tlsProxy = httpsServer(forwardRequest) +tlsProxy.on('connect', tunnel) + +let securePort = 0 +let proxyUrl = '' +let tlsProxyUrl = '' +const trust = getCACertificates('default') +const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy'] +const options = { profile: 'contentFetch' as const, timeout: 10_000 } + +beforeAll(async () => { + await promisify(execFile)('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-sha256', + '-nodes', + '-keyout', + keyPath, + '-out', + certificatePath, + '-days', + '2', + '-subj', + '/CN=origin.invalid', + '-addext', + 'subjectAltName=DNS:origin.invalid,IP:1.1.1.1,IP:127.0.0.1', + '-addext', + 'extendedKeyUsage=serverAuth', + ]) + const cert = readFileSync(certificatePath, 'utf8') + const key = readFileSync(keyPath, 'utf8') + for (const server of [secureOrigin, tlsProxy]) server.setSecureContext({ cert, key }) + setDefaultCACertificates([...trust, cert]) + for (const server of [secureOrigin, proxy, tlsProxy]) { + server.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + } + securePort = (secureOrigin.address() as AddressInfo).port + proxyUrl = `http://sim:proxy-secret@127.0.0.1:${(proxy.address() as AddressInfo).port}` + tlsProxyUrl = `https://sim:proxy-secret@127.0.0.1:${(tlsProxy.address() as AddressInfo).port}` +}) + +beforeEach(() => { + for (const name of PROXY_ENV) vi.stubEnv(name, '') + vi.stubEnv('HTTPS_PROXY', proxyUrl) + vi.stubEnv('HTTP_PROXY', proxyUrl) + tunnels.length = 0 + forwarded.length = 0 + originHeaders = {} +}) + +afterAll(async () => { + for (const socket of sockets) socket.destroy() + await Promise.all( + [secureOrigin, proxy, tlsProxy].map( + (server) => new Promise((resolve) => server.close(() => resolve())) + ) + ) + setDefaultCACertificates(trust) + rmSync(certificateDirectory, { recursive: true, force: true }) +}) + +const PROXY_AUTHORIZATION = `Basic ${Buffer.from('sim:proxy-secret').toString('base64')}` + +describe('guarded requests under HTTPS_PROXY', () => { + it('tunnels a validated request by hostname and keeps proxy credentials off the origin', async () => { + const response = await secureFetchWithValidation('https://origin.invalid/discovery', { + ...options, + headers: { 'Proxy-Authorization': 'must-not-reach-origin' }, + }) + expect(await response.text()).toBe('tls reached') + expect(response.headers.get('x-via-proxy')).toBe('yes') + expect(tunnels).toEqual([ + { authority: 'origin.invalid:443', authorization: PROXY_AUTHORIZATION }, + ]) + expect(originHeaders['proxy-authorization']).toBeUndefined() + expect(originHeaders.host).toBe('origin.invalid') + }) + + it('tunnels pinned and DNS-guarded fetches', async () => { + const pinned = createPinnedFetchWithDispatcher('1.1.1.1', options) + const guarded = createSsrfGuardedFetchWithDispatcher(options) + try { + expect(await (await pinned.fetch('https://origin.invalid/')).text()).toBe('tls reached') + expect(await (await guarded.fetch('https://origin.invalid/')).text()).toBe('tls reached') + expect(tunnels.map((tunnel) => tunnel.authority)).toEqual([ + 'origin.invalid:443', + 'origin.invalid:443', + ]) + } finally { + await Promise.all([pinned.dispatcher.destroy(), guarded.dispatcher.destroy()]) + } + }) + + it('tunnels through an https:// proxy, verifying the proxy certificate', async () => { + vi.stubEnv('HTTPS_PROXY', tlsProxyUrl) + const response = await secureFetchWithValidation('https://origin.invalid/', options) + expect(await response.text()).toBe('tls reached') + expect(tunnels).toEqual([ + { authority: 'origin.invalid:443', authorization: PROXY_AUTHORIZATION }, + ]) + }) + + it('forwards plain-http destinations in absolute form instead of tunneling', async () => { + const response = await secureFetchWithPinnedIP('http://origin.invalid/plain', '1.1.1.1', { + ...options, + profile: 'selfHostedService', + }) + expect(await response.text()).toBe('forwarded') + expect(tunnels).toEqual([]) + expect(forwarded).toEqual([ + { url: 'http://origin.invalid/plain', authorization: PROXY_AUTHORIZATION }, + ]) + }) + + it('connects directly, still pinned, when NO_PROXY names the host', async () => { + vi.stubEnv('NO_PROXY', 'localhost,.invalid') + const response = await secureFetchWithPinnedIP( + `https://origin.invalid:${securePort}/`, + '127.0.0.1', + options + ) + expect(await response.text()).toBe('tls reached') + expect(response.headers.get('x-via-proxy')).toBe('no') + expect(tunnels).toEqual([]) + }) + + it('refuses a destination that resolves to a blocked address without asking the proxy', async () => { + const guarded = createSsrfGuardedFetchWithDispatcher(options) + try { + await expect(guarded.fetch('https://internal.invalid/')).rejects.toThrow(/private|blocked/i) + await expect( + secureFetchWithPinnedIP('https://internal.invalid/', '1.1.1.1', options) + ).rejects.toThrow(/private|blocked/i) + } finally { + await guarded.dispatcher.destroy() + } + expect(tunnels).toEqual([]) + }) + + it('judges each redirect hop again before proxying it', async () => { + const pinned = createPinnedFetchWithDispatcher('1.1.1.1', options) + try { + await expect(pinned.fetch('https://origin.invalid/to-internal')).rejects.toThrow( + /private|blocked/i + ) + await expect( + secureFetchWithPinnedIP('https://origin.invalid/to-internal', '1.1.1.1', options) + ).rejects.toThrow(/private|blocked/i) + } finally { + await pinned.dispatcher.destroy() + } + expect(tunnels.map((tunnel) => tunnel.authority)).toEqual([ + 'origin.invalid:443', + 'origin.invalid:443', + ]) + }) + + it('tunnels guarded fetches and AWS SDK requests under the actual Bun runtime', async () => { + await promisify(execFile)( + 'bun', + [ + '--no-env-file', + 'run', + fileURLToPath(new URL('./fixtures/environment-proxy-runtime.fixture.ts', import.meta.url)), + ], + { + timeout: 15_000, + env: { + ...process.env, + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + NEXT_PUBLIC_FORCE_HOSTED: 'false', + NODE_EXTRA_CA_CERTS: certificatePath, + HTTPS_PROXY: proxyUrl, + https_proxy: '', + NO_PROXY: '', + no_proxy: '', + }, + } + ) + expect(tunnels).toEqual([ + { authority: '1.1.1.1:443', authorization: PROXY_AUTHORIZATION }, + { authority: '1.1.1.1:443', authorization: PROXY_AUTHORIZATION }, + ]) + }) +}) + +describe('proxy selection from the environment', () => { + it.each([ + { + case: 'an empty lowercase variable does not hide the uppercase one', + env: { https_proxy: '', HTTPS_PROXY: 'http://upper:3128' }, + target: 'https://api.example.com/', + proxy: 'http://upper:3128/', + }, + { + case: 'an https destination falls back to HTTP_PROXY', + env: { HTTP_PROXY: 'http://plain:3128' }, + target: 'https://api.example.com/', + proxy: 'http://plain:3128/', + }, + { + case: 'a bare host:port is read as an http proxy', + env: { HTTPS_PROXY: 'proxy.internal:3128' }, + target: 'https://api.example.com/', + proxy: 'http://proxy.internal:3128/', + }, + { + case: 'a NO_PROXY entry with a port only excludes that port', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'api.example.com:8443' }, + target: 'https://api.example.com/', + proxy: 'http://p:3128/', + }, + { + case: 'NO_PROXY=* excludes every destination', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '*' }, + target: 'https://api.example.com/', + proxy: null, + }, + ])('$case', ({ env, target, proxy }) => { + for (const name of PROXY_ENV) vi.stubEnv(name, '') + for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value) + expect(resolveEnvironmentProxy(new URL(target))).toBe(proxy) + }) + + it('fails closed instead of going direct when the proxy URL is unsupported', async () => { + vi.stubEnv('HTTPS_PROXY', 'socks5://127.0.0.1:1080') + await expect(secureFetchWithValidation('https://origin.invalid/', options)).rejects.toThrow( + /http:\/\/ or https:\/\// + ) + expect(tunnels).toEqual([]) + expect(originHeaders).toEqual({}) + }) +}) + +describe('AWS SDK clients under HTTPS_PROXY', () => { + const listBuckets = (endpoint: string) => + new S3Client({ + region: 'us-east-1', + endpoint, + forcePathStyle: true, + credentials: { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'secret' }, + requestHandler: awsRequestHandler(), + }).send(new ListBucketsCommand({})) + + it('tunnels SDK requests by hostname and keeps proxy credentials off the origin', async () => { + await expect(listBuckets('https://origin.invalid')).resolves.toMatchObject({ Buckets: [] }) + expect(tunnels).toEqual([ + { authority: 'origin.invalid:443', authorization: PROXY_AUTHORIZATION }, + ]) + expect(originHeaders['proxy-authorization']).toBeUndefined() + }) + + it('connects directly when NO_PROXY names the endpoint', async () => { + vi.stubEnv('NO_PROXY', '127.0.0.1') + await expect(listBuckets(`https://127.0.0.1:${securePort}`)).resolves.toMatchObject({ + Buckets: [], + }) + expect(tunnels).toEqual([]) + }) + + it('leaves the SDK defaults untouched when no proxy is configured', () => { + vi.stubEnv('HTTPS_PROXY', '') + vi.stubEnv('HTTP_PROXY', '') + expect(awsRequestHandler({ requestTimeout: 10_000 })).not.toBeInstanceOf(NodeHttpHandler) + expect(awsRequestHandler()).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/core/network/environment-proxy.server.ts b/apps/sim/lib/core/network/environment-proxy.server.ts new file mode 100644 index 00000000000..c43f6f7bfa1 --- /dev/null +++ b/apps/sim/lib/core/network/environment-proxy.server.ts @@ -0,0 +1,58 @@ +const DEFAULT_PORTS: Record = { 'http:': 80, 'https:': 443 } + +/** An empty lowercase variable counts as unset, so it cannot hide the uppercase one. */ +function readEnv(lower: string, upper: string): string | undefined { + return process.env[lower]?.trim() || process.env[upper]?.trim() || undefined +} + +/** Whether any `HTTPS_PROXY` / `HTTP_PROXY` is set, before any destination is considered. */ +export function isEnvironmentProxyConfigured(): boolean { + return Boolean(readEnv('https_proxy', 'HTTPS_PROXY') ?? readEnv('http_proxy', 'HTTP_PROXY')) +} + +/** Matches `NO_PROXY` the way undici's `EnvHttpProxyAgent` does, so both stacks agree. */ +function isExcludedByNoProxy(hostname: string, port: number): boolean { + const noProxy = readEnv('no_proxy', 'NO_PROXY') + if (!noProxy) return false + if (noProxy === '*') return true + for (const entry of noProxy.split(/[,\s]/)) { + if (!entry) continue + const withPort = entry.match(/^(.+):(\d+)$/) + const entryHost = (withPort ? withPort[1] : entry).replace(/^\*?\./, '').toLowerCase() + const entryPort = withPort ? Number.parseInt(withPort[2], 10) : 0 + if (entryPort && entryPort !== port) continue + if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true + } + return false +} + +/** + * The operator's `HTTPS_PROXY` / `HTTP_PROXY` for a destination, or null when none applies. + * Lowercase names win, an https destination falls back to `HTTP_PROXY`, and `NO_PROXY` + * excludes hosts with undici's `EnvHttpProxyAgent` matching, so guarded requests leave the + * way the default `fetch` does. Read per call so a value set after startup is honored. + */ +export function resolveEnvironmentProxy(target: URL): string | null { + const defaultPort = DEFAULT_PORTS[target.protocol] + if (defaultPort === undefined) return null + const httpProxy = readEnv('http_proxy', 'HTTP_PROXY') + const proxy = + target.protocol === 'https:' ? (readEnv('https_proxy', 'HTTPS_PROXY') ?? httpProxy) : httpProxy + if (!proxy) return null + if (isExcludedByNoProxy(target.hostname, Number(target.port) || defaultPort)) return null + return normalizeProxyUrl(proxy) +} + +/** Accepts a bare `host:port`, as curl does; anything unparseable fails rather than going direct. */ +function normalizeProxyUrl(value: string): string { + let parsed: URL + try { + parsed = new URL(value.includes('://') ? value : `http://${value}`) + } catch { + throw new Error('The configured HTTP(S)_PROXY is not a valid URL') + } + if (DEFAULT_PORTS[parsed.protocol] === undefined) { + throw new Error('The configured HTTP(S)_PROXY must be an http:// or https:// URL') + } + return parsed.href +} diff --git a/apps/sim/lib/core/network/fixtures/environment-proxy-runtime.fixture.ts b/apps/sim/lib/core/network/fixtures/environment-proxy-runtime.fixture.ts new file mode 100644 index 00000000000..c706ade3b3d --- /dev/null +++ b/apps/sim/lib/core/network/fixtures/environment-proxy-runtime.fixture.ts @@ -0,0 +1,21 @@ +/** Real runtime probe invoked by environment-proxy.server.test.ts against its local proxy. */ +import { ListBucketsCommand, S3Client } from '@aws-sdk/client-s3' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' + +const response = await secureFetchWithValidation( + 'https://1.1.1.1/.well-known/openid-configuration', + { profile: 'contentFetch', timeout: 10_000 } +) +if (response.headers.get('x-via-proxy') !== 'yes') throw new Error('Guarded fetch bypassed proxy') +if ((await response.text()) !== 'tls reached') throw new Error('Guarded fetch response mismatch') + +const buckets = await new S3Client({ + region: 'us-east-1', + endpoint: 'https://1.1.1.1', + forcePathStyle: true, + credentials: { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'secret' }, + requestHandler: awsRequestHandler(), +}).send(new ListBucketsCommand({})) +if (!Array.isArray(buckets.Buckets)) throw new Error('AWS SDK response mismatch') +process.exit(0) diff --git a/apps/sim/lib/core/network/proxy-agent.server.ts b/apps/sim/lib/core/network/proxy-agent.server.ts new file mode 100644 index 00000000000..bdf7c0968b5 --- /dev/null +++ b/apps/sim/lib/core/network/proxy-agent.server.ts @@ -0,0 +1,82 @@ +import http from 'node:http' +import https from 'node:https' +import { isIP } from 'node:net' +import { NodeHttpHandler } from '@smithy/node-http-handler' +import { Agent, type AgentConnectOpts } from 'agent-base' +import { HttpProxyAgent } from 'http-proxy-agent' +import { HttpsProxyAgent } from 'https-proxy-agent' +import { + isEnvironmentProxyConfigured, + resolveEnvironmentProxy, +} from '@/lib/core/network/environment-proxy.server' + +/** Matches the AWS SDK's own default agent, so swapping it in changes routing and nothing else. */ +const AGENT_OPTIONS: http.AgentOptions = { keepAlive: true, maxSockets: 50 } + +/** + * A Node agent for SDKs that bring their own HTTP stack (the AWS SDK, the OTLP exporters) and so + * bypass Bun's proxy-aware `fetch`. Each connection takes the `HTTPS_PROXY` / `HTTP_PROXY` that + * applies to its destination, or a direct keep-alive connection when `NO_PROXY` excludes it. + */ +class EnvironmentProxyAgent extends Agent { + private readonly direct = { + http: new http.Agent(AGENT_OPTIONS), + https: new https.Agent(AGENT_OPTIONS), + } + private readonly proxies = new Map() + + connect(_req: http.ClientRequest, options: AgentConnectOpts): http.Agent { + const secure = options.secureEndpoint + const host = options.host ?? 'localhost' + const authority = isIP(host) === 6 ? `[${host}]` : host + const target = new URL(`${secure ? 'https' : 'http'}://${authority}:${options.port}`) + const proxyUrl = resolveEnvironmentProxy(target) + if (!proxyUrl) return secure ? this.direct.https : this.direct.http + const key = `${target.protocol}${proxyUrl}` + let agent = this.proxies.get(key) + if (!agent) { + agent = secure + ? new HttpsProxyAgent(proxyUrl, AGENT_OPTIONS) + : new HttpProxyAgent(proxyUrl, AGENT_OPTIONS) + this.proxies.set(key, agent) + } + return agent + } +} + +let sharedAgent: EnvironmentProxyAgent | null = null + +/** The process-wide {@link EnvironmentProxyAgent}; only meaningful while a proxy is configured. */ +function environmentProxyAgent(): EnvironmentProxyAgent { + sharedAgent ??= new EnvironmentProxyAgent(AGENT_OPTIONS) + return sharedAgent +} + +interface AwsRequestHandlerOptions { + connectionTimeout?: number + requestTimeout?: number + throwOnRequestTimeout?: boolean +} + +/** + * `requestHandler` for an AWS SDK v3 client. The SDK's Node handlers ignore `HTTPS_PROXY`, so while + * a proxy is configured this returns an HTTP/1.1 handler on {@link environmentProxyAgent} — which + * also replaces the HTTP/2 handler some clients default to, since HTTP/2 cannot use an agent. + * Without a proxy it returns `options` unchanged and the SDK keeps its own defaults. + */ +export function awsRequestHandler( + options?: AwsRequestHandlerOptions +): NodeHttpHandler | AwsRequestHandlerOptions | undefined { + if (!isEnvironmentProxyConfigured()) return options + const agent = environmentProxyAgent() + return new NodeHttpHandler({ ...options, httpAgent: agent, httpsAgent: agent }) +} + +/** + * Agent settings for an OTLP HTTP exporter, which sends through `node:http` and so ignores + * `HTTPS_PROXY`. Empty without a proxy, leaving the exporter's own agent in place. + */ +export function otlpExporterProxyOptions(): { httpAgentOptions?: () => http.Agent } { + if (!isEnvironmentProxyConfigured()) return {} + return { httpAgentOptions: environmentProxyAgent } +} diff --git a/apps/sim/lib/core/network/transport.server.ts b/apps/sim/lib/core/network/transport.server.ts index d925ac17898..afea63b2d3e 100644 --- a/apps/sim/lib/core/network/transport.server.ts +++ b/apps/sim/lib/core/network/transport.server.ts @@ -1,8 +1,10 @@ -import { type Agent, type Dispatcher, request } from 'undici/index.js' +import { type Agent, type Dispatcher, ProxyAgent, request } from 'undici/index.js' import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server' +import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server' import { createGatewayDispatcher } from '@/lib/core/network/gateway.server' import { OutboundRoutingError } from '@/lib/core/network/routing' import type { EgressProfile } from '@/lib/core/security/egress/profiles' +import { validateEgressUrl } from '@/lib/core/security/egress/validate' interface OutboundTransportOptions { profile: EgressProfile @@ -13,8 +15,12 @@ interface OutboundTransportOptions { } interface OutboundTransportOwner { - /** Null delegates to the adapter's existing direct transport. */ - selectDispatcher(): Promise + /** + * Null delegates to the adapter's existing direct transport. Pass the request URL so a + * direct route can honor the operator's `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`, and the + * request's signal so cancelling it also cancels that route's destination check. + */ + selectDispatcher(target?: string, signal?: AbortSignal): Promise close(): Promise destroy(): Promise } @@ -26,17 +32,60 @@ interface OutboundTransportOwner { export function createOutboundTransport(options: OutboundTransportOptions): OutboundTransportOwner { const pools = new Map() const retired = new Map>() + const environmentProxies = new Map() let closed = false const allPools = () => [ ...(options.direct ? [options.direct] : []), ...[...pools.values()].map((pool) => pool.agent), ...[...retired.values()].flatMap((agents) => [...agents]), + ...environmentProxies.values(), ] + /** + * The proxy resolves the destination itself, so pinning cannot hold across it. Every request + * (each redirect hop included) is resolved and judged here first, so a name that points at + * a refused address is stopped before the proxy is asked to reach it. A name that changes + * its answer between this check and the proxy's own lookup is left to the proxy's egress + * policy, which is why the self-hosting docs make the proxy the final egress control. + */ + const selectEnvironmentProxy = async ( + target: string, + signal: AbortSignal | undefined + ): Promise => { + const proxyUrl = resolveEnvironmentProxy(new URL(target)) + if (!proxyUrl) return null + const validation = await validateEgressUrl(target, 'url', options.profile, { + logDetails: false, + signal, + }) + if (!validation.isValid) throw new Error(validation.error, { cause: validation.cause }) + if (closed) throw new OutboundRoutingError('GATEWAY_UNAVAILABLE') + let agent = environmentProxies.get(proxyUrl) + if (!agent) { + agent = new ProxyAgent({ + uri: proxyUrl, + allowH2: false, + // Plain-http destinations use absolute-form forwarding, as curl and Bun do. + proxyTunnel: false, + ...(options.maxResponseSize !== undefined + ? { maxResponseSize: options.maxResponseSize } + : {}), + }) + environmentProxies.set(proxyUrl, agent) + } + return agent + } return { - async selectDispatcher() { + async selectDispatcher(target, signal) { const route = await resolveCurrentOutboundRoute() if (closed) throw new OutboundRoutingError('GATEWAY_UNAVAILABLE') - if (route.kind === 'direct') return options.direct ?? null + if (route.kind === 'direct') { + // A per-request proxy chosen by the workflow outranks the deployment default. + if (!options.proxyUrl && target !== undefined) { + const proxy = await selectEnvironmentProxy(target, signal) + if (proxy) return proxy + } + return options.direct ?? null + } if (options.proxyUrl) throw new OutboundRoutingError('UNSUPPORTED_TRANSPORT') const owner = route.gateway.organizationId const current = pools.get(owner) @@ -63,12 +112,14 @@ export function createOutboundTransport(options: OutboundTransportOptions): Outb await Promise.all(allPools().map((pool) => pool.close())) pools.clear() retired.clear() + environmentProxies.clear() }, async destroy() { closed = true await Promise.all(allPools().map((pool) => pool.destroy())) pools.clear() retired.clear() + environmentProxies.clear() }, } } diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 9941d88e169..fa2131b1a5d 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -1005,7 +1005,7 @@ function createValidatedFetch( ): { fetch: typeof fetch; dispatcher: OutboundFetchDispatcher } { const dispatcher = createOutboundTransport({ ...options, direct }) const rawFetch = async (url: string, init: UndiciRequestInit): Promise => { - const selected = await dispatcher.selectDispatcher() + const selected = await dispatcher.selectDispatcher(url, init.signal ?? undefined) if (!selected) throw new OutboundRoutingError('GATEWAY_UNAVAILABLE') return undiciRequestAsResponse(url, init, selected, options.maxResponseSize) } @@ -1155,7 +1155,7 @@ export async function secureFetchWithPinnedIP( resolvedIP, proxyUrl: options.proxyUrl, }) - const outboundDispatcher = await transport.selectDispatcher() + const outboundDispatcher = await transport.selectDispatcher(url, options.signal) return new Promise((resolve, reject) => { const parsed = new URL(url) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index e8d279eb286..c0adb8e9546 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -10,6 +10,7 @@ import { credential } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull, or } from 'drizzle-orm' import { z } from 'zod' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config' import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' @@ -173,6 +174,7 @@ export async function resolveAwsSessionCredentials( const client = new STSClient({ region, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }, + requestHandler: awsRequestHandler(), }) const response = await client.send( new AssumeRoleCommand({ @@ -198,6 +200,7 @@ export async function resolveAwsSessionCredentials( const client = new STSClient({ region, credentials: { accessKeyId: 'anonymous', secretAccessKey: 'anonymous' }, + requestHandler: awsRequestHandler(), }) const response = await client.send( new AssumeRoleWithWebIdentityCommand({ @@ -305,6 +308,7 @@ async function verifyCallerArn( const identity = await new STSClient({ region: stsRegionFor(config), credentials: session, + requestHandler: awsRequestHandler(), }).send(new GetCallerIdentityCommand({}), { abortSignal: signal }) if (!identity.Arn) throw new Error('AWS returned no caller identity') return identity.Arn diff --git a/apps/sim/lib/internal/appconfig/client.ts b/apps/sim/lib/internal/appconfig/client.ts index d2beb6d0fd6..8c456d2e636 100644 --- a/apps/sim/lib/internal/appconfig/client.ts +++ b/apps/sim/lib/internal/appconfig/client.ts @@ -30,20 +30,18 @@ import { GetLatestConfigurationCommand, StartConfigurationSessionCommand, } from '@aws-sdk/client-appconfigdata' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { AppConfigConnectionConfig } from '@/tools/appconfig/types' export function createAppConfigClient(config: AppConfigConnectionConfig): AppConfigClient { return new AppConfigClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } export function createAppConfigDataClient(config: AppConfigConnectionConfig): AppConfigDataClient { return new AppConfigDataClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/athena/client.ts b/apps/sim/lib/internal/athena/client.ts index a62c800800a..3d6dad493da 100644 --- a/apps/sim/lib/internal/athena/client.ts +++ b/apps/sim/lib/internal/athena/client.ts @@ -1,5 +1,5 @@ import { AthenaClient } from '@aws-sdk/client-athena' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' export interface AthenaConnectionConfig { region: string @@ -9,7 +9,6 @@ export interface AthenaConnectionConfig { export function createAthenaClient(config: AthenaConnectionConfig): AthenaClient { return new AthenaClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index c3e0f75b7cb..8e05d583eef 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { type AwsSessionCredentials, buildAwsRoleSessionName, @@ -23,8 +24,8 @@ import type { * `context.authorizedServiceAccountCredential` (trusted context, never tool input). * 2. {@link withAwsCredential} resolves it to session credentials and * substitutes them for `accessKeyId`/`secretAccessKey` before the contract parses. - * 3. The family's client asks {@link awsClientCredentials} for its SDK - * credentials, which adds the session token for that resolved key pair. + * 3. The family's client builds its SDK config with {@link awsClientConfig}, whose + * credentials add the session token for that resolved key pair. * * With no saved credential, step 2 calls the handler with the original request * object and step 3 returns the same `{ accessKeyId, secretAccessKey }` the @@ -50,6 +51,18 @@ export function awsClientCredentials( return { accessKeyId, secretAccessKey } } +/** + * SDK client settings for a client built from an operation's key fields: its region, + * {@link awsClientCredentials}, and the request handler that honors `HTTPS_PROXY`. + */ +export function awsClientConfig(region: string, accessKeyId: string, secretAccessKey: string) { + return { + region, + credentials: awsClientCredentials(accessKeyId, secretAccessKey), + requestHandler: awsRequestHandler(), + } +} + /** * Runs `operation` inside a resolved session, so clients built during it sign * with the session token. For callers outside a tool handler, such as selectors. diff --git a/apps/sim/lib/internal/cloudformation/client.ts b/apps/sim/lib/internal/cloudformation/client.ts index 06dac7e41e2..82055571cd1 100644 --- a/apps/sim/lib/internal/cloudformation/client.ts +++ b/apps/sim/lib/internal/cloudformation/client.ts @@ -4,7 +4,7 @@ import { type Parameter, type Tag, } from '@aws-sdk/client-cloudformation' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' export interface CloudFormationConnectionConfig { region: string @@ -16,8 +16,7 @@ export function createCloudFormationClient( config: CloudFormationConnectionConfig ): CloudFormationClient { return new CloudFormationClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/cloudtrail/client.ts b/apps/sim/lib/internal/cloudtrail/client.ts index 66e7bc549b9..b66491a376d 100644 --- a/apps/sim/lib/internal/cloudtrail/client.ts +++ b/apps/sim/lib/internal/cloudtrail/client.ts @@ -1,5 +1,5 @@ import { CloudTrailClient } from '@aws-sdk/client-cloudtrail' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' export interface CloudTrailConnectionConfig { region: string @@ -25,8 +25,7 @@ export function createCloudTrailClient( options: CreateCloudTrailClientOptions = {} ): CloudTrailClient { return new CloudTrailClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), ...(options.throttleSensitive ? { retryMode: 'adaptive', maxAttempts: THROTTLE_SENSITIVE_MAX_ATTEMPTS } : {}), diff --git a/apps/sim/lib/internal/cloudwatch/client.ts b/apps/sim/lib/internal/cloudwatch/client.ts index 01e5963acec..ab5e8584cdb 100644 --- a/apps/sim/lib/internal/cloudwatch/client.ts +++ b/apps/sim/lib/internal/cloudwatch/client.ts @@ -10,7 +10,7 @@ import { import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' interface AwsCredentials { region: string @@ -23,16 +23,14 @@ export function createCloudWatchClient( options?: { maxAttempts?: number } ): CloudWatchClient { return new CloudWatchClient({ - region: config.region, + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), ...(options?.maxAttempts !== undefined && { maxAttempts: options.maxAttempts }), - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), }) } export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient { return new CloudWatchLogsClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/codepipeline/client.ts b/apps/sim/lib/internal/codepipeline/client.ts index 0acd3e71883..f1cec6dcce2 100644 --- a/apps/sim/lib/internal/codepipeline/client.ts +++ b/apps/sim/lib/internal/codepipeline/client.ts @@ -1,5 +1,5 @@ import { CodePipelineClient } from '@aws-sdk/client-codepipeline' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' export interface CodePipelineConnectionConfig { region: string @@ -9,7 +9,6 @@ export interface CodePipelineConnectionConfig { export function createCodePipelineClient(config: CodePipelineConnectionConfig): CodePipelineClient { return new CodePipelineClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/dynamodb/client.ts b/apps/sim/lib/internal/dynamodb/client.ts index 34b5f544bef..71bcb38bfcc 100644 --- a/apps/sim/lib/internal/dynamodb/client.ts +++ b/apps/sim/lib/internal/dynamodb/client.ts @@ -8,13 +8,12 @@ import { ScanCommand, UpdateCommand, } from '@aws-sdk/lib-dynamodb' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { DynamoDBConnectionConfig, DynamoDBTableSchema } from '@/tools/dynamodb/types' export function createDynamoDBClient(config: DynamoDBConnectionConfig): DynamoDBDocumentClient { const client = new DynamoDBClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) return DynamoDBDocumentClient.from(client, { @@ -220,8 +219,7 @@ export async function deleteItem( */ export function createRawDynamoDBClient(config: DynamoDBConnectionConfig): DynamoDBClient { return new DynamoDBClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/ec2/client.ts b/apps/sim/lib/internal/ec2/client.ts index 2c70f65a8c6..21b522d6859 100644 --- a/apps/sim/lib/internal/ec2/client.ts +++ b/apps/sim/lib/internal/ec2/client.ts @@ -1,5 +1,5 @@ import { EC2Client, type Tag } from '@aws-sdk/client-ec2' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' interface Ec2ConnectionConfig { region: string @@ -12,9 +12,8 @@ export function createEc2Client( options?: { maxAttempts?: number } ): EC2Client { return new EC2Client({ - region: config.region, + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), ...(options?.maxAttempts !== undefined && { maxAttempts: options.maxAttempts }), - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/iam/client.ts b/apps/sim/lib/internal/iam/client.ts index 8a4429653b4..8cc18c193bd 100644 --- a/apps/sim/lib/internal/iam/client.ts +++ b/apps/sim/lib/internal/iam/client.ts @@ -38,13 +38,12 @@ import { SimulatePrincipalPolicyCommand, UpdateAccessKeyCommand, } from '@aws-sdk/client-iam' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { IAMConnectionConfig, IAMSimulateContextEntry } from '@/tools/iam/types' export function createIAMClient(config: IAMConnectionConfig): IAMClient { return new IAMClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/identity-center/client.ts b/apps/sim/lib/internal/identity-center/client.ts index e7d3b29e3ad..ca751a9e29f 100644 --- a/apps/sim/lib/internal/identity-center/client.ts +++ b/apps/sim/lib/internal/identity-center/client.ts @@ -27,7 +27,7 @@ import { SSOAdminClient, type TargetType, } from '@aws-sdk/client-sso-admin' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import { AWS_FANOUT_CONCURRENCY, mapWithConcurrency, @@ -43,8 +43,7 @@ interface IdentityCenterConnectionConfig { export function createSSOAdminClient(config: IdentityCenterConnectionConfig): SSOAdminClient { return new SSOAdminClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } @@ -52,8 +51,7 @@ export function createIdentityStoreClient( config: IdentityCenterConnectionConfig ): IdentitystoreClient { return new IdentitystoreClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } @@ -63,8 +61,11 @@ export function createIdentityStoreClient( */ export function createOrganizationsClient(config: IdentityCenterConnectionConfig) { return new OrganizationsClient({ - region: resolveOrganizationsRegion(config.region), - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig( + resolveOrganizationsRegion(config.region), + config.accessKeyId, + config.secretAccessKey + ), }) } diff --git a/apps/sim/lib/internal/lambda/client.ts b/apps/sim/lib/internal/lambda/client.ts index 27d216d904c..2f83944c4d9 100644 --- a/apps/sim/lib/internal/lambda/client.ts +++ b/apps/sim/lib/internal/lambda/client.ts @@ -8,7 +8,7 @@ import { type LayersListItem, type LayerVersionsListItem, } from '@aws-sdk/client-lambda' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' export interface LambdaConnectionConfig { region: string @@ -18,8 +18,7 @@ export interface LambdaConnectionConfig { export function createLambdaClient(config: LambdaConnectionConfig): LambdaClient { return new LambdaClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/rds/client.ts b/apps/sim/lib/internal/rds/client.ts index bdefbbefe29..5739a82988e 100644 --- a/apps/sim/lib/internal/rds/client.ts +++ b/apps/sim/lib/internal/rds/client.ts @@ -5,13 +5,12 @@ import { RDSDataClient, type SqlParameter, } from '@aws-sdk/client-rds-data' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { RdsConnectionConfig } from '@/tools/rds/types' export function createRdsClient(config: RdsConnectionConfig): RDSDataClient { return new RDSDataClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/s3/client.ts b/apps/sim/lib/internal/s3/client.ts index 2dfc9f0d156..f7ee626afd5 100644 --- a/apps/sim/lib/internal/s3/client.ts +++ b/apps/sim/lib/internal/s3/client.ts @@ -1,5 +1,5 @@ import { S3Client } from '@aws-sdk/client-s3' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' export interface S3ConnectionConfig { region: string @@ -9,7 +9,6 @@ export interface S3ConnectionConfig { export function createS3Client(config: S3ConnectionConfig): S3Client { return new S3Client({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/secrets-manager/client.ts b/apps/sim/lib/internal/secrets-manager/client.ts index 6a7dd894497..b3606ba4294 100644 --- a/apps/sim/lib/internal/secrets-manager/client.ts +++ b/apps/sim/lib/internal/secrets-manager/client.ts @@ -12,7 +12,7 @@ import { UntagResourceCommand, UpdateSecretCommand, } from '@aws-sdk/client-secrets-manager' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' interface SecretsManagerConnectionConfig { region: string @@ -33,8 +33,7 @@ export function createSecretsManagerClient( config: SecretsManagerConnectionConfig ): SecretsManagerClient { return new SecretsManagerClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/ses/client.ts b/apps/sim/lib/internal/ses/client.ts index 2fe659913ea..f5de62a80fa 100644 --- a/apps/sim/lib/internal/ses/client.ts +++ b/apps/sim/lib/internal/ses/client.ts @@ -22,7 +22,7 @@ import { UpdateEmailTemplateCommand, } from '@aws-sdk/client-sesv2' import { z } from 'zod' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { SESConnectionConfig } from '@/tools/ses/types' const SesBulkEmailDestinationSchema = z.object({ @@ -34,8 +34,7 @@ type SesBulkEmailDestination = z.infer export function createSESClient(config: SESConnectionConfig): SESv2Client { return new SESv2Client({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/sqs/client.ts b/apps/sim/lib/internal/sqs/client.ts index 0b549cbaec4..0910e64dc27 100644 --- a/apps/sim/lib/internal/sqs/client.ts +++ b/apps/sim/lib/internal/sqs/client.ts @@ -1,10 +1,9 @@ import { SQSClient } from '@aws-sdk/client-sqs' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { SqsConnectionConfig } from '@/tools/sqs/types' export function createSqsClient(config: SqsConnectionConfig): SQSClient { return new SQSClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/ssm/client.ts b/apps/sim/lib/internal/ssm/client.ts index 5f5655559f9..1136505669c 100644 --- a/apps/sim/lib/internal/ssm/client.ts +++ b/apps/sim/lib/internal/ssm/client.ts @@ -63,7 +63,7 @@ import type { AwsSsmPutParameterBody } from '@/lib/api/contracts/tools/aws/ssm-p import type { AwsSsmSendCommandBody } from '@/lib/api/contracts/tools/aws/ssm-send-command' import type { AwsSsmStartAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-start-automation-execution' import type { AwsSsmStopAutomationExecutionBody } from '@/lib/api/contracts/tools/aws/ssm-stop-automation-execution' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' interface SsmConnectionConfig { region: string @@ -73,8 +73,7 @@ interface SsmConnectionConfig { export function createSsmClient(config: SsmConnectionConfig): SSMClient { return new SSMClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } diff --git a/apps/sim/lib/internal/sts/client.ts b/apps/sim/lib/internal/sts/client.ts index fe708fddcdf..6ec39d21825 100644 --- a/apps/sim/lib/internal/sts/client.ts +++ b/apps/sim/lib/internal/sts/client.ts @@ -9,13 +9,13 @@ import { STSClient, type Tag, } from '@aws-sdk/client-sts' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import type { STSConnectionConfig } from '@/tools/sts/types' export function createSTSClient(config: STSConnectionConfig): STSClient { return new STSClient({ - region: config.region, - credentials: awsClientCredentials(config.accessKeyId, config.secretAccessKey), + ...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey), }) } @@ -35,6 +35,7 @@ export function createUnauthenticatedSTSClient(region: string): STSClient { return new STSClient({ region, credentials: { accessKeyId: 'anonymous', secretAccessKey: 'anonymous' }, + requestHandler: awsRequestHandler(), }) } diff --git a/apps/sim/lib/internal/textract/operations.ts b/apps/sim/lib/internal/textract/operations.ts index ca7141071dd..2551ab46a29 100644 --- a/apps/sim/lib/internal/textract/operations.ts +++ b/apps/sim/lib/internal/textract/operations.ts @@ -22,7 +22,7 @@ import type { textractParseContract, } from '@/lib/api/contracts/tools/media/document-parse' import { validateOpaqueModelInputProvenance } from '@/lib/execution/model-input-provenance' -import { awsClientCredentials } from '@/lib/internal/aws/credentials' +import { awsClientConfig } from '@/lib/internal/aws/credentials' import { parseS3Uri, resolveDocumentInput } from '@/lib/internal/textract/document-input' import { mapTextractSdkError, textractErrorResponse } from '@/lib/internal/textract/errors' import { @@ -84,8 +84,7 @@ function createTextractClient(input: { secretAccessKey: string }): TextractClient { return new TextractClient({ - region: input.region, - credentials: awsClientCredentials(input.accessKeyId, input.secretAccessKey), + ...awsClientConfig(input.region, input.accessKeyId, input.secretAccessKey), }) } diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index 93efc679c06..2de3c739204 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -3,6 +3,7 @@ import nodemailer from 'nodemailer' import type SESTransport from 'nodemailer/lib/ses-transport' import { getAwsCredentials } from '@/lib/core/config/aws' import { env } from '@/lib/core/config/env' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { sendViaNodemailer } from '@/lib/messaging/email/providers/send-via-nodemailer' import type { MailProvider } from '@/lib/messaging/email/types' @@ -16,7 +17,11 @@ export function createSesProvider(): MailProvider | null { const region = env.AWS_SES_REGION if (!region) return null - const sesClient = new SESv2Client({ region, credentials: getAwsCredentials() }) + const sesClient = new SESv2Client({ + region, + credentials: getAwsCredentials(), + requestHandler: awsRequestHandler(), + }) const sesOptions: SESTransport.Options = { SES: { sesClient, SendEmailCommand }, } diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index 9562fe609db..36b84749950 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -32,6 +32,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getAwsCredentials } from '@/lib/core/config/aws' import { isHosted } from '@/lib/core/config/env-flags' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' const logger = createLogger('HostedKeyMetrics') @@ -63,6 +64,7 @@ function getClient(): CloudWatchClient { client = new CloudWatchClient({ region: process.env.AWS_REGION || 'us-east-1', credentials: getAwsCredentials(), + requestHandler: awsRequestHandler(), }) } return client diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index a0b38ea57c5..b8beaef1c88 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -17,6 +17,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { getAwsCredentials } from '@/lib/core/config/aws' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { assertKnownSizeWithinLimit, readNodeStreamToBufferWithLimit, @@ -63,6 +64,7 @@ export function getS3Client(): S3Client { endpoint: S3_CONFIG.endpoint, forcePathStyle: S3_CONFIG.forcePathStyle, credentials: getAwsCredentials(), + requestHandler: awsRequestHandler(), }) return _s3Client diff --git a/apps/sim/package.json b/apps/sim/package.json index 915384bc341..925c7360ffd 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -162,6 +162,7 @@ "@sim/workflow-persistence": "workspace:*", "@sim/workflow-renderer": "workspace:*", "@sim/workflow-types": "workspace:*", + "@smithy/node-http-handler": "4.12.1", "@t3-oss/env-nextjs": "0.13.11", "@tanstack/react-query": "5.90.8", "@tanstack/react-virtual": "3.13.24", @@ -192,6 +193,7 @@ "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", "@xyflow/react": "12.12.1", + "agent-base": "7.1.4", "ajv": "8.20.0", "archiver": "8.0.0", "better-auth": "1.6.27", diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 26f79b5977a..47aa31619ee 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { getAwsCredentials } from '@/lib/core/config/aws' +import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { validateAwsRegion } from '@/lib/core/security/input-validation' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' @@ -165,6 +166,7 @@ export const bedrockProvider: ProviderConfig = { request.bedrockAccessKeyId && request.bedrockSecretKey ? { accessKeyId: request.bedrockAccessKeyId, secretAccessKey: request.bedrockSecretKey } : getAwsCredentials(), + requestHandler: awsRequestHandler(), } // Key on the full credential (access key id + secret) so a corrected secret diff --git a/bun.lock b/bun.lock index e1b0732c9ec..fc707d68286 100644 --- a/bun.lock +++ b/bun.lock @@ -317,6 +317,7 @@ "@sim/workflow-persistence": "workspace:*", "@sim/workflow-renderer": "workspace:*", "@sim/workflow-types": "workspace:*", + "@smithy/node-http-handler": "4.12.1", "@t3-oss/env-nextjs": "0.13.11", "@tanstack/react-query": "5.90.8", "@tanstack/react-virtual": "3.13.24", @@ -347,6 +348,7 @@ "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", "@xyflow/react": "12.12.1", + "agent-base": "7.1.4", "ajv": "8.20.0", "archiver": "8.0.0", "better-auth": "1.6.27", diff --git a/scripts/check-egress-boundary.ts b/scripts/check-egress-boundary.ts index a325a545d28..0ac6d590e7e 100644 --- a/scripts/check-egress-boundary.ts +++ b/scripts/check-egress-boundary.ts @@ -78,6 +78,10 @@ const ALLOWED = new Set([ 'apps/sim/lib/internal/oracledb/connect-proxy.ts', // Streaming MCP transport, built on the guard's pinned dispatcher. 'apps/sim/lib/mcp/pinned-fetch.ts', + // The operator's HTTPS_PROXY agent for SDKs with their own HTTP stack (AWS, + // OTLP). It only changes how an SDK reaches the endpoint the SDK chose; it + // never takes a URL, so it adds no destination a caller could supply. + 'apps/sim/lib/core/network/proxy-agent.server.ts', ]) function walk(dir: string, out: string[] = []): string[] {