Skip to content

Commit 55700b9

Browse files
authored
improvement(security): added input validation for airtable, lemlist, and more tools to protect against SSRF (#2847)
1 parent 51e3768 commit 55700b9

File tree

5 files changed

+191
-22
lines changed

5 files changed

+191
-22
lines changed

apps/sim/lib/core/security/input-validation.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { loggerMock } from '@sim/testing'
22
import { describe, expect, it, vi } from 'vitest'
33
import {
44
createPinnedUrl,
5+
validateAirtableId,
56
validateAlphanumericId,
67
validateEnum,
78
validateExternalUrl,
@@ -1112,3 +1113,82 @@ describe('validateGoogleCalendarId', () => {
11121113
})
11131114
})
11141115
})
1116+
1117+
describe('validateAirtableId', () => {
1118+
describe('valid base IDs (app prefix)', () => {
1119+
it.concurrent('should accept valid base ID', () => {
1120+
const result = validateAirtableId('appABCDEFGHIJKLMN', 'app', 'baseId')
1121+
expect(result.isValid).toBe(true)
1122+
expect(result.sanitized).toBe('appABCDEFGHIJKLMN')
1123+
})
1124+
1125+
it.concurrent('should accept base ID with mixed case', () => {
1126+
const result = validateAirtableId('appAbCdEfGhIjKlMn', 'app', 'baseId')
1127+
expect(result.isValid).toBe(true)
1128+
})
1129+
1130+
it.concurrent('should accept base ID with numbers', () => {
1131+
const result = validateAirtableId('app12345678901234', 'app', 'baseId')
1132+
expect(result.isValid).toBe(true)
1133+
})
1134+
})
1135+
1136+
describe('valid table IDs (tbl prefix)', () => {
1137+
it.concurrent('should accept valid table ID', () => {
1138+
const result = validateAirtableId('tblABCDEFGHIJKLMN', 'tbl', 'tableId')
1139+
expect(result.isValid).toBe(true)
1140+
})
1141+
})
1142+
1143+
describe('valid webhook IDs (ach prefix)', () => {
1144+
it.concurrent('should accept valid webhook ID', () => {
1145+
const result = validateAirtableId('achABCDEFGHIJKLMN', 'ach', 'webhookId')
1146+
expect(result.isValid).toBe(true)
1147+
})
1148+
})
1149+
1150+
describe('invalid IDs', () => {
1151+
it.concurrent('should reject null', () => {
1152+
const result = validateAirtableId(null, 'app', 'baseId')
1153+
expect(result.isValid).toBe(false)
1154+
expect(result.error).toContain('required')
1155+
})
1156+
1157+
it.concurrent('should reject empty string', () => {
1158+
const result = validateAirtableId('', 'app', 'baseId')
1159+
expect(result.isValid).toBe(false)
1160+
expect(result.error).toContain('required')
1161+
})
1162+
1163+
it.concurrent('should reject wrong prefix', () => {
1164+
const result = validateAirtableId('tblABCDEFGHIJKLMN', 'app', 'baseId')
1165+
expect(result.isValid).toBe(false)
1166+
expect(result.error).toContain('starting with "app"')
1167+
})
1168+
1169+
it.concurrent('should reject too short ID (13 chars after prefix)', () => {
1170+
const result = validateAirtableId('appABCDEFGHIJKLM', 'app', 'baseId')
1171+
expect(result.isValid).toBe(false)
1172+
})
1173+
1174+
it.concurrent('should reject too long ID (15 chars after prefix)', () => {
1175+
const result = validateAirtableId('appABCDEFGHIJKLMNO', 'app', 'baseId')
1176+
expect(result.isValid).toBe(false)
1177+
})
1178+
1179+
it.concurrent('should reject special characters', () => {
1180+
const result = validateAirtableId('appABCDEFGH/JKLMN', 'app', 'baseId')
1181+
expect(result.isValid).toBe(false)
1182+
})
1183+
1184+
it.concurrent('should reject path traversal attempts', () => {
1185+
const result = validateAirtableId('app../etc/passwd', 'app', 'baseId')
1186+
expect(result.isValid).toBe(false)
1187+
})
1188+
1189+
it.concurrent('should reject lowercase prefix', () => {
1190+
const result = validateAirtableId('AppABCDEFGHIJKLMN', 'app', 'baseId')
1191+
expect(result.isValid).toBe(false)
1192+
})
1193+
})
1194+
})

