/** * Unit Tests — 公司文件读写端点的路径越界防护 * * 修的是 `server/routes/company.js` 里六处(3 个 GET + 3 个 PUT,覆盖 * templates / company / workspace)**同一个**写法: * * const absPath = path.resolve(DATA_DIR, cid, filePath); * const safeBoundary = path.resolve(DATA_DIR, cid); * if (!absPath.startsWith(safeBoundary)) → 403 * * `startsWith` 在「一个目录名是另一个的前缀」时会放行: * cid = 'co-a',path = '../co-a-evil/company.json' * → abs = .../data/companies/co-a-evil/company.json * → abs.startsWith('.../data/companies/co-a') === true → 放行 * 于是公司 co-a 能读 co-a-evil 的文件;PUT 那三处是同一个缝隙上的跨公司**写**。 * * 改成 path.relative 判断(越界时结果以 `..` 开头)。这组用例锁四件事: * ① 正常相对路径读得到 * ② `../<前缀兄弟目录>` 被拦(这是原 bug 的精确形态) * ③ 绝对路径入参被拦 * ④ PUT 也拦(写比读更要紧,且原代码两处是复制粘贴的同一个缝) * * 走真 HTTP:把 router 挂到真 express 上 listen(0) 再 fetch(同 devloop/routes.test.js)。 * 不打桩 fs、不导出内部函数 —— 越界防护的正确性只在「真的读到了/没读到文件」这一侧成立。 * * ⚠️ 这个端点的根目录是**硬编码**的 `/data/companies`(无 env 覆盖口), * 所以用例只能在真实目录下建两个 `__pathguard-*` 前缀的临时公司目录, * afterAll 精确删掉自己建的那两个(不做通配清理,免得误删真公司)。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; import express from 'express'; const ROOT = path.resolve(__dirname, '../../..'); const COMPANIES_DIR = path.join(ROOT, 'data/companies'); // 前缀关系是本用例的核心:VICTIM 是 ATTACKER 的字符串前缀 const VICTIM = '__pathguard-co'; const SIBLING = '__pathguard-co-evil'; describe('公司文件端点:路径越界防护', () => { let base; let srv; beforeAll(async () => { for (const c of [VICTIM, SIBLING]) { fs.mkdirSync(path.join(COMPANIES_DIR, c), { recursive: true }); // 38 号文档 Phase 2:company.js 现在挂了 router.param('companyId', ...), // 靠 company.json 判断"这家公司存不存在"——这组用例本来只关心文件路径越界, // 不是公司归属,补一份最小 company.json 让请求能过归属校验那一关, // 不改变本文件真正要测的东西(下面的越界防护)。 fs.writeFileSync(path.join(COMPANIES_DIR, c, 'company.json'), JSON.stringify({ id: c, name: c, ownerId: -1 })); } fs.writeFileSync(path.join(COMPANIES_DIR, VICTIM, 'own.md'), '# 我自己的文件\n'); fs.writeFileSync(path.join(COMPANIES_DIR, SIBLING, 'secret.md'), 'SIBLING-SECRET\n'); const app = express(); app.use(express.json()); app.use('/api/company', require('../../routes/company')); await new Promise((resolve) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; resolve(); }); }); }); afterAll(async () => { if (srv) await new Promise((r) => srv.close(r)); // 只删自己建的那两个目录,逐个具名 —— 不用通配 for (const c of [VICTIM, SIBLING]) { fs.rmSync(path.join(COMPANIES_DIR, c), { recursive: true, force: true }); } }); const get = (cid, p) => fetch(`${base}/api/company/${cid}/files/content?path=${encodeURIComponent(p)}`); it('① 正常相对路径:读得到自己的文件', async () => { const r = await get(VICTIM, 'own.md'); expect(r.status).toBe(200); const d = await r.json(); expect(d.content).toContain('我自己的文件'); }); it('② 前缀兄弟目录(原 bug 的精确形态)→ 403,且内容一个字都不回', async () => { const r = await get(VICTIM, `../${SIBLING}/secret.md`); expect(r.status).toBe(403); const body = await r.text(); // 不只看状态码:确认没有把内容顺带回出去 expect(body).not.toContain('SIBLING-SECRET'); }); it('② 更远的越界(爬到仓库根读源码)→ 403', async () => { const r = await get(VICTIM, '../../../package.json'); expect(r.status).toBe(403); }); it('③ 绝对路径入参 → 403', async () => { const r = await get(VICTIM, '/etc/hosts'); expect(r.status).toBe(403); }); it('④ PUT 同样拦住跨公司写(原代码那三处是同一个缝的复制粘贴)', async () => { const target = path.join(COMPANIES_DIR, SIBLING, 'secret.md'); const before = fs.readFileSync(target, 'utf-8'); const r = await fetch(`${base}/api/company/${VICTIM}/files/content`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: `../${SIBLING}/secret.md`, content: 'PWNED' }), }); expect(r.status).toBe(403); // 真正要断言的是**文件没被改**,而不是只看响应码 expect(fs.readFileSync(target, 'utf-8')).toBe(before); }); it('④ PUT 正常路径仍然写得进去(别把功能一起拦死)', async () => { const r = await fetch(`${base}/api/company/${VICTIM}/files/content`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'nested/new.md', content: 'hello' }), }); expect(r.status).toBe(200); expect(fs.readFileSync(path.join(COMPANIES_DIR, VICTIM, 'nested/new.md'), 'utf-8')).toBe('hello'); }); it('workspace 端点走同一个 helper,同样拦住', async () => { const r = await fetch( `${base}/api/company/${VICTIM}/workspace/files/content?path=${encodeURIComponent(`../../${SIBLING}/secret.md`)}` ); expect(r.status).toBe(403); }); });