chanjs 2.8.3 → 2.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -188,21 +188,26 @@ class Repository extends BaseComponent {
188
188
  return row;
189
189
  }
190
190
 
191
- /** 查询全部,默认上限1000条 */
192
- async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
191
+ /**
192
+ * 查询全部。上限口径统一收口到 config.LIMIT_MAX(this.limit):
193
+ * 未传 limit 时默认取 this.limit;显式传入的 limit 也会被钳制到 [1, this.limit],
194
+ * 避免"all 默认 1000 / 分页上限 300"两套口径并存。
195
+ */
196
+ async all({ query={}, sort={}, fields=[], limit }={}) {
193
197
  const err = this._guard({ query });
194
198
  if (err) return err;
195
- const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
199
+ const size = Math.min(Math.max(Number(limit) || this.limit, 1), this.limit);
200
+ const list = await this._buildBaseQuery({query,sort,fields}).limit(size);
196
201
  return { success:true, code:CODE_OK, msg:"查询成功", data: list };
197
202
  }
198
203
 
199
- /** 分页偏移查询 */
204
+ /** 分页偏移查询(limit 同 all() 口径,钳制到 config.LIMIT_MAX) */
200
205
  async find({ query={}, sort={}, fields=[], limit, offset }={}) {
201
206
  const err = this._guard({ query });
202
207
  if (err) return err;
203
208
  let q = this._buildBaseQuery({query,sort,fields});
204
209
  typeof offset === 'number' && (q = q.offset(offset));
205
- typeof limit === 'number' && (q = q.limit(limit));
210
+ typeof limit === 'number' && (q = q.limit(Math.min(Math.max(limit, 1), this.limit)));
206
211
  return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
207
212
  }
208
213
 
@@ -4,6 +4,7 @@ import {
4
4
  } from "../../middleware/index.js";
5
5
  import { health } from "../../middleware/health.js";
6
6
  import { BODY_LIMIT } from "../../config/index.js";
7
+ import logger from "../../utils/logger.js";
7
8
 
8
9
  /**
9
10
  * 注册核心中间件(顺序固化:不可随意调整)
@@ -17,6 +18,19 @@ export async function registerCoreMiddleware(chan) {
17
18
  await waf(app, cfg.waf ?? { enabled: false });
18
19
  // 1.5 健康检查端点(默认开启,config.health = {enabled,path} 可调;浅模式零 IO)
19
20
  app.use(health(chan, cfg.health ?? {}));
21
+ // 1.6 宿主全局中间件插槽:config.middleware 数组,按声明顺序注册。
22
+ // 元素支持两种形态:express 中间件本身 (req,res,next);或工厂 () => express 中间件(需闭包 chan/config 时用)。
23
+ // 注册位置在核心链早期、模块路由之前,保证对所有响应(含静态资源)生效。
24
+ // 注意:app/router.js(loadCommonRouter)的执行时机在模块路由之后,app.use 在那里
25
+ // 只罩得住 404 兜底请求,安全响应头等全局策略应经本插槽注册而非写在 router.js。
26
+ for (const mw of cfg.middleware ?? []) {
27
+ const fn = typeof mw === "function" && mw.length <= 1 ? mw(chan) : mw;
28
+ if (typeof fn !== "function") {
29
+ logger.warn(`[Chan] config.middleware 含非法项(须为中间件或中间件工厂),已跳过: ${String(mw)}`);
30
+ continue;
31
+ }
32
+ app.use(fn);
33
+ }
20
34
  // 2. Favicon
21
35
  favicon(app);
22
36
  // 3. 静态资源
@@ -172,7 +172,9 @@ const otherDb = app.dbManager.get("logs");
172
172
  | `REDIS_ENABLED` / `REDIS` | Redis 配置 |
173
173
  | `JWT_SECRET` | JWT 签名密钥 |
174
174
  | `LOCALE` | i18n 默认语言(默认 `zh-CN`)|
175
- | `LIMIT_MAX` | Repository 单页上限(默认 300)|
175
+ | `LIMIT_MAX` | Repository 单页上限(默认 300);`all()`/`find()` 显式 limit 亦被钳制到该值 |
176
+ | `middleware` | 宿主全局中间件插槽:数组,元素为 express 中间件或中间件工厂 `(chan) => mw`,注册于核心链早期(健康检查后、静态资源前),对所有响应生效 |
177
+ | `health` | 内置健康检查端点:`{ enabled: true, path: '/health' }`(默认值)。健全部署返回 HTTP **200**、degraded(含内部异常)返回 HTTP **503**,便于探针/负载均衡摘除实例。浅模式零 IO——只读进程内 DB 健康缓存,不实时 ping。`enabled: false` 可整体关闭,`path` 可改路径 |
176
178
  | `SLOW_THRESHOLD` | 数据库慢查询阈值(ms,默认 200),超过记 warn 日志 |
177
179
  | `SHUTDOWN_TIMEOUT` | 优雅停机强制退出超时(ms,默认 5000)|
178
180
  | `BODY_LIMIT` | 请求体上限(默认 `10mb`)|
@@ -1,20 +1,22 @@
1
1
  /**
2
- * 自定义响应头中间件,隐藏原生Express标识,统一输出ChanCMS
3
- * @param {express.Application} app Express实例
4
- * @param {{APP_NAME?: string, APP_VERSION?: string}} [opts] 配置选项
2
+ * 品牌响应头中间件:隐藏原生 Express 标识,统一输出 ChanCMS。
3
+ *
4
+ * 注:安全响应头(X-Content-Type-Options / HSTS / Referrer-Policy /
5
+ * Permissions-Policy / CSP 等)属于宿主业务策略,不在框架层默认设置——
6
+ * 由各项目的 security-headers 中间件统一收敛(本项目见 app/middleware/security-headers.js)。
7
+ * 尤其 CSP frame-ancestors 一旦在框架写死 'self',会与需要被第三方
8
+ * iframe 嵌入的宿主业务直接冲突,故不在此声明。
5
9
  */
