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.
- package/core/Repository.js +10 -5
- package/core/bootstrap/middleware.js +14 -0
- package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +3 -1
- package/middleware/header.js +11 -9
- package/middleware/health.js +20 -8
- package/package.json +1 -1
- package/utils/request.js +45 -2
package/core/Repository.js
CHANGED
|
@@ -188,21 +188,26 @@ class Repository extends BaseComponent {
|
|
|
188
188
|
return row;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/**
|
|
192
|
-
|
|
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
|
|
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`)|
|
package/middleware/header.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
+
};
|
package/middleware/health.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
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
|
-
|
|
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) {
|