company-files-path-guard.test.js 5.82 KB
/**
 * 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、不导出内部函数 —— 越界防护的正确性只在「真的读到了/没读到文件」这一侧成立。
 *
 * ⚠️ 这个端点的根目录是**硬编码**的 `<repo>/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);
  });
});