6
10
  export const header = (app, opts = {}) => {
7
11
  const appName = opts.APP_NAME || "ChanCMS";
8
12
  const appVer = opts.APP_VERSION || "";
9
13
  const poweredBy = appVer ? `${appName}/${appVer}` : appName;
14
+ // 静态资源在核心链第 3 步即被接住、到不了本中间件,Express 会默认补
15
+ // X-Powered-By: Express 泄露技术栈——先禁用默认注入,品牌头只由下方显式设置
16
+ app.disable("x-powered-by");
10
17
 
11
18
  app.use((_, res, next) => {
12
19
  res.setHeader("X-Powered-By", poweredBy);
13
- // 常驻安全响应头:无论 WAF 是否启用都生效
14
- // 禁止 MIME 嗅探(防 XSS 利用 Content-Type 歧义)
15
- res.setHeader("X-Content-Type-Options", "nosniff");
16
- // 防点击劫持:仅允许同源 iframe 嵌套(如需被第三方嵌入请按需放宽)
17
- res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
18
20
  next();
19
21
  });
20
- };
22
+ };
@@ -9,7 +9,11 @@ import { store } from "../storage/index.js";
9
9
  * DB 健康缓存由 error-handler 在真实查询成功/失败时维护(markUp/markDown)。
10
10
  *
11
11
  * 配置(config/index.js):
12
- * health: { enabled: true, path: '/health' } // 默认值,可整体关闭或改路径
12
+ * health: { enabled: true, path: '/health' } // 默认值;enabled:false 可整体关闭,path 可改路径
13
+ *
14
+ * HTTP 状态码:健康 → 200;degraded(含内部异常)→ 503。
15
+ * —— 探针/负载均衡据此摘除故障实例,故 degraded 必须体现为 503。
16
+ * —— enabled:false 时注册的是透传中间件(不是不注册),保证 app.use() 始终拿到函数。
13
17
  *
14
18
  * 响应示例:
15
19
  * { "success":true, "code":0, "msg":"ok", "data":{
@@ -18,12 +22,17 @@ import { store } from "../storage/index.js";
18
22
  * "db":[{ "name":"default", "healthy":true }],
19
23
  * "store":{ "mode":"memory", ... }
20
24
  * } }
25
+ * degraded 时:HTTP 503 + { "success":false, "code":5001, "msg":"degraded", ... }
21
26
  */
22
27
 
23
28
  const DEFAULT_PATH = "/health";
24
29
 
