// Web 终端后端 —— 安全敏感模块:终端 = RCE 级攻击面。 // 安全措施: // 1. 专用终端密码二次确认:PBKDF2-SHA256 600k(与密码箱 routes/passwords.js 同参数,异步版不阻塞事件循环); // 首次调用(未设置密码)即完成设置(needs_setup 流程),强度要求 8 位以上 // 2. 短 TTL token(5 分钟):jwt 带 terminal:true 声明,普通登录 token 不可直接连终端; // 认证成功后种 HttpOnly SameSite=Strict 短 TTL cookie(terminal_token),WS upgrade 优先取 cookie, // ?token= query 仅为旧前端回退(不推荐,会落 access log);带失败限流 + 请求级限流防 PBKDF2 DoS // 3. WS handshake Origin 校验(须与站点域名一致;仅 NODE_ENV=development 时放行 localhost)——纵深防御,非认证边界 // 4. 并发会话上限(MAX_SESSIONS=3):handleUpgrade 内同步预占槽位,杜绝升级回调前的竞态突破 // 5. pty.spawn 使用参数数组传参(防命令注入);kill 时连进程组一起终止(forkpty 子进程为 session leader, // 负 PID 即杀整组),杜绝 & 后台任务变孤儿进程 // 6. 审计:会话开始/结束/密码失败等写审计日志(console.log 带时间戳 + audit_logs 表); // 拒绝类事件(Origin/token/超限)降频落审计防日志洪泛;audit_logs 按 90 天保留策略惰性清理 // 7. 空闲超时(15 分钟无输入)自动断开 // 8. ws close/error → pty.kill() + 进程组清理 + 监听器/计时器全量销毁 // 流控:pty 输出缓冲超限时 pause(仅在 onData 暂停),恢复检查放在 ws 消息处理 + 兜底轮询 // // 部署注意: // - node-pty 是原生模块,需要服务器编译环境(build-essential / python3), // 安装失败时先 rm -rf node_modules 再 npm install(项目已有此重装先例)。 // - sudoers 配置(把 rainweb 换成实际运行用户): // ⚠️ 实测 NOPASSWD: /usr/bin/sudo -i 写法匹配不到(sudo 匹配规则时 -i 会被规范化 // 为实际执行的 login shell 命令,精确命令规则失效),必须放宽为: // echo "rainweb ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/rainweb-terminal // sudo chmod 440 /etc/sudoers.d/rainweb-terminal && sudo visudo -c // 验证:以运行用户执行 `sudo -n -i id` 应直接输出 root 身份且不询问密码。 // 注意:NOPASSWD: ALL 等同把运行用户提权为免密 root,务必确认运行用户无交互登录面。 // - 后端若置于反向代理之后并希望审计 IP 取真实来源 IP,需设置环境变量 TRUST_PROXY=1 // (否则一律用 socket 直连地址,避免伪造 X-Forwarded-For)。 const express = require('express'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const rateLimit = require('express-rate-limit'); const { WebSocketServer, WebSocket } = require('ws'); const db = require('../db'); const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth'); const router = express.Router(); // ---------- 常量 ---------- const TOKEN_TTL = '5m'; // 短 TTL token 有效期 const TOKEN_COOKIE = 'terminal_token'; // HttpOnly 短 TTL cookie 名 const TOKEN_COOKIE_MAX_AGE = 300; // cookie 有效期(秒),与 TOKEN_TTL 一致 const MAX_SESSIONS = 3; // 最大并发终端会话数 const IDLE_TIMEOUT = 15 * 60 * 1000; // 空闲超时:15 分钟无输入自动断开 const PIN_MIN_LEN = 8; // 终端=root shell,PIN 强度要求 8 位以上 const PBKDF2_ITERATIONS = 600000; // 与 routes/passwords.js 一致(OWASP 2023 建议) const MAX_PTY_BUFFER = 1024 * 1024; // pty 输出缓冲上限(超过则暂停 pty 做流控) const WS_MAX_PAYLOAD = 1024 * 1024; // ws 单帧消息上限,超限立即断开 const RESUME_POLL_MS = 2000; // 流控恢复兜底轮询间隔 const WS_CLOSE_BAD_TOKEN = 4001; // token 无效 const WS_CLOSE_OVERLOAD = 1013; // 会话数超限 const WS_CLOSE_IDLE = 4000; // 空闲超时 const WS_CLOSE_BAD_MSG = 4002; // 非法消息 // PIN 失败限流(与 passwords.js unlock 同模式):5 分钟 3 次失败锁定 5 分钟 const authFails = new Map(); const FAIL_LIMIT = 3; const FAIL_WINDOW = 5 * 60 * 1000; const LOCK_DURATION = 5 * 60 * 1000; // /auth 请求级限流:防止异步 PBKDF2 被并发滥用造成 CPU DoS // (失败锁定只在校验之后生效,无法挡住每请求一次的 600k 迭代计算) const authRateLimit = rateLimit({ windowMs: 5 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, message: { error: '尝试次数过多,请稍后再试' }, }); // 拒绝类事件审计降频:同一来源 IP 在窗口内最多记一条(防日志洪泛) const REJECT_AUDIT_INTERVAL = 10 * 1000; const rejectAudit = new Map(); // ip -> lastTs // 并发终端会话集合(计数与统一清理) const activeSessions = new Set(); // node-pty 懒加载:未安装时 REST 接口(status/auth)仍可用,仅 WS 会话被拒绝 let ptyModule = null; try { ptyModule = require('node-pty'); } catch (e) { /* 未安装:连接时提示 */ } // 审计写入计数:每 50 条惰性清理一次过期记录(保留策略,见 db.js) let auditWriteCount = 0; // ---------- 审计 ---------- // ctx: { user, ip }(user 为 JWT payload) function audit(action, detail, ctx) { const userId = ctx && ctx.user ? ctx.user.id : 0; const username = ctx && ctx.user ? ctx.user.username : ''; const ip = ctx && ctx.ip ? ctx.ip : ''; console.log(`[${new Date().toISOString()}] [TERMINAL] ${action} user=${username}(${userId}) ip=${ip} ${detail}`); // 审计落库(audit_logs 表由 db.js initTables 建表;db.run 内部已容错) db.run('INSERT INTO audit_logs (action, user_id, username, detail, ip) VALUES (?, ?, ?, ?, ?)', [action, userId, username, detail, ip]); // 保留策略:每 50 条写入惰性清理 90 天前的记录(created_at 为 UTC datetime,与建表一致) if (++auditWriteCount % 50 === 0) { db.run("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')"); } } // 客户端真实 IP:仅当 TRUST_PROXY=1 时信任 X-Forwarded-For(否则客户端可任意伪造该头) function getClientIp(request) { if (process.env.TRUST_PROXY === '1' && request.headers['x-forwarded-for']) { return String(request.headers['x-forwarded-for']).split(',')[0].trim() || ''; } return (request.socket.remoteAddress || '').toString(); } // 拒绝事件降频审计:同 IP 10 秒内只记一条 function auditRejectOnce(ip, action, detail) { const now = Date.now(); if ((rejectAudit.get(ip) || 0) > now - REJECT_AUDIT_INTERVAL) return; rejectAudit.set(ip, now); audit(action, detail, { ip }); } // ---------- PIN(终端专用密码)---------- const PIN_SALT_KEY = 'terminal_pin_salt'; const PIN_ITER_KEY = 'terminal_pin_iter'; const PIN_HASH_KEY = 'terminal_pin_hash'; function getPinRecord() { const salt = db.getSetting(PIN_SALT_KEY); const hash = db.getSetting(PIN_HASH_KEY); if (!salt || !hash) return null; const iter = parseInt(db.getSetting(PIN_ITER_KEY), 10) || PBKDF2_ITERATIONS; return { salt: Buffer.from(salt, 'hex'), hash: Buffer.from(hash, 'hex'), iter }; } // 异步 PBKDF2:不阻塞事件循环(600k 迭代约几十毫秒,同步版会卡住所有请求) function derivePinHash(pin, salt, iter) { return new Promise((resolve, reject) => { crypto.pbkdf2(pin, salt, iter, 32, 'sha256', (err, key) => { if (err) reject(err); else resolve(key); }); }); } async function setPin(pin) { const salt = crypto.randomBytes(16); const hash = await derivePinHash(pin, salt, PBKDF2_ITERATIONS); db.setSetting(PIN_SALT_KEY, salt.toString('hex')); db.setSetting(PIN_ITER_KEY, String(PBKDF2_ITERATIONS)); db.setSetting(PIN_HASH_KEY, hash.toString('hex')); } async function verifyPin(pin) { const rec = getPinRecord(); if (!rec) return false; const derived = await derivePinHash(pin, rec.salt, rec.iter); // 常数时间比较,防时序侧信道 return crypto.timingSafeEqual(derived, rec.hash); } // ---------- PIN 失败限流 ---------- function isLocked() { const rec = authFails.get('global'); return !!(rec && rec.lockedUntil && Date.now() < rec.lockedUntil); } function recordFail() { const now = Date.now(); let rec = authFails.get('global'); if (!rec || now - rec.firstTs > FAIL_WINDOW) rec = { count: 0, firstTs: now, lockedUntil: 0 }; rec.count += 1; if (rec.count >= FAIL_LIMIT) { rec.lockedUntil = now + LOCK_DURATION; rec.count = 0; } authFails.set('global', rec); } // ---------- 短 TTL token(参考 proxy.js)---------- function issueToken(user) { // terminal:true 声明:只有本接口签发的 token 才能连接终端,普通登录 token 无效 return jwt.sign( { id: user.id, username: user.username, role: user.role, terminal: true }, SECRET, { expiresIn: TOKEN_TTL } ); } function verifyToken(token) { if (!token || typeof token !== 'string') return null; try { const payload = jwt.verify(token, SECRET); if (!payload.terminal) return null; // 必须为终端专用 token if (payload.role !== 'admin') return null; // 防篡改 const u = db.get('SELECT role FROM users WHERE id = ?', [payload.id]); if (!u || u.role !== 'admin') return null; // 复查角色(用户被删/降权立即失效) return payload; } catch { return null; } } // 认证成功 → 种 HttpOnly 短 TTL cookie:WS 握手(同源 GET /ws/terminal)自动携带, // root shell 凭证不再进 URL / access log。Path 精确限定 /ws/terminal,HttpOnly 防 XSS 读取, // SameSite=Strict 防跨站发送。Secure 按连接是否 HTTPS 条件附加。 function setTerminalCookie(res, token) { // L7:HTTPS 判定补 x-forwarded-proto(适配 nginx/Cloudflare 等反代场景—— // 反代 TLS 终结时 req.secure 可能为 false,但 X-Forwarded-Proto: https 表明真实协议为 HTTPS) const isSecure = res.req && ( res.req.secure || String(res.req.headers['x-forwarded-proto'] || '').toLowerCase().startsWith('https') ); res.setHeader('Set-Cookie', `terminal_token=${token}; Path=/ws/terminal; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_COOKIE_MAX_AGE}` + (isSecure ? '; Secure' : '')); } // 极简 Cookie 解析(upgrade 事件不经 Express,无 cookie-parser) function parseCookies(header) { const out = {}; if (!header) return out; for (const part of String(header).split(';')) { const idx = part.indexOf('='); if (idx === -1) continue; const name = part.slice(0, idx).trim(); const value = part.slice(idx + 1).trim(); if (name) { try { out[name] = decodeURIComponent(value); } catch { out[name] = value; } } } return out; } // ---------- REST API ---------- // GET /api/terminal/status —— 是否已设置密码 + 当前会话数 router.get('/status', authMiddleware, adminOnly, (req, res) => { res.json({ hasPin: !!getPinRecord(), sessions: activeSessions.size, maxSessions: MAX_SESSIONS, idleTimeoutMs: IDLE_TIMEOUT, }); }); // POST /api/terminal/auth —— 设置/校验终端专用密码,通过后发放 5 分钟短 TTL token // 并种 HttpOnly cookie(terminal_token)。 // 首次(未设置密码)调用即完成设置(前端按 /status 的 hasPin 决定显示设置表单或密码输入框), // 响应带 setup:true 标记。响应仍含 token 字段(保持 API 契约),前端无需再从 URL 传递。 router.post('/auth', authRateLimit, authMiddleware, adminOnly, async (req, res) => { const pin = req.body && req.body.pin; if (!pin || typeof pin !== 'string') return res.status(400).json({ error: '请输入终端密码' }); if (pin.length > 128) return res.status(400).json({ error: '终端密码过长' }); if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' }); const rec = getPinRecord(); const ctx = { user: req.user, ip: getClientIp(req) }; if (!rec) { // 首次设置:无密码 → 设置并发放 token if (pin.length < PIN_MIN_LEN) { return res.status(400).json({ error: `终端密码至少 ${PIN_MIN_LEN} 位` }); } try { await setPin(pin); } catch (e) { return res.status(500).json({ error: '密码处理失败' }); } authFails.delete('global'); audit('PIN_SETUP', '终端专用密码已设置', ctx); const token = issueToken(req.user); setTerminalCookie(res, token); return res.json({ needs_setup: false, setup: true, token, expiresIn: TOKEN_TTL, message: '终端密码已设置', }); } let pinOk = false; try { pinOk = await verifyPin(pin); } catch { pinOk = false; } if (!pinOk) { recordFail(); audit('PIN_FAIL', '终端密码校验失败', ctx); if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' }); return res.status(401).json({ error: '终端密码错误' }); } authFails.delete('global'); audit('PIN_OK', '终端密码校验通过,发放短 TTL token', ctx); const token = issueToken(req.user); setTerminalCookie(res, token); res.json({ token, expiresIn: TOKEN_TTL }); }); // ---------- WS /ws/terminal ---------- const isDev = process.env.NODE_ENV === 'development'; // Origin 校验:必须与站点域名一致(site_url 或请求 Host 头)。 // localhost 豁免仅 NODE_ENV=development 时生效(生产环境非浏览器客户端无法借 localhost 绕过)。 // 注意:这是纵深防御,不是认证边界——真正把关的是 token/cookie 校验。 function originAllowed(request) { const origin = request.headers.origin; if (!origin) return false; try { const o = new URL(origin); const allowed = []; const siteUrl = db.getSetting('site_url'); if (siteUrl) { try { allowed.push(new URL(siteUrl).host); } catch {} } if (request.headers.host) allowed.push(request.headers.host); if (isDev && ['127.0.0.1', 'localhost', '::1'].includes(o.hostname)) return true; return allowed.includes(o.host); } catch { return false; } } // pty 安全:参数数组传参(防命令注入),禁止字符串拼接;sudo -i 获得 root shell。 // cwd 必须用运行用户可访问的目录:直接给 '/root' 时 spawn 不抛错, // 而是 shell 启动后 chdir(2) 失败(Permission denied)——sudo -i 本身会把 // 工作目录与 HOME 切到 /root,所以这里给运行用户当前目录即可。 function spawnPty(cols, rows) { if (!ptyModule) return null; // node-pty 未安装 const opts = { name: 'xterm-color', cols, rows, cwd: process.cwd() }; return ptyModule.spawn('sudo', ['-i'], opts); } // 终止 pty:先杀 shell,再连进程组一起杀(forkpty 子进程是 session leader,pgid == pid, // 负 PID 即杀整组),500ms 后兜底 SIGKILL,杜绝 `&` 后台任务变孤儿进程。 function killPty(pty) { if (!pty) return; const pid = pty.pid; try { pty.kill(); } catch {} // 标准接口:向 shell 发 SIGKILL if (pid && Number.isInteger(pid)) { try { process.kill(-pid, 'SIGTERM'); } catch {} const t = setTimeout(() => { try { process.kill(-pid, 'SIGKILL'); } catch {} }, 500); if (t.unref) t.unref(); } } // WS 服务:noServer 模式,由 server.js 的 http server 'upgrade' 事件转交; // maxPayload 限制单帧大小,超限由 ws 自动关闭(1009)。 const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD }); // server.js 调用:返回 true 表示已接管该 upgrade 请求(含拒绝),false 表示非终端路径 function handleUpgrade(request, socket, head) { let pathname = null; try { pathname = new URL(request.url, 'http://localhost').pathname; } catch {} if (pathname !== '/ws/terminal') return false; const ip = getClientIp(request); // 1) Origin 校验(纵深防御;拒绝事件降频落审计) if (!originAllowed(request)) { auditRejectOnce(ip, 'WS_ORIGIN_REJECT', 'Origin 校验失败'); socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); return true; } // 2) token 校验:优先 HttpOnly cookie(推荐),?token= query 仅作旧前端回退(不推荐,落 access log) const cookies = parseCookies(request.headers.cookie); let token = cookies[TOKEN_COOKIE]; if (!token) { try { token = new URL(request.url, 'http://localhost').searchParams.get('token'); } catch {} } const payload = verifyToken(token); if (!payload) { auditRejectOnce(ip, 'WS_TOKEN_REJECT', 'token 无效或过期'); socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); socket.destroy(); return true; } // 3) 并发会话上限:同步预占槽位再检查(升级回调前执行,杜绝并发竞态突破上限) const slot = { pending: true }; activeSessions.add(slot); if (activeSessions.size > MAX_SESSIONS) { activeSessions.delete(slot); auditRejectOnce(ip, 'WS_OVERLOAD', '并发会话数超限被拒绝'); socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); socket.destroy(); return true; } // 升级中途失败(socket 断开等)时释放预占槽位,防止泄漏 const releaseSlot = () => { activeSessions.delete(slot); }; socket.once('close', releaseSlot); socket.once('error', releaseSlot); // 4) 升级并触发连接处理(槽位随会话移交) wss.handleUpgrade(request, socket, head, (ws) => { socket.removeListener('close', releaseSlot); socket.removeListener('error', releaseSlot); if (ws.readyState !== WebSocket.OPEN) { // 升级异常:释放槽位 activeSessions.delete(slot); return; } wss.emit('connection', ws, request, payload, slot); }); return true; } wss.on('connection', (ws, request, payload, slot) => { // 复用 handleUpgrade 预占的槽位(已在 activeSessions 中,幂等 add), // 升级失败路径已由 releaseSlot/readyState 检查负责释放。 const session = slot || {}; session.id = session.id || crypto.randomBytes(4).toString('hex'); session.startedAt = session.startedAt || Date.now(); activeSessions.add(session); let ptyProc = null; let idleTimer = null; let resumeTimer = null; let closed = false; const ctx = { user: payload, ip: getClientIp(request) }; audit('SESSION_START', `终端会话 ${session.id} 建立`, ctx); function cleanup() { if (closed) return; closed = true; clearTimeout(idleTimer); clearInterval(resumeTimer); // 销毁 pty:kill() + 进程组清理(含后台任务),事件回调为一次性注册随对象失效 killPty(ptyProc); ptyProc = null; activeSessions.delete(session); } function scheduleIdle() { clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (closed) return; audit('SESSION_IDLE_TIMEOUT', `终端会话 ${session.id} 空闲 ${IDLE_TIMEOUT / 60000} 分钟无输入,自动断开`, ctx); try { ws.close(WS_CLOSE_IDLE, 'idle timeout'); } catch {} cleanup(); }, IDLE_TIMEOUT); if (idleTimer.unref) idleTimer.unref(); // 不阻止进程退出 } // 流控恢复:onData 暂停后不再触发回调,恢复检查必须在消息路径/轮询中执行 function maybeResume() { if (closed || !session._paused || !ptyProc) return; if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < MAX_PTY_BUFFER / 2) { session._paused = false; try { ptyProc.resume(); } catch {} } } ws.on('message', (data, isBinary) => { if (closed) return; maybeResume(); // 任意帧到达即尝试恢复(resize/输入/控制消息均可) // 二进制帧 = pty 输入(触发空闲计时刷新) if (isBinary) { if (!ptyProc) return; scheduleIdle(); try { ptyProc.write(data); } catch {} return; } // 文本帧 = JSON 控制消息 let msg; try { msg = JSON.parse(data.toString('utf8')); } catch { try { ws.close(WS_CLOSE_BAD_MSG, 'invalid json'); } catch {} cleanup(); return; } if (!msg || typeof msg.type !== 'string') { try { ws.close(WS_CLOSE_BAD_MSG, 'bad message'); } catch {} cleanup(); return; } switch (msg.type) { case 'auth': // 连接后可选再发 token 二次认证;无效立即断开(4001) if (!verifyToken(msg.token)) { audit('SESSION_AUTH_FAIL', `终端会话 ${session.id} 二次认证失败`, ctx); try { ws.close(WS_CLOSE_BAD_TOKEN, 'bad token'); } catch {} cleanup(); } break; case 'resize': { const cols = Math.min(Math.max(parseInt(msg.cols, 10) || 80, 2), 500); const rows = Math.min(Math.max(parseInt(msg.rows, 10) || 24, 2), 200); if (ptyProc) { try { ptyProc.resize(cols, rows); } catch {} } break; } case 'ping': try { ws.send(JSON.stringify({ type: 'pong' })); } catch {} break; case 'bye': audit('SESSION_BYE', `终端会话 ${session.id} 主动退出`, ctx); try { ws.close(1000, 'bye'); } catch {} cleanup(); break; default: break; // 未知控制消息忽略 } }); ws.on('error', (err) => { audit('SESSION_ERROR', `终端会话 ${session.id} WebSocket 错误: ${err.message}`, ctx); cleanup(); }); ws.on('close', () => { audit('SESSION_END', `终端会话 ${session.id} 连接关闭`, ctx); cleanup(); }); // 启动 pty(sudo -i → root shell) try { ptyProc = spawnPty(80, 24); } catch (e) { audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} pty 启动失败: ${e.message}`, ctx); try { ws.close(1011, 'pty spawn failed'); } catch {} cleanup(); return; } if (!ptyProc) { audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} 拒绝:node-pty 未安装或无法加载`, ctx); try { ws.close(1011, 'node-pty not installed'); } catch {} cleanup(); return; } // 兜底恢复轮询:即使客户端不再发任何帧,也能在缓冲排空后恢复 pty resumeTimer = setInterval(() => maybeResume(), RESUME_POLL_MS); if (resumeTimer.unref) resumeTimer.unref(); // pty 输出 → ws 二进制帧(binaryType=arraybuffer) ptyProc.onData((data) => { if (closed || ws.readyState !== WebSocket.OPEN) return; // 仅做暂停判断(数据流动时天然触发);恢复由 maybeResume(消息/轮询)负责 if (!session._paused && ws.bufferedAmount > MAX_PTY_BUFFER) { session._paused = true; try { ptyProc.pause(); } catch {} } try { ws.send(data, { binary: true }); } catch {} }); ptyProc.onExit(({ exitCode, signal }) => { audit('SESSION_EXIT', `终端会话 ${session.id} 进程退出 code=${exitCode} signal=${signal}`, ctx); cleanup(); if (ws.readyState === WebSocket.OPEN) { try { ws.close(1000, 'pty exited'); } catch {} } }); scheduleIdle(); audit('SESSION_READY', `终端会话 ${session.id} pty 就绪`, ctx); }); module.exports = router; module.exports.handleUpgrade = handleUpgrade;