diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 03de97f9..1c764985 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -503,7 +503,10 @@ export function createTerminalRuntime({ } try { - await fs.promises.access(cwd); + const stats = await fs.promises.stat(cwd); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Invalid working directory' }); + } } catch { return res.status(400).json({ error: 'Invalid working directory' }); } diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 326e1b4b..e3b771c7 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -5,8 +5,23 @@ import { describe, expect, it } from 'vitest'; import { createTerminalRuntime } from './runtime.js'; -function createRuntime(server) { - const app = { +function createResponse() { + return { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + }; +} + +function createRuntime(server, overrides = {}) { + const app = overrides.app ?? { post() {}, get() {}, delete() {}, @@ -27,10 +42,47 @@ function createRuntime(server) { TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 30_000, TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1_000, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: 3, + ...overrides, }); } describe('terminal runtime', () => { + it('rejects regular files as terminal working directories', async () => { + const postRoutes = new Map(); + const app = { + post(route, ...handlers) { + postRoutes.set(route, handlers.at(-1)); + }, + get() {}, + delete() {}, + }; + const server = new EventEmitter(); + const runtime = createRuntime(server, { + app, + fs: { + promises: { + stat: async () => ({ isDirectory: () => false }), + }, + }, + uiAuthController: { enabled: false }, + buildAugmentedPath: () => '', + TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 1000, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1000, + }); + + try { + const createRoute = postRoutes.get('/api/terminal/create'); + const res = createResponse(); + + await createRoute({ body: { cwd: '/tmp/not-a-directory' } }, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'Invalid working directory' }); + } finally { + await runtime.shutdown(); + } + }); + it('removes its websocket upgrade listener on shutdown', async () => { const server = new EventEmitter(); const runtime = createRuntime(server);