From afcbcf2b54b77b9a07bbb07413edd681bce58b13 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 22 May 2026 10:33:36 -0700 Subject: [PATCH] fix(tools): pin resolved IP in DB connectors to prevent DNS-rebinding SSRF (#4725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tools): pin resolved IP in DB connectors to prevent DNS-rebinding SSRF `validateDatabaseHost` resolved an IP that was then discarded — drivers re-resolved the hostname at connect time, enabling DNS-rebinding TOCTOU. - mongodb: pass resolved IP via MongoClient `lookup` option - mysql: pin TCP socket via `stream` factory; keep hostname for TLS servername - postgresql: connect to resolved IP; pass `ssl` object with `servername` for SNI - redis: parse URL explicitly and pass options-only (URL+options breaks override due to ioredis's lodash.defaults); pin host and set `tls.servername` for rediss - neo4j: pin IP for plain `bolt://`; leave `bolt+s`/`neo4j+s` unchanged to keep Aura cert validation working (driver hardcodes servername with no override) * chore(tools): remove explainer comments from DB connector SSRF fix * fix(tools): add explicit TCP timeout to mysql stream factory * fix(tools): unify postgres ssl handling to send SNI in preferred mode * fix(tools): preserve postgres 'preferred' fallback behavior for backward compat * fix(tools): reject non-numeric Redis URL db segment instead of silently using db 0 --- apps/sim/app/api/tools/mongodb/utils.ts | 6 +++- apps/sim/app/api/tools/mysql/utils.ts | 8 ++++++ apps/sim/app/api/tools/neo4j/utils.ts | 9 +++++- apps/sim/app/api/tools/postgresql/utils.ts | 15 +++++----- apps/sim/app/api/tools/redis/execute/route.ts | 28 ++++++++++++++++++- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/tools/mongodb/utils.ts b/apps/sim/app/api/tools/mongodb/utils.ts index 33e6af90ae..7fb17e1742 100644 --- a/apps/sim/app/api/tools/mongodb/utils.ts +++ b/apps/sim/app/api/tools/mongodb/utils.ts @@ -1,5 +1,8 @@ import { MongoClient } from 'mongodb' -import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { + createPinnedLookup, + validateDatabaseHost, +} from '@/lib/core/security/input-validation.server' import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types' export async function createMongoDBConnection(config: MongoDBConnectionConfig) { @@ -30,6 +33,7 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) { connectTimeoutMS: 10000, socketTimeoutMS: 10000, maxPoolSize: 1, + lookup: createPinnedLookup(hostValidation.resolvedIP ?? config.host), }) await client.connect() diff --git a/apps/sim/app/api/tools/mysql/utils.ts b/apps/sim/app/api/tools/mysql/utils.ts index 30883aa7f2..971bc31ba2 100644 --- a/apps/sim/app/api/tools/mysql/utils.ts +++ b/apps/sim/app/api/tools/mysql/utils.ts @@ -1,3 +1,4 @@ +import net from 'node:net' import mysql from 'mysql2/promise' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' @@ -16,12 +17,19 @@ export async function createMySQLConnection(config: MySQLConnectionConfig) { throw new Error(hostValidation.error) } + const resolvedIP = hostValidation.resolvedIP ?? config.host + const connectionConfig: mysql.ConnectionOptions = { host: config.host, port: config.port, database: config.database, user: config.username, password: config.password, + stream: () => { + const socket = net.connect({ host: resolvedIP, port: config.port, timeout: 10000 }) + socket.setNoDelay(true) + return socket + }, } if (config.ssl === 'disabled') { diff --git a/apps/sim/app/api/tools/neo4j/utils.ts b/apps/sim/app/api/tools/neo4j/utils.ts index f843d723a0..ac0bdf0eb0 100644 --- a/apps/sim/app/api/tools/neo4j/utils.ts +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -18,7 +18,14 @@ export async function createNeo4jDriver(config: Neo4jConnectionConfig) { protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt' } - const uri = `${protocol}://${config.host}:${config.port}` + const useIPPinning = !protocol.endsWith('+s') + const resolvedIP = hostValidation.resolvedIP ?? config.host + const uriHost = useIPPinning + ? resolvedIP.includes(':') + ? `[${resolvedIP}]` + : resolvedIP + : config.host + const uri = `${protocol}://${uriHost}:${config.port}` const driverConfig: any = { maxConnectionPoolSize: 1, diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index 55f0bbe930..dfeeab9ead 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -8,17 +8,18 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) throw new Error(hostValidation.error) } - const sslConfig = + const resolvedHost = hostValidation.resolvedIP ?? config.host + const pinIP = config.ssl !== 'preferred' + + const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = config.ssl === 'disabled' ? false - : config.ssl === 'required' - ? 'require' - : config.ssl === 'preferred' - ? 'prefer' - : 'require' + : config.ssl === 'preferred' + ? 'prefer' + : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ - host: config.host, + host: pinIP ? resolvedHost : config.host, port: config.port, database: config.database, username: config.username, diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index c3fb36c85b..7a38c676b9 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -36,7 +36,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: hostValidation.error }, { status: 400 }) } - client = new Redis(url, { + const resolvedIP = hostValidation.resolvedIP ?? hostname + const tlsEnabled = parsedUrl.protocol === 'rediss:' + const port = parsedUrl.port ? Number(parsedUrl.port) : 6379 + const username = parsedUrl.username ? decodeURIComponent(parsedUrl.username) : undefined + const password = parsedUrl.password ? decodeURIComponent(parsedUrl.password) : undefined + + let db = 0 + if (parsedUrl.pathname && parsedUrl.pathname.length > 1) { + const dbSegment = parsedUrl.pathname.slice(1) + const parsedDb = Number.parseInt(dbSegment, 10) + if (!Number.isFinite(parsedDb) || String(parsedDb) !== dbSegment) { + return NextResponse.json( + { error: `Invalid Redis database index in URL path: '${dbSegment}'` }, + { status: 400 } + ) + } + db = parsedDb + } + + client = new Redis({ + host: resolvedIP, + port, + username, + password, + db, + family: resolvedIP.includes(':') ? 6 : 4, + tls: tlsEnabled ? { servername: hostname } : undefined, connectTimeout: 10000, commandTimeout: 10000, maxRetriesPerRequest: 1,