|
9 | 9 | import { after, before, describe, it, mock } from 'node:test'; |
10 | 10 | import assert from 'node:assert/strict'; |
11 | 11 | import http from 'node:http'; |
12 | | -import { mkdtempSync, rmSync } from 'node:fs'; |
13 | | -import { tmpdir } from 'node:os'; |
14 | | -import { join } from 'node:path'; |
| 12 | +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; |
| 13 | +import { tmpdir, homedir } from 'node:os'; |
| 14 | +import { join, parse } from 'node:path'; |
15 | 15 |
|
16 | 16 | const CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-system-routes-home-')); |
17 | 17 | const PROJECT_ROOT = mkdtempSync(join(tmpdir(), 'ccw-system-routes-project-')); |
| 18 | +const OUTSIDE_ROOT = mkdtempSync(join(tmpdir(), 'ccw-system-routes-outside-')); |
18 | 19 |
|
19 | 20 | const systemRoutesUrl = new URL('../../dist/core/routes/system-routes.js', import.meta.url); |
20 | 21 | systemRoutesUrl.searchParams.set('t', String(Date.now())); |
@@ -140,6 +141,7 @@ describe('system routes integration', async () => { |
140 | 141 | process.env.CCW_DATA_DIR = originalEnv.CCW_DATA_DIR; |
141 | 142 | rmSync(CCW_HOME, { recursive: true, force: true }); |
142 | 143 | rmSync(PROJECT_ROOT, { recursive: true, force: true }); |
| 144 | + rmSync(OUTSIDE_ROOT, { recursive: true, force: true }); |
143 | 145 | }); |
144 | 146 |
|
145 | 147 | it('GET /api/health returns ok payload', async () => { |
@@ -237,5 +239,60 @@ describe('system routes integration', async () => { |
237 | 239 | await new Promise<void>((resolve) => originalClose(() => resolve())); |
238 | 240 | } |
239 | 241 | }); |
240 | | -}); |
241 | 242 |
|
| 243 | + it('GET /api/file reads JSON within initialPath and rejects outside paths', async () => { |
| 244 | + const insideFile = join(PROJECT_ROOT, '.review', 'fixes', 'active-fix-session.json'); |
| 245 | + mkdirSync(join(PROJECT_ROOT, '.review', 'fixes'), { recursive: true }); |
| 246 | + writeFileSync(insideFile, JSON.stringify({ ok: true }), 'utf8'); |
| 247 | + |
| 248 | + const outsideFile = join(OUTSIDE_ROOT, 'outside.json'); |
| 249 | + writeFileSync(outsideFile, JSON.stringify({ ok: true }), 'utf8'); |
| 250 | + |
| 251 | + const { server, baseUrl } = await createServer(PROJECT_ROOT); |
| 252 | + try { |
| 253 | + const ok = await requestJson(baseUrl, 'GET', `/api/file?path=${encodeURIComponent(insideFile)}`); |
| 254 | + assert.equal(ok.status, 200); |
| 255 | + assert.equal(ok.json.ok, true); |
| 256 | + |
| 257 | + const denied = await requestJson(baseUrl, 'GET', `/api/file?path=${encodeURIComponent(outsideFile)}`); |
| 258 | + assert.equal(denied.status, 403); |
| 259 | + assert.equal(denied.json.error, 'Access denied'); |
| 260 | + } finally { |
| 261 | + await new Promise<void>((resolve) => server.close(() => resolve())); |
| 262 | + } |
| 263 | + }); |
| 264 | + |
| 265 | + it('POST /api/dialog/browse rejects paths outside allowed roots', async () => { |
| 266 | + const { server, baseUrl } = await createServer(PROJECT_ROOT); |
| 267 | + try { |
| 268 | + const rootPath = parse(homedir()).root; |
| 269 | + const denied = await requestJson(baseUrl, 'POST', '/api/dialog/browse', { path: rootPath, showHidden: true }); |
| 270 | + assert.equal(denied.status, 403); |
| 271 | + assert.equal(denied.json.error, 'Access denied'); |
| 272 | + } finally { |
| 273 | + await new Promise<void>((resolve) => server.close(() => resolve())); |
| 274 | + } |
| 275 | + }); |
| 276 | + |
| 277 | + it('POST /api/dialog/open-file accepts files under initialPath and rejects outside paths', async () => { |
| 278 | + const allowedFile = join(PROJECT_ROOT, 'allowed.txt'); |
| 279 | + writeFileSync(allowedFile, 'ok', 'utf8'); |
| 280 | + |
| 281 | + const rootPath = parse(homedir()).root; |
| 282 | + const deniedPath = join(rootPath, 'ccw-not-allowed.txt'); |
| 283 | + |
| 284 | + const { server, baseUrl } = await createServer(PROJECT_ROOT); |
| 285 | + try { |
| 286 | + const ok = await requestJson(baseUrl, 'POST', '/api/dialog/open-file', { path: allowedFile }); |
| 287 | + assert.equal(ok.status, 200); |
| 288 | + assert.equal(ok.json.success, true); |
| 289 | + assert.equal(ok.json.isFile, true); |
| 290 | + |
| 291 | + const denied = await requestJson(baseUrl, 'POST', '/api/dialog/open-file', { path: deniedPath }); |
| 292 | + assert.equal(denied.status, 403); |
| 293 | + assert.equal(denied.json.error, 'Access denied'); |
| 294 | + } finally { |
| 295 | + await new Promise<void>((resolve) => server.close(() => resolve())); |
| 296 | + } |
| 297 | + }); |
| 298 | +}); |
0 commit comments