fix(security): harden findings — path traversal, SSRF, IDOR, file auth, credential access (#4571)

* fix(security): harden HIGH deepsec findings across multiple attack surfaces

- Supabase tools (get_row, delete, update): validate table name with strict
  identifier regex and encodeURIComponent to prevent LLM-controlled path
  traversal to admin endpoints; add missing empty-filter guard to update
  matching the delete.ts pattern

- SFTP/SMTP/SharePoint upload routes: add verifyFileAccess ownership check
  before downloadFileFromStorage, matching the WordPress reference pattern;
  rejects files the requesting user does not own with 404

- Gmail labels, OneDrive folders, Wealthbox items (×2): replace bare
  resolveOAuthAccountId + workspace-only membership check with
  authorizeCredentialUse which enforces credentialMember table; use
  credentialOwnerUserId for token refresh instead of bare accountRow.userId

- A2A utils: thread pre-resolved IP from validateUrlWithDNS into A2A SDK
  via pinnedFetch (secureFetchWithPinnedIP) for JsonRpcTransportFactory,
  RestTransportFactory, and DefaultAgentCardResolver, closing the TOCTOU
  DNS rebinding window

- SSH utils: cap stdout/stderr accumulation at 16 MB with truncation marker
  to prevent OOM from unbounded command output

- Form DELETE route: replace db.delete() with db.update({archivedAt}) for
  true soft delete matching the schema's archivedAt column

- Workflow admin import: fix Array.isArray() guard that silently dropped
  all variables (export format is Record, not Array)

- Multipart upload: apply checkStorageQuota and MAX_WORKSPACE_FILE_SIZE to
  mothership context, closing the quota bypass for workspace-scoped storage

* fix(security): eliminate workspace env lost-update race with atomic JSONB ops

PUT: use `variables || excluded.variables` in onConflictDoUpdate so
concurrent writes merge atomically in the DB instead of last-writer-wins
at the application layer.

DELETE: replace the read-modify-write upsert with a single UPDATE that
removes keys via the JSONB `-` operator, preventing concurrent deletes
from resurrecting previously-removed secrets.

* fix(security): address audit findings from security fix review

- SMTP send: restructure attachment loop from Promise.all to sequential
  for...of so verifyFileAccess denial returns 404 instead of propagating
  as a generic 500 via the SMTP error classifier

- Supabase tools: extend table-name validation and encodeURIComponent to
  the five previously missed tools — insert, upsert, count, query,
  text_search — completing coverage across all nine Supabase tools

- Credential routes: remove unnecessary `request as any` casts in Gmail,
  OneDrive, and Wealthbox routes; authorizeCredentialUse already accepts
  NextRequest directly

- Form soft delete: also set isActive=false alongside archivedAt so that
  any future code paths querying by isActive see a consistent state

- SSH utils: fix exit code fallback from 0 to -1 so an abnormally closed
  connection that supplies no exit code is not reported as success

- Workspace env: capitalize EXCLUDED.variables in the onConflictDoUpdate
  set clause to make the pseudo-table reference unambiguous

* fix(security): address PR review comments and harden deepsec fixes

- fix(env): replace jsonb operators with transaction+FOR UPDATE read-modify-write
  - PUT: uses db.transaction + SELECT FOR UPDATE + JS merge to avoid lost-update race
  - DELETE: same pattern; fixes variable scope bug where current was referenced outside tx
  - removes broken || and - jsonb operators that fail on json-typed column

- fix(ssh): trim truncated output consistently with non-truncated path

- fix(gmail): remove redundant resolveOAuthAccountId call
  - adds credentialType field to CredentialAccessResult
  - authorizeCredentialUse now returns credentialType in all success paths
  - gmail/labels route uses authz.credentialType and authz.resolvedCredentialId directly

- fix(supabase): centralize table identifier validation
  - adds validateDatabaseIdentifier() to input-validation.ts
  - all 8 supabase tools use the shared util instead of inline regex

* fix(workflows): fix VariableType assignment in admin workflow import route

The intermediate Record cast used 'string' for the type field which TypeScript
correctly rejected — WorkflowVariable.type is 'VariableType', not string.
Changed the cast to use VariableType so both branches typecheck correctly.

* fix(a2a): handle Request objects in pinnedFetch URL extraction

* fix(security): extract shared file-access guard; merge workspace/mothership branch

* fix(security): advisory lock for env first-insert race; handle all BodyInit types in pinnedFetch

* chore: remove inline comment from advisory lock

* fix(security): remove stray comment; narrow credentialType to literal union

* fix(security): add credentialId validation to wealthbox oauth route; fix null body override in pinnedFetch

* fix(security): stream A2A response body to unblock SSE; keep text/json/arrayBuffer for non-streaming callers

* fix(security): resolve credentialId guard on OneDrive, use assertToolFileAccess in WordPress, memoize body buffer to prevent silent empty reads, fix ArrayBuffer type cast

* fix(security): handle string[][] HeadersInit format in pinnedFetch

* fix(security): keep abort listener alive during body streaming; clean up in stream end/error/cancel

* chore: remove extraneous inline comment

* fix(security): cleanup abort listener when maxResponseBytes limit is exceeded
This commit is contained in:
Waleed
2026-05-12 17:40:25 -07:00
committed by GitHub
parent ec936be5ca
commit 6503671102
26 changed files with 469 additions and 355 deletions
@@ -1,14 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { wealthboxOAuthItemsContract } from '@/lib/api/contracts/selectors/wealthbox'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -30,51 +28,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const parsed = await parseRequest(wealthboxOAuthItemsContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, type } = parsed.data.query
const query = parsed.data.query.query ?? ''
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const credentialIdValidation = validatePathSegment(credentialId, {
paramName: 'credentialId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credentialId,
authz.credentialOwnerUserId,
requestId
)
+31
View File
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { document, knowledgeBase, workspaceFile } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, like, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getFileMetadata } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { BLOB_CHAT_CONFIG, S3_CHAT_CONFIG } from '@/lib/uploads/config'
@@ -587,6 +588,36 @@ async function authorizeFileAccess(
}
}
/**
* Guard helper for tool routes that download user files from storage.
*
* Validates that `key` is a non-empty string, that `userId` is present, and
* that the authenticated user owns the file. Returns a 404 `NextResponse` on
* any failure so callers can `return` it immediately; returns `null` when
* access is granted.
*/
export async function assertToolFileAccess(
key: unknown,
userId: string | undefined,
requestId: string,
routeLogger: ReturnType<typeof createLogger>
): Promise<NextResponse | null> {
if (typeof key !== 'string' || key.length === 0) {
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
if (!userId) {
routeLogger.warn(`[${requestId}] File access check requires userId but none available`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
const hasAccess = await verifyFileAccess(key, userId)
if (!hasAccess) {
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
return null
}
/**
* Get chat storage configuration based on current storage provider
*/
+1 -6
View File
@@ -136,7 +136,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const config = getStorageConfig(storageContext)
let customKey: string | undefined
if (context === 'workspace') {
if (context === 'workspace' || context === 'mothership') {
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) {
return NextResponse.json(
@@ -158,11 +158,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 413 }
)
}
} else if (context === 'mothership') {
const { generateWorkspaceFileKey } = await import(
'@/lib/uploads/contexts/workspace/workspace-file-manager'
)
customKey = generateWorkspaceFileKey(workspaceId, fileName)
} else if (context === 'execution') {
const workflowId = (data as { workflowId?: unknown }).workflowId
const executionId = (data as { executionId?: unknown }).executionId
+5 -2
View File
@@ -197,9 +197,12 @@ export const DELETE = withRouteHandler(
return createErrorResponse('Form not found or access denied', 404)
}
await db.delete(form).where(eq(form.id, id))
await db
.update(form)
.set({ archivedAt: new Date(), isActive: false, updatedAt: new Date() })
.where(eq(form.id, id))
logger.info(`Form ${id} deleted (soft delete)`)
logger.info(`Form ${id} soft deleted`)
recordAudit({
workspaceId: formWorkspaceId ?? null,
+11 -48
View File
@@ -1,11 +1,8 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -13,7 +10,6 @@ import { getScopesForService } from '@/lib/oauth/utils'
import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -32,13 +28,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated labels request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const parsed = await parseRequest(gmailLabelsSelectorContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, query } = parsed.data.query
@@ -50,52 +39,26 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
let accessToken: string | null = null
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (authz.credentialType === 'service_account') {
accessToken = await getServiceAccountToken(
resolved.credentialId,
authz.resolvedCredentialId,
getScopesForService('gmail'),
impersonateEmail
)
} else {
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credentialId,
authz.credentialOwnerUserId,
requestId,
getScopesForService('gmail')
)
@@ -1,15 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { onedriveFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
export const dynamic = 'force-dynamic'
@@ -23,11 +20,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validation = onedriveFoldersQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? '',
@@ -51,37 +43,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
@@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
createSftpConnection,
getSftp,
@@ -95,6 +96,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
for (const file of userFiles) {
try {
const denied = await assertToolFileAccess(
file.key,
authResult.userId,
requestId,
logger
)
if (denied) return denied
logger.info(
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
)
@@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types'
@@ -82,6 +83,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const errors: SharepointUploadError[] = []
for (const userFile of userFiles) {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+20 -22
View File
@@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -119,28 +120,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
const attachmentBuffers = await Promise.all(
attachments.map(async (file) => {
try {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
const buffer = await downloadFileFromStorage(file, requestId, logger)
return {
filename: file.name,
content: buffer,
contentType: file.type || 'application/octet-stream',
}
} catch (error) {
logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
throw new Error(
`Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
})
)
const attachmentBuffers: { filename: string; content: Buffer; contentType: string }[] = []
for (const file of attachments) {
const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger)
if (denied) return denied
try {
logger.info(`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`)
const buffer = await downloadFileFromStorage(file, requestId, logger)
attachmentBuffers.push({
filename: file.name,
content: buffer,
contentType: file.type || 'application/octet-stream',
})
} catch (error) {
logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
throw new Error(
`Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
logger.info(`[${requestId}] Processed ${attachmentBuffers.length} attachment(s)`)
mailOptions.attachments = attachmentBuffers
+31 -5
View File
@@ -174,6 +174,8 @@ export async function createSSHConnection(config: SSHConnectionConfig): Promise<
})
}
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
/**
* Execute a command on the SSH connection
*/
@@ -187,21 +189,45 @@ export function executeSSHCommand(client: Client, command: string): Promise<SSHC
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
let stdoutTruncated = false
let stderrTruncated = false
stream.on('close', (code: number) => {
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 0,
stdout: stdoutTruncated
? `${stdout.trim()}\n[output truncated: exceeded 16MB limit]`
: stdout.trim(),
stderr: stderrTruncated
? `${stderr.trim()}\n[stderr truncated: exceeded 16MB limit]`
: stderr.trim(),
exitCode: code ?? -1,
})
})
stream.on('data', (data: Buffer) => {
stdout += data.toString()
const remaining = MAX_OUTPUT_BYTES - stdoutBytes
if (remaining <= 0) {
stdoutTruncated = true
return
}
const chunk = data.subarray(0, remaining)
stdout += chunk.toString()
stdoutBytes += chunk.length
if (data.length > remaining) stdoutTruncated = true
})
stream.stderr.on('data', (data: Buffer) => {
stderr += data.toString()
const remaining = MAX_OUTPUT_BYTES - stderrBytes
if (remaining <= 0) {
stderrTruncated = true
return
}
const chunk = data.subarray(0, remaining)
stderr += chunk.toString()
stderrBytes += chunk.length
if (data.length > remaining) stderrTruncated = true
})
})
})
+10 -42
View File
@@ -1,15 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { wealthboxItemsSelectorContract } from '@/lib/api/contracts/selectors/wealthbox'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -31,13 +28,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const parsed = await parseRequest(wealthboxItemsSelectorContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, type } = parsed.data.query
@@ -55,39 +45,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credentialId,
authz.credentialOwnerUserId,
requestId
)
@@ -11,7 +11,7 @@ import {
processSingleFileToUserFile,
} from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { verifyFileAccess } from '@/app/api/files/authorization'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -78,22 +78,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
if (typeof userFile.key !== 'string' || userFile.key.length === 0) {
logger.warn(`[${requestId}] File access check rejected: missing key`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
if (!authResult.userId) {
logger.warn(`[${requestId}] File access check requires userId but none available`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
const hasAccess = await verifyFileAccess(userFile.key, authResult.userId)
if (!hasAccess) {
logger.warn(`[${requestId}] File access denied for user`, {
userId: authResult.userId,
key: userFile.key,
})
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
logger.info(`[${requestId}] Downloading file from storage`, {
fileName: userFile.name,
@@ -34,6 +34,7 @@ import {
} from '@/app/api/v1/admin/responses'
import {
extractWorkflowMetadata,
type VariableType,
type WorkflowImportRequest,
type WorkflowVariable,
} from '@/app/api/v1/admin/types'
@@ -118,14 +119,38 @@ export const POST = withRouteHandler(
return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`)
}
if (workflowData.variables && Array.isArray(workflowData.variables)) {
if (
workflowData.variables &&
typeof workflowData.variables === 'object' &&
!Array.isArray(workflowData.variables)
) {
const variablesRecord: Record<string, WorkflowVariable> = {}
const vars = workflowData.variables as Record<
string,
{ id?: string; name: string; type?: VariableType; value: unknown }
>
Object.entries(vars).forEach(([key, v]) => {
const varId = v.id || key
variablesRecord[varId] = {
id: varId,
name: v.name,
type: v.type ?? 'string',
value: v.value,
}
})
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
} else if (workflowData.variables && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<string, WorkflowVariable> = {}
workflowData.variables.forEach((v) => {
const varId = v.id || generateId()
variablesRecord[varId] = {
id: varId,
name: v.name,
type: v.type || 'string',
type: (v.type as VariableType) ?? 'string',
value: v.value,
}
})
@@ -3,7 +3,7 @@ import { db } from '@sim/db'
import { workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
removeWorkspaceEnvironmentContract,
@@ -96,16 +96,6 @@ export const PUT = withRouteHandler(
if (!parsed.success) return parsed.response
const { variables } = parsed.data.body
// Read existing encrypted ws vars
const existingRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const existingEncrypted: Record<string, string> = (existingRows[0]?.variables as any) || {}
// Encrypt incoming
const encryptedIncoming = await Promise.all(
Object.entries(variables).map(async ([key, value]) => {
const { encrypted } = await encryptSecret(value)
@@ -113,22 +103,37 @@ export const PUT = withRouteHandler(
})
).then((entries) => Object.fromEntries(entries))
const merged = { ...existingEncrypted, ...encryptedIncoming }
const { existingEncrypted, merged } = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`)
// Upsert by unique workspace_id
await db
.insert(workspaceEnvironment)
.values({
id: generateId(),
workspaceId,
variables: merged,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: merged, updatedAt: new Date() },
})
const [existingRow] = await tx
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const existing = ((existingRow?.variables as Record<string, string>) ?? {}) as Record<
string,
string
>
const mergedVars = { ...existing, ...encryptedIncoming }
await tx
.insert(workspaceEnvironment)
.values({
id: generateId(),
workspaceId,
variables: mergedVars,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: mergedVars, updatedAt: new Date() },
})
return { existingEncrypted: existing, merged: mergedVars }
})
const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted))
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId })
@@ -183,39 +188,41 @@ export const DELETE = withRouteHandler(
if (!parsed.success) return parsed.response
const { keys } = parsed.data.body
const wsRows = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const result = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`)
const current: Record<string, string> = (wsRows[0]?.variables as any) || {}
let changed = false
for (const k of keys) {
if (k in current) {
delete current[k]
changed = true
const [existingRow] = await tx
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
if (!existingRow) return null
const current: Record<string, string> =
(existingRow.variables as Record<string, string>) ?? {}
let modified = false
for (const k of keys) {
if (k in current) {
delete current[k]
modified = true
}
}
}
if (!changed) {
if (!modified) return null
await tx
.update(workspaceEnvironment)
.set({ variables: current, updatedAt: new Date() })
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
return { remainingKeysCount: Object.keys(current).length }
})
if (!result) {
return NextResponse.json({ success: true })
}
await db
.insert(workspaceEnvironment)
.values({
id: wsRows[0]?.id || generateId(),
workspaceId,
variables: current,
createdAt: wsRows[0]?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys })
recordAudit({
@@ -229,7 +236,7 @@ export const DELETE = withRouteHandler(
description: `Removed ${keys.length} workspace environment variable(s)`,
metadata: {
removedKeys: keys,
remainingKeysCount: Object.keys(current).length,
remainingKeysCount: result.remainingKeysCount,
},
request,
})
+72 -3
View File
@@ -14,11 +14,17 @@ import {
type Client,
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
JsonRpcTransportFactory,
RestTransportFactory,
} from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import { A2A_TERMINAL_STATES } from './constants'
@@ -60,13 +66,76 @@ export async function createA2AClient(agentUrl: string, apiKey?: string): Promis
throw new Error(validation.error || 'Agent URL validation failed')
}
const resolvedIP = validation.resolvedIP!
const pinnedFetch = async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1]
): Promise<Response> => {
const url = input instanceof Request ? input.url : input.toString()
const method = init?.method ?? (input instanceof Request ? input.method : undefined)
const rawHeaders = init?.headers ?? (input instanceof Request ? input.headers : undefined)
const headers =
rawHeaders instanceof Headers
? Object.fromEntries(rawHeaders.entries())
: Array.isArray(rawHeaders)
? Object.fromEntries(rawHeaders as string[][])
: (rawHeaders as Record<string, string> | undefined)
let body: string | Buffer | Uint8Array | undefined
if (init?.body !== undefined && init.body !== null) {
if (typeof init.body === 'string' || Buffer.isBuffer(init.body)) {
body = init.body as string | Buffer
} else if (init.body instanceof Uint8Array) {
body = init.body
} else if (init.body instanceof ArrayBuffer) {
body = new Uint8Array(init.body)
} else {
const text = await new Response(init.body as BodyInit).text()
if (text) body = text
}
} else if (init?.body === undefined && input instanceof Request && !input.bodyUsed) {
const text = await input.text()
if (text) body = text
}
const signal =
init?.signal instanceof AbortSignal
? init.signal
: input instanceof Request && input.signal instanceof AbortSignal
? input.signal
: undefined
const res = await secureFetchWithPinnedIP(url, resolvedIP, { method, headers, body, signal })
const resHeaders = new Headers(res.headers.toRecord())
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
}
const pinnedTransports = [
new JsonRpcTransportFactory({ fetchImpl: pinnedFetch }),
new RestTransportFactory({ fetchImpl: pinnedFetch }),
]
const pinnedCardResolver = new DefaultAgentCardResolver({ fetchImpl: pinnedFetch })
const baseOptions = ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
transports: pinnedTransports,
cardResolver: pinnedCardResolver,
})
const factoryOptions = apiKey
? ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
? ClientFactoryOptions.createFrom(baseOptions, {
clientConfig: {
interceptors: [new ApiKeyInterceptor(apiKey)],
},
})
: ClientFactoryOptions.default
: baseOptions
const factory = new ClientFactory(factoryOptions)
// Try standard A2A path first (/.well-known/agent.json)
+5
View File
@@ -13,6 +13,7 @@ export interface CredentialAccessResult {
credentialOwnerUserId?: string
workspaceId?: string
resolvedCredentialId?: string
credentialType?: 'oauth' | 'service_account'
}
/**
@@ -114,6 +115,7 @@ export async function authorizeCredentialUse(
credentialOwnerUserId: actingUserId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.id,
credentialType: 'service_account',
}
}
@@ -182,6 +184,7 @@ export async function authorizeCredentialUse(
credentialOwnerUserId: accountRow.userId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.accountId,
credentialType: 'oauth',
}
}
@@ -252,6 +255,7 @@ export async function authorizeCredentialUse(
credentialOwnerUserId: accountRow.userId,
workspaceId: workflowContext.workspaceId,
resolvedCredentialId: workspaceCredential.accountId,
credentialType: 'oauth',
}
}
@@ -279,5 +283,6 @@ export async function authorizeCredentialUse(
requesterUserId: auth.userId,
credentialOwnerUserId: legacyAccount.userId,
resolvedCredentialId: credentialId,
credentialType: 'oauth',
}
}
@@ -251,6 +251,7 @@ export interface SecureFetchResponse {
status: number
statusText: string
headers: SecureFetchHeaders
body: ReadableStream<Uint8Array> | null
text: () => Promise<string>
json: () => Promise<unknown>
arrayBuffer: () => Promise<ArrayBuffer>
@@ -361,67 +362,89 @@ export async function secureFetchWithPinnedIP(
return
}
const chunks: Buffer[] = []
let totalBytes = 0
let responseTerminated = false
res.on('data', (chunk: Buffer) => {
if (responseTerminated) return
totalBytes += chunk.length
if (
typeof maxResponseBytes === 'number' &&
maxResponseBytes > 0 &&
totalBytes > maxResponseBytes
) {
responseTerminated = true
res.destroy(new Error(`Response exceeded maximum size of ${maxResponseBytes} bytes`))
return
}
chunks.push(chunk)
})
res.on('error', (error) => {
settledReject(error)
})
res.on('end', () => {
if (responseTerminated) return
const bodyBuffer = Buffer.concat(chunks)
const body = bodyBuffer.toString('utf-8')
const headersRecord: Record<string, string> = {}
let setCookieArray: string[] = []
for (const [key, value] of Object.entries(res.headers)) {
const lowerKey = key.toLowerCase()
if (lowerKey === 'set-cookie') {
if (Array.isArray(value)) {
setCookieArray = value
headersRecord[lowerKey] = value.join(', ')
} else if (typeof value === 'string') {
setCookieArray = [value]
headersRecord[lowerKey] = value
}
} else if (typeof value === 'string') {
headersRecord[lowerKey] = value
} else if (Array.isArray(value)) {
const headersRecord: Record<string, string> = {}
let setCookieArray: string[] = []
for (const [key, value] of Object.entries(res.headers)) {
const lowerKey = key.toLowerCase()
if (lowerKey === 'set-cookie') {
if (Array.isArray(value)) {
setCookieArray = value
headersRecord[lowerKey] = value.join(', ')
} else if (typeof value === 'string') {
setCookieArray = [value]
headersRecord[lowerKey] = value
}
} else if (typeof value === 'string') {
headersRecord[lowerKey] = value
} else if (Array.isArray(value)) {
headersRecord[lowerKey] = value.join(', ')
}
}
settledResolve({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
statusText: res.statusMessage || '',
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
text: async () => body,
json: async () => JSON.parse(body),
arrayBuffer: async () =>
bodyBuffer.buffer.slice(
bodyBuffer.byteOffset,
bodyBuffer.byteOffset + bodyBuffer.byteLength
),
})
let totalBytes = 0
const nodeRes = res
const body = new ReadableStream<Uint8Array>({
start(controller) {
nodeRes.on('data', (chunk: Buffer) => {
totalBytes += chunk.length
if (
typeof maxResponseBytes === 'number' &&
maxResponseBytes > 0 &&
totalBytes > maxResponseBytes
) {
cleanupAbort()
controller.error(
new Error(`Response exceeded maximum size of ${maxResponseBytes} bytes`)
)
nodeRes.destroy()
return
}
controller.enqueue(new Uint8Array(chunk))
})
nodeRes.on('end', () => {
cleanupAbort()
controller.close()
})
nodeRes.on('error', (err) => {
cleanupAbort()
controller.error(err)
})
},
cancel() {
cleanupAbort()
nodeRes.destroy()
},
})
let bodyBufferPromise: Promise<Buffer> | null = null
function readBodyAsBuffer(): Promise<Buffer> {
if (!bodyBufferPromise) {
bodyBufferPromise = (async () => {
const reader = body.getReader()
const buffers: Uint8Array[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value) buffers.push(value)
}
return Buffer.concat(buffers.map((b) => Buffer.from(b)))
})()
}
return bodyBufferPromise
}
settledResolve({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
statusText: res.statusMessage || '',
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
body,
text: async () => (await readBodyAsBuffer()).toString('utf-8'),
json: async () => JSON.parse((await readBodyAsBuffer()).toString('utf-8')),
arrayBuffer: async () => {
const buf = await readBodyAsBuffer()
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
},
})
})
@@ -433,7 +456,6 @@ export async function secureFetchWithPinnedIP(
}
}
const settledResolve: typeof resolve = (value) => {
cleanupAbort()
resolve(value)
}
const settledReject: typeof reject = (reason) => {
@@ -1593,3 +1593,30 @@ export function validateWorkdayTenantUrl(
return { isValid: true, sanitized: url as string }
}
/**
* Validates a database identifier (table or column name) to prevent SQL injection.
*
* Accepts only identifiers that start with a letter or underscore and contain
* only letters, digits, and underscores — the safe subset of SQL identifiers.
*
* @param value - The identifier to validate
* @param paramName - Name of the parameter for error messages (e.g. 'table', 'column')
* @returns ValidationResult with isValid flag and optional error message
*/
export function validateDatabaseIdentifier(
value: unknown,
paramName = 'identifier'
): ValidationResult {
if (typeof value !== 'string' || value.length === 0) {
return { isValid: false, error: `${paramName} is required` }
}
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
logger.warn('Invalid database identifier', { paramName, value: value.substring(0, 100) })
return {
isValid: false,
error: `Invalid ${paramName}: must start with a letter or underscore and contain only letters, digits, and underscores`,
}
}
return { isValid: true, sanitized: value }
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseCountParams, SupabaseCountResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -50,9 +51,10 @@ export const countTool: ToolConfig<SupabaseCountParams, SupabaseCountResponse> =
request: {
url: (params) => {
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=*`
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
// Add filters if provided
if (params.filter?.trim()) {
url += `&${params.filter.trim()}`
}
+5 -3
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseDeleteParams, SupabaseDeleteResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -44,10 +45,11 @@ export const deleteTool: ToolConfig<SupabaseDeleteParams, SupabaseDeleteResponse
request: {
url: (params) => {
// Construct the URL for the Supabase REST API with select to return deleted data
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=*`
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
// Add filters (required for delete) - using PostgREST syntax
if (params.filter?.trim()) {
url += `&${params.filter.trim()}`
} else {
+5 -4
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseGetRowParams, SupabaseGetRowResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -50,16 +51,16 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
request: {
url: (params) => {
// Construct the URL for the Supabase REST API
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
const selectColumns = params.select?.trim() || '*'
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=${encodeURIComponent(selectColumns)}`
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=${encodeURIComponent(selectColumns)}`
// Add filters (required for get_row) - using PostgREST syntax
if (params.filter?.trim()) {
url += `&${params.filter.trim()}`
}
// Limit to 1 row since we want a single row
url += `&limit=1`
return url
+6 -1
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseInsertParams, SupabaseInsertResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -43,7 +44,11 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
},
request: {
url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=*`,
url: (params) => {
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
return `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
},
method: 'POST',
headers: (params) => {
const headers: Record<string, string> = {
+4 -2
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseQueryParams, SupabaseQueryResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -68,9 +69,10 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
request: {
url: (params) => {
// Construct the URL for the Supabase REST API
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
const selectColumns = params.select?.trim() || '*'
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=${encodeURIComponent(selectColumns)}`
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=${encodeURIComponent(selectColumns)}`
// Add filters if provided - using PostgREST syntax
if (params.filter?.trim()) {
+4 -2
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseTextSearchParams, SupabaseTextSearchResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -74,11 +75,12 @@ export const textSearchTool: ToolConfig<SupabaseTextSearchParams, SupabaseTextSe
request: {
url: (params) => {
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
const searchType = params.searchType || 'websearch'
const language = params.language || 'english'
// Build the text search filter
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=*`
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
// Map search types to PostgREST operators
// plfts = plainto_tsquery (natural language), phfts = phraseto_tsquery, wfts = websearch_to_tsquery
+9 -3
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseUpdateParams, SupabaseUpdateResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -50,12 +51,17 @@ export const updateTool: ToolConfig<SupabaseUpdateParams, SupabaseUpdateResponse
request: {
url: (params) => {
// Construct the URL for the Supabase REST API with select to return updated data
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=*`
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
// Add filters (required for update) - using PostgREST syntax
if (params.filter?.trim()) {
url += `&${params.filter.trim()}`
} else {
throw new Error(
'Filter is required for update operations to prevent accidental update of all rows'
)
}
return url
+6 -1
View File
@@ -1,3 +1,4 @@
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
import type { SupabaseUpsertParams, SupabaseUpsertResponse } from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
@@ -43,7 +44,11 @@ export const upsertTool: ToolConfig<SupabaseUpsertParams, SupabaseUpsertResponse
},
request: {
url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/${params.table}?select=*`,
url: (params) => {
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
if (!tableValidation.isValid) throw new Error(tableValidation.error)
return `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
},
method: 'POST',
headers: (params) => {
const headers: Record<string, string> = {