mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(security): add authentication and input validation to API routes (#2959)
* fix(security): add authentication and input validation to API routes * moved utils * remove extraneous commetns * removed unused dep
This commit is contained in:
@@ -8,6 +8,7 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentCardAPI')
|
||||
|
||||
@@ -95,6 +96,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
if (
|
||||
@@ -160,6 +166,11 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
@@ -194,6 +205,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
@@ -1118,17 +1119,13 @@ async function handlePushNotificationSet(
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(params.pushNotificationConfig.url)
|
||||
if (url.protocol !== 'https:') {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
const urlValidation = validateExternalUrl(
|
||||
params.pushNotificationConfig.url,
|
||||
'Push notification URL'
|
||||
)
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,14 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: vi.fn(),
|
||||
}))
|
||||
@@ -110,6 +118,24 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
|
||||
describe('Security Tests', () => {
|
||||
it('should reject unauthorized requests', async () => {
|
||||
const { checkHybridAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkHybridAuth).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
})
|
||||
|
||||
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
@@ -581,6 +582,12 @@ export async function POST(req: NextRequest) {
|
||||
let resolvedCode = '' // Store resolved code for error reporting
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(req)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized function execution attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
||||
|
||||
@@ -3,7 +3,9 @@ import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
@@ -20,6 +22,11 @@ export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Provider API request started`, {
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: request.headers.get('User-Agent'),
|
||||
@@ -85,6 +92,13 @@ export async function POST(request: NextRequest) {
|
||||
verbosity,
|
||||
})
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
|
||||
if (!workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
let finalApiKey: string | undefined = apiKey
|
||||
try {
|
||||
if (provider === 'vertex' && vertexCredential) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createA2AClient } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -39,6 +40,18 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
||||
|
||||
const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: urlValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] A2A set push notification request`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLDeleteAPI')
|
||||
@@ -21,6 +22,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL delete attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLExecuteAPI')
|
||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL execute attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLInsertAPI')
|
||||
@@ -42,6 +43,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL insert attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLIntrospectAPI')
|
||||
@@ -19,6 +20,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL introspect attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = IntrospectSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLQueryAPI')
|
||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL query attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = QuerySchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLUpdateAPI')
|
||||
@@ -40,6 +41,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL update attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = UpdateSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLDeleteAPI')
|
||||
@@ -21,6 +22,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL delete attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createPostgresConnection,
|
||||
executeQuery,
|
||||
@@ -24,6 +25,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL execute attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLInsertAPI')
|
||||
@@ -42,6 +43,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL insert attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLIntrospectAPI')
|
||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL introspect attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = IntrospectSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLQueryAPI')
|
||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL query attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = QuerySchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLUpdateAPI')
|
||||
@@ -40,6 +41,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL update attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = UpdateSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHCheckCommandExistsAPI')
|
||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH check command exists attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = CheckCommandExistsSchema.parse(body)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper, Stats } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
getFileType,
|
||||
@@ -39,10 +40,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH check file exists attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = CheckFileExistsSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
escapeShellArg,
|
||||
@@ -27,10 +28,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH create directory attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = CreateDirectorySchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -53,7 +59,6 @@ export async function POST(request: NextRequest) {
|
||||
const dirPath = sanitizePath(params.path)
|
||||
const escapedPath = escapeShellArg(dirPath)
|
||||
|
||||
// Check if directory already exists
|
||||
const checkResult = await executeSSHCommand(
|
||||
client,
|
||||
`test -d '${escapedPath}' && echo "exists"`
|
||||
@@ -70,7 +75,6 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory
|
||||
const mkdirFlag = params.recursive ? '-p' : ''
|
||||
const command = `mkdir ${mkdirFlag} -m ${params.permissions} '${escapedPath}'`
|
||||
const result = await executeSSHCommand(client, command)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
escapeShellArg,
|
||||
@@ -27,10 +28,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH delete file attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteFileSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -53,7 +59,6 @@ export async function POST(request: NextRequest) {
|
||||
const filePath = sanitizePath(params.path)
|
||||
const escapedPath = escapeShellArg(filePath)
|
||||
|
||||
// Check if path exists
|
||||
const checkResult = await executeSSHCommand(
|
||||
client,
|
||||
`test -e '${escapedPath}' && echo "exists"`
|
||||
@@ -62,7 +67,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: `Path does not exist: ${filePath}` }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build delete command
|
||||
let command: string
|
||||
if (params.recursive) {
|
||||
command = params.force ? `rm -rf '${escapedPath}'` : `rm -r '${escapedPath}'`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHDownloadFileAPI')
|
||||
@@ -34,10 +35,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH download file attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DownloadFileSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, executeSSHCommand, sanitizeCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHExecuteCommandAPI')
|
||||
@@ -21,10 +22,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH execute command attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteCommandSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -44,7 +50,6 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
// Build command with optional working directory
|
||||
let command = sanitizeCommand(params.command)
|
||||
if (params.workingDirectory) {
|
||||
command = `cd "${params.workingDirectory}" && ${command}`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHExecuteScriptAPI')
|
||||
@@ -22,10 +23,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH execute script attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteScriptSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -45,13 +51,10 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
// Create a temporary script file, execute it, and clean up
|
||||
const scriptPath = `/tmp/sim_script_${requestId}.sh`
|
||||
const escapedScriptPath = escapeShellArg(scriptPath)
|
||||
const escapedInterpreter = escapeShellArg(params.interpreter)
|
||||
|
||||
// Build the command to create, execute, and clean up the script
|
||||
// Note: heredoc with quoted delimiter ('SIMEOF') prevents variable expansion
|
||||
let command = `cat > '${escapedScriptPath}' << 'SIMEOF'
|
||||
${params.script}
|
||||
SIMEOF
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHGetSystemInfoAPI')
|
||||
@@ -19,10 +20,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH get system info attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = GetSystemInfoSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, FileEntry, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
getFileType,
|
||||
@@ -60,10 +61,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH list directory attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ListDirectorySchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
escapeShellArg,
|
||||
@@ -27,9 +28,16 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH move/rename attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = MoveRenameSchema.parse(body)
|
||||
|
||||
// Validate SSH authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHReadFileContentAPI')
|
||||
@@ -35,6 +36,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH read file content attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ReadFileContentSchema.parse(body)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHUploadFileAPI')
|
||||
@@ -37,6 +38,12 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH upload file attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = UploadFileSchema.parse(body)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHWriteFileContentAPI')
|
||||
@@ -36,10 +37,15 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH write file content attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = WriteFileContentSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -19,6 +19,9 @@ interface TextProps {
|
||||
* - Automatically detects and renders HTML content safely
|
||||
* - Applies prose styling for HTML content (links, code, lists, etc.)
|
||||
* - Falls back to plain text rendering for non-HTML content
|
||||
*
|
||||
* Note: This component renders trusted, internally-defined content only
|
||||
* (e.g., trigger setup instructions). It is NOT used for user-generated content.
|
||||
*/
|
||||
export function Text({ blockId, subBlockId, content, className }: TextProps) {
|
||||
const containsHtml = /<[^>]+>/.test(content)
|
||||
|
||||
@@ -448,7 +448,7 @@ export const SearchModal = memo(function SearchModal({
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
||||
|
||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
||||
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
() => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'],
|
||||
[]
|
||||
)
|
||||
|
||||
|
||||
@@ -102,6 +102,47 @@ function calculateAliasScore(
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate multi-word match score
|
||||
* Each word in the query must appear somewhere in the field
|
||||
* Returns a score based on how well the words match
|
||||
*/
|
||||
function calculateMultiWordScore(
|
||||
queryWords: string[],
|
||||
field: string
|
||||
): { score: number; matchType: 'word-boundary' | 'substring' | null } {
|
||||
const normalizedField = field.toLowerCase().trim()
|
||||
const fieldWords = normalizedField.split(/[\s\-_/:]+/)
|
||||
|
||||
let allWordsMatch = true
|
||||
let totalScore = 0
|
||||
let hasWordBoundary = false
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
const wordBoundaryMatch = fieldWords.some((fw) => fw.startsWith(queryWord))
|
||||
const substringMatch = normalizedField.includes(queryWord)
|
||||
|
||||
if (wordBoundaryMatch) {
|
||||
totalScore += SCORE_WORD_BOUNDARY
|
||||
hasWordBoundary = true
|
||||
} else if (substringMatch) {
|
||||
totalScore += SCORE_SUBSTRING_MATCH
|
||||
} else {
|
||||
allWordsMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!allWordsMatch) {
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
return {
|
||||
score: totalScore / queryWords.length,
|
||||
matchType: hasWordBoundary ? 'word-boundary' : 'substring',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items using tiered matching algorithm
|
||||
* Returns items sorted by relevance (highest score first)
|
||||
@@ -117,6 +158,8 @@ export function searchItems<T extends SearchableItem>(
|
||||
}
|
||||
|
||||
const results: SearchResult<T>[] = []
|
||||
const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const isMultiWord = queryWords.length > 1
|
||||
|
||||
for (const item of items) {
|
||||
const nameMatch = calculateFieldScore(normalizedQuery, item.name)
|
||||
@@ -127,16 +170,35 @@ export function searchItems<T extends SearchableItem>(
|
||||
|
||||
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||
|
||||
const nameScore = nameMatch.score
|
||||
const descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
let nameScore = nameMatch.score
|
||||
let descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
const aliasScore = aliasMatch.score
|
||||
|
||||
let bestMatchType = nameMatch.matchType
|
||||
|
||||
// For multi-word queries, also try matching each word independently and take the better score
|
||||
if (isMultiWord) {
|
||||
const multiWordNameMatch = calculateMultiWordScore(queryWords, item.name)
|
||||
if (multiWordNameMatch.score > nameScore) {
|
||||
nameScore = multiWordNameMatch.score
|
||||
bestMatchType = multiWordNameMatch.matchType
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
const multiWordDescMatch = calculateMultiWordScore(queryWords, item.description)
|
||||
const multiWordDescScore = multiWordDescMatch.score * DESCRIPTION_WEIGHT
|
||||
if (multiWordDescScore > descScore) {
|
||||
descScore = multiWordDescScore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bestScore = Math.max(nameScore, descScore, aliasScore)
|
||||
|
||||
if (bestScore > 0) {
|
||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||
matchType = nameMatch.matchType || 'substring'
|
||||
matchType = bestMatchType || 'substring'
|
||||
} else if (aliasScore >= descScore) {
|
||||
matchType = 'alias'
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
const logger = createLogger('TriggerWebhookExecution')
|
||||
@@ -397,7 +398,7 @@ async function executeWebhookJobInternal(
|
||||
requestId,
|
||||
userId: payload.userId,
|
||||
})
|
||||
Object.assign(input, processedInput)
|
||||
safeAssign(input, processedInput as Record<string, unknown>)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`[${requestId}] No valid triggerId found for block ${payload.blockId}`)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { NormalizedBlockOutput, UserFile } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
|
||||
type ExecutionKind = 'chat' | 'manual' | 'api'
|
||||
|
||||
@@ -346,7 +347,7 @@ function buildLegacyStarterOutput(
|
||||
const finalObject = isPlainObject(finalInput) ? finalInput : undefined
|
||||
|
||||
if (finalObject) {
|
||||
Object.assign(output, finalObject)
|
||||
safeAssign(output, finalObject)
|
||||
output.input = { ...finalObject }
|
||||
} else {
|
||||
output.input = finalInput
|
||||
|
||||
@@ -4,6 +4,7 @@ import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
|
||||
const logger = createLogger('A2APushNotifications')
|
||||
|
||||
@@ -45,7 +46,17 @@ export async function deliverPushNotification(taskId: string, state: TaskState):
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(config.url, {
|
||||
const urlValidation = await validateUrlWithDNS(config.url, 'webhook URL')
|
||||
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
|
||||
logger.error('Push notification URL validation failed', {
|
||||
taskId,
|
||||
url: config.url,
|
||||
error: urlValidation.error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const response = await secureFetchWithPinnedIP(config.url, urlValidation.resolvedIP, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
@@ -59,7 +70,7 @@ export async function deliverPushNotification(taskId: string, state: TaskState):
|
||||
artifacts: (task.artifacts as Artifact[]) || [],
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"jose": "6.0.11",
|
||||
"js-tiktoken": "1.0.21",
|
||||
"js-yaml": "4.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jszip": "3.10.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
@@ -137,7 +138,6 @@
|
||||
"posthog-node": "5.9.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.1",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSON5 from 'json5'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
@@ -30,7 +31,7 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
|
||||
const parsed = new Function(`return ${valueToEvaluate}`)()
|
||||
const parsed = JSON5.parse(valueToEvaluate)
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
@@ -43,12 +44,12 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
}
|
||||
case 'array':
|
||||
try {
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
const parsed = JSON5.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid JSON array'
|
||||
return 'Not a valid array'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid JSON array syntax'
|
||||
return 'Invalid array syntax'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSON5 from 'json5'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
@@ -125,8 +126,7 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
// eslint-disable-next-line no-new-func
|
||||
const parsed = new Function(`return ${valueToEvaluate}`)()
|
||||
const parsed = JSON5.parse(valueToEvaluate)
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
}
|
||||
@@ -138,12 +138,12 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
}
|
||||
case 'array': {
|
||||
try {
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
const parsed = JSON5.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid JSON array'
|
||||
return 'Not a valid array'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid JSON array syntax'
|
||||
return 'Invalid array syntax'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ScrapeParams, ScrapeResponse } from '@/tools/firecrawl/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const scrapeTool: ToolConfig<ScrapeParams, ScrapeResponse> = {
|
||||
@@ -64,7 +65,7 @@ export const scrapeTool: ToolConfig<ScrapeParams, ScrapeResponse> = {
|
||||
body.zeroDataRetention = params.zeroDataRetention
|
||||
|
||||
if (params.scrapeOptions) {
|
||||
Object.assign(body, params.scrapeOptions)
|
||||
safeAssign(body, params.scrapeOptions as Record<string, unknown>)
|
||||
}
|
||||
|
||||
return body
|
||||
|
||||
@@ -29,7 +29,6 @@ describe('Function Execute Tool', () => {
|
||||
|
||||
describe('Request Construction', () => {
|
||||
it.concurrent('should set correct URL for code execution', () => {
|
||||
// Since this is an internal route, actual URL will be the concatenated base URL + path
|
||||
expect(tester.getRequestUrl({})).toBe('/api/function/execute')
|
||||
})
|
||||
|
||||
@@ -61,6 +60,7 @@ describe('Function Execute Tool', () => {
|
||||
language: 'javascript',
|
||||
timeout: 5000,
|
||||
workflowId: undefined,
|
||||
userId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('Function Execute Tool', () => {
|
||||
isCustomTool: false,
|
||||
language: 'javascript',
|
||||
workflowId: undefined,
|
||||
userId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,6 +108,7 @@ describe('Function Execute Tool', () => {
|
||||
isCustomTool: false,
|
||||
language: 'javascript',
|
||||
workflowId: undefined,
|
||||
userId: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -350,7 +352,7 @@ describe('Function Execute Tool', () => {
|
||||
it.concurrent('should handle extremely short timeout', async () => {
|
||||
const body = tester.getRequestBody({
|
||||
code: 'return 42',
|
||||
timeout: 1, // 1ms timeout
|
||||
timeout: 1,
|
||||
}) as { timeout: number }
|
||||
|
||||
expect(body.timeout).toBe(1)
|
||||
|
||||
@@ -90,6 +90,7 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
|
||||
blockNameMapping: params.blockNameMapping || {},
|
||||
blockOutputSchemas: params.blockOutputSchemas || {},
|
||||
workflowId: params._context?.workflowId,
|
||||
userId: params._context?.userId,
|
||||
isCustomTool: params.isCustomTool || false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CodeExecutionInput {
|
||||
blockOutputSchemas?: Record<string, Record<string, unknown>>
|
||||
_context?: {
|
||||
workflowId?: string
|
||||
userId?: string
|
||||
}
|
||||
isCustomTool?: boolean
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type SubBlockCondition,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import { isEmptyTagValue } from '@/tools/shared/tags'
|
||||
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
@@ -450,7 +451,7 @@ export async function createLLMToolSchema(
|
||||
const enrichedSchema = await enrichmentConfig.enrichSchema(dependencyValue)
|
||||
|
||||
if (enrichedSchema) {
|
||||
Object.assign(propertySchema, enrichedSchema)
|
||||
safeAssign(propertySchema, enrichedSchema as Record<string, unknown>)
|
||||
schema.properties[paramId] = propertySchema
|
||||
|
||||
if (param.required) {
|
||||
|
||||
94
apps/sim/tools/safe-assign.test.ts
Normal file
94
apps/sim/tools/safe-assign.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isSafeKey, safeAssign } from '@/tools/safe-assign'
|
||||
|
||||
describe('isSafeKey', () => {
|
||||
it.concurrent('should return false for __proto__', () => {
|
||||
expect(isSafeKey('__proto__')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for constructor', () => {
|
||||
expect(isSafeKey('constructor')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for prototype', () => {
|
||||
expect(isSafeKey('prototype')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for normal keys', () => {
|
||||
expect(isSafeKey('name')).toBe(true)
|
||||
expect(isSafeKey('email')).toBe(true)
|
||||
expect(isSafeKey('customField')).toBe(true)
|
||||
expect(isSafeKey('data')).toBe(true)
|
||||
expect(isSafeKey('__internal')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeAssign', () => {
|
||||
it.concurrent('should assign safe properties', () => {
|
||||
const target = { a: 1 }
|
||||
const source = { b: 2, c: 3 }
|
||||
const result = safeAssign(target, source)
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 2, c: 3 })
|
||||
expect(result).toBe(target)
|
||||
})
|
||||
|
||||
it.concurrent('should filter out __proto__ key', () => {
|
||||
const target = { a: 1 }
|
||||
const source = { b: 2, __proto__: { polluted: true } } as Record<string, unknown>
|
||||
const result = safeAssign(target, source)
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
expect((result as any).__proto__).toBe(Object.prototype)
|
||||
expect((Object.prototype as any).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should filter out constructor key', () => {
|
||||
const target = { a: 1 }
|
||||
const source = { b: 2, constructor: { prototype: { polluted: true } } }
|
||||
const result = safeAssign(target, source)
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
expect((Object.prototype as any).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should filter out prototype key', () => {
|
||||
const target = { a: 1 }
|
||||
const source = { b: 2, prototype: { polluted: true } }
|
||||
const result = safeAssign(target, source)
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
expect((Object.prototype as any).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle null source', () => {
|
||||
const target = { a: 1 }
|
||||
const result = safeAssign(target, null as any)
|
||||
|
||||
expect(result).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it.concurrent('should handle undefined source', () => {
|
||||
const target = { a: 1 }
|
||||
const result = safeAssign(target, undefined as any)
|
||||
|
||||
expect(result).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it.concurrent('should handle non-object source', () => {
|
||||
const target = { a: 1 }
|
||||
const result = safeAssign(target, 'string' as any)
|
||||
|
||||
expect(result).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it.concurrent('should prevent prototype pollution attack', () => {
|
||||
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}, "normal": "value"}')
|
||||
const target = {}
|
||||
safeAssign(target, maliciousPayload)
|
||||
|
||||
const newObj = {}
|
||||
expect((newObj as any).isAdmin).toBeUndefined()
|
||||
expect((target as any).normal).toBe('value')
|
||||
})
|
||||
})
|
||||
25
apps/sim/tools/safe-assign.ts
Normal file
25
apps/sim/tools/safe-assign.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
/**
|
||||
* Checks if a key is safe to use in object assignment (not a prototype pollution vector)
|
||||
*/
|
||||
export function isSafeKey(key: string): boolean {
|
||||
return !DANGEROUS_KEYS.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely assigns properties from source to target, filtering out prototype pollution keys.
|
||||
* Use this instead of Object.assign() when the source may contain user-controlled data.
|
||||
*/
|
||||
export function safeAssign<T extends object>(target: T, source: Record<string, unknown>): T {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return target
|
||||
}
|
||||
|
||||
for (const key of Object.keys(source)) {
|
||||
if (isSafeKey(key)) {
|
||||
;(target as Record<string, unknown>)[key] = source[key]
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import type {
|
||||
AddContactParams,
|
||||
ContactResult,
|
||||
@@ -72,7 +73,7 @@ export const sendGridAddContactTool: ToolConfig<AddContactParams, ContactResult>
|
||||
typeof params.customFields === 'string'
|
||||
? JSON.parse(params.customFields)
|
||||
: params.customFields
|
||||
Object.assign(contact, customFields)
|
||||
safeAssign(contact, customFields as Record<string, unknown>)
|
||||
}
|
||||
|
||||
const body: SendGridContactRequest = {
|
||||
|
||||
@@ -320,7 +320,8 @@ export function createCustomToolRequestBody(
|
||||
workflowVariables: workflowVariables, // Workflow variables for <variable.name> resolution
|
||||
blockData: blockData, // Runtime block outputs for <block.field> resolution
|
||||
blockNameMapping: blockNameMapping, // Block name to ID mapping
|
||||
workflowId: workflowId, // Pass workflowId for server-side context
|
||||
workflowId: params._context?.workflowId || workflowId, // Pass workflowId for server-side context
|
||||
userId: params._context?.userId, // Pass userId for auth context
|
||||
isCustomTool: true, // Flag to indicate this is a custom tool execution
|
||||
}
|
||||
}
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -144,6 +144,7 @@
|
||||
"jose": "6.0.11",
|
||||
"js-tiktoken": "1.0.21",
|
||||
"js-yaml": "4.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jszip": "3.10.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
@@ -168,7 +169,6 @@
|
||||
"posthog-node": "5.9.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.1",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -3023,8 +3023,6 @@
|
||||
|
||||
"react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="],
|
||||
|
||||
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="],
|
||||
|
||||
"react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="],
|
||||
|
||||
Reference in New Issue
Block a user