fix(tools): pin resolved IP in DB connectors to prevent DNS-rebinding SSRF (#4725)

* 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
This commit is contained in:
Waleed
2026-05-22 10:33:36 -07:00
committed by GitHub
parent b2ad5e9127
commit afcbcf2b54
5 changed files with 56 additions and 10 deletions
+5 -1
View File
@@ -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()
+8
View File
@@ -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') {
+8 -1
View File
@@ -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,
+8 -7
View File
@@ -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,
+27 -1
View File
@@ -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,