chanjs 2.7.3 → 2.7.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/USAGE.md +533 -0
- package/config/index.js +37 -6
- package/core/App.js +166 -0
- package/core/Container.js +77 -0
- package/core/Controller.js +29 -0
- package/core/Database.js +93 -0
- package/core/Repository.js +327 -0
- package/core/Service.js +11 -0
- package/core/bootstrap/error-handler.js +104 -0
- package/core/bootstrap/hook-runner.js +64 -0
- package/core/bootstrap/middleware.js +35 -0
- package/core/bootstrap/router-loader.js +53 -0
- package/core/errors.js +224 -0
- package/core/loader.js +89 -0
- package/core/registry.js +17 -0
- package/doc/Cache.md +279 -106
- package/doc/Common.md +590 -134
- package/doc/Controller.md +166 -95
- package/doc/Help.md +299 -698
- package/doc/QuickStart.md +116 -0
- package/doc/Repository.md +560 -0
- package/doc/Service.md +201 -527
- package/index.js +75 -37
- package/middleware/body.js +17 -0
- package/middleware/cookie.js +7 -15
- package/middleware/cors.js +9 -27
- package/middleware/favicon.js +7 -17
- package/middleware/header.js +15 -16
- package/middleware/index.js +11 -11
- package/middleware/log.js +26 -56
- package/middleware/static.js +15 -28
- package/middleware/template.js +75 -115
- package/middleware/validate.js +79 -0
- package/middleware/waf.js +174 -197
- package/package.json +11 -3
- package/response/code.js +73 -0
- package/response/index.js +9 -6
- package/response/response.js +82 -236
- package/security/checker.js +26 -74
- package/security/index.js +4 -9
- package/security/jwt.js +69 -142
- package/security/keywords.js +32 -136
- package/security/rate-limit.js +38 -80
- package/security/sign.js +83 -176
- package/security/xss-filter.js +21 -53
- package/storage/cache.js +57 -196
- package/storage/index.js +3 -6
- package/storage/redis.js +123 -181
- package/storage/store.js +163 -188
- package/utils/data-parse.js +42 -186
- package/utils/file.js +73 -244
- package/utils/filter.js +22 -25
- package/utils/html.js +49 -33
- package/utils/index.js +21 -7
- package/utils/ip.js +31 -71
- package/utils/logger.js +117 -0
- package/utils/pages.js +55 -0
- package/utils/paths.js +18 -0
- package/utils/request.js +94 -136
- package/utils/signal.js +87 -0
- package/utils/time.js +33 -75
- package/utils/tree.js +112 -104
- package/App.js +0 -533
- package/base/Aop.js +0 -195
- package/base/Container.js +0 -161
- package/base/Controller.js +0 -65
- package/base/Database.js +0 -133
- package/base/Event.js +0 -61
- package/base/Repository.js +0 -644
- package/common/api.js +0 -35
- package/common/code.js +0 -52
- package/common/email.js +0 -191
- package/common/index.js +0 -5
- package/common/pages.js +0 -120
- package/common/utils.js +0 -73
- package/config/code.js +0 -166
- package/config/paths.js +0 -60
- package/doc/Aop.md +0 -269
- package/doc/Email.md +0 -114
- package/doc/Event.md +0 -232
- package/global/env.js +0 -11
- package/global/import.js +0 -39
- package/global/index.js +0 -8
- package/helper/index.js +0 -79
- package/loader/index.js +0 -6
- package/loader/loader.js +0 -138
- package/middleware/compress.js +0 -185
- package/middleware/setBody.js +0 -32
- package/realtime/index.js +0 -7
- package/realtime/sse.js +0 -424
- package/realtime/websocket.js +0 -540
- package/schedule/index.js +0 -6
- package/schedule/schedule.js +0 -491
package/security/jwt.js
CHANGED
|
@@ -1,175 +1,102 @@
|
|
|
1
1
|
import jwt from "jsonwebtoken";
|
|
2
2
|
import { store } from "../storage/store.js";
|
|
3
|
+
import logger from "../utils/logger.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const REVOKED_KEY_PREFIX = "jwt:revoked:";
|
|
6
|
+
const ALLOWED_ALGORITHMS = ["HS256"];
|
|
7
|
+
|
|
8
|
+
const JWT_ERR_MSG = {
|
|
9
|
+
TokenExpiredError: "令牌已过期",
|
|
10
|
+
JsonWebTokenError: "无效的令牌",
|
|
11
|
+
default: "令牌验证失败",
|
|
12
|
+
};
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
|
-
*
|
|
15
|
+
* 校验token是否在黑名单(fail-open:存储异常直接放行)
|
|
12
16
|
*/
|
|
13
|
-
const
|
|
17
|
+
const isTokenRevoked = async token => {
|
|
18
|
+
if (!token) return false;
|
|
19
|
+
try { return !!await store.get(`${REVOKED_KEY_PREFIX}${token}`); }
|
|
20
|
+
catch { return false; }
|
|
21
|
+
};
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @param {
|
|
18
|
-
* @param {string} secretKey
|
|
19
|
-
* @param {string} [
|
|
20
|
-
* @returns {string|null}
|
|
21
|
-
* @description
|
|
22
|
-
* 使用 HS256 算法生成 JWT 令牌
|
|
23
|
-
* 如果 secretKey 未配置,会输出安全错误并返回 null
|
|
24
|
-
* @example
|
|
25
|
-
* const token = setToken({ userId: 123 }, 'my-secret-key', '1h');
|
|
26
|
-
* console.log(token); // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
24
|
+
* 签发JWT令牌
|
|
25
|
+
* @param {object} data 载荷数据
|
|
26
|
+
* @param {string} secretKey 签名密钥
|
|
27
|
+
* @param {string} [expire='7d'] 有效期
|
|
28
|
+
* @returns {string|null}
|
|
27
29
|
*/
|
|
28
|
-
export
|
|
30
|
+
export const setToken = (data = {}, secretKey, expire = "7d") => {
|
|
29
31
|
if (!secretKey) {
|
|
30
|
-
|
|
32
|
+
logger.error("[安全错误] JWT_SECRET 未配置");
|
|
31
33
|
return null;
|
|
32
34
|
}
|
|
33
35
|
try {
|
|
34
|
-
return jwt.sign(data, secretKey, {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
});
|
|
38
|
-
} catch (error) {
|
|
39
|
-
console.error("令牌生成失败:", error.message);
|
|
36
|
+
return jwt.sign(data, secretKey, { algorithm: "HS256", expiresIn: expire });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.error("令牌生成失败", err.message);
|
|
40
39
|
return null;
|
|
41
40
|
}
|
|
42
|
-
}
|
|
41
|
+
};
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
|
-
*
|
|
46
|
-
* @
|
|
47
|
-
* @param {string}
|
|
48
|
-
* @
|
|
49
|
-
* @returns {Promise<Object|null>} 解码后的令牌数据,验证失败时返回 null
|
|
50
|
-
* @description
|
|
51
|
-
* 验证 JWT 令牌的有效性并返回解码后的数据
|
|
52
|
-
* - 强制校验 algorithms: ['HS256'],防止 alg=none 攻击
|
|
53
|
-
* - 自动检查黑名单,已撤销的 token 返回 null
|
|
54
|
-
* 支持的错误类型:
|
|
55
|
-
* - TokenExpiredError: 令牌已过期
|
|
56
|
-
* - JsonWebTokenError: 无效的令牌格式
|
|
57
|
-
* @example
|
|
58
|
-
* const data = await getToken('eyJhbGciOiJIUzI1NiIs...', 'my-secret-key');
|
|
59
|
-
* if (data) {
|
|
60
|
-
* console.log(data.userId); // 123
|
|
61
|
-
* }
|
|
44
|
+
* 校验并解析JWT,先校验黑名单
|
|
45
|
+
* @param {string} token
|
|
46
|
+
* @param {string} secretKey
|
|
47
|
+
* @returns {Promise<{valid:boolean, reason:string, payload?:object, error?:Error}>}
|
|
62
48
|
*/
|
|
63
|
-
export async
|
|
64
|
-
if (!secretKey) {
|
|
65
|
-
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
// 黑名单校验:已撤销的 token 直接拒绝
|
|
69
|
-
if (await isTokenRevoked(token)) {
|
|
70
|
-
console.error('[安全错误] 令牌已被撤销');
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
return new Promise((resolve) => {
|
|
74
|
-
// 强制指定算法白名单,防止 alg=none 等绕过
|
|
75
|
-
jwt.verify(token, secretKey, { algorithms: ALLOWED_ALGORITHMS }, (err, decode) => {
|
|
76
|
-
if (err) {
|
|
77
|
-
let errorMessage = "令牌验证失败";
|
|
78
|
-
if (err.name === "TokenExpiredError") {
|
|
79
|
-
errorMessage = "令牌已过期";
|
|
80
|
-
} else if (err.name === "JsonWebTokenError") {
|
|
81
|
-
errorMessage = "无效的令牌";
|
|
82
|
-
}
|
|
83
|
-
console.error(errorMessage, "令牌异常信息:", err.message);
|
|
84
|
-
resolve(null);
|
|
85
|
-
} else {
|
|
86
|
-
resolve(decode);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
}
|
|
49
|
+
export const verifyToken = async (token, secretKey) => {
|
|
50
|
+
if (!secretKey) return { valid: false, reason: "missing_secret" };
|
|
51
|
+
if (!token) return { valid: false, reason: "missing" };
|
|
91
52
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
* @param {string} token - 要验证的 JWT 令牌
|
|
95
|
-
* @param {string} secretKey - JWT 密钥
|
|
96
|
-
* @returns {Object|null} 解码后的令牌数据,验证失败时返回 null
|
|
97
|
-
* @description
|
|
98
|
-
* 同步版本的令牌验证方法
|
|
99
|
-
* - 强制校验 algorithms: ['HS256']
|
|
100
|
-
* - 注意:同步版本无法检查黑名单(黑名单走异步 store),如需校验请用 getToken
|
|
101
|
-
* 如果令牌无效或过期,静默返回 null
|
|
102
|
-
* @example
|
|
103
|
-
* const data = verifyToken('eyJhbGciOiJIUzI1NiIs...', 'my-secret-key');
|
|
104
|
-
* if (data) {
|
|
105
|
-
* console.log('令牌有效:', data);
|
|
106
|
-
* }
|
|
107
|
-
*/
|
|
108
|
-
export function verifyToken(token, secretKey) {
|
|
53
|
+
// 先验签(无效/过期/伪造 token 不查 Redis)
|
|
54
|
+
let payload;
|
|
109
55
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
56
|
+
payload = await new Promise((resolve, reject) => {
|
|
57
|
+
jwt.verify(token, secretKey, { algorithms: ALLOWED_ALGORITHMS }, (err, decoded) => {
|
|
58
|
+
err ? reject(err) : resolve(decoded);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
112
61
|
} catch (err) {
|
|
113
|
-
|
|
62
|
+
const reason = err.name === "TokenExpiredError" ? "expired" : "invalid";
|
|
63
|
+
logger.error(JWT_ERR_MSG[err.name] ?? JWT_ERR_MSG.default, err.message);
|
|
64
|
+
return { valid: false, reason, error: err };
|
|
114
65
|
}
|
|
115
|
-
|
|
66
|
+
|
|
67
|
+
// 签名合法后再查黑名单
|
|
68
|
+
if (await isTokenRevoked(token)) {
|
|
69
|
+
logger.error("[安全拦截] 令牌已注销");
|
|
70
|
+
return { valid: false, reason: "revoked" };
|
|
71
|
+
}
|
|
72
|
+
return { valid: true, reason: "ok", payload };
|
|
73
|
+
};
|
|
116
74
|
|
|
117
75
|
/**
|
|
118
|
-
*
|
|
119
|
-
* @param {Object} data - 要编码到令牌中的数据
|
|
120
|
-
* @param {string} secretKey - JWT 密钥
|
|
121
|
-
* @param {string} [expiresIn="7d"] - 令牌过期时间,默认 7 天
|
|
122
|
-
* @returns {string|null} 生成的 JWT 令牌,失败时返回 null
|
|
123
|
-
* @description
|
|
124
|
-
* setToken 函数的别名,提供更语义化的函数名
|
|
125
|
-
* @example
|
|
126
|
-
* const token = generateToken({ userId: 123 }, 'my-secret-key', '1h');
|
|
76
|
+
* 校验并解析JWT,成功返回载荷
|
|
127
77
|
*/
|
|
128
|
-
export
|
|
129
|
-
|
|
130
|
-
|
|
78
|
+
export const getToken = async (token, secretKey) => {
|
|
79
|
+
const { valid, payload } = await verifyToken(token, secretKey);
|
|
80
|
+
return valid ? payload : null;
|
|
81
|
+
};
|
|
131
82
|
|
|
132
83
|
/**
|
|
133
|
-
*
|
|
134
|
-
* @
|
|
135
|
-
* @param {
|
|
136
|
-
* @
|
|
137
|
-
* @returns {Promise<boolean>} 是否撤销成功
|
|
138
|
-
* @description
|
|
139
|
-
* 将 token 写入黑名单,使其立即失效
|
|
140
|
-
* 用于:用户登出、修改密码、踢人下线等场景
|
|
141
|
-
* @example
|
|
142
|
-
* await revokeToken(token, 7 * 24 * 60 * 60 * 1000);
|
|
84
|
+
* 注销令牌,加入黑名单
|
|
85
|
+
* @param {string} token
|
|
86
|
+
* @param {number} ttl 令牌剩余有效毫秒数
|
|
87
|
+
* @returns {Promise<boolean>}
|
|
143
88
|
*/
|
|
144
|
-
export
|
|
145
|
-
if (!token
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await store.set(key, { revokedAt: Date.now() }, ttlMs);
|
|
89
|
+
export const revokeToken = async (token, ttl) => {
|
|
90
|
+
if (!token) return false;
|
|
91
|
+
if (!ttl || ttl <= 0) {
|
|
92
|
+
logger.info("[JWT] token 已过期或无剩余有效期,无需注销");
|
|
149
93
|
return true;
|
|
150
|
-
} catch (err) {
|
|
151
|
-
console.error('[JWT] 撤销令牌失败:', err.message);
|
|
152
|
-
return false;
|
|
153
94
|
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* 检查 token 是否在黑名单中
|
|
158
|
-
* @async
|
|
159
|
-
* @param {string} token - 待检查的令牌
|
|
160
|
-
* @returns {Promise<boolean>} true 表示已被撤销
|
|
161
|
-
* @description
|
|
162
|
-
* 用于在 verify 前主动检查 token 是否已撤销
|
|
163
|
-
* 异常时返回 false(fail-open,避免 store 故障导致所有用户登出)
|
|
164
|
-
*/
|
|
165
|
-
export async function isTokenRevoked(token) {
|
|
166
|
-
if (!token || typeof token !== 'string') return false;
|
|
167
95
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// fail-open:store 异常时放行,避免误伤所有用户
|
|
96
|
+
await store.set(`${REVOKED_KEY_PREFIX}${token}`, 1, ttl);
|
|
97
|
+
return true;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.error("令牌注销失败", err.message);
|
|
173
100
|
return false;
|
|
174
101
|
}
|
|
175
|
-
}
|
|
102
|
+
};
|
package/security/keywords.js
CHANGED
|
@@ -1,179 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* 定义各类恶意攻击的关键词检测规则
|
|
4
|
-
*
|
|
5
|
-
* 性能优化:
|
|
6
|
-
* 1. 每类关键词合并成一个大正则做预检(单次 test 完成整类匹配)
|
|
7
|
-
* 2. 预检命中后再遍历该类的具体关键词定位命中项
|
|
8
|
-
* 3. 正常请求:8 个大正则 test,比原版 80+ 次正则 test 快 10 倍
|
|
9
|
-
* 4. 恶意请求:大正则 + 该类关键词遍历,开销略增但可接受
|
|
2
|
+
* 安全检测恶意关键词规则库
|
|
10
3
|
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* 关键词规则定义
|
|
14
|
-
* @type {Object}
|
|
15
|
-
*/
|
|
16
|
-
export const KEYWORD_RULES = {
|
|
17
|
-
/**
|
|
18
|
-
* 整词匹配规则(需完整匹配)
|
|
19
|
-
*/
|
|
4
|
+
const KEYWORD_RULES = Object.freeze({
|
|
20
5
|
wholeWord: [
|
|
21
|
-
"netcat", "nc", "php-cgi", "process", "require","exec","import",
|
|
6
|
+
"netcat", "nc", "php-cgi", "process", "require", "exec", "import",
|
|
22
7
|
"child_process", "execSync", "mainModule"
|
|
23
8
|
],
|
|
24
|
-
/**
|
|
25
|
-
* 敏感文件扩展名
|
|
26
|
-
*/
|
|
27
9
|
extensions: [
|
|
28
10
|
".php", ".asp", ".aspx", ".jsp", ".jspx", ".do", ".action", ".cgi",
|
|
29
|
-
".py", ".pl", ".cfm", ".jhtml", ".shtml",".sql",".env",".git"
|
|
11
|
+
".py", ".pl", ".cfm", ".jhtml", ".shtml", ".sql", ".env", ".git"
|
|
30
12
|
],
|
|
31
|
-
/**
|
|
32
|
-
* 敏感目录名称
|
|
33
|
-
*/
|
|
34
13
|
directories: [
|
|
35
14
|
"/administrator", "/wp-admin", "phpMyAdmin", "cgi-bin",
|
|
36
15
|
"setup", "staging", "internal", "debug", "metadata", "secret"
|
|
37
16
|
],
|
|
38
|
-
/**
|
|
39
|
-
* SQL 注入关键词
|
|
40
|
-
* 已移除过于宽泛的词:benchmark( concat( version( 等正常业务可能出现的字符
|
|
41
|
-
* 仅保留强 SQL 注入特征词
|
|
42
|
-
*/
|
|
43
17
|
sqlInjection: [
|
|
44
18
|
"sleep(", "extractvalue(", "updatexml(",
|
|
45
19
|
"union select", "union all", "select @@", "drop ", "alter ", "truncate ",
|
|
46
20
|
"(select", "information_schema", "load_file(", "into outfile", "into dumpfile"
|
|
47
21
|
],
|
|
48
|
-
/**
|
|
49
|
-
* 命令注入关键词
|
|
50
|
-
* 已移除过宽词:kill / su / ssh / chmod / halt / reboot / mount / ln -s / benchmark( / concat( / version(
|
|
51
|
-
* 这些词在正常业务字符串中频繁出现,误伤率高
|
|
52
|
-
* 保留强命令注入特征词
|
|
53
|
-
*/
|
|
54
22
|
commandInjection: [
|
|
55
23
|
"cmd=", "system(", "exec(", "shell_exec(", "passthru(",
|
|
56
24
|
"eval(", "assert(", "preg_replace", "bash -i", "rm -rf",
|
|
57
25
|
"wget ", "curl ", "base64_decode", "phpinfo()",
|
|
58
|
-
"killall", "shutdown", "fdisk",
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"userdel", "usermod", "groupadd", "groupdel", "passwd",
|
|
63
|
-
"chpasswd"
|
|
26
|
+
"killall", "shutdown", "fdisk", "mkfs", "dd ",
|
|
27
|
+
"scp ", "rsync", "nc ", "netcat", "nmap", "iptables",
|
|
28
|
+
"systemctl", "service", "crontab", "sudo", "useradd",
|
|
29
|
+
"userdel", "usermod", "groupadd", "groupdel", "passwd", "chpasswd"
|
|
64
30
|
],
|
|
65
|
-
/**
|
|
66
|
-
* 路径遍历关键词
|
|
67
|
-
*/
|
|
68
31
|
pathTraversal: [
|
|
69
32
|
"../", "..\\", "/etc/passwd", "/etc/shadow", "/etc/hosts",
|
|
70
|
-
"/
|
|
33
|
+
"/root/", "__dirname", "__filename"
|
|
71
34
|
],
|
|
72
|
-
/**
|
|
73
|
-
* XSS 攻击关键词
|
|
74
|
-
*/
|
|
75
35
|
xss: [
|
|
76
36
|
"<script", "javascript:", "onerror=", "onload=", "onclick=",
|
|
77
37
|
"alert(", "document.cookie", "document.write"
|
|
78
38
|
],
|
|
79
|
-
|
|
80
|
-
* 编码绕过关键词
|
|
81
|
-
*/
|
|
82
|
-
encoding: [
|
|
83
|
-
"0x7e", "UNION%20SELECT", "%27OR%27", "{{", "}}", "${", "1+1"
|
|
84
|
-
],
|
|
85
|
-
/**
|
|
86
|
-
* 敏感标识符
|
|
87
|
-
*/
|
|
39
|
+
encoding: ["0x7e", "UNION%20SELECT", "%27OR%27"],
|
|
88
40
|
sensitiveIdentifiers: [
|
|
89
|
-
"wp-", "
|
|
41
|
+
"wp-", ".well-known/",
|
|
90
42
|
"fs.readFile", "fs.existsSync", "process.env", "process.argv"
|
|
91
43
|
],
|
|
92
|
-
};
|
|
44
|
+
});
|
|
93
45
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* @private
|
|
97
|
-
*/
|
|
98
|
-
const REGEX_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g;
|
|
99
|
-
function escapeKeyword(keyword, isWholeWord = false) {
|
|
100
|
-
const escaped = keyword.replace(REGEX_SPECIAL_CHARS, "\\$&").replace(/ /g, "\\s");
|
|
101
|
-
return isWholeWord ? `\\b${escaped}\\b` : escaped;
|
|
102
|
-
}
|
|
46
|
+
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
|
|
47
|
+
const PRIORITY = ["sqlInjection", "xss", "commandInjection"];
|
|
103
48
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
* @param {boolean} isWholeWord - 是否整词匹配
|
|
109
|
-
* @returns {RegExp|null} 合并后的大正则,空数组返回 null
|
|
110
|
-
* @private
|
|
111
|
-
*/
|
|
112
|
-
function buildMergedRegex(keywords, isWholeWord = false) {
|
|
113
|
-
if (!keywords || !keywords.length) return null;
|
|
114
|
-
const parts = keywords.map(k => escapeKeyword(k, isWholeWord));
|
|
115
|
-
// 合并成一个大正则,用非捕获组 (?:...) 避免 capture 开销
|
|
116
|
-
return new RegExp(`(?:${parts.join('|')})`, 'i');
|
|
117
|
-
}
|
|
49
|
+
const escapeKeyword = (keyword, wholeWord = false) => {
|
|
50
|
+
const str = keyword.replace(REGEX_SPECIAL, "\\$&").replace(/ /g, "\\s");
|
|
51
|
+
return wholeWord ? `\\b${str}\\b` : str;
|
|
52
|
+
};
|
|
118
53
|
|
|
119
54
|
/**
|
|
120
|
-
*
|
|
121
|
-
* - mergedRegex: 该类别所有关键词合并的大正则,用于快速预检
|
|
122
|
-
* - patterns: 原始关键词数组 + 各自正则,用于命中后定位具体关键词
|
|
123
|
-
* @type {Object}
|
|
55
|
+
* 预构建每个分类的合并正则(priority 在前,其余按原顺序)
|
|
124
56
|
*/
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (!Array.isArray(keywords)) continue;
|
|
130
|
-
|
|
131
|
-
const isWholeWord = category === 'wholeWord';
|
|
132
|
-
|
|
133
|
-
// 单关键词正则(命中后定位用)
|
|
134
|
-
const patterns = keywords.map((keyword) => ({
|
|
135
|
-
keyword,
|
|
136
|
-
regex: new RegExp(
|
|
137
|
-
isWholeWord ? `\\b${escapeKeyword(keyword)}\\b` : escapeKeyword(keyword),
|
|
138
|
-
"i"
|
|
139
|
-
),
|
|
140
|
-
}));
|
|
141
|
-
|
|
142
|
-
// 合并大正则(预检用)
|
|
143
|
-
const mergedRegex = buildMergedRegex(keywords, isWholeWord);
|
|
57
|
+
const buildCategoryRegex = (cat, words) => {
|
|
58
|
+
const wholeWord = cat === "wholeWord";
|
|
59
|
+
return new RegExp(words.map(k => `(${escapeKeyword(k, wholeWord)})`).join("|"), "i");
|
|
60
|
+
};
|
|
144
61
|
|
|
145
|
-
|
|
62
|
+
const keywordRegexCache = (() => {
|
|
63
|
+
const cache = {};
|
|
64
|
+
for (const [cat, words] of Object.entries(KEYWORD_RULES)) {
|
|
65
|
+
cache[cat] = { merged: buildCategoryRegex(cat, words) };
|
|
146
66
|
}
|
|
147
|
-
|
|
148
67
|
return cache;
|
|
149
68
|
})();
|
|
150
69
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
* 允许动态添加或更新关键词检测规则
|
|
156
|
-
* @example
|
|
157
|
-
* updateKeywordCache({
|
|
158
|
-
* customKeywords: ['malicious', 'attack']
|
|
159
|
-
* });
|
|
160
|
-
*/
|
|
161
|
-
export function updateKeywordCache(newRules = {}) {
|
|
162
|
-
for (const [category, keywords] of Object.entries(newRules)) {
|
|
163
|
-
if (!Array.isArray(keywords)) continue;
|
|
70
|
+
const SORTED_CATEGORIES = [
|
|
71
|
+
...PRIORITY,
|
|
72
|
+
...Object.keys(KEYWORD_RULES).filter(c => !PRIORITY.includes(c)),
|
|
73
|
+
];
|
|
164
74
|
|
|
165
|
-
|
|
166
|
-
const patterns = keywords.map((keyword) => ({
|
|
167
|
-
keyword,
|
|
168
|
-
regex: new RegExp(
|
|
169
|
-
isWholeWord ? `\\b${escapeKeyword(keyword)}\\b` : escapeKeyword(keyword),
|
|
170
|
-
"i"
|
|
171
|
-
),
|
|
172
|
-
}));
|
|
173
|
-
|
|
174
|
-
keywordRegexCache[category] = {
|
|
175
|
-
mergedRegex: buildMergedRegex(keywords, isWholeWord),
|
|
176
|
-
patterns,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
}
|
|
75
|
+
export { KEYWORD_RULES, keywordRegexCache, SORTED_CATEGORIES };
|
package/security/rate-limit.js
CHANGED
|
@@ -1,105 +1,63 @@
|
|
|
1
1
|
import { isIgnored } from "./checker.js";
|
|
2
2
|
import { getIp } from "../utils/ip.js";
|
|
3
3
|
import { store } from "../storage/store.js";
|
|
4
|
+
import { CODE_RATE_LIMIT } from "../response/code.js";
|
|
5
|
+
import logger from "../utils/logger.js";
|
|
6
|
+
|
|
7
|
+
const TIME_UNIT = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
|
|
8
|
+
const DEFAULT_MAX = 60;
|
|
9
|
+
const DEFAULT_WINDOW_MS = 60 * 1000;
|
|
10
|
+
const KEY_PREFIX = "ratelimit:";
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
|
-
*
|
|
7
|
-
* 改用服务端存储(Map+TTL 或 Redis),按 IP 维度限流
|
|
8
|
-
* 彻底废弃 Cookie 存储方案(旧版可被任意绕过)
|
|
13
|
+
* 解析限流时间窗口,支持数字毫秒 / 1s/1m/1h/1d 字符串
|
|
9
14
|
*/
|
|
15
|
+
const parseWindowMs = time => {
|
|
16
|
+
if (typeof time === "number") return time;
|
|
17
|
+
if (typeof time !== "string") return DEFAULT_WINDOW_MS;
|
|
18
|
+
const m = time.match(/^(\d+)([smhd])$/);
|
|
19
|
+
return m ? Number(m[1]) * TIME_UNIT[m[2]] : DEFAULT_WINDOW_MS;
|
|
20
|
+
};
|
|
10
21
|
|
|
11
22
|
/**
|
|
12
|
-
*
|
|
13
|
-
* @private
|
|
23
|
+
* 限流拦截响应
|
|
14
24
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (typeof time !== 'string') return 60 * 1000;
|
|
18
|
-
|
|
19
|
-
const match = time.match(/^(\d+)([smhd])$/);
|
|
20
|
-
if (!match) return 60 * 1000;
|
|
21
|
-
|
|
22
|
-
const value = parseInt(match[1], 10);
|
|
23
|
-
const unit = match[2];
|
|
24
|
-
const multipliers = {
|
|
25
|
-
's': 1000,
|
|
26
|
-
'm': 60 * 1000,
|
|
27
|
-
'h': 60 * 60 * 1000,
|
|
28
|
-
'd': 24 * 60 * 60 * 1000,
|
|
29
|
-
};
|
|
30
|
-
return value * (multipliers[unit] || 1000);
|
|
31
|
-
}
|
|
25
|
+
const deny = (res, retryAfter, msg) =>
|
|
26
|
+
res.status(429).json({ success: false, code: CODE_RATE_LIMIT, msg, data: { retryAfter } });
|
|
32
27
|
|
|
33
28
|
/**
|
|
34
|
-
*
|
|
35
|
-
* @param {Object} rateLimitConfig - 限流配置
|
|
36
|
-
* @param {number|string} rateLimitConfig.windowMs - 时间窗口
|
|
37
|
-
* @param {number} rateLimitConfig.max - 最大请求次数
|
|
38
|
-
* @param {Array<string>} [rateLimitConfig.ignorePaths] - 忽略的路径数组
|
|
39
|
-
* @returns {Function} Express 中间件函数
|
|
40
|
-
* @description
|
|
41
|
-
* 基于 IP 的限流算法,状态存储在服务端
|
|
42
|
-
* - Redis 模式(启用时):跨实例共享,适合多机部署
|
|
43
|
-
* - 内存模式(默认):Map+TTL+LRU,上限 10 万条/约 20MB,单机可用
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* const rateLimiter = createRateLimitMiddleware({
|
|
47
|
-
* windowMs: '1m',
|
|
48
|
-
* max: 60,
|
|
49
|
-
* ignorePaths: ['/health']
|
|
50
|
-
* });
|
|
29
|
+
* 生成IP+UID双维度限流中间件
|
|
51
30
|
*/
|
|
52
|
-
export
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
ignorePaths: rateLimitConfig?.ignorePaths || [],
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const KEY_PREFIX = 'ratelimit:';
|
|
31
|
+
export const createRateLimitMiddleware = cfg => {
|
|
32
|
+
const windowMs = parseWindowMs(cfg?.windowMs);
|
|
33
|
+
const max = cfg?.max ?? DEFAULT_MAX;
|
|
34
|
+
const ignorePaths = cfg?.ignorePaths ?? [];
|
|
60
35
|
|
|
61
36
|
return async (req, res, next) => {
|
|
62
37
|
try {
|
|
63
|
-
if (isIgnored(req.path,
|
|
64
|
-
return next();
|
|
65
|
-
}
|
|
38
|
+
if (isIgnored(req.path, ignorePaths)) return next();
|
|
66
39
|
|
|
67
|
-
const ip = getIp(req);
|
|
68
|
-
// 双维度限流:IP + uid(登录用户),避免分布式 IP 攻击时单 IP 维度失效
|
|
69
|
-
// - 未登录用户:仅按 IP 限流
|
|
70
|
-
// - 登录用户:IP 和 uid 各自独立计数,任一超限即拒绝
|
|
40
|
+
const ip = getIp(req) || "unknown";
|
|
71
41
|
const uid = req.user?.uid;
|
|
72
42
|
const ipKey = `${KEY_PREFIX}ip:${ip}`;
|
|
73
43
|
const uidKey = uid ? `${KEY_PREFIX}uid:${uid}` : null;
|
|
74
44
|
|
|
75
|
-
//
|
|
76
|
-
const ipCount = await
|
|
77
|
-
|
|
78
|
-
|
|
45
|
+
// 计数自增(一次操作完成限流判定,存储异常 fail-close 拦截)
|
|
46
|
+
const [ipCount, uidCount] = await Promise.all([
|
|
47
|
+
store.incrAndExpire(ipKey, windowMs),
|
|
48
|
+
uidKey ? store.incrAndExpire(uidKey, windowMs) : 0,
|
|
49
|
+
]);
|
|
79
50
|
|
|
80
|
-
if (ipCount >
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
console.error(`[WAF 限流拦截] 路径:${req.path} 维度:${dim} IP计数:${ipCount}/${config.max} UID计数:${uidCount}/${config.max} IP:${ip} UID:${uid || '-'}`);
|
|
86
|
-
return res.status(429).json({
|
|
87
|
-
code: 429,
|
|
88
|
-
success: false,
|
|
89
|
-
msg: '请求过于频繁,请稍后重试',
|
|
90
|
-
retryAfter,
|
|
91
|
-
});
|
|
51
|
+
if (ipCount > max || uidCount > max) {
|
|
52
|
+
const dim = ipCount > max ? "IP" : "UID";
|
|
53
|
+
logger.error(`[WAF限流] path:${req.path} dim:${dim} ipCnt:${ipCount}/${max} uidCnt:${uidCount}/${max} ip:${ip} uid:${uid ?? "-"}`);
|
|
54
|
+
return deny(res, Math.ceil(windowMs / 1000), "请求过于频繁,请稍后重试");
|
|
92
55
|
}
|
|
93
|
-
|
|
94
56
|
next();
|
|
95
|
-
} catch (
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
return res.status(429).json({
|
|
99
|
-
code: 429,
|
|
100
|
-
success: false,
|
|
101
|
-
msg: '限流服务暂时不可用',
|
|
102
|
-
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.error(`[WAF限流异常] path:${req.path} err:${err.message}`);
|
|
59
|
+
// fail-close:存储异常直接拦截
|
|
60
|
+
return res.status(429).json({ success: false, code: CODE_RATE_LIMIT, msg: "限流服务暂时不可用" });
|
|
103
61
|
}
|
|
104
62
|
};
|
|
105
|
-
}
|
|
63
|
+
};
|