From d932884afa8da660f39fec569ecb0b9106f1d582 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 22 May 2026 09:12:33 -0700 Subject: [PATCH 1/6] fix(tools): pin resolved IP in DB connectors to prevent DNS-rebinding SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- apps/sim/app/api/tools/mongodb/utils.ts | 6 +++++- apps/sim/app/api/tools/mysql/utils.ts | 9 +++++++++ apps/sim/app/api/tools/neo4j/utils.ts | 10 +++++++++- apps/sim/app/api/tools/postgresql/utils.ts | 15 +++++++------- apps/sim/app/api/tools/redis/execute/route.ts | 20 ++++++++++++++++++- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/tools/mongodb/utils.ts b/apps/sim/app/api/tools/mongodb/utils.ts index 33e6af90ae7..7fb17e17424 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 30883aa7f2a..572e85c12b8 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,20 @@ 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, + // Pin socket to resolved IP to prevent DNS rebinding; mysql2 still uses config.host for TLS servername. + stream: () => { + const socket = net.connect(config.port, resolvedIP) + 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 f843d723a05..61ca0a294c9 100644 --- a/apps/sim/app/api/tools/neo4j/utils.ts +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -18,7 +18,15 @@ export async function createNeo4jDriver(config: Neo4jConnectionConfig) { protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt' } - const uri = `${protocol}://${config.host}:${config.port}` + // neo4j-driver hardcodes TLS servername to the URI host with no override, so we only pin the IP for non-TLS to preserve Aura cert validation. + 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 55f0bbe9304..2cfbea024c7 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 + + // `rejectUnauthorized: false` matches postgres.js's `'require'` string semantics; `servername` is set so SNI works with the pinned IP host. + 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: resolvedHost, 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 c3fb36c85b0..60612f724cd 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -36,7 +36,25 @@ 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 + const dbIndex = + parsedUrl.pathname && parsedUrl.pathname.length > 1 + ? Number.parseInt(parsedUrl.pathname.slice(1), 10) + : Number.NaN + + // Pin to resolved IP to prevent DNS rebinding; for `rediss://`, pass original hostname as TLS servername. + client = new Redis({ + host: resolvedIP, + port, + username, + password, + db: Number.isFinite(dbIndex) ? dbIndex : 0, + family: resolvedIP.includes(':') ? 6 : 4, + tls: tlsEnabled ? { servername: hostname } : undefined, connectTimeout: 10000, commandTimeout: 10000, maxRetriesPerRequest: 1, From 42300279bde6d7d170c2926452793fb65729abba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 22 May 2026 09:18:25 -0700 Subject: [PATCH 2/6] chore(tools): remove explainer comments from DB connector SSRF fix --- apps/sim/app/api/tools/mysql/utils.ts | 1 - apps/sim/app/api/tools/neo4j/utils.ts | 1 - apps/sim/app/api/tools/postgresql/utils.ts | 1 - apps/sim/app/api/tools/redis/execute/route.ts | 1 - 4 files changed, 4 deletions(-) diff --git a/apps/sim/app/api/tools/mysql/utils.ts b/apps/sim/app/api/tools/mysql/utils.ts index 572e85c12b8..fa7563a15ec 100644 --- a/apps/sim/app/api/tools/mysql/utils.ts +++ b/apps/sim/app/api/tools/mysql/utils.ts @@ -25,7 +25,6 @@ export async function createMySQLConnection(config: MySQLConnectionConfig) { database: config.database, user: config.username, password: config.password, - // Pin socket to resolved IP to prevent DNS rebinding; mysql2 still uses config.host for TLS servername. stream: () => { const socket = net.connect(config.port, resolvedIP) socket.setNoDelay(true) diff --git a/apps/sim/app/api/tools/neo4j/utils.ts b/apps/sim/app/api/tools/neo4j/utils.ts index 61ca0a294c9..ac0bdf0eb0e 100644 --- a/apps/sim/app/api/tools/neo4j/utils.ts +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -18,7 +18,6 @@ export async function createNeo4jDriver(config: Neo4jConnectionConfig) { protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt' } - // neo4j-driver hardcodes TLS servername to the URI host with no override, so we only pin the IP for non-TLS to preserve Aura cert validation. const useIPPinning = !protocol.endsWith('+s') const resolvedIP = hostValidation.resolvedIP ?? config.host const uriHost = useIPPinning diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index 2cfbea024c7..fa12dc91d9d 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -10,7 +10,6 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) const resolvedHost = hostValidation.resolvedIP ?? config.host - // `rejectUnauthorized: false` matches postgres.js's `'require'` string semantics; `servername` is set so SNI works with the pinned IP host. const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = config.ssl === 'disabled' ? false diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index 60612f724cd..9709a9c7e93 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -46,7 +46,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? Number.parseInt(parsedUrl.pathname.slice(1), 10) : Number.NaN - // Pin to resolved IP to prevent DNS rebinding; for `rediss://`, pass original hostname as TLS servername. client = new Redis({ host: resolvedIP, port, From 47def7bd11ff584a7fc16bff9e889eb7160d2918 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 22 May 2026 09:44:33 -0700 Subject: [PATCH 3/6] fix(tools): add explicit TCP timeout to mysql stream factory --- apps/sim/app/api/tools/mysql/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/mysql/utils.ts b/apps/sim/app/api/tools/mysql/utils.ts index fa7563a15ec..971bc31ba21 100644 --- a/apps/sim/app/api/tools/mysql/utils.ts +++ b/apps/sim/app/api/tools/mysql/utils.ts @@ -26,7 +26,7 @@ export async function createMySQLConnection(config: MySQLConnectionConfig) { user: config.username, password: config.password, stream: () => { - const socket = net.connect(config.port, resolvedIP) + const socket = net.connect({ host: resolvedIP, port: config.port, timeout: 10000 }) socket.setNoDelay(true) return socket }, From 8c1664560ae3b4780ec85bd9a0909f9454d6c3c6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 22 May 2026 10:07:34 -0700 Subject: [PATCH 4/6] fix(tools): unify postgres ssl handling to send SNI in preferred mode --- apps/sim/app/api/tools/postgresql/utils.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index fa12dc91d9d..b34e50e1e93 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -10,12 +10,8 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) const resolvedHost = hostValidation.resolvedIP ?? config.host - const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = - config.ssl === 'disabled' - ? false - : config.ssl === 'preferred' - ? 'prefer' - : { rejectUnauthorized: false, servername: config.host } + const sslConfig: boolean | { rejectUnauthorized: boolean; servername?: string } = + config.ssl === 'disabled' ? false : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ host: resolvedHost, From 5aaf0e315f834dedf58a177da12283d930a495ef Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 22 May 2026 10:11:57 -0700 Subject: [PATCH 5/6] fix(tools): preserve postgres 'preferred' fallback behavior for backward compat --- apps/sim/app/api/tools/postgresql/utils.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index b34e50e1e93..dfeeab9eadb 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -9,12 +9,17 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) } const resolvedHost = hostValidation.resolvedIP ?? config.host + const pinIP = config.ssl !== 'preferred' - const sslConfig: boolean | { rejectUnauthorized: boolean; servername?: string } = - config.ssl === 'disabled' ? false : { rejectUnauthorized: false, servername: config.host } + const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = + config.ssl === 'disabled' + ? false + : config.ssl === 'preferred' + ? 'prefer' + : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ - host: resolvedHost, + host: pinIP ? resolvedHost : config.host, port: config.port, database: config.database, username: config.username, From 32c4b1ad245b1df98974ae4558b3f15f2be0f4a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 22 May 2026 10:20:52 -0700 Subject: [PATCH 6/6] fix(tools): reject non-numeric Redis URL db segment instead of silently using db 0 --- apps/sim/app/api/tools/redis/execute/route.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index 9709a9c7e93..7a38c676b95 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -41,17 +41,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const port = parsedUrl.port ? Number(parsedUrl.port) : 6379 const username = parsedUrl.username ? decodeURIComponent(parsedUrl.username) : undefined const password = parsedUrl.password ? decodeURIComponent(parsedUrl.password) : undefined - const dbIndex = - parsedUrl.pathname && parsedUrl.pathname.length > 1 - ? Number.parseInt(parsedUrl.pathname.slice(1), 10) - : Number.NaN + + 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: Number.isFinite(dbIndex) ? dbIndex : 0, + db, family: resolvedIP.includes(':') ? 6 : 4, tls: tlsEnabled ? { servername: hostname } : undefined, connectTimeout: 10000,