company-files-path-guard.test.js
5.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
* 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 });
}
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);
});
});