From eefbf5344e20983b7d5d2fd9b5dd15bc40a2ab33 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 16:56:37 -0800 Subject: [PATCH 1/4] fix(security): add authentication and input validation to API routes --- .../sim/app/api/a2a/agents/[agentId]/route.ts | 16 ++++ apps/sim/app/api/a2a/serve/[agentId]/route.ts | 17 ++-- .../app/api/function/execute/route.test.ts | 26 +++++ apps/sim/app/api/function/execute/route.ts | 7 ++ apps/sim/app/api/providers/route.ts | 14 +++ .../tools/a2a/set-push-notification/route.ts | 13 +++ apps/sim/app/api/tools/mysql/delete/route.ts | 7 ++ apps/sim/app/api/tools/mysql/execute/route.ts | 7 ++ apps/sim/app/api/tools/mysql/insert/route.ts | 7 ++ .../app/api/tools/mysql/introspect/route.ts | 7 ++ apps/sim/app/api/tools/mysql/query/route.ts | 7 ++ apps/sim/app/api/tools/mysql/update/route.ts | 7 ++ .../app/api/tools/postgresql/delete/route.ts | 7 ++ .../app/api/tools/postgresql/execute/route.ts | 7 ++ .../app/api/tools/postgresql/insert/route.ts | 7 ++ .../api/tools/postgresql/introspect/route.ts | 7 ++ .../app/api/tools/postgresql/query/route.ts | 7 ++ .../app/api/tools/postgresql/update/route.ts | 7 ++ .../tools/ssh/check-command-exists/route.ts | 8 ++ .../api/tools/ssh/check-file-exists/route.ts | 9 +- .../api/tools/ssh/create-directory/route.ts | 9 +- .../app/api/tools/ssh/delete-file/route.ts | 9 +- .../app/api/tools/ssh/download-file/route.ts | 9 +- .../api/tools/ssh/execute-command/route.ts | 9 +- .../app/api/tools/ssh/execute-script/route.ts | 9 +- .../api/tools/ssh/get-system-info/route.ts | 9 +- .../app/api/tools/ssh/list-directory/route.ts | 9 +- .../app/api/tools/ssh/move-rename/route.ts | 8 ++ .../api/tools/ssh/read-file-content/route.ts | 8 ++ .../app/api/tools/ssh/upload-file/route.ts | 8 ++ .../api/tools/ssh/write-file-content/route.ts | 9 +- .../sub-block/components/text/text.tsx | 3 + .../components/search-modal/search-modal.tsx | 2 +- .../components/search-modal/search-utils.ts | 68 +++++++++++++- apps/sim/background/webhook-execution.ts | 3 +- apps/sim/executor/utils/start-block.ts | 3 +- apps/sim/lib/a2a/push-notifications.ts | 15 ++- apps/sim/package.json | 1 + apps/sim/stores/panel/variables/store.ts | 9 +- apps/sim/stores/variables/store.ts | 10 +- apps/sim/tools/firecrawl/scrape.ts | 3 +- apps/sim/tools/function/execute.test.ts | 6 +- apps/sim/tools/function/execute.ts | 1 + apps/sim/tools/function/types.ts | 1 + apps/sim/tools/params.ts | 4 +- apps/sim/tools/sendgrid/add_contact.ts | 3 +- apps/sim/tools/utils.test.ts | 94 +++++++++++++++++++ apps/sim/tools/utils.ts | 29 +++++- bun.lock | 1 + 49 files changed, 503 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/api/a2a/agents/[agentId]/route.ts b/apps/sim/app/api/a2a/agents/[agentId]/route.ts index 74c13af879..65f22e5b60 100644 --- a/apps/sim/app/api/a2a/agents/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/agents/[agentId]/route.ts @@ -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 ({ 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"', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 0940054b2e..af79d7b82e 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -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') diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 5c5c6798b8..4a654512a1 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -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) { diff --git a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts index d407609418..11dbf7684a 100644 --- a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/mysql/delete/route.ts b/apps/sim/app/api/tools/mysql/delete/route.ts index 4b33288036..025e03a048 100644 --- a/apps/sim/app/api/tools/mysql/delete/route.ts +++ b/apps/sim/app/api/tools/mysql/delete/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/mysql/execute/route.ts b/apps/sim/app/api/tools/mysql/execute/route.ts index 8e4ac396af..769eedcda1 100644 --- a/apps/sim/app/api/tools/mysql/execute/route.ts +++ b/apps/sim/app/api/tools/mysql/execute/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/mysql/insert/route.ts b/apps/sim/app/api/tools/mysql/insert/route.ts index 5e8fd4674b..ef458cff08 100644 --- a/apps/sim/app/api/tools/mysql/insert/route.ts +++ b/apps/sim/app/api/tools/mysql/insert/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/mysql/introspect/route.ts b/apps/sim/app/api/tools/mysql/introspect/route.ts index 5a9cb53c5f..8a48418d67 100644 --- a/apps/sim/app/api/tools/mysql/introspect/route.ts +++ b/apps/sim/app/api/tools/mysql/introspect/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/mysql/query/route.ts b/apps/sim/app/api/tools/mysql/query/route.ts index ad8535ce29..bb3a05bcf1 100644 --- a/apps/sim/app/api/tools/mysql/query/route.ts +++ b/apps/sim/app/api/tools/mysql/query/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/mysql/update/route.ts b/apps/sim/app/api/tools/mysql/update/route.ts index c196bf9248..ed72adcd72 100644 --- a/apps/sim/app/api/tools/mysql/update/route.ts +++ b/apps/sim/app/api/tools/mysql/update/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/postgresql/delete/route.ts b/apps/sim/app/api/tools/postgresql/delete/route.ts index f18df3db1a..d8126ab7fd 100644 --- a/apps/sim/app/api/tools/postgresql/delete/route.ts +++ b/apps/sim/app/api/tools/postgresql/delete/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/postgresql/execute/route.ts b/apps/sim/app/api/tools/postgresql/execute/route.ts index 403823e367..fa3d7bd522 100644 --- a/apps/sim/app/api/tools/postgresql/execute/route.ts +++ b/apps/sim/app/api/tools/postgresql/execute/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/postgresql/insert/route.ts b/apps/sim/app/api/tools/postgresql/insert/route.ts index e01cc9fe27..ba8e063031 100644 --- a/apps/sim/app/api/tools/postgresql/insert/route.ts +++ b/apps/sim/app/api/tools/postgresql/insert/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/postgresql/introspect/route.ts b/apps/sim/app/api/tools/postgresql/introspect/route.ts index 3fc5e41fcf..ddd4c7d4b3 100644 --- a/apps/sim/app/api/tools/postgresql/introspect/route.ts +++ b/apps/sim/app/api/tools/postgresql/introspect/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/postgresql/query/route.ts b/apps/sim/app/api/tools/postgresql/query/route.ts index a6ee4bad26..5a59365001 100644 --- a/apps/sim/app/api/tools/postgresql/query/route.ts +++ b/apps/sim/app/api/tools/postgresql/query/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/postgresql/update/route.ts b/apps/sim/app/api/tools/postgresql/update/route.ts index 862f6dffb4..59786937d6 100644 --- a/apps/sim/app/api/tools/postgresql/update/route.ts +++ b/apps/sim/app/api/tools/postgresql/update/route.ts @@ -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) diff --git a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts index 57fc1b087e..323b161cfc 100644 --- a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts @@ -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,9 +21,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 check command exists attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = CheckCommandExistsSchema.parse(body) + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts index 445ab3bd39..8dee526d63 100644 --- a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts @@ -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,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 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 + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/create-directory/route.ts b/apps/sim/app/api/tools/ssh/create-directory/route.ts index 43c0d27218..908afbea98 100644 --- a/apps/sim/app/api/tools/ssh/create-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/create-directory/route.ts @@ -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,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 create directory attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = CreateDirectorySchema.parse(body) - // Validate authentication + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/delete-file/route.ts b/apps/sim/app/api/tools/ssh/delete-file/route.ts index 3961fe60c2..45cfc874a0 100644 --- a/apps/sim/app/api/tools/ssh/delete-file/route.ts +++ b/apps/sim/app/api/tools/ssh/delete-file/route.ts @@ -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,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 delete file attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = DeleteFileSchema.parse(body) - // Validate authentication + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/download-file/route.ts b/apps/sim/app/api/tools/ssh/download-file/route.ts index 3693f22edb..76a998ce03 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -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,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 download file attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = DownloadFileSchema.parse(body) - // Validate authentication + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/execute-command/route.ts b/apps/sim/app/api/tools/ssh/execute-command/route.ts index 1d53d38535..a6c962acbd 100644 --- a/apps/sim/app/api/tools/ssh/execute-command/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-command/route.ts @@ -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,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 execute command attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = ExecuteCommandSchema.parse(body) - // Validate authentication + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/execute-script/route.ts b/apps/sim/app/api/tools/ssh/execute-script/route.ts index 956318495f..5116829c71 100644 --- a/apps/sim/app/api/tools/ssh/execute-script/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-script/route.ts @@ -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,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 execute script attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = ExecuteScriptSchema.parse(body) - // Validate authentication + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/get-system-info/route.ts b/apps/sim/app/api/tools/ssh/get-system-info/route.ts index 9925013478..b95a5404f2 100644 --- a/apps/sim/app/api/tools/ssh/get-system-info/route.ts +++ b/apps/sim/app/api/tools/ssh/get-system-info/route.ts @@ -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,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 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 + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/list-directory/route.ts b/apps/sim/app/api/tools/ssh/list-directory/route.ts index 30f8f5d236..625e37ef3e 100644 --- a/apps/sim/app/api/tools/ssh/list-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/list-directory/route.ts @@ -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,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 list directory attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = ListDirectorySchema.parse(body) - // Validate authentication + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/move-rename/route.ts b/apps/sim/app/api/tools/ssh/move-rename/route.ts index d1387026dd..832fc22253 100644 --- a/apps/sim/app/api/tools/ssh/move-rename/route.ts +++ b/apps/sim/app/api/tools/ssh/move-rename/route.ts @@ -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' }, diff --git a/apps/sim/app/api/tools/ssh/read-file-content/route.ts b/apps/sim/app/api/tools/ssh/read-file-content/route.ts index c44390bfc0..5dbcc9ed26 100644 --- a/apps/sim/app/api/tools/ssh/read-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/read-file-content/route.ts @@ -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,9 +36,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 read file content attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = ReadFileContentSchema.parse(body) + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/upload-file/route.ts b/apps/sim/app/api/tools/ssh/upload-file/route.ts index 0f736a417d..a81940be3e 100644 --- a/apps/sim/app/api/tools/ssh/upload-file/route.ts +++ b/apps/sim/app/api/tools/ssh/upload-file/route.ts @@ -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,9 +38,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 upload file attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const params = UploadFileSchema.parse(body) + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/write-file-content/route.ts b/apps/sim/app/api/tools/ssh/write-file-content/route.ts index 77c075abb4..7eea51e85c 100644 --- a/apps/sim/app/api/tools/ssh/write-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/write-file-content/route.ts @@ -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,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 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 + // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx index 3bab6d5c57..ab8772d626 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx @@ -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) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index e813109a93..d18a40348d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -448,7 +448,7 @@ export const SearchModal = memo(function SearchModal({ }, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs]) const sectionOrder = useMemo( - () => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'], + () => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'], [] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts index a7bcb5d672..08525b16f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts @@ -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( } const results: SearchResult[] = [] + 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( 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['matchType'] = 'substring' if (nameScore >= descScore && nameScore >= aliasScore) { - matchType = nameMatch.matchType || 'substring' + matchType = bestMatchType || 'substring' } else if (aliasScore >= descScore) { matchType = 'alias' } else { diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index c34b5497b0..4140441426 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -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/utils' 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) } } else { logger.debug(`[${requestId}] No valid triggerId found for block ${payload.blockId}`) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index d18229d20e..9c10cf9f6d 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -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/utils' 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 diff --git a/apps/sim/lib/a2a/push-notifications.ts b/apps/sim/lib/a2a/push-notifications.ts index 74e39d8f7a..da7fa3ad0d 100644 --- a/apps/sim/lib/a2a/push-notifications.ts +++ b/apps/sim/lib/a2a/push-notifications.ts @@ -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) { diff --git a/apps/sim/package.json b/apps/sim/package.json index 14b97e95cc..e5c15245e9 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -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", diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index e2bdb21f50..70ebc2b719 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -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 } diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index df55e0b82e..9a61163417 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -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 } diff --git a/apps/sim/tools/firecrawl/scrape.ts b/apps/sim/tools/firecrawl/scrape.ts index 58cb01027b..83edd800e7 100644 --- a/apps/sim/tools/firecrawl/scrape.ts +++ b/apps/sim/tools/firecrawl/scrape.ts @@ -1,5 +1,6 @@ import type { ScrapeParams, ScrapeResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' +import { safeAssign } from '@/tools/utils' export const scrapeTool: ToolConfig = { id: 'firecrawl_scrape', @@ -64,7 +65,7 @@ export const scrapeTool: ToolConfig = { body.zeroDataRetention = params.zeroDataRetention if (params.scrapeOptions) { - Object.assign(body, params.scrapeOptions) + safeAssign(body, params.scrapeOptions as Record) } return body diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index dc1b6eb20e..938d2c0410 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -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) diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index d7f59daa89..dc8dee1901 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -90,6 +90,7 @@ export const functionExecuteTool: ToolConfig> _context?: { workflowId?: string + userId?: string } isCustomTool?: boolean } diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index e957f12ce2..cc80d23ef1 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -7,7 +7,7 @@ import { import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import { isEmptyTagValue } from '@/tools/shared/tags' import type { ParameterVisibility, ToolConfig } from '@/tools/types' -import { getTool } from '@/tools/utils' +import { getTool, safeAssign } from '@/tools/utils' const logger = createLogger('ToolsParams') type ToolParamDefinition = ToolConfig['params'][string] @@ -450,7 +450,7 @@ export async function createLLMToolSchema( const enrichedSchema = await enrichmentConfig.enrichSchema(dependencyValue) if (enrichedSchema) { - Object.assign(propertySchema, enrichedSchema) + safeAssign(propertySchema, enrichedSchema as Record) schema.properties[paramId] = propertySchema if (param.required) { diff --git a/apps/sim/tools/sendgrid/add_contact.ts b/apps/sim/tools/sendgrid/add_contact.ts index 5c91e37f35..a45042cbab 100644 --- a/apps/sim/tools/sendgrid/add_contact.ts +++ b/apps/sim/tools/sendgrid/add_contact.ts @@ -5,6 +5,7 @@ import type { SendGridContactRequest, } from '@/tools/sendgrid/types' import type { ToolConfig } from '@/tools/types' +import { safeAssign } from '@/tools/utils' export const sendGridAddContactTool: ToolConfig = { id: 'sendgrid_add_contact', @@ -72,7 +73,7 @@ export const sendGridAddContactTool: ToolConfig typeof params.customFields === 'string' ? JSON.parse(params.customFields) : params.customFields - Object.assign(contact, customFields) + safeAssign(contact, customFields as Record) } const body: SendGridContactRequest = { diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index 5eae3eb767..5fa2046c4e 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -8,6 +8,8 @@ import { executeRequest, formatRequestParams, getClientEnvVars, + isSafeKey, + safeAssign, validateRequiredParametersAfterMerge, } from '@/tools/utils' @@ -39,6 +41,98 @@ afterEach(() => { vi.clearAllMocks() }) +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 + 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') + }) +}) + describe('transformTable', () => { it.concurrent('should return empty object for null input', () => { const result = transformTable(null) diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 8dc30ddc79..6db050ed5e 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -9,6 +9,32 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' const logger = createLogger('ToolsUtils') +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(target: T, source: Record): T { + if (!source || typeof source !== 'object') { + return target + } + + for (const key of Object.keys(source)) { + if (isSafeKey(key)) { + ;(target as Record)[key] = source[key] + } + } + return target +} + /** * Strips version suffix (_v2, _v3, etc.) from a tool ID or name * @example stripVersionSuffix('notion_search_v2') => 'notion_search' @@ -320,7 +346,8 @@ export function createCustomToolRequestBody( workflowVariables: workflowVariables, // Workflow variables for resolution blockData: blockData, // Runtime block outputs for 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 } } diff --git a/bun.lock b/bun.lock index 5674d2ad8a..1ecbf9ec35 100644 --- a/bun.lock +++ b/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", From 5941faaa000e57079e0ddd968328cd61592dfc4c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 17:32:37 -0800 Subject: [PATCH 2/4] moved utils --- apps/sim/background/webhook-execution.ts | 2 +- apps/sim/executor/utils/start-block.ts | 2 +- apps/sim/tools/firecrawl/scrape.ts | 2 +- apps/sim/tools/params.ts | 3 +- apps/sim/tools/safe-assign.test.ts | 94 ++++++++++++++++++++++++ apps/sim/tools/safe-assign.ts | 25 +++++++ apps/sim/tools/sendgrid/add_contact.ts | 2 +- apps/sim/tools/utils.test.ts | 94 ------------------------ apps/sim/tools/utils.ts | 26 ------- 9 files changed, 125 insertions(+), 125 deletions(-) create mode 100644 apps/sim/tools/safe-assign.test.ts create mode 100644 apps/sim/tools/safe-assign.ts diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 4140441426..a32ce09021 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -17,7 +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/utils' +import { safeAssign } from '@/tools/safe-assign' import { getTrigger, isTriggerValid } from '@/triggers' const logger = createLogger('TriggerWebhookExecution') diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 9c10cf9f6d..7df680518d 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -8,7 +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/utils' +import { safeAssign } from '@/tools/safe-assign' type ExecutionKind = 'chat' | 'manual' | 'api' diff --git a/apps/sim/tools/firecrawl/scrape.ts b/apps/sim/tools/firecrawl/scrape.ts index 83edd800e7..6e56819a3c 100644 --- a/apps/sim/tools/firecrawl/scrape.ts +++ b/apps/sim/tools/firecrawl/scrape.ts @@ -1,6 +1,6 @@ import type { ScrapeParams, ScrapeResponse } from '@/tools/firecrawl/types' +import { safeAssign } from '@/tools/safe-assign' import type { ToolConfig } from '@/tools/types' -import { safeAssign } from '@/tools/utils' export const scrapeTool: ToolConfig = { id: 'firecrawl_scrape', diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index cc80d23ef1..9ac5a9788b 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -5,9 +5,10 @@ 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, safeAssign } from '@/tools/utils' +import { getTool } from '@/tools/utils' const logger = createLogger('ToolsParams') type ToolParamDefinition = ToolConfig['params'][string] diff --git a/apps/sim/tools/safe-assign.test.ts b/apps/sim/tools/safe-assign.test.ts new file mode 100644 index 0000000000..a9b915461e --- /dev/null +++ b/apps/sim/tools/safe-assign.test.ts @@ -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 + 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') + }) +}) diff --git a/apps/sim/tools/safe-assign.ts b/apps/sim/tools/safe-assign.ts new file mode 100644 index 0000000000..eae76be341 --- /dev/null +++ b/apps/sim/tools/safe-assign.ts @@ -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(target: T, source: Record): T { + if (!source || typeof source !== 'object') { + return target + } + + for (const key of Object.keys(source)) { + if (isSafeKey(key)) { + ;(target as Record)[key] = source[key] + } + } + return target +} diff --git a/apps/sim/tools/sendgrid/add_contact.ts b/apps/sim/tools/sendgrid/add_contact.ts index a45042cbab..5c483e9737 100644 --- a/apps/sim/tools/sendgrid/add_contact.ts +++ b/apps/sim/tools/sendgrid/add_contact.ts @@ -1,3 +1,4 @@ +import { safeAssign } from '@/tools/safe-assign' import type { AddContactParams, ContactResult, @@ -5,7 +6,6 @@ import type { SendGridContactRequest, } from '@/tools/sendgrid/types' import type { ToolConfig } from '@/tools/types' -import { safeAssign } from '@/tools/utils' export const sendGridAddContactTool: ToolConfig = { id: 'sendgrid_add_contact', diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index 5fa2046c4e..5eae3eb767 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -8,8 +8,6 @@ import { executeRequest, formatRequestParams, getClientEnvVars, - isSafeKey, - safeAssign, validateRequiredParametersAfterMerge, } from '@/tools/utils' @@ -41,98 +39,6 @@ afterEach(() => { vi.clearAllMocks() }) -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 - 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') - }) -}) - describe('transformTable', () => { it.concurrent('should return empty object for null input', () => { const result = transformTable(null) diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 6db050ed5e..6d9d27601d 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -9,32 +9,6 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' const logger = createLogger('ToolsUtils') -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(target: T, source: Record): T { - if (!source || typeof source !== 'object') { - return target - } - - for (const key of Object.keys(source)) { - if (isSafeKey(key)) { - ;(target as Record)[key] = source[key] - } - } - return target -} - /** * Strips version suffix (_v2, _v3, etc.) from a tool ID or name * @example stripVersionSuffix('notion_search_v2') => 'notion_search' From 380b9d8db5744bf321794c93d9ba8330ad0055ae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 17:36:36 -0800 Subject: [PATCH 3/4] remove extraneous commetns --- apps/sim/app/api/tools/ssh/check-command-exists/route.ts | 1 - apps/sim/app/api/tools/ssh/check-file-exists/route.ts | 1 - apps/sim/app/api/tools/ssh/create-directory/route.ts | 3 --- apps/sim/app/api/tools/ssh/delete-file/route.ts | 3 --- apps/sim/app/api/tools/ssh/download-file/route.ts | 1 - apps/sim/app/api/tools/ssh/execute-command/route.ts | 2 -- apps/sim/app/api/tools/ssh/execute-script/route.ts | 4 ---- apps/sim/app/api/tools/ssh/get-system-info/route.ts | 1 - apps/sim/app/api/tools/ssh/list-directory/route.ts | 1 - apps/sim/app/api/tools/ssh/read-file-content/route.ts | 1 - apps/sim/app/api/tools/ssh/upload-file/route.ts | 1 - apps/sim/app/api/tools/ssh/write-file-content/route.ts | 1 - 12 files changed, 20 deletions(-) diff --git a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts index 323b161cfc..a401fdf826 100644 --- a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts @@ -30,7 +30,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = CheckCommandExistsSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts index 8dee526d63..f53ae5bf49 100644 --- a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts @@ -49,7 +49,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = CheckFileExistsSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/create-directory/route.ts b/apps/sim/app/api/tools/ssh/create-directory/route.ts index 908afbea98..ca39310f38 100644 --- a/apps/sim/app/api/tools/ssh/create-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/create-directory/route.ts @@ -37,7 +37,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = CreateDirectorySchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, @@ -60,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"` @@ -77,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) diff --git a/apps/sim/app/api/tools/ssh/delete-file/route.ts b/apps/sim/app/api/tools/ssh/delete-file/route.ts index 45cfc874a0..671957c8a1 100644 --- a/apps/sim/app/api/tools/ssh/delete-file/route.ts +++ b/apps/sim/app/api/tools/ssh/delete-file/route.ts @@ -37,7 +37,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = DeleteFileSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, @@ -60,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"` @@ -69,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}'` diff --git a/apps/sim/app/api/tools/ssh/download-file/route.ts b/apps/sim/app/api/tools/ssh/download-file/route.ts index 76a998ce03..5fa40e0846 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -44,7 +44,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = DownloadFileSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/execute-command/route.ts b/apps/sim/app/api/tools/ssh/execute-command/route.ts index a6c962acbd..c8b289d8b7 100644 --- a/apps/sim/app/api/tools/ssh/execute-command/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-command/route.ts @@ -31,7 +31,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = ExecuteCommandSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, @@ -51,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}` diff --git a/apps/sim/app/api/tools/ssh/execute-script/route.ts b/apps/sim/app/api/tools/ssh/execute-script/route.ts index 5116829c71..7b4325fd82 100644 --- a/apps/sim/app/api/tools/ssh/execute-script/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-script/route.ts @@ -32,7 +32,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = ExecuteScriptSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, @@ -52,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 diff --git a/apps/sim/app/api/tools/ssh/get-system-info/route.ts b/apps/sim/app/api/tools/ssh/get-system-info/route.ts index b95a5404f2..8a745a7f81 100644 --- a/apps/sim/app/api/tools/ssh/get-system-info/route.ts +++ b/apps/sim/app/api/tools/ssh/get-system-info/route.ts @@ -29,7 +29,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = GetSystemInfoSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/list-directory/route.ts b/apps/sim/app/api/tools/ssh/list-directory/route.ts index 625e37ef3e..1d39a454ba 100644 --- a/apps/sim/app/api/tools/ssh/list-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/list-directory/route.ts @@ -70,7 +70,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = ListDirectorySchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/read-file-content/route.ts b/apps/sim/app/api/tools/ssh/read-file-content/route.ts index 5dbcc9ed26..49a300ef09 100644 --- a/apps/sim/app/api/tools/ssh/read-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/read-file-content/route.ts @@ -45,7 +45,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = ReadFileContentSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/upload-file/route.ts b/apps/sim/app/api/tools/ssh/upload-file/route.ts index a81940be3e..a5d1dc1698 100644 --- a/apps/sim/app/api/tools/ssh/upload-file/route.ts +++ b/apps/sim/app/api/tools/ssh/upload-file/route.ts @@ -47,7 +47,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = UploadFileSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, diff --git a/apps/sim/app/api/tools/ssh/write-file-content/route.ts b/apps/sim/app/api/tools/ssh/write-file-content/route.ts index 7eea51e85c..0ecbb64072 100644 --- a/apps/sim/app/api/tools/ssh/write-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/write-file-content/route.ts @@ -46,7 +46,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const params = WriteFileContentSchema.parse(body) - // Validate SSH authentication if (!params.password && !params.privateKey) { return NextResponse.json( { error: 'Either password or privateKey must be provided' }, From b83cd3be474d8326a18457e197ce1d731faa4dec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 17:41:07 -0800 Subject: [PATCH 4/4] removed unused dep --- apps/sim/package.json | 1 - bun.lock | 3 --- 2 files changed, 4 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index e5c15245e9..229c468697 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -138,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", diff --git a/bun.lock b/bun.lock index 1ecbf9ec35..868487996a 100644 --- a/bun.lock +++ b/bun.lock @@ -169,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", @@ -3024,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=="],