chanjs 2.8.4 → 2.8.6
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.
|
@@ -174,6 +174,7 @@ const otherDb = app.dbManager.get("logs");
|
|
|
174
174
|
| `LOCALE` | i18n 默认语言(默认 `zh-CN`)|
|
|
175
175
|
| `LIMIT_MAX` | Repository 单页上限(默认 300);`all()`/`find()` 显式 limit 亦被钳制到该值 |
|
|
176
176
|
| `middleware` | 宿主全局中间件插槽:数组,元素为 express 中间件或中间件工厂 `(chan) => mw`,注册于核心链早期(健康检查后、静态资源前),对所有响应生效 |
|
|
177
|
+
| `health` | 内置健康检查端点:`{ enabled: true, path: '/health' }`(默认值)。健全部署返回 HTTP **200**、degraded(含内部异常)返回 HTTP **503**,便于探针/负载均衡摘除实例。浅模式零 IO——只读进程内 DB 健康缓存,不实时 ping。`enabled: false` 可整体关闭,`path` 可改路径 |
|
|
177
178
|
| `SLOW_THRESHOLD` | 数据库慢查询阈值(ms,默认 200),超过记 warn 日志 |
|
|
178
179
|
| `SHUTDOWN_TIMEOUT` | 优雅停机强制退出超时(ms,默认 5000)|
|
|
179
180
|
| `BODY_LIMIT` | 请求体上限(默认 `10mb`)|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "chanjs",
|
|
4
|
-
"version": "2.8.
|
|
4
|
+
"version": "2.8.6",
|
|
5
5
|
"description": "chanjs基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"module": "index.js",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"pg": "^8.23.0",
|
|
52
52
|
"pino": "^9.5.0",
|
|
53
53
|
"pino-http": "^10.3.0",
|
|
54
|
+
"pino-pretty": "^13.1.3",
|
|
54
55
|
"serve-favicon": "^2.5.1",
|
|
55
56
|
"xss": "^1.0.15"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
|
-
"pino-pretty": "^11.3.0",
|
|
59
59
|
"zod": "^4.4.3"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
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) {
|