middleware.js 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. 'use strict';
  2. // 标准中间件:错误边界。
  3. // 路由处理器只需 throw,由这里统一映射为响应:
  4. // · err.status 优先(处理器可对上游错误标注 502 等)
  5. // · 智能问数的参数类错误码 → 400
  6. // · 其余 → 500
  7. // 响应体形状与拆分前一致:{ error: message, code?: errCode }
  8. const crypto = require('crypto');
  9. const { sendJson } = require('./util.js');
  10. const ASK_CLIENT_CODES = new Set(['ASK_NO_MODEL', 'ASK_EMPTY', 'ASK_NO_ENTITY']);
  11. function errorStatus(e) {
  12. if (e && e.status) return e.status;
  13. if (e && ASK_CLIENT_CODES.has(e.code)) return 400;
  14. return 500;
  15. }
  16. function sendError(res, e) {
  17. if (res.headersSent) {
  18. // 响应已开始(流式文件等)时只能终止连接,与拆分前 writeHead 抛错的表现一致但不崩进程
  19. res.destroy();
  20. return;
  21. }
  22. const body = { error: (e && e.message) || String(e) };
  23. if (e && e.code !== undefined) body.code = e.code;
  24. sendJson(res, errorStatus(e), body);
  25. }
  26. // 包裹异步处理器:同步/异步异常都进入错误边界
  27. function errorBoundary(handler) {
  28. return async (c) => {
  29. try {
  30. await handler(c);
  31. } catch (e) {
  32. sendError(c.res, e);
  33. }
  34. };
  35. }
  36. // ---------- 访问口令(局域网模式) ----------
  37. // config.accessToken 非空时启用:/api/* 与 /files 需要口令(cookie / header / query 三选一)。
  38. // 本机回环访问与健康检查放行;静态页面放行(无登录页,页面先加载,前端 401 后引导输入)。
  39. function parseCookie(header) {
  40. const out = {};
  41. String(header || '').split(';').forEach((part) => {
  42. const i = part.indexOf('=');
  43. if (i > 0) {
  44. try { out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); }
  45. catch { out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); }
  46. }
  47. });
  48. return out;
  49. }
  50. function isLoopbackRemote(req) {
  51. const addr = String((req.socket && req.socket.remoteAddress) || '');
  52. return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
  53. }
  54. // 常量时间比较:LAN 场景防时序侧信道逐字节猜口令
  55. function safeEqual(a, b) {
  56. const ba = Buffer.from(String(a));
  57. const bb = Buffer.from(String(b));
  58. if (ba.length !== bb.length) {
  59. crypto.timingSafeEqual(ba, ba); // 长度不等也耗一遍比较,不泄漏长度信息
  60. return false;
  61. }
  62. return crypto.timingSafeEqual(ba, bb);
  63. }
  64. function makeTokenGuard(config) {
  65. return function tokenGuard(req, pathname, searchParams) {
  66. const expected = config.accessToken;
  67. if (!expected) return true;
  68. if (pathname === '/healthz' || pathname === '/readyz') return true;
  69. if (!pathname.startsWith('/api/') && !pathname.startsWith('/files')) return true;
  70. if (isLoopbackRemote(req)) return true;
  71. if (safeEqual(searchParams.get('token') || '', expected)) return true;
  72. if (safeEqual(req.headers['x-access-token'] || '', expected)) return true;
  73. if (safeEqual(parseCookie(req.headers.cookie).accessToken || '', expected)) return true;
  74. return false;
  75. };
  76. }
  77. // ---------- 本地 CSRF 防护 ----------
  78. // 回环请求在口令守卫里全放行,意味着用户浏览器里任意网页都能向 127.0.0.1 打
  79. // no-cors POST(不带自定义头也能被 readBody 解析)。浏览器发起的跨站写请求必带
  80. // Origin;本站同源请求的 Origin 与 Host 一致;非浏览器客户端(curl/脚本)不带
  81. // Origin → 放行,仍受口令守卫约束。
  82. function makeOriginGuard() {
  83. const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
  84. return function originGuard(req) {
  85. if (SAFE_METHODS.has(req.method)) return true;
  86. const origin = req.headers.origin;
  87. if (!origin) return true;
  88. try {
  89. return new URL(origin).host === (req.headers.host || '');
  90. } catch {
  91. return false;
  92. }
  93. };
  94. }
  95. module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary, makeTokenGuard, makeOriginGuard };