From 3182e7235958eead7bf7346c8fe2c030dc9b67e7 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 15 Jan 2026 19:28:33 -0800 Subject: [PATCH] improvement(security): added input validation for airtable, lemlist, and more tools to protect against SSRF --- .../core/security/input-validation.test.ts | 80 +++++++++++++++++++ .../sim/lib/core/security/input-validation.ts | 51 ++++++++++++ .../lib/webhooks/provider-subscriptions.ts | 60 ++++++++++++++ apps/sim/tools/pulse/parser.ts | 11 --- apps/sim/tools/reducto/parser.ts | 11 --- 5 files changed, 191 insertions(+), 22 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index ad440447c8..7f455cb97e 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -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) + }) + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index b6d1fe77c1..b5440ce166 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -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 * diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 9e8f729c26..dd10973d3c 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -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, diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts index 55dba481a8..8f95683c0a 100644 --- a/apps/sim/tools/pulse/parser.ts +++ b/apps/sim/tools/pulse/parser.ts @@ -86,7 +86,6 @@ export const pulseParserTool: ToolConfig = 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 = 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 = 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 = 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 diff --git a/apps/sim/tools/reducto/parser.ts b/apps/sim/tools/reducto/parser.ts index bd7b74c297..6732f4bc0c 100644 --- a/apps/sim/tools/reducto/parser.ts +++ b/apps/sim/tools/reducto/parser.ts @@ -63,7 +63,6 @@ export const reductoParserTool: ToolConfig 0) { const validPages = params.pages.filter( @@ -162,7 +152,6 @@ export const reductoParserTool: ToolConfig