Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/sim/lib/core/security/input-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
import {
createPinnedUrl,
validateAirtableId,
validateAlphanumericId,
validateEnum,
validateExternalUrl,
Expand Down Expand Up @@ -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)
})
})
})
51 changes: 51 additions & 0 deletions apps/sim/lib/core/security/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
60 changes: 60 additions & 0 deletions apps/sim/lib/webhooks/provider-subscriptions.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 0 additions & 11 deletions apps/sim/tools/pulse/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === '')
Expand Down Expand Up @@ -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(
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 0 additions & 11 deletions apps/sim/tools/reducto/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === '')
Expand Down Expand Up @@ -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(
Expand All @@ -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
}
Expand All @@ -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(
Expand All @@ -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 {
Expand Down