diff --git a/server/routes/download-tasks.integration.test.ts b/server/routes/download-tasks.integration.test.ts index 32ef5af0..cc376845 100644 --- a/server/routes/download-tasks.integration.test.ts +++ b/server/routes/download-tasks.integration.test.ts @@ -423,6 +423,30 @@ describe('Download tasks API integration', () => { }) }) + it('normalizes target folder paths when creating download tasks', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + await registerDownloaderThroughDeviceLogin(app, 'target-folder-downloader') + const user = await authedHeaders(app, 'target-folder-user@example.com') + + const createTaskRes = await app.request('/api/download-tasks', { + method: 'POST', + headers: { ...user, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: { type: 'http', uri: 'https://example.com/fixture.txt' }, + targetFolder: '/media//Movies/', + }), + }) + + expect(createTaskRes.status).toBe(201) + await expect(createTaskRes.json()).resolves.toMatchObject({ targetFolder: 'media/Movies' }) + + const rows = await db.all<{ target_folder: string }>( + sql`SELECT target_folder FROM download_tasks ORDER BY created_at DESC LIMIT 1`, + ) + expect(rows[0].target_folder).toBe('media/Movies') + }) + it('returns storage failure details when multipart upload completion fails', async () => { vi.mocked(S3Service.prototype.completeMultipartUpload).mockRejectedValueOnce(new Error('InvalidPart: part missing')) const { app, db } = await createTestApp() diff --git a/shared/schemas/downloads.ts b/shared/schemas/downloads.ts index 2dc3f363..48741fcb 100644 --- a/shared/schemas/downloads.ts +++ b/shared/schemas/downloads.ts @@ -92,13 +92,25 @@ export const createDownloaderSchema = z.object({ const downloadUriSchema = z.string().min(1).max(4096) const downloadTaskCategorySchema = z.string().trim().min(1).max(120) const downloadTaskTagsSchema = z.array(z.string().trim().min(1).max(80)).max(20) +const targetFolderSchema = z + .string() + .max(1024) + .transform((value) => + value + .replace(/\\/g, '/') + .split('/') + .map((part) => part.trim()) + .filter((part) => part.length > 0 && part !== '.') + .join('/'), + ) + .refine((value) => !value.split('/').includes('..'), { message: 'Target folder cannot contain ..' }) export const createDownloadTaskSchema = z.object({ source: z.object({ type: downloadSourceTypeSchema, uri: downloadUriSchema, }), - targetFolder: z.string(), + targetFolder: targetFolderSchema, name: z.string().min(1).max(255).optional(), category: downloadTaskCategorySchema.optional(), tags: downloadTaskTagsSchema.optional(), diff --git a/shared/schemas/schemas.test.ts b/shared/schemas/schemas.test.ts index 878e2b2d..3d5e5ace 100644 --- a/shared/schemas/schemas.test.ts +++ b/shared/schemas/schemas.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { copyMatterSchema, + createDownloadTaskSchema, createMatterSchema, createStorageSchema, signInSchema, @@ -124,3 +125,26 @@ describe('copyMatterSchema', () => { expect(result.success).toBe(true) }) }) + +describe('createDownloadTaskSchema', () => { + it('normalizes target folder paths', () => { + const result = createDownloadTaskSchema.safeParse({ + source: { type: 'http', uri: 'https://example.com/file.zip' }, + targetFolder: '/media//Movies\\2026/', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.targetFolder).toBe('media/Movies/2026') + } + }) + + it('rejects parent directory target folder segments', () => { + const result = createDownloadTaskSchema.safeParse({ + source: { type: 'http', uri: 'https://example.com/file.zip' }, + targetFolder: 'media/../private', + }) + + expect(result.success).toBe(false) + }) +})