Files
blog/editor-api/test/role-perm.mjs
T
zqlit 68416366eb fix(editor-api): 身份判定改为会话优先,防止配置漂移静默提权
identify() 原来「令牌优先」:令牌对得上就用 X-Editor-* 头的身份,没有头时默认 admin。
国内线路的 nginx 目前不注入令牌,但一旦配置回滚到「注入令牌」的旧版本,编辑的请求
会命中令牌分支 → 静默获得管理员权限(看到全部文章、能改别人的、能全量发布)。

改为「会话(真实的人)优先于共享令牌(服务身份)」,把边界写死在代码里而不是靠配置。
新增断言:编辑会话 + 管理员令牌头 → 仍按编辑身份。role-perm.mjs 47/47
2026-10-05 14:41:02 +08:00

474 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* editor-api「管理员 / 编辑」角色权限矩阵 —— 端到端测试。
*
* node test/role-perm.mjs
*
* 自己搭一个一次性 git 仓库(含 3 篇不同归属的文章)跑真实 HTTP 请求,
* 测完连临时目录一起删掉 —— 不碰真仓库、不碰线上。
*
* 覆盖的是这次改造的核心承诺:
* · 编辑只能看/改/删/传图到自己的文章(含老文章按昵称认领的兜底)
* · 编辑建文章时归属与署名由服务端钉死,前端伪造 author_id 无效
* · 编辑发布只提交自己的文章目录,不会把别人未提交的改动一起带走
* · 「国内线路」一键跳转不能把编辑变成管理员(签名覆盖身份)
* · 管理员能力与改造前一致
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile, spawn } from 'node:child_process';
const PORT = 8099;
const BASE = `http://127.0.0.1:${PORT}`;
const TOKEN = 'test-token-' + crypto.randomBytes(4).toString('hex');
const ADMIN_PASS = 'local-admin-pass';
const ADMIN = { uid: '1', name: '群林', role: 'admin' };
const EDITOR = { uid: '7', name: '小美', role: 'editor' };
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'editor-role-'));
const BLOG = path.join(TMP, 'blog');
const TRASH = path.join(TMP, 'trash');
const ORIGIN = path.join(TMP, 'origin.git');
let pass = 0;
const fails = [];
function check(name, cond, extra = '') {
if (cond) {
pass++;
console.log(' ✅ ' + name);
} else {
fails.push(name + (extra ? ' → ' + extra : ''));
console.log(' ❌ ' + name + (extra ? ' → ' + extra : ''));
}
}
function section(t) {
console.log('\n' + t);
}
// ------------------------------------------------------------------ 搭仓库
// 用异步 execFile,不用 execFileSync ——
// 本机(Windows + 沙箱)同步版 spawn 一律 EBUSY,异步版正常。
// editor-api 自己的 git.mjs 用的也是异步,所以线上没这个问题。
function sh(cwd, args) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => {
if (err) reject(Object.assign(err, { stdout, stderr }));
else resolve(stdout);
});
});
}
function writePost(dirName, fmLines, body) {
const dir = path.join(BLOG, 'content', 'posts', '2026', dirName);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.md'), ['---', ...fmLines, '---', '', body, ''].join('\n'), 'utf8');
return dir;
}
async function setupRepo() {
fs.mkdirSync(BLOG, { recursive: true });
await sh(TMP, ['init', '--bare', '-b', 'main', ORIGIN]);
await sh(BLOG, ['init', '-b', 'main']);
await sh(BLOG, ['config', 'user.name', 'tester']);
await sh(BLOG, ['config', 'user.email', 'tester@local']);
// ① 小美(id=7)的文章
writePost('2026-10-01-妈妈的菜-20261001093000', [
'title: 妈妈的菜', 'date: 2026-10-01', 'slug: 20261001093000',
'author: 小美', 'author_id: 7', 'layout: post', 'tags: []', 'categories: []', 'draft: false',
], '今天做了红烧肉。');
// ② 群林(id=1)的文章
writePost('2026-10-02-我的随笔-20261002120000', [
'title: 我的随笔', 'date: 2026-10-02', 'slug: 20261002120000',
'author: 群林', 'author_id: 1', 'layout: post', 'tags: []', 'categories: []', 'draft: false',
], '随便写点。');
// ③ 老文章:没有 author_id,只能按昵称认领(改造前 write-server 写的那些)
writePost('2026-10-03-老文章-20261003150000', [
'title: 老文章', 'date: 2026-10-03', 'slug: 20261003150000',
'author: 小美', 'layout: post', 'tags: []', 'categories: []', 'draft: false',
], '很久以前写的。');
await sh(BLOG, ['add', '-A']);
await sh(BLOG, ['commit', '-m', 'init']);
await sh(BLOG, ['remote', 'add', 'origin', ORIGIN]);
await sh(BLOG, ['push', '-u', 'origin', 'main']);
}
const DIR_MINE = '2026-10-01-妈妈的菜-20261001093000';
const DIR_ADMIN = '2026-10-02-我的随笔-20261002120000';
const DIR_LEGACY = '2026-10-03-老文章-20261003150000';
// ------------------------------------------------------------------ 起服务
const child = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], {
env: {
...process.env,
PORT: String(PORT),
BIND_HOST: '127.0.0.1',
BLOG_ROOT: BLOG,
TRASH_DIR: TRASH,
SESSION_FILE: path.join(TMP, 'sessions.json'),
EDITOR_TOKEN: TOKEN,
ADMIN_USER: 'admin',
ADMIN_PASS,
GIT_BRANCH: 'main',
PUSH_REMOTES: 'origin',
GIT_PATHS: 'content,static',
// 故意指向一个连不上的地址:验证「CF 不可达时回退本机管理员」这条应急通道
RSS_API_BASE: 'http://127.0.0.1:9',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLog = '';
child.stdout.on('data', (d) => (serverLog += d));
child.stderr.on('data', (d) => (serverLog += d));
async function waitUp() {
for (let i = 0; i < 50; i++) {
try {
const r = await fetch(BASE + '/health');
if (r.ok) return true;
} catch {}
await new Promise((s) => setTimeout(s, 120));
}
return false;
}
async function call(method, p, { body, ident, headers = {}, cookie, raw } = {}) {
const h = { ...headers };
if (ident) {
h['X-Editor-Token'] = TOKEN;
h['X-Editor-Uid'] = ident.uid;
h['X-Editor-User'] = encodeURIComponent(ident.name);
h['X-Editor-Role'] = ident.role;
}
if (cookie) h.Cookie = cookie;
if (body !== undefined && !raw) h['Content-Type'] = 'application/json';
const res = await fetch(BASE + p, {
method,
headers: h,
body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined,
redirect: 'manual',
});
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
return { status: res.status, data, cookie: res.headers.get('set-cookie') };
}
const P = (id) => '/posts/' + encodeURIComponent(id);
const readFile = (dirName) =>
fs.readFileSync(path.join(BLOG, 'content', 'posts', '2026', dirName, 'index.md'), 'utf8');
/**
* ⑧ 直连登录的**主路径**:国内机把账号密码转发给 Cloudflare 的用户表校验。
* 用一个假的 CF(只实现 /api/v2/user/access_token)来测 ——
* 不依赖真线上账号,也能覆盖「编辑能不能从国内线路登录」这条。
*/
async function sectionCfLogin() {
section('⑧ 直连登录走 Cloudflare 用户表(主路径)');
const CF_PORT = 8098;
const PORT2 = 8097;
const http = await import('node:http');
const stub = http.createServer((req, res) => {
let buf = '';
req.on('data', (c) => (buf += c));
req.on('end', () => {
if (!req.url.startsWith('/api/v2/user/access_token')) {
res.writeHead(404).end('{}');
return;
}
const body = JSON.parse(buf || '{}');
const okPair =
(body.email === 'xiaomei' && body.password === 'good-pass') ||
(body.email === 'someone' && body.password === 'plain-pass') ||
(body.email === 'admin' && body.password === 'admin-pass');
if (!okPair) {
res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"msg":"Unauthorized"}');
return;
}
const users = {
xiaomei: { id: 7, name: '小美', role: 'editor', is_admin: false },
admin: { id: 1, name: '群林', role: 'admin', is_admin: true },
// 普通评论用户:有账号,但没写作后台权限
someone: { id: 42, name: '路人', role: 'user', is_admin: false },
};
res.writeHead(200, { 'Content-Type': 'application/json' }).end(
JSON.stringify({ token: 'tok', user: users[body.email] }),
);
});
});
await new Promise((s) => stub.listen(CF_PORT, '127.0.0.1', s));
const srv2 = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], {
env: {
...process.env,
PORT: String(PORT2),
BIND_HOST: '127.0.0.1',
BLOG_ROOT: BLOG,
TRASH_DIR: TRASH,
SESSION_FILE: path.join(TMP, 'sessions2.json'),
EDITOR_TOKEN: TOKEN,
ADMIN_USER: 'admin',
ADMIN_PASS,
GIT_BRANCH: 'main',
PUSH_REMOTES: 'origin',
RSS_API_BASE: `http://127.0.0.1:${CF_PORT}`,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log2 = '';
srv2.stdout.on('data', (d) => (log2 += d));
srv2.stderr.on('data', (d) => (log2 += d));
const B2 = `http://127.0.0.1:${PORT2}`;
for (let i = 0; i < 50; i++) {
try {
if ((await fetch(B2 + '/health')).ok) break;
} catch {}
await new Promise((s) => setTimeout(s, 120));
}
const call2 = async (p, body) => {
const res = await fetch(B2 + p, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
redirect: 'manual',
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
return { status: res.status, data, cookie: res.headers.get('set-cookie') };
};
const post = async (p, body, cookie) => {
const res = await fetch(B2 + p, {
method: 'GET',
headers: cookie ? { Cookie: cookie } : {},
});
return { status: res.status, data: await res.json().catch(() => null) };
};
let r = await call2('/admin/login', { user: 'xiaomei', password: 'good-pass' });
check('编辑账号从国内线路登录 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140));
check('会话角色是 editor(不是 admin)', r.data.user && r.data.user.role === 'editor' && r.data.user.name === '小美', JSON.stringify(r.data.user));
const ck = String(r.cookie).split(';')[0];
r = await post('/posts', null, ck);
check('编辑登录后只看到自己的文章', r.data && r.data.total === 3, 'total=' + (r.data && r.data.total));
r = await call2('/admin/login', { user: 'someone', password: 'plain-pass' });
check('普通评论用户 → 401(没有写作后台权限)', r.status === 401, 'got ' + r.status);
r = await call2('/admin/login', { user: 'xiaomei', password: 'bad-pass' });
check('密码错(CF 可达)→ 401', r.status === 401, 'got ' + r.status);
r = await call2('/admin/login', { user: 'admin', password: 'admin-pass' });
check('管理员账号 → 200 且角色 admin', r.status === 200 && r.data.user.role === 'admin', JSON.stringify(r.data.user));
srv2.kill();
stub.close();
}
// ------------------------------------------------------------------ 跑
async function main() {
await setupRepo();
if (!(await waitUp())) {
console.error('服务没起来:\n' + serverLog);
process.exit(1);
}
section('① 身份门禁');
let r = await call('GET', '/posts');
check('不带凭据 → 401', r.status === 401, 'got ' + r.status);
r = await call('GET', '/posts', { headers: { 'X-Editor-Token': TOKEN } });
check('只带令牌、无身份头 → 视为管理员(兼容 curl / 旧 Worker)', r.status === 200 && r.data.total === 3, JSON.stringify(r.data).slice(0, 120));
r = await call('GET', '/posts', { headers: { 'X-Editor-Token': 'wrong', 'X-Editor-Uid': '7', 'X-Editor-User': '%E5%B0%8F%E7%BE%8E', 'X-Editor-Role': 'editor' } });
check('令牌不对 → 401(伪造身份头没用)', r.status === 401, 'got ' + r.status);
r = await call('GET', '/posts', { ident: EDITOR });
check('编辑看到 2 篇(自己 + 老的昵称认领),看不到群林那篇', r.status === 200 && r.data.total === 2 &&
!r.data.posts.some((p) => p.id === DIR_ADMIN), JSON.stringify(r.data.posts?.map((p) => p.id)));
r = await call('GET', '/posts', { ident: ADMIN });
check('管理员看到全部 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total);
section('② 编辑不能碰别人的文章');
r = await call('GET', P(DIR_ADMIN), { ident: EDITOR });
check('打开别人的文章 → 403', r.status === 403, 'got ' + r.status);
r = await call('PUT', P(DIR_ADMIN), { ident: EDITOR, body: { content: '我改' } });
check('改别人的文章 → 403', r.status === 403, 'got ' + r.status);
check('别人的文章内容没被动', readFile(DIR_ADMIN).includes('随便写点。'));
r = await call('DELETE', P(DIR_ADMIN), { ident: EDITOR });
check('删别人的文章 → 403', r.status === 403, 'got ' + r.status);
check('别人的文章还在原处', fs.existsSync(path.join(BLOG, 'content/posts/2026', DIR_ADMIN, 'index.md')));
r = await call('POST', '/upload?key=' + encodeURIComponent(DIR_ADMIN), {
ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'),
headers: { 'Content-Type': 'image/png' },
});
check('往别人文章目录传图 → 403', r.status === 403, 'got ' + r.status);
r = await call('POST', '/upload', {
ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'),
headers: { 'Content-Type': 'image/png' },
});
check('不带 key 传图(公共图库)→ 403', r.status === 403, 'got ' + r.status);
section('③ 编辑自己的文章:正常能力 + 老文章认领');
r = await call('PUT', P(DIR_LEGACY), { ident: EDITOR, body: { content: '很久以前写的。改一个字。' } });
check('改自己(老文章按昵称认领)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140));
const legacyText = readFile(DIR_LEGACY);
check('老文章保存后补上了 author_id: 7(正式划到自己名下)', /author_id:\s*7\b/.test(legacyText), legacyText.slice(0, 200));
check('老文章正文改到了', legacyText.includes('改一个字'));
r = await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '今天做了红烧肉,很好吃。' } });
check('改自己(有 author_id)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140));
r = await call('POST', '/upload?name=a.png&key=' + encodeURIComponent(DIR_MINE), {
ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'),
headers: { 'Content-Type': 'image/png' },
});
check('往自己文章目录传图 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140));
check('图片确实落在自己文章目录里', r.data.dir === 'content/posts/2026/' + DIR_MINE, String(r.data.dir));
section('④ 新建文章的归属由服务端钉死');
r = await call('POST', '/posts', {
ident: EDITOR,
body: {
frontMatter: { title: '伪造归属测试', slug: 'fakeowner' + Date.now(), author: '群林', author_id: 1 },
content: '正文',
},
});
check('新建 → 200', r.status === 200 && r.data.id, JSON.stringify(r.data).slice(0, 140));
const created = readFile(r.data.id);
check('author_id 被强制成 7(前端传的 1 无效)', /author_id:\s*7\b/.test(created), created.slice(0, 240));
check('author 被强制成「小美」(不能替别人署名)', /author:\s*小美/.test(created), created.slice(0, 240));
section('⑤ 发布范围:编辑只提交自己的目录');
// 两边各改一处:编辑改自己的,管理员改自己的 —— 编辑发布时不能顺走管理员那笔
await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '准备发布' } });
await call('PUT', P(DIR_ADMIN), { ident: ADMIN, body: { content: '管理员自己的未提交改动' } });
r = await call('GET', '/git/status', { ident: EDITOR });
check('编辑看到的状态是「限定范围」的', r.status === 200 && r.data.scoped === true, JSON.stringify(r.data).slice(0, 160));
check('编辑只看到自己目录的改动', (r.data.files || []).every((f) => f.includes(DIR_MINE) || f.includes(DIR_LEGACY) || f.includes('fakeowner')),
JSON.stringify(r.data.files));
r = await call('GET', '/git/status', { ident: ADMIN });
check('管理员看到全量改动(含编辑的和自己的)', r.data.scoped === false && (r.data.files || []).some((f) => f.includes(DIR_MINE)),
JSON.stringify(r.data.files));
r = await call('POST', '/git/publish', { ident: EDITOR, body: { message: '编辑:发布我的' } });
check('编辑发布 → 成功', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 200));
check('返回里标明是限定范围发布', r.data.scoped === true, String(r.data.scoped));
// core.quotepath=false:中文目录名默认会被 git 转成 \346\210\221 八进制转义,
// 拿它跟原始目录名比字符串必然对不上(编辑器里已经踩过一次同样的坑)
const committed = await sh(BLOG, ['-c', 'core.quotepath=false', 'show', '--stat', '--oneline', 'HEAD']);
check('提交里没有管理员的文章', !committed.includes(DIR_ADMIN), committed.slice(0, 300));
check('提交里有编辑自己的文章', committed.includes(DIR_MINE), committed.slice(0, 300));
const stillDirty = await sh(BLOG, ['-c', 'core.quotepath=false', 'status', '--porcelain']);
check('管理员那篇仍是未提交状态(没被顺走)', stillDirty.includes(DIR_ADMIN), stillDirty);
section('⑥ 国内线路一键跳转不能越权');
const ts = String(Date.now());
const sigFor = (uid, name, role) =>
crypto.createHmac('sha256', TOKEN).update(`${ts}\n${uid}\n${name}\n${role}`).digest('hex');
r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=deadbeef`);
check('签名错 → 403', r.status === 403, 'got ' + r.status);
r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=${sigFor('7', '小美', 'editor')}`);
check('编辑的正确签名 → 302 + 发 Cookie', r.status === 302 && !!r.cookie, 'got ' + r.status);
// 拿这枚 Cookie 冒充管理员操作
const edCookie = String(r.cookie).split(';')[0];
r = await call('GET', P(DIR_ADMIN), { cookie: edCookie });
check('用这枚 Cookie 打开管理员那篇 → 仍 403(跳转没有把编辑变成管理员)', r.status === 403, 'got ' + r.status);
r = await call('GET', '/posts', { cookie: edCookie });
// 到这一步编辑名下有 3 篇了:自己的、按昵称认领的老文章、以及第 ④ 节新建的那篇
check('用这枚 Cookie 列文章 → 只看到自己的 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total);
// ★ 会话优先于共享令牌。国内线路的 nginx 目前不注入令牌,但万一配置漂移回
// 「注入令牌」的旧版本,请求会同时带着管理员身份的令牌头 + 编辑的浏览器会话。
// 此时必须以「人」为准 —— 否则编辑会静默拿到管理员权限。
r = await call('GET', '/posts', { cookie: edCookie, ident: ADMIN });
check('编辑会话 + 管理员令牌头 → 仍按编辑身份(只看自己 3 篇)',
r.status === 200 && r.data.total === 3, 'total=' + r.data.total);
// 把签名载荷里的 r 改成 admin(签名仍是按 editor 算的)→ 必须拒
r = await call('GET', `/admin/handoff?ts=${String(Date.now())}&u=7&n=%E5%B0%8F%E7%BE%8E&r=admin&t=${sigFor('7', '小美', 'editor')}`);
check('把角色改成 admin 但签名不匹配 → 403', r.status === 403, 'got ' + r.status);
section('⑦ 直连登录(CF 不可达时的应急通道)');
// 本测试故意把 RSS_API_BASE 指到连不上的地址,模拟「国内机连不上 Cloudflare」。
// 此时账号密码**无法校验**,如实回 503 比谎报 401「密码错」有用得多
// (线上 CF 正常时,密码错会由 CF 回 401 → 这里回 401,见下一条)。
r = await call('POST', '/admin/login', { body: { user: 'admin', password: 'wrong' } });
check('CF 不可达 + 不是本机管理员 → 503(如实说「校验不了」)', r.status === 503, 'got ' + r.status);
r = await call('POST', '/admin/login', { body: { user: 'admin', password: ADMIN_PASS } });
check('本机管理员账号 → 200(CF 连不上也能进)', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140));
const admCookie = String(r.cookie).split(';')[0];
check('会话带上角色 admin', r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data.user));
r = await call('GET', '/posts', { cookie: admCookie });
check('这枚 Cookie 能看到全部 3+1 篇', r.status === 200 && r.data.total >= 4, 'total=' + r.data.total);
r = await call('GET', '/admin/session', { cookie: admCookie });
check('会话探测返回身份(含 role)', r.status === 200 && r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data));
r = await call('GET', '/admin/session');
check('没会话时 → 401 且 mode=direct', r.status === 401 && r.data.mode === 'direct', JSON.stringify(r.data));
await sectionCfLogin();
console.log('\n' + '─'.repeat(60));
console.log(`通过 ${pass} / ${pass + fails.length}`);
if (fails.length) {
console.log('\n失败项:');
for (const f of fails) console.log(' · ' + f);
}
return fails.length ? 1 : 0;
}
let code = 1;
try {
code = await main();
} catch (e) {
console.error('测试异常崩溃:', e);
} finally {
child.kill();
await new Promise((s) => setTimeout(s, 250));
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 3 });
} catch (e) {
console.error('(临时目录没删干净,可手动删:' + TMP + ')');
}
process.exit(code);
}