chanjs 2.7.2 → 2.7.3
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/App.js +232 -16
- package/base/Aop.js +20 -3
- package/base/Container.js +80 -3
- package/base/Controller.js +38 -9
- package/base/Database.js +50 -0
- package/base/Event.js +12 -0
- package/base/{Service.js → Repository.js} +644 -539
- package/common/api.js +18 -8
- package/common/code.js +25 -15
- package/common/email.js +98 -17
- package/common/index.js +1 -1
- package/config/code.js +138 -82
- package/global/index.js +1 -1
- package/helper/index.js +43 -41
- package/index.js +19 -6
- package/loader/index.js +6 -0
- package/{helper → loader}/loader.js +41 -27
- package/middleware/compress.js +185 -0
- package/middleware/cors.js +36 -24
- package/middleware/header.js +5 -10
- package/middleware/index.js +1 -0
- package/middleware/log.js +27 -3
- package/middleware/setBody.js +9 -1
- package/middleware/static.js +2 -1
- package/middleware/template.js +139 -4
- package/middleware/waf.js +136 -76
- package/package.json +2 -3
- package/realtime/index.js +7 -0
- package/realtime/sse.js +424 -0
- package/realtime/websocket.js +540 -0
- package/response/index.js +12 -0
- package/response/response.js +258 -0
- package/schedule/index.js +6 -0
- package/schedule/schedule.js +491 -0
- package/{helper → security}/checker.js +23 -8
- package/security/index.js +14 -0
- package/{helper → security}/jwt.js +175 -107
- package/security/keywords.js +179 -0
- package/security/rate-limit.js +105 -0
- package/security/sign.js +210 -0
- package/security/xss-filter.js +63 -0
- package/storage/cache.js +258 -0
- package/storage/index.js +9 -0
- package/storage/redis.js +258 -0
- package/storage/store.js +266 -0
- package/{helper → utils}/file.js +106 -15
- package/{helper → utils}/filter.js +2 -1
- package/{helper → utils}/html.js +19 -1
- package/utils/index.js +34 -0
- package/{helper → utils}/ip.js +25 -16
- package/utils/request.js +172 -0
- package/{helper → utils}/time.js +1 -1
- package/utils/tree.js +121 -0
- package/common/category.js +0 -22
- package/common/sms.js +0 -104
- package/extend/art-template.js +0 -129
- package/extend/index.js +0 -6
- package/global/global.js +0 -63
- package/helper/cache.js +0 -187
- package/helper/keywords.js +0 -132
- package/helper/rate-limit.js +0 -116
- package/helper/request.js +0 -47
- package/helper/response.js +0 -180
- package/helper/sign.js +0 -96
- package/helper/tree.js +0 -77
- package/helper/xss-filter.js +0 -42
- /package/{helper → utils}/data-parse.js +0 -0
|
@@ -1,107 +1,175 @@
|
|
|
1
|
-
import jwt from "jsonwebtoken";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* }
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
* @
|
|
97
|
-
* @
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* @example
|
|
103
|
-
* const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
import { store } from "../storage/store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JWT 黑名单 Key 前缀(store 适配层,内存/Redis 双后端)
|
|
6
|
+
* 用于实现 token 主动失效机制
|
|
7
|
+
*/
|
|
8
|
+
const REVOKED_KEY_PREFIX = 'jwt:revoked:';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 默认签名算法白名单(强制 HS256,防止 alg=none 绕过)
|
|
12
|
+
*/
|
|
13
|
+
const ALLOWED_ALGORITHMS = ['HS256'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 生成 JWT 令牌
|
|
17
|
+
* @param {Object} data - 要编码到令牌中的数据
|
|
18
|
+
* @param {string} secretKey - JWT 密钥
|
|
19
|
+
* @param {string} [time="7d"] - 令牌过期时间,默认 7 天
|
|
20
|
+
* @returns {string|null} 生成的 JWT 令牌,失败时返回 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...
|
|
27
|
+
*/
|
|
28
|
+
export function setToken(data = {}, secretKey, time = "7d") {
|
|
29
|
+
if (!secretKey) {
|
|
30
|
+
console.error('[安全错误] JWT_SECRET 必须配置');
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return jwt.sign(data, secretKey, {
|
|
35
|
+
expiresIn: time,
|
|
36
|
+
algorithm: "HS256",
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error("令牌生成失败:", error.message);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 验证并解析 JWT 令牌(异步)
|
|
46
|
+
* @async
|
|
47
|
+
* @param {string} token - 要验证的 JWT 令牌
|
|
48
|
+
* @param {string} secretKey - JWT 密钥
|
|
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
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
export async function getToken(token, secretKey) {
|
|
64
|
+
if (!secretKey) {
|
|
65
|
+
console.error('[安全错误] JWT_SECRET 必须配置');
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 验证 JWT 令牌(同步)
|
|
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) {
|
|
109
|
+
try {
|
|
110
|
+
// 强制指定算法白名单,防止 alg=none 等绕过
|
|
111
|
+
return jwt.verify(token, secretKey, { algorithms: ALLOWED_ALGORITHMS });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 生成 JWT 令牌(setToken 的别名)
|
|
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');
|
|
127
|
+
*/
|
|
128
|
+
export function generateToken(data, secretKey, expiresIn = "7d") {
|
|
129
|
+
return setToken(data, secretKey, expiresIn);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 撤销 JWT 令牌(写入黑名单)
|
|
134
|
+
* @async
|
|
135
|
+
* @param {string} token - 要撤销的令牌
|
|
136
|
+
* @param {number} [ttlMs] - 黑名单保留时长(毫秒),默认与 token 剩余有效期对齐
|
|
137
|
+
* @returns {Promise<boolean>} 是否撤销成功
|
|
138
|
+
* @description
|
|
139
|
+
* 将 token 写入黑名单,使其立即失效
|
|
140
|
+
* 用于:用户登出、修改密码、踢人下线等场景
|
|
141
|
+
* @example
|
|
142
|
+
* await revokeToken(token, 7 * 24 * 60 * 60 * 1000);
|
|
143
|
+
*/
|
|
144
|
+
export async function revokeToken(token, ttlMs = 7 * 24 * 60 * 60 * 1000) {
|
|
145
|
+
if (!token || typeof token !== 'string') return false;
|
|
146
|
+
try {
|
|
147
|
+
const key = `${REVOKED_KEY_PREFIX}${token}`;
|
|
148
|
+
await store.set(key, { revokedAt: Date.now() }, ttlMs);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error('[JWT] 撤销令牌失败:', err.message);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
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
|
+
try {
|
|
168
|
+
const key = `${REVOKED_KEY_PREFIX}${token}`;
|
|
169
|
+
const v = await store.get(key);
|
|
170
|
+
return !!v;
|
|
171
|
+
} catch {
|
|
172
|
+
// fail-open:store 异常时放行,避免误伤所有用户
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安全关键词规则定义
|
|
3
|
+
* 定义各类恶意攻击的关键词检测规则
|
|
4
|
+
*
|
|
5
|
+
* 性能优化:
|
|
6
|
+
* 1. 每类关键词合并成一个大正则做预检(单次 test 完成整类匹配)
|
|
7
|
+
* 2. 预检命中后再遍历该类的具体关键词定位命中项
|
|
8
|
+
* 3. 正常请求:8 个大正则 test,比原版 80+ 次正则 test 快 10 倍
|
|
9
|
+
* 4. 恶意请求:大正则 + 该类关键词遍历,开销略增但可接受
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 关键词规则定义
|
|
14
|
+
* @type {Object}
|
|
15
|
+
*/
|
|
16
|
+
export const KEYWORD_RULES = {
|
|
17
|
+
/**
|
|
18
|
+
* 整词匹配规则(需完整匹配)
|
|
19
|
+
*/
|
|
20
|
+
wholeWord: [
|
|
21
|
+
"netcat", "nc", "php-cgi", "process", "require","exec","import",
|
|
22
|
+
"child_process", "execSync", "mainModule"
|
|
23
|
+
],
|
|
24
|
+
/**
|
|
25
|
+
* 敏感文件扩展名
|
|
26
|
+
*/
|
|
27
|
+
extensions: [
|
|
28
|
+
".php", ".asp", ".aspx", ".jsp", ".jspx", ".do", ".action", ".cgi",
|
|
29
|
+
".py", ".pl", ".cfm", ".jhtml", ".shtml",".sql",".env",".git"
|
|
30
|
+
],
|
|
31
|
+
/**
|
|
32
|
+
* 敏感目录名称
|
|
33
|
+
*/
|
|
34
|
+
directories: [
|
|
35
|
+
"/administrator", "/wp-admin", "phpMyAdmin", "cgi-bin",
|
|
36
|
+
"setup", "staging", "internal", "debug", "metadata", "secret"
|
|
37
|
+
],
|
|
38
|
+
/**
|
|
39
|
+
* SQL 注入关键词
|
|
40
|
+
* 已移除过于宽泛的词:benchmark( concat( version( 等正常业务可能出现的字符
|
|
41
|
+
* 仅保留强 SQL 注入特征词
|
|
42
|
+
*/
|
|
43
|
+
sqlInjection: [
|
|
44
|
+
"sleep(", "extractvalue(", "updatexml(",
|
|
45
|
+
"union select", "union all", "select @@", "drop ", "alter ", "truncate ",
|
|
46
|
+
"(select", "information_schema", "load_file(", "into outfile", "into dumpfile"
|
|
47
|
+
],
|
|
48
|
+
/**
|
|
49
|
+
* 命令注入关键词
|
|
50
|
+
* 已移除过宽词:kill / su / ssh / chmod / halt / reboot / mount / ln -s / benchmark( / concat( / version(
|
|
51
|
+
* 这些词在正常业务字符串中频繁出现,误伤率高
|
|
52
|
+
* 保留强命令注入特征词
|
|
53
|
+
*/
|
|
54
|
+
commandInjection: [
|
|
55
|
+
"cmd=", "system(", "exec(", "shell_exec(", "passthru(",
|
|
56
|
+
"eval(", "assert(", "preg_replace", "bash -i", "rm -rf",
|
|
57
|
+
"wget ", "curl ", "base64_decode", "phpinfo()",
|
|
58
|
+
"killall", "shutdown", "fdisk",
|
|
59
|
+
"mkfs", "dd ", "scp ", "rsync", "nc ",
|
|
60
|
+
"netcat", "nmap", "iptables", "systemctl", "service",
|
|
61
|
+
"crontab", "sudo", "useradd",
|
|
62
|
+
"userdel", "usermod", "groupadd", "groupdel", "passwd",
|
|
63
|
+
"chpasswd"
|
|
64
|
+
],
|
|
65
|
+
/**
|
|
66
|
+
* 路径遍历关键词
|
|
67
|
+
*/
|
|
68
|
+
pathTraversal: [
|
|
69
|
+
"../", "..\\", "/etc/passwd", "/etc/shadow", "/etc/hosts",
|
|
70
|
+
"/etc/", "/var/www/", "/app/", "/root/", "__dirname", "__filename"
|
|
71
|
+
],
|
|
72
|
+
/**
|
|
73
|
+
* XSS 攻击关键词
|
|
74
|
+
*/
|
|
75
|
+
xss: [
|
|
76
|
+
"<script", "javascript:", "onerror=", "onload=", "onclick=",
|
|
77
|
+
"alert(", "document.cookie", "document.write"
|
|
78
|
+
],
|
|
79
|
+
/**
|
|
80
|
+
* 编码绕过关键词
|
|
81
|
+
*/
|
|
82
|
+
encoding: [
|
|
83
|
+
"0x7e", "UNION%20SELECT", "%27OR%27", "{{", "}}", "${", "1+1"
|
|
84
|
+
],
|
|
85
|
+
/**
|
|
86
|
+
* 敏感标识符
|
|
87
|
+
*/
|
|
88
|
+
sensitiveIdentifiers: [
|
|
89
|
+
"wp-", "smtp", "redirect", "configs", ".well-known/",
|
|
90
|
+
"fs.readFile", "fs.existsSync", "process.env", "process.argv"
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 转义正则特殊字符 + 空格转 \s
|
|
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
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 把同类的关键词数组合并成一个大正则(用 | 连接)
|
|
106
|
+
* 用 (?:...) 非捕获组,避免捕获组过多影响性能
|
|
107
|
+
* @param {Array<string>} keywords - 关键词数组
|
|
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
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 关键词检测缓存(双层结构)
|
|
121
|
+
* - mergedRegex: 该类别所有关键词合并的大正则,用于快速预检
|
|
122
|
+
* - patterns: 原始关键词数组 + 各自正则,用于命中后定位具体关键词
|
|
123
|
+
* @type {Object}
|
|
124
|
+
*/
|
|
125
|
+
export let keywordRegexCache = (() => {
|
|
126
|
+
const cache = {};
|
|
127
|
+
|
|
128
|
+
for (const [category, keywords] of Object.entries(KEYWORD_RULES)) {
|
|
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);
|
|
144
|
+
|
|
145
|
+
cache[category] = { mergedRegex, patterns };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return cache;
|
|
149
|
+
})();
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 更新关键词缓存
|
|
153
|
+
* @param {Object} newRules - 新的关键词规则
|
|
154
|
+
* @description
|
|
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;
|
|
164
|
+
|
|
165
|
+
const isWholeWord = category === 'wholeWord';
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isIgnored } from "./checker.js";
|
|
2
|
+
import { getIp } from "../utils/ip.js";
|
|
3
|
+
import { store } from "../storage/store.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 访问频率限制工具
|
|
7
|
+
* 改用服务端存储(Map+TTL 或 Redis),按 IP 维度限流
|
|
8
|
+
* 彻底废弃 Cookie 存储方案(旧版可被任意绕过)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 解析时间窗口参数
|
|
13
|
+
* @private
|
|
14
|
+
*/
|
|
15
|
+
function parseWindowMs(time) {
|
|
16
|
+
if (typeof time === 'number') return time;
|
|
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
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
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
|
+
* });
|
|
51
|
+
*/
|
|
52
|
+
export function createRateLimitMiddleware(rateLimitConfig) {
|
|
53
|
+
const config = {
|
|
54
|
+
windowMs: parseWindowMs(rateLimitConfig?.windowMs),
|
|
55
|
+
max: rateLimitConfig?.max || 60,
|
|
56
|
+
ignorePaths: rateLimitConfig?.ignorePaths || [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const KEY_PREFIX = 'ratelimit:';
|
|
60
|
+
|
|
61
|
+
return async (req, res, next) => {
|
|
62
|
+
try {
|
|
63
|
+
if (isIgnored(req.path, config.ignorePaths)) {
|
|
64
|
+
return next();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ip = getIp(req);
|
|
68
|
+
// 双维度限流:IP + uid(登录用户),避免分布式 IP 攻击时单 IP 维度失效
|
|
69
|
+
// - 未登录用户:仅按 IP 限流
|
|
70
|
+
// - 登录用户:IP 和 uid 各自独立计数,任一超限即拒绝
|
|
71
|
+
const uid = req.user?.uid;
|
|
72
|
+
const ipKey = `${KEY_PREFIX}ip:${ip}`;
|
|
73
|
+
const uidKey = uid ? `${KEY_PREFIX}uid:${uid}` : null;
|
|
74
|
+
|
|
75
|
+
// IP 维度自增
|
|
76
|
+
const ipCount = await store.incrAndExpire(ipKey, config.windowMs);
|
|
77
|
+
// uid 维度自增(登录用户才走)
|
|
78
|
+
const uidCount = uidKey ? await store.incrAndExpire(uidKey, config.windowMs) : 0;
|
|
79
|
+
|
|
80
|
+
if (ipCount > config.max || uidCount > config.max) {
|
|
81
|
+
// 返回窗口剩余等待时间(秒)
|
|
82
|
+
const retryAfter = Math.ceil(config.windowMs / 1000);
|
|
83
|
+
const dim = ipCount > config.max ? 'IP' : 'UID';
|
|
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
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
next();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`[WAF 限流异常] 路径:${req.path} 错误:${error.message}`);
|
|
97
|
+
// 异常时拒绝请求,避免限流服务故障时被绕过
|
|
98
|
+
return res.status(429).json({
|
|
99
|
+
code: 429,
|
|
100
|
+
success: false,
|
|
101
|
+
msg: '限流服务暂时不可用',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|