apps/sim/lib/core/security/input-validation.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,57 @@ export function createPinnedUrl(originalUrl: string, resolvedIP: string): string
896896
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
897897
}
898898

899+
/**
900+
* Validates an Airtable ID (base, table, or webhook ID)
901+
*
902+
* Airtable IDs have specific prefixes:
903+
* - Base IDs: "app" + 14 alphanumeric characters (e.g., appXXXXXXXXXXXXXX)
904+
* - Table IDs: "tbl" + 14 alphanumeric characters
905+
* - Webhook IDs: "ach" + 14 alphanumeric characters
906+
*
907+
* @param value - The ID to validate
908+
* @param expectedPrefix - The expected prefix ('app', 'tbl', or 'ach')
909+
* @param paramName - Name of the parameter for error messages
910+
* @returns ValidationResult
911+
*
912+
* @example
913+
* ```typescript
914+
* const result = validateAirtableId(baseId, 'app', 'baseId')
915+
* if (!result.isValid) {
916+
* throw new Error(result.error)
917+
* }
918+
* ```
919+
*/
920+
export function validateAirtableId(
921+
value: string | null | undefined,
922+
expectedPrefix: 'app' | 'tbl' | 'ach',
923+
paramName = 'ID'
924+
): ValidationResult {
925+
if (value === null || value === undefined || value === '') {
926+
return {
927+
isValid: false,
928+
error: `${paramName} is required`,
929+
}
930+
}
931+
932+
// Airtable IDs: prefix (3 chars) + 14 alphanumeric characters = 17 chars total
933+
const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`)
934+
935+
if (!airtableIdPattern.test(value)) {
936+
logger.warn('Invalid Airtable ID format', {
937+
paramName,
938+
expectedPrefix,
939+
value: value.substring(0, 20),
940+
})
941+
return {
942+
isValid: false,
943+
error: `${paramName} must be a valid Airtable ID starting with "${expectedPrefix}"`,
944+
}
945+
}
946+
947+
return { isValid: true, sanitized: value }
948+
}
949+
899950
/**
900951
* Validates a Google Calendar ID
901952
*

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import type { NextRequest } from 'next/server'
3+
import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation'
34
import { getBaseUrl } from '@/lib/core/utils/urls'
45
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
56

@@ -358,6 +359,15 @@ export async function deleteAirtableWebhook(
358359
return
359360
}
360361

362+
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
363+
if (!baseIdValidation.isValid) {
364+
airtableLogger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, {
365+
webhookId: webhook.id,
366+
baseId: baseId.substring(0, 20),
367+
})
368+
return
369+
}
370+
361371
const userIdForToken = workflow.userId
362372
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
363373
if (!accessToken) {
@@ -428,6 +438,15 @@ export async function deleteAirtableWebhook(
428438
return
429439
}
430440

441+
const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId')
442+
if (!webhookIdValidation.isValid) {
443+
airtableLogger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, {
444+
webhookId: webhook.id,
445+
externalId: resolvedExternalId.substring(0, 20),
446+
})
447+
return
448+
}
449+
431450
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
432451
const airtableResponse = await fetch(airtableDeleteUrl, {
433452
method: 'DELETE',
@@ -732,6 +751,14 @@ export async function deleteLemlistWebhook(webhook: any, requestId: string): Pro
732751
const authString = Buffer.from(`:${apiKey}`).toString('base64')
733752

734753
const deleteById = async (id: string) => {
754+
const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50)
755+
if (!validation.isValid) {
756+
lemlistLogger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, {
757+
id: id.substring(0, 30),
758+
})
759+
return
760+
}
761+
735762
const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}`
736763
const lemlistResponse = await fetch(lemlistApiUrl, {
737764
method: 'DELETE',
@@ -823,6 +850,24 @@ export async function deleteWebflowWebhook(
823850
return
824851
}
825852

853+
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
854+
if (!siteIdValidation.isValid) {
855+
webflowLogger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, {
856+
webhookId: webhook.id,
857+
siteId: siteId.substring(0, 30),
858+
})
859+
return
860+
}
861+
862+
const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100)
863+
if (!webhookIdValidation.isValid) {
864+
webflowLogger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, {
865+
webhookId: webhook.id,
866+
externalId: externalId.substring(0, 30),
867+
})
868+
return
869+
}
870+
826871
const accessToken = await getOAuthToken(workflow.userId, 'webflow')
827872
if (!accessToken) {
828873
webflowLogger.warn(
@@ -1122,6 +1167,16 @@ export async function createAirtableWebhookSubscription(
11221167
)
11231168
}
11241169

1170+
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
1171+
if (!baseIdValidation.isValid) {
1172+
throw new Error(baseIdValidation.error)
1173+
}
1174+
1175+
const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId')
1176+
if (!tableIdValidation.isValid) {
1177+
throw new Error(tableIdValidation.error)
1178+
}
1179+
11251180
const accessToken = await getOAuthToken(userId, 'airtable')
11261181
if (!accessToken) {
11271182
airtableLogger.warn(
@@ -1354,6 +1409,11 @@ export async function createWebflowWebhookSubscription(
13541409
throw new Error('Site ID is required to create Webflow webhook')
13551410
}
13561411

1412+
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
1413+
if (!siteIdValidation.isValid) {
1414+
throw new Error(siteIdValidation.error)
1415+
}
1416+
13571417
if (!triggerId) {
13581418
webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, {
13591419
webhookId: webhookData.id,

apps/sim/tools/pulse/parser.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
8686
throw new Error('Missing or invalid API key: A valid Pulse API key is required')
8787
}
8888

89-
// Check if we have a file upload instead of direct URL
9089
if (
9190
params.fileUpload &&
9291
(!params.filePath || params.filePath === 'null' || params.filePath === '')
@@ -137,13 +136,6 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
137136
if (!['http:', 'https:'].includes(url.protocol)) {
138137
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`)
139138
}
140-
141-
if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) {
142-
throw new Error(
143-
'Google Drive links are not supported. ' +
144-
'Please upload your document or provide a direct download link.'
145-
)
146-
}
147139
} catch (error) {
148140
const errorMessage = error instanceof Error ? error.message : String(error)
149141
throw new Error(
@@ -156,12 +148,10 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
156148
filePath: url.toString(),
157149
}
158150

159-
// Check if this is an internal workspace file path
160151
if (params.fileUpload?.path?.startsWith('/api/files/serve/')) {
161152
requestBody.filePath = params.fileUpload.path
162153
}
163154

164-
// Add optional parameters
165155
if (params.pages && typeof params.pages === 'string' && params.pages.trim() !== '') {
166156
requestBody.pages = params.pages.trim()
167157
}
@@ -204,7 +194,6 @@ export const pulseParserTool: ToolConfig<PulseParserInput, PulseParserOutput> =
204194
throw new Error('Invalid response format from Pulse API')
205195
}
206196

207-
// Pass through the native Pulse API response
208197
const pulseData =
209198
parseResult.output && typeof parseResult.output === 'object'
210199
? parseResult.output

apps/sim/tools/reducto/parser.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
6363
throw new Error('Missing or invalid API key: A valid Reducto API key is required')
6464
}
6565

66-
// Check if we have a file upload instead of direct URL
6766
if (
6867
params.fileUpload &&
6968
(!params.filePath || params.filePath === 'null' || params.filePath === '')
@@ -110,13 +109,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
110109
if (!['http:', 'https:'].includes(url.protocol)) {
111110
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`)
112111
}
113-
114-
if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) {
115-
throw new Error(
116-
'Google Drive links are not supported by the Reducto API. ' +
117-
'Please upload your PDF to a public web server or provide a direct download link.'
118-
)
119-
}
120112
} catch (error) {
121113
const errorMessage = error instanceof Error ? error.message : String(error)
122114
throw new Error(
@@ -129,7 +121,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
129121
filePath: url.toString(),
130122
}
131123

132-
// Check if this is an internal workspace file path
133124
if (params.fileUpload?.path?.startsWith('/api/files/serve/')) {
134125
requestBody.filePath = params.fileUpload.path
135126
}
@@ -138,7 +129,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
138129
requestBody.tableOutputFormat = params.tableOutputFormat
139130
}
140131

141-
// Page selection
142132
if (params.pages !== undefined && params.pages !== null) {
143133
if (Array.isArray(params.pages) && params.pages.length > 0) {
144134
const validPages = params.pages.filter(
@@ -162,7 +152,6 @@ export const reductoParserTool: ToolConfig<ReductoParserInput, ReductoParserOutp
162152
throw new Error('Invalid response format from Reducto API')
163153
}
164154

165-
// Pass through the native Reducto response
166155
const reductoData = data.output ?? data
167156

168157
return {

0 commit comments

Comments
 (0)