diff --git a/packages/http-routes/src/routes/web/cache.test.ts b/packages/http-routes/src/routes/web/cache.test.ts index eb936b0c..e61a1ee2 100644 --- a/packages/http-routes/src/routes/web/cache.test.ts +++ b/packages/http-routes/src/routes/web/cache.test.ts @@ -297,3 +297,50 @@ describe('registerCacheRoutes data directory routes', () => { await app.close() }) }) + +describe('registerCacheRoutes save-to-downloads', () => { + it('writes a plain file name inside the downloads directory', async () => { + const app = Fastify() + registerCacheRoutes(app, { pathProvider: createPathProvider() }) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/save-to-downloads', + payload: { + filename: 'result.csv', + dataUrl: `data:text/csv;charset=utf-8,${encodeURIComponent('a,b\n1,2')}`, + }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + success: true, + filePath: path.join(testSystemDir, 'downloads', 'result.csv'), + }) + + await app.close() + }) + + it('refuses a file name that would escape the downloads directory', async () => { + const app = Fastify() + registerCacheRoutes(app, { pathProvider: createPathProvider() }) + await app.ready() + + const escapedPath = path.join(testSystemDir, 'escaped.txt') + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/save-to-downloads', + payload: { + filename: '../escaped.txt', + dataUrl: `data:text/plain;charset=utf-8,${encodeURIComponent('attacker controlled')}`, + }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { success: false, error: 'Invalid filename' }) + assert.equal(fs.existsSync(escapedPath), false) + + await app.close() + }) +}) diff --git a/packages/http-routes/src/routes/web/cache.ts b/packages/http-routes/src/routes/web/cache.ts index b20ae609..e07665ad 100644 --- a/packages/http-routes/src/routes/web/cache.ts +++ b/packages/http-routes/src/routes/web/cache.ts @@ -267,7 +267,15 @@ export function registerCacheRoutes(server: FastifyInstance, ctx: CacheRouteCont fs.mkdirSync(downloadsDir, { recursive: true }) } - const filePath = path.join(downloadsDir, filename) + // Confine the write to the downloads directory: browser callers pick this + // name, and `path.join` collapses `..`, so anything but a plain file name + // would otherwise resolve outside `downloadsDir`. + const safeName = path.basename(filename) + if (safeName !== filename || safeName === '.' || safeName === '..') { + return { success: false, error: 'Invalid filename' } + } + + const filePath = path.join(downloadsDir, safeName) fs.writeFileSync(filePath, buffer) return { success: true, filePath }