improvement(security): added input validation for airtable, lemlist, and more tools to protect against SSRF (#2847)

This commit is contained in:
Waleed
2026-01-15 19:33:29 -08:00
committed by GitHub
parent 51e376847f
commit 55700b9bf4
5 changed files with 191 additions and 22 deletions
@@ -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,
-11
View File
@@ -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
-11
View File
@@ -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 {