mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(security): added input validation for airtable, lemlist, and more tools to protect against SSRF (#2847)
This commit is contained in:
@@ -2,6 +2,7 @@ import { loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createPinnedUrl,
|
||||
validateAirtableId,
|
||||
validateAlphanumericId,
|
||||
validateEnum,
|
||||
validateExternalUrl,
|
||||
@@ -1112,3 +1113,82 @@ describe('validateGoogleCalendarId', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateAirtableId', () => {
|
||||
describe('valid base IDs (app prefix)', () => {
|
||||
it.concurrent('should accept valid base ID', () => {
|
||||
const result = validateAirtableId('appABCDEFGHIJKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.sanitized).toBe('appABCDEFGHIJKLMN')
|
||||
})
|
||||
|
||||
it.concurrent('should accept base ID with mixed case', () => {
|
||||
const result = validateAirtableId('appAbCdEfGhIjKlMn', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should accept base ID with numbers', () => {
|
||||
const result = validateAirtableId('app12345678901234', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid table IDs (tbl prefix)', () => {
|
||||
it.concurrent('should accept valid table ID', () => {
|
||||
const result = validateAirtableId('tblABCDEFGHIJKLMN', 'tbl', 'tableId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid webhook IDs (ach prefix)', () => {
|
||||
it.concurrent('should accept valid webhook ID', () => {
|
||||
const result = validateAirtableId('achABCDEFGHIJKLMN', 'ach', 'webhookId')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid IDs', () => {
|
||||
it.concurrent('should reject null', () => {
|
||||
const result = validateAirtableId(null, 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject empty string', () => {
|
||||
const result = validateAirtableId('', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('required')
|
||||
})
|
||||
|
||||
it.concurrent('should reject wrong prefix', () => {
|
||||
const result = validateAirtableId('tblABCDEFGHIJKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('starting with "app"')
|
||||
})
|
||||
|
||||
it.concurrent('should reject too short ID (13 chars after prefix)', () => {
|
||||
const result = validateAirtableId('appABCDEFGHIJKLM', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject too long ID (15 chars after prefix)', () => {
|
||||
const result = validateAirtableId('appABCDEFGHIJKLMNO', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject special characters', () => {
|
||||
const result = validateAirtableId('appABCDEFGH/JKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject path traversal attempts', () => {
|
||||
const result = validateAirtableId('app../etc/passwd', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject lowercase prefix', () => {
|
||||
const result = validateAirtableId('AppABCDEFGHIJKLMN', 'app', 'baseId')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -896,6 +896,57 @@ export function createPinnedUrl(originalUrl: string, resolvedIP: string): string
|
||||
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an Airtable ID (base, table, or webhook ID)
|
||||
*
|
||||
* Airtable IDs have specific prefixes:
|
||||
* - Base IDs: "app" + 14 alphanumeric characters (e.g., appXXXXXXXXXXXXXX)
|
||||
* - Table IDs: "tbl" + 14 alphanumeric characters
|
||||
* - Webhook IDs: "ach" + 14 alphanumeric characters
|
||||
*
|
||||
* @param value - The ID to validate
|
||||
* @param expectedPrefix - The expected prefix ('app', 'tbl', or 'ach')
|
||||
* @param paramName - Name of the parameter for error messages
|
||||
* @returns ValidationResult
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = validateAirtableId(baseId, 'app', 'baseId')
|
||||
* if (!result.isValid) {
|
||||
* throw new Error(result.error)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validateAirtableId(
|
||||
value: string | null | undefined,
|
||||
expectedPrefix: 'app' | 'tbl' | 'ach',
|
||||
paramName = 'ID'
|
||||
): ValidationResult {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} is required`,
|
||||
}
|
||||
}
|
||||
|
||||
// Airtable IDs: prefix (3 chars) + 14 alphanumeric characters = 17 chars total
|
||||
const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`)
|
||||
|
||||
if (!airtableIdPattern.test(value)) {
|
||||
logger.warn('Invalid Airtable ID format', {
|
||||
paramName,
|
||||
expectedPrefix,
|
||||
value: value.substring(0, 20),
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} must be a valid Airtable ID starting with "${expectedPrefix}"`,
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, sanitized: value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Google Calendar ID
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -358,6 +359,15 @@ export async function deleteAirtableWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
|
||||
if (!baseIdValidation.isValid) {
|
||||
airtableLogger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
baseId: baseId.substring(0, 20),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const userIdForToken = workflow.userId
|
||||
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
|
||||
if (!accessToken) {
|
||||
@@ -428,6 +438,15 @@ export async function deleteAirtableWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId')
|
||||
if (!webhookIdValidation.isValid) {
|
||||
airtableLogger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
externalId: resolvedExternalId.substring(0, 20),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
|
||||
const airtableResponse = await fetch(airtableDeleteUrl, {
|
||||
method: 'DELETE',
|
||||
@@ -732,6 +751,14 @@ export async function deleteLemlistWebhook(webhook: any, requestId: string): Pro
|
||||
const authString = Buffer.from(`:${apiKey}`).toString('base64')
|
||||
|
||||
const deleteById = async (id: string) => {
|
||||
const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50)
|
||||
if (!validation.isValid) {
|
||||
lemlistLogger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, {
|
||||
id: id.substring(0, 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}`
|
||||
const lemlistResponse = await fetch(lemlistApiUrl, {
|
||||
method: 'DELETE',
|
||||
@@ -823,6 +850,24 @@ export async function deleteWebflowWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
|
||||
if (!siteIdValidation.isValid) {
|
||||
webflowLogger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
siteId: siteId.substring(0, 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100)
|
||||
if (!webhookIdValidation.isValid) {
|
||||
webflowLogger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, {
|
||||
webhookId: webhook.id,
|
||||
externalId: externalId.substring(0, 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = await getOAuthToken(workflow.userId, 'webflow')
|
||||
if (!accessToken) {
|
||||
webflowLogger.warn(
|
||||
@@ -1122,6 +1167,16 @@ export async function createAirtableWebhookSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
|
||||
if (!baseIdValidation.isValid) {
|
||||
throw new Error(baseIdValidation.error)
|
||||
}
|
||||
|
||||
const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId')
|
||||
if (!tableIdValidation.isValid) {
|
||||
throw new Error(tableIdValidation.error)
|
||||
}
|
||||
|
||||
const accessToken = await getOAuthToken(userId, 'airtable')
|
||||
if (!accessToken) {
|
||||
airtableLogger.warn(
|
||||
@@ -1354,6 +1409,11 @@ export async function createWebflowWebhookSubscription(
|
||||
throw new Error('Site ID is required to create Webflow webhook')
|
||||
}
|
||||
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
|
||||
if (!siteIdValidation.isValid) {
|
||||
throw new Error(siteIdValidation.error)
|
||||
}
|
||||
|
||||
if (!triggerId) {
|
||||
webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, {
|
||||
webhookId: webhookData.id,
|
||||
|
||||
@@ -86,7 +86,6 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
|
||||
throw new Error('Missing or invalid API key: A valid Pulse API key is required')
|
||||
}
|
||||
|
||||
// Check if we have a file upload instead of direct URL
|
||||
if (
|
||||
params.fileUpload &&
|
||||
(!params.filePath || params.filePath === 'null' || params.filePath === '')
|
||||
@@ -137,13 +136,6 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`)
|
||||
}
|
||||
|
||||
if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) {
|
||||
throw new Error(
|
||||
'Google Drive links are not supported. ' +
|
||||
'Please upload your document or provide a direct download link.'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
@@ -156,12 +148,10 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
|
||||
filePath: url.toString(),
|
||||
}
|
||||
|
||||
// Check if this is an internal workspace file path
|
||||
if (params.fileUpload?.path?.startsWith('/api/files/serve/')) {
|
||||
requestBody.filePath = params.fileUpload.path
|
||||
}
|
||||
|
||||
// Add optional parameters
|
||||
if (params.pages && typeof params.pages === 'string' && params.pages.trim() !== '') {
|
||||
requestBody.pages = params.pages.trim()
|
||||
}
|
||||
@@ -204,7 +194,6 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
|
||||
throw new Error('Invalid response format from Pulse API')
|
||||
}
|
||||
|
||||
// Pass through the native Pulse API response
|
||||
const pulseData =
|
||||
parseResult.output && typeof parseResult.output === 'object'
|
||||
? parseResult.output
|
||||
|
||||
@@ -63,7 +63,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
|
||||
throw new Error('Missing or invalid API key: A valid Reducto API key is required')
|
||||
}
|
||||
|
||||
// Check if we have a file upload instead of direct URL
|
||||
if (
|
||||
params.fileUpload &&
|
||||
(!params.filePath || params.filePath === 'null' || params.filePath === '')
|
||||
@@ -110,13 +109,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`)
|
||||
}
|
||||
|
||||
if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) {
|
||||
throw new Error(
|
||||
'Google Drive links are not supported by the Reducto API. ' +
|
||||
'Please upload your PDF to a public web server or provide a direct download link.'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
@@ -129,7 +121,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
|
||||
filePath: url.toString(),
|
||||
}
|
||||
|
||||
// Check if this is an internal workspace file path
|
||||
if (params.fileUpload?.path?.startsWith('/api/files/serve/')) {
|
||||
requestBody.filePath = params.fileUpload.path
|
||||
}
|
||||
@@ -138,7 +129,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
|
||||
requestBody.tableOutputFormat = params.tableOutputFormat
|
||||
}
|
||||
|
||||
// Page selection
|
||||
if (params.pages !== undefined && params.pages !== null) {
|
||||
if (Array.isArray(params.pages) && params.pages.length > 0) {
|
||||
const validPages = params.pages.filter(
|
||||
@@ -162,7 +152,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
|
||||
throw new Error('Invalid response format from Reducto API')
|
||||
}
|
||||
|
||||
// Pass through the native Reducto response
|
||||
const reductoData = data.output ?? data
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user