25
30
  export const health = (chan, opts = {}) => {
26
- if (opts.enabled === false) return;
31
+ // 关闭时必须返回「合法中间件」,不能 return undefined:
32
+ // 调用方 core/bootstrap/middleware.js 是无条件 app.use(health(chan, cfg.health ?? {})),
33
+ // 返回 undefined 会让 Express 抛 `TypeError: app.use() requires a middleware function`,
34
+ // 且发生在启动阶段 → 进程直接退出(配置项反而变成崩溃开关)。
35
+ if (opts.enabled === false) return (req, res, next) => next();
27
36
  const path = opts.path || DEFAULT_PATH;
28
37
  const startedAt = Date.now();
29
38
 
@@ -32,9 +41,11 @@ export const health = (chan, opts = {}) => {
32
41
  try {
33
42
  const db = chan.dbManager?.healthSnapshot?.() ?? [];
34
43
  const degraded = db.some(d => !d.healthy);
35
- res.status(200).json({
36
- success: true,
37
- code: 0,
44
+ // 状态码必须反映真实健康度:degraded → 503。
45
+ // 旧实现恒 200,探针/负载均衡无法据此摘除故障实例(degraded 形同无效)。
46
+ res.status(degraded ? 503 : 200).json({
47
+ success: !degraded,
48
+ code: degraded ? 5001 : 0,
38
49
  msg: degraded ? "degraded" : "ok",
39
50
  data: {
40
51
  status: degraded ? "degraded" : "ok",
@@ -45,9 +56,10 @@ export const health = (chan, opts = {}) => {
45
56
  });
46
57
  } catch (err) {
47
58
  logger.error(`[Health] 健康检查异常:${err.message}`);
48
- res.status(200).json({
49
- success: true,
50
- code: 0,
59
+ // 内部异常同样按「不可用」上报,不再伪装成 200
60
+ res.status(503).json({
61
+ success: false,
62
+ code: 5001,
51
63
  msg: "degraded",
52
64
  data: { status: "degraded", uptime: Math.floor((Date.now() - startedAt) / 1000), db: [], store: null },
53
65
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "chanjs",
4
- "version": "2.8.3",
4
+ "version": "2.8.5",
5
5
  "description": "chanjs基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。",
6
6
  "main": "index.js",
7
7
  "module": "index.js",
package/utils/request.js CHANGED
@@ -5,6 +5,8 @@ import { promisify } from "util";
5
5
  const dnsLookup = promisify(dns.lookup);
6
6
  const DEFAULT_TIMEOUT = 10000;
7
7
  const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
8
+ /** 重定向跳数上限(逐跳重新过 SSRF 校验) */
9
+ const MAX_REDIRECTS = 5;
8
10
 
9
11
  /** IPv4 私网/回环段 */
10
12
  const IPV4_PRIVATE = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.0\.0\.0$)/;
@@ -38,6 +40,11 @@ async function isPrivateUrl(urlStr) {
38
40
  /**
39
41
  * HTTP 请求封装:超时/重试/SSRF 防护/大小限制
40
42
  * 支持 JSON 和原始数据(Buffer/文本)两种模式
43
+ *
44
+ * SSRF 防护是「逐跳」的:redirect 固定为 "manual",重定向不自动跟随,
45
+ * 每一跳(含 302/301/307/308 的目标)都重新做协议白名单 + DNS 解析后 IP 校验,
46
+ * 跳数上限 MAX_REDIRECTS。调用方无法通过 options 覆盖 redirect。
47
+ *
41
48
  * @param {string} url - 请求地址
42
49
  * @param {Object} [options={}] - 请求配置
43
50
  * @param {number} [options.timeout=10000] - 超时时间(ms)
@@ -60,7 +67,7 @@ export async function request(url, options = {}) {
60
67
  ...rest
61
68
  } = options;
62
69
 
63
- // SSRF 防护
70
+ // SSRF 防护(首跳;重定向目标在下方请求循环内逐跳校验)
64
71
  if (await isPrivateUrl(rawUrl)) {
65
72
  return { success: false, error: `SSRF防护拦截:${rawUrl}` };
66
73
  }
@@ -87,7 +94,43 @@ export async function request(url, options = {}) {
87
94
  const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null;
88
95
 
89
96
  try {
90
- const res = await fetch(rawUrl, { ...fetchOpts, signal: controller.signal });
97
+ // 重定向逐跳处理:redirect:"manual" 让 fetch 不自动跟随,由本循环接管,
98
+ // 并对「每一跳」重新做 SSRF 校验。
99
+ // 旧实现未设 redirect(默认 "follow")且只校验了首个 URL ——
100
+ // 公网地址返回 302 指向 127.0.0.1 / 169.254.169.254 即可绕过防护。
101
+ // 注意:redirect 放在 ...fetchOpts 之后,调用方无法覆盖该安全约束。
102
+ let currentUrl = rawUrl;
103
+ let res;
104
+ for (let hop = 0; ; hop++) {
105
+ if (hop > MAX_REDIRECTS) {
106
+ if (timer) clearTimeout(timer);
107
+ return { success: false, error: `重定向次数超过上限(${MAX_REDIRECTS}):${rawUrl}` };
108
+ }
109
+ res = await fetch(currentUrl, { ...fetchOpts, redirect: "manual", signal: controller.signal });
110
+ // 非 3xx 即最终响应
111
+ if (!(res.status >= 300 && res.status < 400)) break;
112
+
113
+ const loc = res.headers.get("location");
114
+ // 释放重定向响应体,避免 socket 泄漏
115
+ try { await res.body?.cancel(); } catch { /* 已关闭则忽略 */ }
116
+ if (!loc) {
117
+ if (timer) clearTimeout(timer);
118
+ return { success: false, error: `HTTP ${res.status} 重定向缺少 Location 头` };
119
+ }
120
+ let next;
121
+ try {
122
+ next = new URL(loc, currentUrl).href; // 相对路径按当前 URL 补全
123
+ } catch {
124
+ if (timer) clearTimeout(timer);
125
+ return { success: false, error: `重定向地址非法:${loc}` };
126
+ }
127
+ // 关键:重定向目标必须重新过一遍 SSRF 校验(协议白名单 + DNS 解析后 IP 判定)
128
+ if (await isPrivateUrl(next)) {
129
+ if (timer) clearTimeout(timer);
130
+ return { success: false, error: `SSRF防护拦截(重定向目标):${next}` };
131
+ }
132
+ currentUrl = next;
133
+ }
91
134
  if (timer) clearTimeout(timer);
92
135
 
93
136
  if (!res.ok) {