Skip to content

Commit 6c476cf

Browse files
authored
fix(files): don't reject external URLs containing '..' in file parse validation (#4821)
* fix(files): don't reject external URLs containing '..' in file parse validation 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. * test(files): fix external-URL assertion to handle undefined error * test(files): assert success explicitly in external-URL traversal test * fix(files): keep traversal protection for https URLs matching internal serve paths
1 parent 97f7fe9 commit 6c476cf

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

apps/sim/app/api/files/parse/route.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,68 @@ describe('Files Parse API - Path Traversal Security', () => {
796796
}
797797
})
798798

799+
it('should not treat .. inside external URLs as path traversal', async () => {
800+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
801+
isValid: true,
802+
resolvedIP: '203.0.113.10',
803+
})
804+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
805+
new Response('slack file content', {
806+
status: 200,
807+
headers: { 'content-type': 'text/plain' },
808+
})
809+
)
810+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
811+
812+
// Slack truncates long titles with a literal ellipsis, so the slug contains `..`
813+
const slackUrl =
814+
'https://files.slack.com/files-pri/T08-F0B/_other__no_invitation_messages_get_sent_-_sim_on_railway...txt'
815+
816+
const request = new NextRequest('http://localhost:3000/api/files/parse', {
817+
method: 'POST',
818+
body: JSON.stringify({ filePath: slackUrl, workspaceId: 'workspace-id' }),
819+
})
820+
821+
const response = await POST(request)
822+
const result = await response.json()
823+
824+
expect(result.success).toBe(true)
825+
// The URL reaching the pinned fetch proves it passed validation and routed
826+
// to external-URL handling rather than being rejected as a local path.
827+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
828+
slackUrl,
829+
'203.0.113.10',
830+
expect.any(Object)
831+
)
832+
})
833+
834+
it('should still reject traversal in https URLs that look like internal serve URLs', async () => {
835+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
836+
isValid: true,
837+
resolvedIP: '203.0.113.10',
838+
})
839+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
840+
new Response('should never be fetched', { status: 200 })
841+
)
842+
843+
// Absolute https URL containing `/api/files/serve/` matches isInternalFileUrl and would
844+
// route to handleCloudFile — so it must keep traversal protection, not be waved through
845+
// as an external URL.
846+
const request = new NextRequest('http://localhost:3000/api/files/parse', {
847+
method: 'POST',
848+
body: JSON.stringify({
849+
filePath: 'https://attacker.com/api/files/serve/../../../etc/passwd',
850+
}),
851+
})
852+
853+
const response = await POST(request)
854+
const result = await response.json()
855+
856+
expect(result.success).toBe(false)
857+
expect(result.error).toMatch(/Access denied: path traversal detected/)
858+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
859+
})
860+
799861
it('should handle encoded path traversal attempts', async () => {
800862
const encodedMaliciousPaths = [
801863
'/api/files/serve/%2e%2e%2f%2e%2e%2fetc%2fpasswd', // ../../../etc/passwd

apps/sim/app/api/files/parse/route.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,13 +419,33 @@ function assertParsedContentWithinLimit(content: string, maxBytes?: number): str
419419
}
420420

421421
/**
422-
* Validate file path for security - prevents null byte injection and path traversal attacks
422+
* Validate file path for security - prevents null byte injection and path traversal attacks.
423+
*
424+
* External URLs (`http`/`https`) are fetched over HTTP — with SSRF protection applied
425+
* downstream in `fetchExternalUrlToWorkspace` (DNS resolution + private/reserved IP blocking)
426+
* — and are never resolved against the filesystem, so `..`/`~` are legal URL content and must
427+
* not be rejected. Providers such as Slack routinely emit slugs containing a literal `...`.
428+
*
429+
* Internal file URLs (`/api/files/serve/...`) ARE resolved to storage keys and filesystem
430+
* paths via `extractStorageKey`, so they keep full traversal protection. The external
431+
* short-circuit explicitly excludes them: `parseFileSingle` routes anything matching
432+
* `isInternalFileUrl` to `handleCloudFile` (even an absolute `https://host/api/files/serve/...`),
433+
* so such inputs must stay subject to the `..`/`~` checks rather than being waved through as
434+
* external URLs. Only the leading-`/` "outside allowed directory" check is relaxed for them,
435+
* since that prefix is expected.
423436
*/
424437
function validateFilePath(filePath: string): { isValid: boolean; error?: string } {
425438
if (filePath.includes('\0')) {
426439
return { isValid: false, error: 'Invalid path: null byte detected' }
427440
}
428441

442+
if (
443+
(filePath.startsWith('http://') || filePath.startsWith('https://')) &&
444+
!isInternalFileUrl(filePath)
445+
) {
446+
return { isValid: true }
447+
}
448+
429449
if (filePath.includes('..')) {
430450
return { isValid: false, error: 'Access denied: path traversal detected' }
431451
}

0 commit comments

Comments
 (0)