From e0d676b71247276b113bf6bf078bcdd67ec6604c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 30 May 2026 19:19:32 -0700 Subject: [PATCH 1/4] fix(files): don't reject external URLs containing '..' in file parse validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file block's file_fetch operation rejected any external URL whose path contained '..' (e.g. Slack files-pri slugs with a literal '...') with 'Access denied: path traversal detected'. Traversal checks only apply to local paths — external http(s) URLs are fetched with SSRF protection downstream and are never resolved against the filesystem, so they now short-circuit as valid. Internal /api/files/serve/ URLs keep full traversal protection. --- apps/sim/app/api/files/parse/route.test.ts | 33 ++++++++++++++++++++++ apps/sim/app/api/files/parse/route.ts | 15 +++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index b2ab510c9f..57444ea536 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -796,6 +796,39 @@ describe('Files Parse API - Path Traversal Security', () => { } }) + it('should not treat .. inside external URLs as path traversal', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('slack file content', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }) + ) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + + // Slack truncates long titles with a literal ellipsis, so the slug contains `..` + const slackUrl = + 'https://files.slack.com/files-pri/T08-F0B/_other__no_invitation_messages_get_sent_-_sim_on_railway...txt' + + const request = new NextRequest('http://localhost:3000/api/files/parse', { + method: 'POST', + body: JSON.stringify({ filePath: slackUrl, workspaceId: 'workspace-id' }), + }) + + const response = await POST(request) + const result = await response.json() + + expect(result.error).not.toMatch(/Access denied: path traversal detected/) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + slackUrl, + '203.0.113.10', + expect.any(Object) + ) + }) + it('should handle encoded path traversal attempts', async () => { const encodedMaliciousPaths = [ '/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd', // ../../../etc/passwd diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index ea4f493dd8..cd306b5e5b 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -419,13 +419,26 @@ function assertParsedContentWithinLimit(content: string, maxBytes?: number): str } /** - * Validate file path for security - prevents null byte injection and path traversal attacks + * Validate file path for security - prevents null byte injection and path traversal attacks. + * + * External URLs (`http`/`https`) are fetched over HTTP — with SSRF protection applied + * downstream in `fetchExternalUrlToWorkspace` (DNS resolution + private/reserved IP blocking) + * — and are never resolved against the filesystem, so `..`/`~` are legal URL content and must + * not be rejected. Providers such as Slack routinely emit slugs containing a literal `...`. + * + * Internal file URLs (`/api/files/serve/...`) ARE resolved to storage keys and filesystem + * paths via `extractStorageKey`, so they keep full traversal protection — only the leading-`/` + * "outside allowed directory" check is relaxed for them, since that prefix is expected. */ function validateFilePath(filePath: string): { isValid: boolean; error?: string } { if (filePath.includes('\0')) { return { isValid: false, error: 'Invalid path: null byte detected' } } + if (filePath.startsWith('http://') || filePath.startsWith('https://')) { + return { isValid: true } + } + if (filePath.includes('..')) { return { isValid: false, error: 'Access denied: path traversal detected' } } From e34ad516fc9415240c7adf372bdeb3f2b8d5fc9e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 30 May 2026 19:23:17 -0700 Subject: [PATCH 2/4] test(files): fix external-URL assertion to handle undefined error --- apps/sim/app/api/files/parse/route.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index 57444ea536..1c3b9745bb 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -821,7 +821,9 @@ describe('Files Parse API - Path Traversal Security', () => { const response = await POST(request) const result = await response.json() - expect(result.error).not.toMatch(/Access denied: path traversal detected/) + expect(result.error ?? '').not.toContain('path traversal detected') + // The URL reaching the pinned fetch proves it passed validation and routed + // to external-URL handling rather than being rejected as a local path. expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( slackUrl, '203.0.113.10', From a9a5503d764e5b670afbfaaec7b3b98227fc2fa2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 30 May 2026 19:25:21 -0700 Subject: [PATCH 3/4] test(files): assert success explicitly in external-URL traversal test --- apps/sim/app/api/files/parse/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index 1c3b9745bb..ba9c98ab8a 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -821,7 +821,7 @@ describe('Files Parse API - Path Traversal Security', () => { const response = await POST(request) const result = await response.json() - expect(result.error ?? '').not.toContain('path traversal detected') + expect(result.success).toBe(true) // The URL reaching the pinned fetch proves it passed validation and routed // to external-URL handling rather than being rejected as a local path. expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( From 499e8344c6c5a60787bd422fe6301e39feef4a73 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 30 May 2026 19:29:43 -0700 Subject: [PATCH 4/4] fix(files): keep traversal protection for https URLs matching internal serve paths --- apps/sim/app/api/files/parse/route.test.ts | 27 ++++++++++++++++++++++ apps/sim/app/api/files/parse/route.ts | 13 ++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index ba9c98ab8a..ccf9dd684a 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -831,6 +831,33 @@ describe('Files Parse API - Path Traversal Security', () => { ) }) + it('should still reject traversal in https URLs that look like internal serve URLs', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response('should never be fetched', { status: 200 }) + ) + + // Absolute https URL containing `/api/files/serve/` matches isInternalFileUrl and would + // route to handleCloudFile — so it must keep traversal protection, not be waved through + // as an external URL. + const request = new NextRequest('http://localhost:3000/api/files/parse', { + method: 'POST', + body: JSON.stringify({ + filePath: 'https://attacker.com/api/files/serve/../../../etc/passwd', + }), + }) + + const response = await POST(request) + const result = await response.json() + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Access denied: path traversal detected/) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + it('should handle encoded path traversal attempts', async () => { const encodedMaliciousPaths = [ '/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd', // ../../../etc/passwd diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index cd306b5e5b..b925a36603 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -427,15 +427,22 @@ function assertParsedContentWithinLimit(content: string, maxBytes?: number): str * not be rejected. Providers such as Slack routinely emit slugs containing a literal `...`. * * Internal file URLs (`/api/files/serve/...`) ARE resolved to storage keys and filesystem - * paths via `extractStorageKey`, so they keep full traversal protection — only the leading-`/` - * "outside allowed directory" check is relaxed for them, since that prefix is expected. + * paths via `extractStorageKey`, so they keep full traversal protection. The external + * short-circuit explicitly excludes them: `parseFileSingle` routes anything matching + * `isInternalFileUrl` to `handleCloudFile` (even an absolute `https://host/api/files/serve/...`), + * so such inputs must stay subject to the `..`/`~` checks rather than being waved through as + * external URLs. Only the leading-`/` "outside allowed directory" check is relaxed for them, + * since that prefix is expected. */ function validateFilePath(filePath: string): { isValid: boolean; error?: string } { if (filePath.includes('\0')) { return { isValid: false, error: 'Invalid path: null byte detected' } } - if (filePath.startsWith('http://') || filePath.startsWith('https://')) { + if ( + (filePath.startsWith('http://') || filePath.startsWith('https://')) && + !isInternalFileUrl(filePath) + ) { return { isValid: true } }