mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(security): add SSRF protection to database tools and webhook delivery (#3500)
* fix(security): add SSRF protection to database tools and webhook delivery * fix(security): address review comments on SSRF PR - Remove Promise.race timeout pattern to avoid unhandled rejections (http.request timeout is sufficient for webhook delivery) - Use safeCompare in verifyCronAuth instead of inline HMAC logic - Strip IPv6 brackets before validateDatabaseHost in Redis route * fix(security): allow HTTP webhooks and fix misleading MCP error docs - Add allowHttp option to validateExternalUrl, validateUrlWithDNS, and secureFetchWithValidation to support HTTP webhook URLs - Pass allowHttp: true for webhook delivery and test endpoints - Fix misleading JSDoc on createMcpErrorResponse (doesn't log errors) - Mark unused error param with underscore prefix * fix(security): forward allowHttp option through redirect validation Pass allowHttp to validateUrlWithDNS in the redirect handler of secureFetchWithPinnedIP so HTTP-to-HTTP redirects work when allowHttp is enabled for webhook delivery. * fix(security): block localhost when allowHttp is enabled When allowHttp is true (user-supplied webhook URLs), explicitly block localhost/loopback in both validateExternalUrl and validateUrlWithDNS to prevent SSRF against internal services. * fix(security): always strip multi-line content in sanitizeConnectionError Take the first line of the error message regardless of length to prevent leaking sensitive data from multi-line error messages.
This commit is contained in:
@@ -192,7 +192,8 @@ export const POST = withMcpAuth<{ id: string }>('read')(
|
||||
)
|
||||
} catch (error) {
|
||||
connectionStatus = 'error'
|
||||
lastError = error instanceof Error ? error.message : 'Connection test failed'
|
||||
lastError =
|
||||
error instanceof Error ? error.message.split('\n')[0].slice(0, 200) : 'Connection failed'
|
||||
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,20 @@ interface TestConnectionResult {
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a user-friendly error message from connection errors.
|
||||
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
|
||||
* verbose internals (Zod details, full response bodies, stack traces).
|
||||
*/
|
||||
function sanitizeConnectionError(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return 'Unknown connection error'
|
||||
}
|
||||
|
||||
const firstLine = error.message.split('\n')[0]
|
||||
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Test connection to an MCP server before registering it
|
||||
*/
|
||||
@@ -137,8 +151,7 @@ export const POST = withMcpAuth('write')(
|
||||
} catch (toolError) {
|
||||
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
|
||||
result.success = false
|
||||
const errorMessage = toolError instanceof Error ? toolError.message : 'Unknown error'
|
||||
result.error = `Connection established but could not list tools: ${errorMessage}`
|
||||
result.error = 'Connection established but could not list tools'
|
||||
result.warnings = result.warnings || []
|
||||
result.warnings.push(
|
||||
'Server connected but tool listing failed - connection may be incomplete'
|
||||
@@ -163,11 +176,7 @@ export const POST = withMcpAuth('write')(
|
||||
logger.warn(`[${requestId}] MCP server test failed:`, error)
|
||||
|
||||
result.success = false
|
||||
if (error instanceof Error) {
|
||||
result.error = error.message
|
||||
} else {
|
||||
result.error = 'Unknown connection error'
|
||||
}
|
||||
result.error = sanitizeConnectionError(error)
|
||||
} finally {
|
||||
if (client) {
|
||||
try {
|
||||
|
||||
@@ -89,11 +89,12 @@ export const POST = withMcpAuth('read')(
|
||||
tool = tools.find((t) => t.name === toolName) ?? null
|
||||
|
||||
if (!tool) {
|
||||
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
|
||||
availableTools: tools.map((t) => t.name),
|
||||
})
|
||||
return createMcpErrorResponse(
|
||||
new Error(
|
||||
`Tool ${toolName} not found on server ${serverId}. Available tools: ${tools.map((t) => t.name).join(', ')}`
|
||||
),
|
||||
'Tool not found',
|
||||
new Error('Tool not found'),
|
||||
'Tool not found on the specified server',
|
||||
404
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to cancel task',
|
||||
error: 'Failed to cancel task',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete push notification',
|
||||
error: 'Failed to delete push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
|
||||
error: 'Failed to fetch Agent Card',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get push notification',
|
||||
error: 'Failed to get push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get task',
|
||||
error: 'Failed to get task',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to resubscribe',
|
||||
error: 'Failed to resubscribe',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to connect to agent: ${clientError instanceof Error ? clientError.message : 'Unknown error'}`,
|
||||
error: 'Failed to connect to agent',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
@@ -158,7 +158,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to send message: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
|
||||
error: 'Failed to send message to agent',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
@@ -218,7 +218,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
error: 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set push notification',
|
||||
error: 'Failed to set push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { MongoClient } from 'mongodb'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types'
|
||||
|
||||
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const credentials =
|
||||
config.username && config.password
|
||||
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
export interface MySQLConnectionConfig {
|
||||
host: string
|
||||
@@ -10,6 +11,11 @@ export interface MySQLConnectionConfig {
|
||||
}
|
||||
|
||||
export async function createMySQLConnection(config: MySQLConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const connectionConfig: mysql.ConnectionOptions = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import neo4j from 'neo4j-driver'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import type { Neo4jConnectionConfig } from '@/tools/neo4j/types'
|
||||
|
||||
export async function createNeo4jDriver(config: Neo4jConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const isAuraHost =
|
||||
config.host === 'databases.neo4j.io' || config.host.endsWith('.databases.neo4j.io')
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Introspecting PostgreSQL schema on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import postgres from 'postgres'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
|
||||
|
||||
export function createPostgresConnection(config: PostgresConnectionConfig) {
|
||||
export async function createPostgresConnection(config: PostgresConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const sslConfig =
|
||||
config.ssl === 'disabled'
|
||||
? false
|
||||
|
||||
@@ -3,6 +3,7 @@ import Redis from 'ioredis'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('RedisAPI')
|
||||
|
||||
@@ -24,6 +25,16 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const { url, command, args } = RequestSchema.parse(body)
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname =
|
||||
parsedUrl.hostname.startsWith('[') && parsedUrl.hostname.endsWith(']')
|
||||
? parsedUrl.hostname.slice(1, -1)
|
||||
: parsedUrl.hostname
|
||||
const hostValidation = await validateDatabaseHost(hostname, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
return NextResponse.json({ error: hostValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
client = new Redis(url, {
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 10000,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -135,18 +136,18 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
|
||||
headers['sim-signature'] = `t=${timestamp},v1=${signature}`
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookConfig.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const response = await secureFetchWithValidation(
|
||||
webhookConfig.url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeout: 10000,
|
||||
allowHttp: true,
|
||||
},
|
||||
'webhookUrl'
|
||||
)
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
|
||||
return {
|
||||
@@ -157,12 +158,10 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
if (err.name === 'AbortError') {
|
||||
return { success: false, error: 'Request timeout after 10 seconds' }
|
||||
}
|
||||
return { success: false, error: err.message }
|
||||
logger.warn('Webhook test failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return { success: false, error: 'Failed to deliver webhook' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,13 +267,15 @@ async function testSlack(
|
||||
|
||||
return {
|
||||
success: result.ok,
|
||||
error: result.error,
|
||||
error: result.ok ? undefined : `Slack error: ${result.error || 'unknown'}`,
|
||||
channel: result.channel,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
logger.warn('Slack test notification failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return { success: false, error: 'Failed to send Slack notification' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
@@ -207,18 +208,18 @@ async function deliverWebhook(
|
||||
headers['sim-signature'] = `t=${payload.timestamp},v1=${signature}`
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookConfig.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const response = await secureFetchWithValidation(
|
||||
webhookConfig.url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeout: 30000,
|
||||
allowHttp: true,
|
||||
},
|
||||
'webhookUrl'
|
||||
)
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
@@ -226,11 +227,13 @@ async function deliverWebhook(
|
||||
error: response.ok ? undefined : `HTTP ${response.status}`,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
logger.warn('Webhook delivery failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
webhookUrl: webhookConfig.url,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: err.name === 'AbortError' ? 'Request timeout' : err.message,
|
||||
error: 'Failed to deliver webhook',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ClientFactoryOptions,
|
||||
} from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
|
||||
import { A2A_TERMINAL_STATES } from './constants'
|
||||
|
||||
@@ -43,6 +44,11 @@ class ApiKeyInterceptor implements CallInterceptor {
|
||||
* Tries standard path first, falls back to root URL for compatibility.
|
||||
*/
|
||||
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
|
||||
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl')
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.error || 'Agent URL validation failed')
|
||||
}
|
||||
|
||||
const factoryOptions = apiKey
|
||||
? ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
|
||||
clientConfig: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isLegacyApiKeyFormat,
|
||||
} from '@/lib/api-key/crypto'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
|
||||
const logger = createLogger('ApiKeyAuth')
|
||||
|
||||
@@ -39,7 +40,7 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
return safeCompare(inputKey, decrypted)
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
return false
|
||||
@@ -54,27 +55,27 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
return safeCompare(inputKey, decrypted)
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
// Fall through to plain text comparison if decryption fails
|
||||
}
|
||||
}
|
||||
// Legacy format can match against plain text storage
|
||||
return inputKey === storedKey
|
||||
return safeCompare(inputKey, storedKey)
|
||||
}
|
||||
|
||||
// If no recognized prefix, fall back to original behavior
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
return safeCompare(inputKey, decrypted)
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
}
|
||||
}
|
||||
|
||||
return inputKey === storedKey
|
||||
return safeCompare(inputKey, storedKey)
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error:', { error })
|
||||
return false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { jwtVerify, SignJWT } from 'jose'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
|
||||
const logger = createLogger('CronAuth')
|
||||
|
||||
@@ -81,7 +82,8 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${env.CRON_SECRET}`
|
||||
if (authHeader !== expectedAuth) {
|
||||
const isValid = authHeader !== null && safeCompare(authHeader, expectedAuth)
|
||||
if (!isValid) {
|
||||
const contextInfo = context ? ` for ${context}` : ''
|
||||
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
|
||||
providedAuth: authHeader,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
|
||||
export function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
@@ -13,7 +14,7 @@ export function checkInternalApiKey(req: NextRequest) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
if (!safeCompare(apiKey, expectedApiKey)) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,9 @@ export function setDeploymentAuthCookie(
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds CORS headers to allow cross-origin requests for embedded deployments
|
||||
* Adds CORS headers to allow cross-origin requests for embedded deployments.
|
||||
* Embedded chat widgets and forms are designed to run on any customer domain,
|
||||
* so we reflect the requesting origin rather than restricting to an allowlist.
|
||||
*/
|
||||
export function addCorsHeaders(response: NextResponse, request: NextRequest): NextResponse {
|
||||
const origin = request.headers.get('origin') || ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
@@ -91,8 +91,8 @@ export function generatePassword(length = 24): string {
|
||||
* @returns True if strings are equal, false otherwise
|
||||
*/
|
||||
export function safeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
||||
const key = 'safeCompare'
|
||||
const ha = createHmac('sha256', key).update(a).digest()
|
||||
const hb = createHmac('sha256', key).update(b).digest()
|
||||
return timingSafeEqual(ha, hb)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ function isPrivateOrReservedIP(ip: string): boolean {
|
||||
*/
|
||||
export async function validateUrlWithDNS(
|
||||
url: string | null | undefined,
|
||||
paramName = 'url'
|
||||
paramName = 'url',
|
||||
options: { allowHttp?: boolean } = {}
|
||||
): Promise<AsyncValidationResult> {
|
||||
const basicValidation = validateExternalUrl(url, paramName)
|
||||
const basicValidation = validateExternalUrl(url, paramName, options)
|
||||
if (!basicValidation.isValid) {
|
||||
return basicValidation
|
||||
}
|
||||
@@ -88,7 +89,10 @@ export async function validateUrlWithDNS(
|
||||
return ip === '127.0.0.1' || ip === '::1'
|
||||
})()
|
||||
|
||||
if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback)) {
|
||||
if (
|
||||
isPrivateOrReservedIP(address) &&
|
||||
!(isLocalhost && resolvedIsLoopback && !options.allowHttp)
|
||||
) {
|
||||
logger.warn('URL resolves to blocked IP address', {
|
||||
paramName,
|
||||
hostname,
|
||||
@@ -118,6 +122,70 @@ export async function validateUrlWithDNS(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a database hostname by resolving DNS and checking the resolved IP
|
||||
* against private/reserved ranges to prevent SSRF via database connections.
|
||||
*
|
||||
* Unlike validateHostname (which enforces strict RFC hostname format), this
|
||||
* function is permissive about hostname format to avoid breaking legitimate
|
||||
* database hostnames (e.g. underscores in Docker/K8s service names). It only
|
||||
* blocks localhost and private/reserved IPs.
|
||||
*
|
||||
* @param host - The database hostname to validate
|
||||
* @param paramName - Name of the parameter for error messages
|
||||
* @returns AsyncValidationResult with resolved IP
|
||||
*/
|
||||
export async function validateDatabaseHost(
|
||||
host: string | null | undefined,
|
||||
paramName = 'host'
|
||||
): Promise<AsyncValidationResult> {
|
||||
if (!host) {
|
||||
return { isValid: false, error: `${paramName} is required` }
|
||||
}
|
||||
|
||||
const lowerHost = host.toLowerCase()
|
||||
|
||||
if (lowerHost === 'localhost') {
|
||||
return { isValid: false, error: `${paramName} cannot be localhost` }
|
||||
}
|
||||
|
||||
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
|
||||
return { isValid: false, error: `${paramName} cannot be a private IP address` }
|
||||
}
|
||||
|
||||
try {
|
||||
const { address } = await dns.lookup(host, { verbatim: true })
|
||||
|
||||
if (isPrivateOrReservedIP(address)) {
|
||||
logger.warn('Database host resolves to blocked IP address', {
|
||||
paramName,
|
||||
hostname: host,
|
||||
resolvedIP: address,
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} resolves to a blocked IP address`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
resolvedIP: address,
|
||||
originalHostname: host,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('DNS lookup failed for database host', {
|
||||
paramName,
|
||||
hostname: host,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} hostname could not be resolved`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SecureFetchOptions {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
@@ -183,7 +251,7 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
|
||||
export async function secureFetchWithPinnedIP(
|
||||
url: string,
|
||||
resolvedIP: string,
|
||||
options: SecureFetchOptions = {},
|
||||
options: SecureFetchOptions & { allowHttp?: boolean } = {},
|
||||
redirectCount = 0
|
||||
): Promise<SecureFetchResponse> {
|
||||
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
||||
@@ -231,7 +299,7 @@ export async function secureFetchWithPinnedIP(
|
||||
res.resume()
|
||||
const redirectUrl = resolveRedirectUrl(url, location)
|
||||
|
||||
validateUrlWithDNS(redirectUrl, 'redirectUrl')
|
||||
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
|
||||
.then((validation) => {
|
||||
if (!validation.isValid) {
|
||||
reject(new Error(`Redirect blocked: ${validation.error}`))
|
||||
@@ -340,10 +408,12 @@ export async function secureFetchWithPinnedIP(
|
||||
*/
|
||||
export async function secureFetchWithValidation(
|
||||
url: string,
|
||||
options: SecureFetchOptions = {},
|
||||
options: SecureFetchOptions & { allowHttp?: boolean } = {},
|
||||
paramName = 'url'
|
||||
): Promise<SecureFetchResponse> {
|
||||
const validation = await validateUrlWithDNS(url, paramName)
|
||||
const validation = await validateUrlWithDNS(url, paramName, {
|
||||
allowHttp: options.allowHttp,
|
||||
})
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.error)
|
||||
}
|
||||
|
||||
@@ -676,7 +676,8 @@ export function validateJiraIssueKey(
|
||||
*/
|
||||
export function validateExternalUrl(
|
||||
url: string | null | undefined,
|
||||
paramName = 'url'
|
||||
paramName = 'url',
|
||||
options: { allowHttp?: boolean } = {}
|
||||
): ValidationResult {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return {
|
||||
@@ -709,7 +710,20 @@ export function validateExternalUrl(
|
||||
}
|
||||
}
|
||||
|
||||
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
|
||||
if (options.allowHttp) {
|
||||
if (protocol !== 'https:' && protocol !== 'http:') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} must use http:// or https:// protocol`,
|
||||
}
|
||||
}
|
||||
if (isLocalhost) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} cannot point to localhost`,
|
||||
}
|
||||
}
|
||||
} else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} must use https:// protocol`,
|
||||
|
||||
@@ -246,7 +246,7 @@ describe('categorizeError', () => {
|
||||
const error = new Error('Server not accessible')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(404)
|
||||
expect(result.message).toBe('Server not accessible')
|
||||
expect(result.message).toBe('Resource not found')
|
||||
})
|
||||
|
||||
it.concurrent('returns 401 for authentication errors', () => {
|
||||
@@ -267,28 +267,28 @@ describe('categorizeError', () => {
|
||||
const error = new Error('Invalid parameter provided')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Invalid parameter provided')
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for missing required errors', () => {
|
||||
const error = new Error('Missing required field: name')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Missing required field: name')
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for validation errors', () => {
|
||||
const error = new Error('Validation failed for input')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Validation failed for input')
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for generic errors', () => {
|
||||
const error = new Error('Something went wrong')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Something went wrong')
|
||||
expect(result.message).toBe('Internal server error')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for non-Error objects', () => {
|
||||
|
||||
+15
-18
@@ -49,18 +49,18 @@ export const MCP_CLIENT_CONSTANTS = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Create standardized MCP error response
|
||||
* Create standardized MCP error response.
|
||||
* Always returns the defaultMessage to clients to prevent leaking internal error details.
|
||||
* Callers are responsible for logging the original error before calling this function.
|
||||
*/
|
||||
export function createMcpErrorResponse(
|
||||
error: unknown,
|
||||
_error: unknown,
|
||||
defaultMessage: string,
|
||||
status = 500
|
||||
): NextResponse {
|
||||
const errorMessage = error instanceof Error ? error.message : defaultMessage
|
||||
|
||||
const response: McpApiResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
error: defaultMessage,
|
||||
}
|
||||
|
||||
return NextResponse.json(response, { status })
|
||||
@@ -115,36 +115,33 @@ export function validateRequiredFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error categorization for more specific HTTP status codes
|
||||
* Enhanced error categorization for more specific HTTP status codes.
|
||||
* Returns safe, generic messages to prevent leaking internal details.
|
||||
*/
|
||||
export function categorizeError(error: unknown): { message: string; status: number } {
|
||||
if (!(error instanceof Error)) {
|
||||
return { message: 'Unknown error occurred', status: 500 }
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase()
|
||||
const msg = error.message.toLowerCase()
|
||||
|
||||
if (message.includes('timeout')) {
|
||||
if (msg.includes('timeout')) {
|
||||
return { message: 'Request timed out', status: 408 }
|
||||
}
|
||||
|
||||
if (message.includes('not found') || message.includes('not accessible')) {
|
||||
return { message: error.message, status: 404 }
|
||||
if (msg.includes('not found') || msg.includes('not accessible')) {
|
||||
return { message: 'Resource not found', status: 404 }
|
||||
}
|
||||
|
||||
if (message.includes('authentication') || message.includes('unauthorized')) {
|
||||
if (msg.includes('authentication') || msg.includes('unauthorized')) {
|
||||
return { message: 'Authentication required', status: 401 }
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes('invalid') ||
|
||||
message.includes('missing required') ||
|
||||
message.includes('validation')
|
||||
) {
|
||||
return { message: error.message, status: 400 }
|
||||
if (msg.includes('invalid') || msg.includes('missing required') || msg.includes('validation')) {
|
||||
return { message: 'Invalid request parameters', status: 400 }
|
||||
}
|
||||
|
||||
return { message: error.message, status: 500 }
|
||||
return { message: 'Internal server error', status: 500 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
|
||||
@@ -800,14 +801,14 @@ export async function verifyProviderAuth(
|
||||
|
||||
if (secretHeaderName) {
|
||||
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
|
||||
if (headerValue === configToken) {
|
||||
if (headerValue && safeCompare(headerValue, configToken)) {
|
||||
isTokenValid = true
|
||||
}
|
||||
} else {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader?.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7)
|
||||
if (token === configToken) {
|
||||
if (safeCompare(token, configToken)) {
|
||||
isTokenValid = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type { IRoomManager } from '@/socket/rooms'
|
||||
|
||||
interface Logger {
|
||||
@@ -21,7 +22,8 @@ function checkInternalApiKey(req: IncomingMessage): { success: boolean; error?:
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
const apiKeyStr = Array.isArray(apiKey) ? apiKey[0] : apiKey
|
||||
if (!apiKeyStr || !safeCompare(apiKeyStr, expectedApiKey)) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const genericWebhookTrigger: TriggerConfig = {
|
||||
title: 'Require Authentication',
|
||||
type: 'switch',
|
||||
description: 'Require authentication for all webhook requests',
|
||||
defaultValue: false,
|
||||
defaultValue: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
@@ -36,6 +36,7 @@ export const genericWebhookTrigger: TriggerConfig = {
|
||||
description: 'Token used to authenticate webhook requests via Bearer token or custom header',
|
||||
password: true,
|
||||
required: false,
|
||||
value: () => crypto.randomUUID(),
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user