From abfa2f8d51901247acc6397960210569e84d72b1 Mon Sep 17 00:00:00 2001 From: waleedlatif1 Date: Sat, 6 Sep 2025 13:27:40 -0700 Subject: [PATCH 1/6] update infra and remove railway --- .github/workflows/build.yml | 39 ++++++++++++++++++++++++++++++++----- .github/workflows/ci.yml | 22 --------------------- railway.json | 21 -------------------- 3 files changed, 34 insertions(+), 48 deletions(-) delete mode 100644 railway.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30d2eb2608..518add6b14 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,7 +55,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') uses: docker/login-action@v3 with: registry: ghcr.io @@ -69,7 +69,7 @@ jobs: images: ${{ matrix.image }} tags: | type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }} - type=raw,value=staging-${{ github.sha }}-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }} + type=raw,value=staging-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }} type=sha,format=long,suffix=-${{ matrix.arch }} - name: Build and push Docker image @@ -78,7 +78,7 @@ jobs: context: . file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} - push: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} + push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=build-v3 @@ -89,7 +89,7 @@ jobs: create-manifests: runs-on: ubuntu-latest needs: build-and-push - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') strategy: matrix: include: @@ -115,6 +115,7 @@ jobs: images: ${{ matrix.image }} tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=staging,enable=${{ github.ref == 'refs/heads/staging' }} type=sha,format=long - name: Create and push manifest @@ -148,4 +149,32 @@ jobs: docker manifest inspect "$arm64_image" || echo "ARM64 image not found" exit 1 fi - done \ No newline at end of file + done + + trigger-infrastructure-deploy: + runs-on: ubuntu-latest + needs: create-manifests + if: github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/main' + permissions: + contents: read + + steps: + - name: Trigger staging deployment + if: github.ref == 'refs/heads/staging' + run: | + curl -X POST \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Authorization: token ${{ secrets.INFRA_DEPLOY_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/${{ secrets.INFRA_REPO }}/dispatches \ + -d '{"event_type":"staging-deploy","client_payload":{"sha":"${{ github.sha }}","ref":"${{ github.ref }}"}}' + + - name: Trigger production deployment + if: github.ref == 'refs/heads/main' + run: | + curl -X POST \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Authorization: token ${{ secrets.INFRA_DEPLOY_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/${{ secrets.INFRA_REPO }}/dispatches \ + -d '{"event_type":"production-deploy","client_payload":{"sha":"${{ github.sha }}","ref":"${{ github.ref }}"}}' \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffd68d8b87..0f64e4f960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,25 +53,3 @@ jobs: fail_ci_if_error: false verbose: true - migrations: - name: Apply Database Migrations - runs-on: ubuntu-latest - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') - needs: test - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Apply migrations - working-directory: ./apps/sim - env: - DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || secrets.STAGING_DATABASE_URL }} - run: bunx drizzle-kit migrate diff --git a/railway.json b/railway.json deleted file mode 100644 index 62d6da767e..0000000000 --- a/railway.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://railway.app/railway.schema.json", - "build": { - "builder": "NIXPACKS", - "buildCommand": "cd apps/sim && bun install --frozen-lockfile && bun run build" - }, - "deploy": { - "startCommand": "cd apps/sim && NODE_ENV=production bun run socket-server/index.ts", - "healthcheckPath": "/health", - "healthcheckTimeout": 300, - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 10 - }, - "environments": { - "production": { - "variables": { - "NODE_ENV": "production" - } - } - } -} From 0df5303883d21f2e81b2e2b81963557f89d055b9 Mon Sep 17 00:00:00 2001 From: waleedlatif1 Date: Sat, 13 Sep 2025 02:15:07 -0700 Subject: [PATCH 2/6] fix(security): fix ssrf vuln --- .../app/api/function/execute/route.test.ts | 171 ++++++++++++------ apps/sim/app/api/function/execute/route.ts | 26 ++- 2 files changed, 136 insertions(+), 61 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 5ca4eeb36a..3effb01281 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -48,8 +48,53 @@ describe('Function Execute API Route', () => { vi.clearAllMocks() }) + describe('Security Tests', () => { + it.concurrent('should create secure fetch in VM context', async () => { + const req = createMockRequest('POST', { + code: 'return "test"', + useLocalVM: true, + }) + + const { POST } = await import('@/app/api/function/execute/route') + await POST(req) + + expect(mockCreateContext).toHaveBeenCalled() + const contextArgs = mockCreateContext.mock.calls[0][0] + expect(contextArgs).toHaveProperty('fetch') + expect(typeof contextArgs.fetch).toBe('function') + + expect(contextArgs.fetch.name).toBe('secureFetch') + }) + + it.concurrent('should block SSRF attacks through secure fetch wrapper', async () => { + const { validateProxyUrl } = await import('@/lib/security/url-validation') + + expect(validateProxyUrl('http://169.254.169.254/latest/meta-data/').isValid).toBe(false) + expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(false) + expect(validateProxyUrl('http://192.168.1.1/config').isValid).toBe(false) + expect(validateProxyUrl('http://10.0.0.1/internal').isValid).toBe(false) + }) + + it.concurrent('should allow legitimate external URLs', async () => { + const { validateProxyUrl } = await import('@/lib/security/url-validation') + + expect(validateProxyUrl('https://api.github.com/user').isValid).toBe(true) + expect(validateProxyUrl('https://httpbin.org/get').isValid).toBe(true) + expect(validateProxyUrl('http://example.com/api').isValid).toBe(true) + }) + + it.concurrent('should block dangerous protocols', async () => { + const { validateProxyUrl } = await import('@/lib/security/url-validation') + + // Test that dangerous protocols are blocked + expect(validateProxyUrl('file:///etc/passwd').isValid).toBe(false) + expect(validateProxyUrl('ftp://internal.server/files').isValid).toBe(false) + expect(validateProxyUrl('gopher://old.server/menu').isValid).toBe(false) + }) + }) + describe('Basic Function Execution', () => { - it('should execute simple JavaScript code successfully', async () => { + it.concurrent('should execute simple JavaScript code successfully', async () => { const req = createMockRequest('POST', { code: 'return "Hello World"', timeout: 5000, @@ -66,7 +111,7 @@ describe('Function Execute API Route', () => { expect(data.output).toHaveProperty('executionTime') }) - it('should handle missing code parameter', async () => { + it.concurrent('should handle missing code parameter', async () => { const req = createMockRequest('POST', { timeout: 5000, }) @@ -80,7 +125,7 @@ describe('Function Execute API Route', () => { expect(data).toHaveProperty('error') }) - it('should use default timeout when not provided', async () => { + it.concurrent('should use default timeout when not provided', async () => { const req = createMockRequest('POST', { code: 'return "test"', useLocalVM: true, @@ -100,7 +145,7 @@ describe('Function Execute API Route', () => { }) describe('Template Variable Resolution', () => { - it('should resolve environment variables with {{var_name}} syntax', async () => { + it.concurrent('should resolve environment variables with {{var_name}} syntax', async () => { const req = createMockRequest('POST', { code: 'return {{API_KEY}}', useLocalVM: true, @@ -116,7 +161,7 @@ describe('Function Execute API Route', () => { // The code should be resolved to: return "secret-key-123" }) - it('should resolve tag variables with syntax', async () => { + it.concurrent('should resolve tag variables with syntax', async () => { const req = createMockRequest('POST', { code: 'return ', useLocalVM: true, @@ -132,7 +177,7 @@ describe('Function Execute API Route', () => { // The code should be resolved with the email object }) - it('should NOT treat email addresses as template variables', async () => { + it.concurrent('should NOT treat email addresses as template variables', async () => { const req = createMockRequest('POST', { code: 'return "Email sent to user"', useLocalVM: true, @@ -151,7 +196,7 @@ describe('Function Execute API Route', () => { // Should not try to replace as a template variable }) - it('should only match valid variable names in angle brackets', async () => { + it.concurrent('should only match valid variable names in angle brackets', async () => { const req = createMockRequest('POST', { code: 'return + "" + ', useLocalVM: true, @@ -170,64 +215,70 @@ describe('Function Execute API Route', () => { }) describe('Gmail Email Data Handling', () => { - it('should handle Gmail webhook data with email addresses containing angle brackets', async () => { - const gmailData = { - email: { - id: '123', - from: 'Waleed Latif ', - to: 'User ', - subject: 'Test Email', - bodyText: 'Hello world', - }, - rawEmail: { - id: '123', - payload: { - headers: [ - { name: 'From', value: 'Waleed Latif ' }, - { name: 'To', value: 'User ' }, - ], + it.concurrent( + 'should handle Gmail webhook data with email addresses containing angle brackets', + async () => { + const gmailData = { + email: { + id: '123', + from: 'Waleed Latif ', + to: 'User ', + subject: 'Test Email', + bodyText: 'Hello world', }, - }, - } - - const req = createMockRequest('POST', { - code: 'return ', - useLocalVM: true, - params: gmailData, - }) + rawEmail: { + id: '123', + payload: { + headers: [ + { name: 'From', value: 'Waleed Latif ' }, + { name: 'To', value: 'User ' }, + ], + }, + }, + } - const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(req) + const req = createMockRequest('POST', { + code: 'return ', + useLocalVM: true, + params: gmailData, + }) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) - }) + const { POST } = await import('@/app/api/function/execute/route') + const response = await POST(req) - it('should properly serialize complex email objects with special characters', async () => { - const complexEmailData = { - email: { - from: 'Test User ', - bodyHtml: '
HTML content with "quotes" and \'apostrophes\'
', - bodyText: 'Text with\nnewlines\tand\ttabs', - }, + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) } + ) - const req = createMockRequest('POST', { - code: 'return ', - useLocalVM: true, - params: complexEmailData, - }) + it.concurrent( + 'should properly serialize complex email objects with special characters', + async () => { + const complexEmailData = { + email: { + from: 'Test User ', + bodyHtml: '
HTML content with "quotes" and \'apostrophes\'
', + bodyText: 'Text with\nnewlines\tand\ttabs', + }, + } - const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(req) + const req = createMockRequest('POST', { + code: 'return ', + useLocalVM: true, + params: complexEmailData, + }) - expect(response.status).toBe(200) - }) + const { POST } = await import('@/app/api/function/execute/route') + const response = await POST(req) + + expect(response.status).toBe(200) + } + ) }) describe('Custom Tools', () => { - it('should handle custom tool execution with direct parameter access', async () => { + it.concurrent('should handle custom tool execution with direct parameter access', async () => { const req = createMockRequest('POST', { code: 'return location + " weather is sunny"', useLocalVM: true, @@ -246,7 +297,7 @@ describe('Function Execute API Route', () => { }) describe('Security and Edge Cases', () => { - it('should handle malformed JSON in request body', async () => { + it.concurrent('should handle malformed JSON in request body', async () => { const req = new NextRequest('http://localhost:3000/api/function/execute', { method: 'POST', body: 'invalid json{', @@ -259,7 +310,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(500) }) - it('should handle timeout parameter', async () => { + it.concurrent('should handle timeout parameter', async () => { const req = createMockRequest('POST', { code: 'return "test"', useLocalVM: true, @@ -277,7 +328,7 @@ describe('Function Execute API Route', () => { ) }) - it('should handle empty parameters object', async () => { + it.concurrent('should handle empty parameters object', async () => { const req = createMockRequest('POST', { code: 'return "no params"', useLocalVM: true, @@ -485,7 +536,7 @@ SyntaxError: Invalid or unexpected token expect(data.debug.lineContent).toBe('return a + b + c + d;') }) - it('should provide helpful suggestions for common syntax errors', async () => { + it.concurrent('should provide helpful suggestions for common syntax errors', async () => { const mockScript = vi.fn().mockImplementation(() => { const error = new Error('Unexpected end of input') error.name = 'SyntaxError' @@ -517,7 +568,7 @@ SyntaxError: Invalid or unexpected token }) describe('Utility Functions', () => { - it('should properly escape regex special characters', async () => { + it.concurrent('should properly escape regex special characters', async () => { // This tests the escapeRegExp function indirectly const req = createMockRequest('POST', { code: 'return {{special.chars+*?}}', @@ -534,7 +585,7 @@ SyntaxError: Invalid or unexpected token // Should handle special regex characters in variable names }) - it('should handle JSON serialization edge cases', async () => { + it.concurrent('should handle JSON serialization edge cases', async () => { // Test with complex but not circular data first const req = createMockRequest('POST', { code: 'return ', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 2182078b15..a046fa3c39 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -4,6 +4,7 @@ import { env, isTruthy } from '@/lib/env' import { executeInE2B } from '@/lib/execution/e2b' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { createLogger } from '@/lib/logs/console/logger' +import { validateProxyUrl } from '@/lib/security/url-validation' import { generateRequestId } from '@/lib/utils' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -11,6 +12,29 @@ export const maxDuration = 60 const logger = createLogger('FunctionExecuteAPI') +function createSecureFetch(requestId: string) { + const originalFetch = (globalThis as any).fetch || require('node-fetch').default + + return async function secureFetch(input: any, init?: any) { + const url = typeof input === 'string' ? input : input?.url || input + + if (!url || typeof url !== 'string') { + throw new Error('Invalid URL provided to fetch') + } + + const validation = validateProxyUrl(url) + if (!validation.isValid) { + logger.warn(`[${requestId}] Blocked fetch request due to SSRF validation`, { + url: url.substring(0, 100), + error: validation.error, + }) + throw new Error(`Security Error: ${validation.error}`) + } + + return originalFetch(input, init) + } +} + // Constants for E2B code wrapping line counts const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {' const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():' @@ -737,7 +761,7 @@ export async function POST(req: NextRequest) { params: executionParams, environmentVariables: envVars, ...contextVariables, - fetch: (globalThis as any).fetch || require('node-fetch').default, + fetch: createSecureFetch(requestId), console: { log: (...args: any[]) => { const logMessage = `${args From 503b07e5a5ff93a690839c20199444ac957c17f1 Mon Sep 17 00:00:00 2001 From: waleedlatif1 Date: Sat, 13 Sep 2025 02:27:43 -0700 Subject: [PATCH 3/6] fix path validation for file serve --- apps/sim/app/api/files/utils.test.ts | 92 +++++++++++++++++++++++++++- apps/sim/app/api/files/utils.ts | 76 ++++++++++++++++++++--- 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index d0ad4567ac..ac2edd4db3 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createFileResponse, extractFilename } from './utils' +import { createFileResponse, extractFilename, findLocalFile } from './utils' describe('extractFilename', () => { describe('legitimate file paths', () => { @@ -325,3 +325,93 @@ describe('extractFilename', () => { }) }) }) + +describe('findLocalFile - Path Traversal Security Tests', () => { + describe('path traversal attack prevention', () => { + it('should reject classic path traversal attacks', () => { + const maliciousInputs = [ + '../../../etc/passwd', + '..\\..\\..\\windows\\system32\\config\\sam', + '../../../../etc/shadow', + '../config.json', + '..\\config.ini', + ] + + maliciousInputs.forEach((input) => { + const result = findLocalFile(input) + expect(result).toBeNull() + }) + }) + + it('should reject encoded path traversal attempts', () => { + const encodedInputs = [ + '%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd + '..%2f..%2fetc%2fpasswd', + '..%5c..%5cconfig.ini', + ] + + encodedInputs.forEach((input) => { + const result = findLocalFile(input) + expect(result).toBeNull() + }) + }) + + it('should reject mixed path separators', () => { + const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32'] + + mixedInputs.forEach((input) => { + const result = findLocalFile(input) + expect(result).toBeNull() + }) + }) + + it('should reject filenames with dangerous characters', () => { + const dangerousInputs = [ + 'file:with:colons.txt', + 'file|with|pipes.txt', + 'file?with?questions.txt', + 'file*with*asterisks.txt', + ] + + dangerousInputs.forEach((input) => { + const result = findLocalFile(input) + expect(result).toBeNull() + }) + }) + + it('should reject null and empty inputs', () => { + expect(findLocalFile('')).toBeNull() + expect(findLocalFile(' ')).toBeNull() + expect(findLocalFile('\t\n')).toBeNull() + }) + + it('should reject filenames that become empty after sanitization', () => { + const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..'] + + emptyAfterSanitization.forEach((input) => { + const result = findLocalFile(input) + expect(result).toBeNull() + }) + }) + }) + + describe('security validation passes for legitimate files', () => { + it('should accept properly formatted filenames without throwing errors', () => { + // These tests verify that legitimate filenames pass the security checks + // The function may return null if files don't exist, but should not throw security errors + const legitimateInputs = [ + 'document.pdf', + 'image.png', + 'data.csv', + 'report-2024.doc', + 'file_with_underscores.txt', + 'file-with-dashes.json', + ] + + legitimateInputs.forEach((input) => { + // Should not throw security errors for legitimate filenames + expect(() => findLocalFile(input)).not.toThrow() + }) + }) + }) +}) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index 4e427bb77c..9583d324f7 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -1,5 +1,5 @@ import { existsSync } from 'fs' -import { join } from 'path' +import { join, resolve, sep } from 'path' import { NextResponse } from 'next/server' import { UPLOAD_DIR } from '@/lib/uploads/setup' @@ -192,18 +192,76 @@ export function extractFilename(path: string): string { } /** - * Find a file in possible local storage locations + * Sanitize filename to prevent path traversal attacks */ -export function findLocalFile(filename: string): string | null { - const possiblePaths = [join(UPLOAD_DIR, filename), join(process.cwd(), 'uploads', filename)] +function sanitizeFilename(filename: string): string { + if (!filename || typeof filename !== 'string') { + throw new Error('Invalid filename provided') + } - for (const path of possiblePaths) { - if (existsSync(path)) { - return path - } + // Remove any path traversal sequences + const sanitized = filename + .replace(/\.\./g, '') // Remove .. sequences + .replace(/[/\\]/g, '') // Remove path separators + .replace(/^\./g, '') // Remove leading dots + .trim() + + // Ensure filename is not empty after sanitization + if (!sanitized || sanitized.length === 0) { + throw new Error('Invalid or empty filename after sanitization') + } + + // Prevent absolute paths and ensure filename doesn't contain dangerous characters + if ( + sanitized.includes(':') || + sanitized.includes('|') || + sanitized.includes('?') || + sanitized.includes('*') + ) { + throw new Error('Filename contains invalid characters') } - return null + return sanitized +} + +/** + * Find a file in possible local storage locations with proper path validation + */ +export function findLocalFile(filename: string): string | null { + try { + // Sanitize the filename to prevent path traversal + const sanitizedFilename = sanitizeFilename(filename) + + const possiblePaths = [ + join(UPLOAD_DIR, sanitizedFilename), + join(process.cwd(), 'uploads', sanitizedFilename), + ] + + for (const path of possiblePaths) { + // Additional security check: ensure the resolved path is within allowed directories + const resolvedPath = resolve(path) + const allowedDirs = [resolve(UPLOAD_DIR), resolve(process.cwd(), 'uploads')] + + // Check if the resolved path is within one of the allowed directories + const isWithinAllowedDir = allowedDirs.some( + (allowedDir) => resolvedPath.startsWith(allowedDir + sep) || resolvedPath === allowedDir + ) + + if (!isWithinAllowedDir) { + continue // Skip this path as it's outside allowed directories + } + + if (existsSync(resolvedPath)) { + return resolvedPath + } + } + + return null + } catch (error) { + // Log the error but don't expose details to prevent information disclosure + console.error('Error in findLocalFile:', error) + return null + } } const SAFE_INLINE_TYPES = new Set([ From 2b5d5330d72a0430446d496bf136aefa3c417f4a Mon Sep 17 00:00:00 2001 From: waleedlatif1 Date: Sat, 13 Sep 2025 02:28:23 -0700 Subject: [PATCH 4/6] Revert "update infra and remove railway" This reverts commit abfa2f8d51901247acc6397960210569e84d72b1. --- .github/workflows/build.yml | 39 +++++-------------------------------- .github/workflows/ci.yml | 22 +++++++++++++++++++++ railway.json | 21 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 34 deletions(-) create mode 100644 railway.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 518add6b14..30d2eb2608 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,7 +55,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry - if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io @@ -69,7 +69,7 @@ jobs: images: ${{ matrix.image }} tags: | type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }} - type=raw,value=staging-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }} + type=raw,value=staging-${{ github.sha }}-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }} type=sha,format=long,suffix=-${{ matrix.arch }} - name: Build and push Docker image @@ -78,7 +78,7 @@ jobs: context: . file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} - push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') }} + push: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=build-v3 @@ -89,7 +89,7 @@ jobs: create-manifests: runs-on: ubuntu-latest needs: build-and-push - if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' strategy: matrix: include: @@ -115,7 +115,6 @@ jobs: images: ${{ matrix.image }} tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=raw,value=staging,enable=${{ github.ref == 'refs/heads/staging' }} type=sha,format=long - name: Create and push manifest @@ -149,32 +148,4 @@ jobs: docker manifest inspect "$arm64_image" || echo "ARM64 image not found" exit 1 fi - done - - trigger-infrastructure-deploy: - runs-on: ubuntu-latest - needs: create-manifests - if: github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/main' - permissions: - contents: read - - steps: - - name: Trigger staging deployment - if: github.ref == 'refs/heads/staging' - run: | - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${{ secrets.INFRA_DEPLOY_TOKEN }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/${{ secrets.INFRA_REPO }}/dispatches \ - -d '{"event_type":"staging-deploy","client_payload":{"sha":"${{ github.sha }}","ref":"${{ github.ref }}"}}' - - - name: Trigger production deployment - if: github.ref == 'refs/heads/main' - run: | - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${{ secrets.INFRA_DEPLOY_TOKEN }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/${{ secrets.INFRA_REPO }}/dispatches \ - -d '{"event_type":"production-deploy","client_payload":{"sha":"${{ github.sha }}","ref":"${{ github.ref }}"}}' \ No newline at end of file + done \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f64e4f960..ffd68d8b87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,25 @@ jobs: fail_ci_if_error: false verbose: true + migrations: + name: Apply Database Migrations + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Apply migrations + working-directory: ./apps/sim + env: + DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || secrets.STAGING_DATABASE_URL }} + run: bunx drizzle-kit migrate diff --git a/railway.json b/railway.json new file mode 100644 index 0000000000..62d6da767e --- /dev/null +++ b/railway.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS", + "buildCommand": "cd apps/sim && bun install --frozen-lockfile && bun run build" + }, + "deploy": { + "startCommand": "cd apps/sim && NODE_ENV=production bun run socket-server/index.ts", + "healthcheckPath": "/health", + "healthcheckTimeout": 300, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + }, + "environments": { + "production": { + "variables": { + "NODE_ENV": "production" + } + } + } +} From 4f669138946473959b1d9b35b105c8b0541e464d Mon Sep 17 00:00:00 2001 From: waleedlatif1 Date: Sat, 13 Sep 2025 10:38:23 -0700 Subject: [PATCH 5/6] lint --- apps/sim/app/api/files/utils.test.ts | 16 +++++++--------- apps/sim/app/api/files/utils.ts | 12 ++++-------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index ac2edd4db3..b3deae47bd 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -328,7 +328,7 @@ describe('extractFilename', () => { describe('findLocalFile - Path Traversal Security Tests', () => { describe('path traversal attack prevention', () => { - it('should reject classic path traversal attacks', () => { + it.concurrent('should reject classic path traversal attacks', () => { const maliciousInputs = [ '../../../etc/passwd', '..\\..\\..\\windows\\system32\\config\\sam', @@ -343,7 +343,7 @@ describe('findLocalFile - Path Traversal Security Tests', () => { }) }) - it('should reject encoded path traversal attempts', () => { + it.concurrent('should reject encoded path traversal attempts', () => { const encodedInputs = [ '%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd '..%2f..%2fetc%2fpasswd', @@ -356,7 +356,7 @@ describe('findLocalFile - Path Traversal Security Tests', () => { }) }) - it('should reject mixed path separators', () => { + it.concurrent('should reject mixed path separators', () => { const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32'] mixedInputs.forEach((input) => { @@ -365,7 +365,7 @@ describe('findLocalFile - Path Traversal Security Tests', () => { }) }) - it('should reject filenames with dangerous characters', () => { + it.concurrent('should reject filenames with dangerous characters', () => { const dangerousInputs = [ 'file:with:colons.txt', 'file|with|pipes.txt', @@ -379,13 +379,13 @@ describe('findLocalFile - Path Traversal Security Tests', () => { }) }) - it('should reject null and empty inputs', () => { + it.concurrent('should reject null and empty inputs', () => { expect(findLocalFile('')).toBeNull() expect(findLocalFile(' ')).toBeNull() expect(findLocalFile('\t\n')).toBeNull() }) - it('should reject filenames that become empty after sanitization', () => { + it.concurrent('should reject filenames that become empty after sanitization', () => { const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..'] emptyAfterSanitization.forEach((input) => { @@ -396,9 +396,7 @@ describe('findLocalFile - Path Traversal Security Tests', () => { }) describe('security validation passes for legitimate files', () => { - it('should accept properly formatted filenames without throwing errors', () => { - // These tests verify that legitimate filenames pass the security checks - // The function may return null if files don't exist, but should not throw security errors + it.concurrent('should accept properly formatted filenames without throwing errors', () => { const legitimateInputs = [ 'document.pdf', 'image.png', diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index 9583d324f7..67b759e33b 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -1,8 +1,11 @@ import { existsSync } from 'fs' import { join, resolve, sep } from 'path' import { NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console/logger' import { UPLOAD_DIR } from '@/lib/uploads/setup' +const logger = createLogger('FilesUtils') + /** * Response type definitions */ @@ -199,19 +202,16 @@ function sanitizeFilename(filename: string): string { throw new Error('Invalid filename provided') } - // Remove any path traversal sequences const sanitized = filename .replace(/\.\./g, '') // Remove .. sequences .replace(/[/\\]/g, '') // Remove path separators .replace(/^\./g, '') // Remove leading dots .trim() - // Ensure filename is not empty after sanitization if (!sanitized || sanitized.length === 0) { throw new Error('Invalid or empty filename after sanitization') } - // Prevent absolute paths and ensure filename doesn't contain dangerous characters if ( sanitized.includes(':') || sanitized.includes('|') || @@ -229,7 +229,6 @@ function sanitizeFilename(filename: string): string { */ export function findLocalFile(filename: string): string | null { try { - // Sanitize the filename to prevent path traversal const sanitizedFilename = sanitizeFilename(filename) const possiblePaths = [ @@ -238,11 +237,9 @@ export function findLocalFile(filename: string): string | null { ] for (const path of possiblePaths) { - // Additional security check: ensure the resolved path is within allowed directories const resolvedPath = resolve(path) const allowedDirs = [resolve(UPLOAD_DIR), resolve(process.cwd(), 'uploads')] - // Check if the resolved path is within one of the allowed directories const isWithinAllowedDir = allowedDirs.some( (allowedDir) => resolvedPath.startsWith(allowedDir + sep) || resolvedPath === allowedDir ) @@ -258,8 +255,7 @@ export function findLocalFile(filename: string): string | null { return null } catch (error) { - // Log the error but don't expose details to prevent information disclosure - console.error('Error in findLocalFile:', error) + logger.error('Error in findLocalFile:', error) return null } } From b60b3f20580223a8fbf146998c4a8fdae116dc26 Mon Sep 17 00:00:00 2001 From: waleedlatif1 Date: Sat, 13 Sep 2025 11:31:40 -0700 Subject: [PATCH 6/6] ack PR comments --- apps/sim/app/api/files/utils.ts | 4 +++- apps/sim/app/api/function/execute/route.test.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index 67b759e33b..2e88f0c9c4 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -216,7 +216,9 @@ function sanitizeFilename(filename: string): string { sanitized.includes(':') || sanitized.includes('|') || sanitized.includes('?') || - sanitized.includes('*') + sanitized.includes('*') || + sanitized.includes('\x00') || // Null bytes + /[\x00-\x1F\x7F]/.test(sanitized) // Control characters ) { throw new Error('Filename contains invalid characters') } diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 3effb01281..8e32ae9cc0 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -86,7 +86,6 @@ describe('Function Execute API Route', () => { it.concurrent('should block dangerous protocols', async () => { const { validateProxyUrl } = await import('@/lib/security/url-validation') - // Test that dangerous protocols are blocked expect(validateProxyUrl('file:///etc/passwd').isValid).toBe(false) expect(validateProxyUrl('ftp://internal.server/files').isValid).toBe(false) expect(validateProxyUrl('gopher://old.server/menu').isValid).toBe(false)