Skip to content

Commit 1ece3d4

Browse files
waleedlatif1claude
andcommitted
fix(data-drains): DNS-resolve S3 endpoint for SSRF defense
The schema-level validateExternalUrl only catches private/metadata IP literals — a hostname like evil.example.com that resolves to 169.254.169.254 or a VPC IP would slip past, and the AWS SDK then resolves the host itself (bypassing the guard). Run validateUrlWithDNS at test() time and once per session at the start of each run, matching the webhook destination's DNS-aware check. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f9e487a commit 1ece3d4

1 file changed

Lines changed: 24 additions & 0 deletions

File tree

  • apps/sim/lib/data-drains/destinations

apps/sim/lib/data-drains/destinations/s3.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger'
88
import { generateShortId } from '@sim/utils/id'
99
import { z } from 'zod'
1010
import { validateExternalUrl } from '@/lib/core/security/input-validation'
11+
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
1112
import type { DrainDestination } from '@/lib/data-drains/types'
1213

1314
const logger = createLogger('DataDrainS3Destination')
@@ -101,6 +102,22 @@ function isS3ServiceException(error: unknown): error is S3ServiceException {
101102
* (`AccessDenied`, `NoSuchBucket`, `InvalidAccessKeyId`,
102103
* `SignatureDoesNotMatch`, ...) instead of opaque "UnknownError".
103104
*/
105+
/**
106+
* Resolves the optional custom endpoint and confirms it does not point at a
107+
* private, loopback, or cloud-metadata address. The schema-level
108+
* `validateExternalUrl` only catches IP literals, so a hostname like
109+
* `evil.example.com` resolving to `169.254.169.254` would slip past it; the
110+
* AWS SDK then resolves the host itself, bypassing the SSRF guard. This is
111+
* the same DNS-aware check used by the webhook destination.
112+
*/
113+
async function assertEndpointIsPublic(endpoint: string | undefined): Promise<void> {
114+
if (!endpoint) return
115+
const result = await validateUrlWithDNS(endpoint, 'endpoint')
116+
if (!result.isValid) {
117+
throw new Error(result.error ?? 'S3 endpoint failed SSRF validation')
118+
}
119+
}
120+
104121
async function withS3ErrorContext<T>(action: string, fn: () => Promise<T>): Promise<T> {
105122
try {
106123
return await fn()
@@ -128,6 +145,7 @@ export const s3Destination: DrainDestination<S3DestinationConfig, S3DestinationC
128145
credentialsSchema: s3CredentialsSchema,
129146

130147
async test({ config, credentials, signal }) {
148+
await assertEndpointIsPublic(config.endpoint)
131149
const client = buildClient(config, credentials)
132150
// Probe with an actual write — HeadBucket only checks read/list and
133151
// produces both false positives (read-only creds pass, deliver later
@@ -166,8 +184,14 @@ export const s3Destination: DrainDestination<S3DestinationConfig, S3DestinationC
166184

167185
openSession({ config, credentials }) {
168186
const client = buildClient(config, credentials)
187+
// Cache the DNS-aware endpoint check across all chunks in a run so we
188+
// pay the lookup once. The SDK creates its own connections, so we can't
189+
// pin the IP — but doing the check before any S3 call still rejects
190+
// hostnames that resolve to internal targets at the start of the run.
191+
const endpointCheck = assertEndpointIsPublic(config.endpoint)
169192
return {
170193
async deliver({ body, contentType, metadata, signal }) {
194+
await endpointCheck
171195
const key = buildKey(config, metadata)
172196
await withS3ErrorContext('put-object', () =>
173197
client.send(

0 commit comments

Comments
 (0)