chanjs 2.7.2 → 2.7.4
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 +4 -4
- 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
package/middleware/waf.js
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (!cookieHeader) return {};
|
|
8
|
-
const cookies = {};
|
|
9
|
-
cookieHeader.split(';').forEach(cookie => {
|
|
10
|
-
const [name, value] = cookie.trim().split('=');
|
|
11
|
-
if (name && value) {
|
|
12
|
-
cookies[name] = decodeURIComponent(value);
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
return cookies;
|
|
16
|
-
}
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { getIp } from "../utils/ip.js";
|
|
3
|
+
import { checkKeywords } from "../security/checker.js";
|
|
4
|
+
import { filterXSS } from "../security/xss-filter.js";
|
|
5
|
+
import { createRateLimitMiddleware } from "../security/rate-limit.js";
|
|
6
|
+
import { store } from "../storage/store.js";
|
|
17
7
|
|
|
18
8
|
/**
|
|
19
9
|
* Web应用防火墙(WAF)中间件
|
|
20
|
-
*
|
|
10
|
+
* 改进点:
|
|
11
|
+
* 1. 封禁状态存服务端(Map+TTL 或 Redis),按 IP+UA 维度
|
|
12
|
+
* - 彻底废弃 Cookie/localStorage 标记方案(旧版可被任意绕过)
|
|
13
|
+
* 2. 命中恶意关键词后封禁一段时间(默认 30 分钟,可配置)
|
|
14
|
+
* 3. 支持配置阈值:连续命中 N 次后封禁
|
|
15
|
+
* 4. 安全响应头补充 X-Frame-Options / HSTS 等
|
|
16
|
+
* 5. 通过 store 适配层:Redis 启用时走 Redis,否则走内存,运行时异常自动降级
|
|
21
17
|
*/
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
19
|
+
// 封禁状态 Redis Key 前缀
|
|
20
|
+
const BLOCK_KEY_PREFIX = 'waf:block:';
|
|
21
|
+
// 封禁命中次数 Redis Key 前缀
|
|
22
|
+
const STRIKE_KEY_PREFIX = 'waf:strike:';
|
|
26
23
|
|
|
27
24
|
/**
|
|
28
25
|
* 路径白名单 - 这些路径不会被 WAF 检查
|
|
29
|
-
* @type {Array<string>}
|
|
30
26
|
*/
|
|
31
27
|
const WAF_PATH_WHITELIST = [
|
|
32
28
|
"/.well-known/appspecific/", // Chrome DevTools
|
|
@@ -37,60 +33,119 @@ const WAF_PATH_WHITELIST = [
|
|
|
37
33
|
"/sitemap.xml", // 站点地图
|
|
38
34
|
];
|
|
39
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 可信 IP 白名单 - 这些 IP 跳过限流和封禁
|
|
38
|
+
* 本机回环 + RFC 1918 三大私有网段,避免开发和内网环境被误限
|
|
39
|
+
*/
|
|
40
|
+
const TRUSTED_IPS = new Set(['127.0.0.1', '::1', '0.0.0.0']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 检查 IP 是否可信(本机回环或 RFC 1918 内网)
|
|
44
|
+
* RFC 1918 私有网段:
|
|
45
|
+
* 10.0.0.0/8 (10.x.x.x)
|
|
46
|
+
* 172.16.0.0/12 (172.16.x.x ~ 172.31.x.x)
|
|
47
|
+
* 192.168.0.0/16 (192.168.x.x)
|
|
48
|
+
*/
|
|
49
|
+
function isTrustedIp(ip) {
|
|
50
|
+
if (!ip) return false;
|
|
51
|
+
if (TRUSTED_IPS.has(ip)) return true;
|
|
52
|
+
// 10.x.x.x
|
|
53
|
+
if (ip.startsWith('10.')) return true;
|
|
54
|
+
// 192.168.x.x
|
|
55
|
+
if (ip.startsWith('192.168.')) return true;
|
|
56
|
+
// 172.16.x.x ~ 172.31.x.x
|
|
57
|
+
if (ip.startsWith('172.')) {
|
|
58
|
+
const second = parseInt(ip.split('.')[1], 10);
|
|
59
|
+
if (second >= 16 && second <= 31) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/**
|
|
41
65
|
* 检查路径是否在白名单中
|
|
42
|
-
* @param {string} path - 请求路径
|
|
43
|
-
* @returns {boolean} 是否在白名单中
|
|
44
66
|
*/
|
|
45
67
|
function isWhitelistedPath(path) {
|
|
46
|
-
return WAF_PATH_WHITELIST.some(whitelistPath =>
|
|
68
|
+
return WAF_PATH_WHITELIST.some(whitelistPath =>
|
|
47
69
|
path.startsWith(whitelistPath) || path === whitelistPath
|
|
48
70
|
);
|
|
49
71
|
}
|
|
50
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 生成 IP+UA 维度的标识(sha256 摘要,避免 UA 中特殊字符影响 Key)
|
|
75
|
+
* @param {string} ip - 客户端 IP
|
|
76
|
+
* @param {string} ua - User-Agent
|
|
77
|
+
* @returns {string} 32 位十六进制摘要
|
|
78
|
+
*/
|
|
79
|
+
function buildFingerprint(ip, ua) {
|
|
80
|
+
return crypto
|
|
81
|
+
.createHash('sha256')
|
|
82
|
+
.update(`${ip}|${ua || ''}`)
|
|
83
|
+
.digest('hex')
|
|
84
|
+
.slice(0, 32);
|
|
85
|
+
}
|
|
86
|
+
|
|
51
87
|
/**
|
|
52
88
|
* 创建 WAF 中间件
|
|
53
89
|
* @private
|
|
54
90
|
* @param {Object} wafConfig - WAF 配置对象
|
|
55
|
-
* @param {boolean} wafConfig.enabled - 是否启用 WAF
|
|
56
|
-
* @param {Object} wafConfig.rateLimit - 限流配置
|
|
57
|
-
* @returns {Function} Express 中间件函数
|
|
58
91
|
*/
|
|
59
92
|
function createWafMiddleware(wafConfig) {
|
|
60
93
|
const rateLimitMiddleware = createRateLimitMiddleware(wafConfig.rateLimit);
|
|
61
94
|
|
|
95
|
+
// 封禁配置(默认 30 分钟,命中 3 次恶意关键词后封禁)
|
|
96
|
+
const blockConfig = wafConfig.block || {};
|
|
97
|
+
const BLOCK_DURATION = blockConfig.duration || 30 * 60 * 1000; // 30 分钟
|
|
98
|
+
const STRIKE_THRESHOLD = blockConfig.strikeThreshold || 3; // 命中 3 次封禁
|
|
99
|
+
const STRIKE_WINDOW = blockConfig.strikeWindow || 60 * 60 * 1000; // 1 小时内累计
|
|
100
|
+
|
|
62
101
|
return async (req, res, next) => {
|
|
63
102
|
try {
|
|
64
103
|
if (!wafConfig.enabled) {
|
|
65
|
-
console.log('[WAF] WAF已禁用,跳过检查');
|
|
66
104
|
return next();
|
|
67
105
|
}
|
|
68
106
|
|
|
69
107
|
const clientIp = getIp(req);
|
|
108
|
+
|
|
109
|
+
// 可信 IP(本机/内网)仅跳过限流和封禁状态检查(硬约束:避免误限内网)
|
|
110
|
+
// 但仍执行关键词检查和 XSS 过滤,防止 X-Forwarded-For 伪造内网 IP 绕过 WAF(S-09 修复点)
|
|
111
|
+
const isTrusted = isTrustedIp(clientIp);
|
|
112
|
+
|
|
70
113
|
const requestPath = req.path || "";
|
|
71
|
-
|
|
114
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
115
|
+
const fingerprint = buildFingerprint(clientIp, userAgent);
|
|
116
|
+
|
|
117
|
+
// 安全响应头
|
|
72
118
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
73
119
|
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
|
|
120
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
74
121
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
122
|
+
// 可信 IP 跳过封禁状态检查(避免误封内网);非可信 IP 才查封禁状态
|
|
123
|
+
if (!isTrusted && !isWhitelistedPath(requestPath)) {
|
|
124
|
+
const blockKey = `${BLOCK_KEY_PREFIX}${fingerprint}`;
|
|
125
|
+
const blocked = await store.get(blockKey);
|
|
126
|
+
|
|
127
|
+
if (blocked) {
|
|
128
|
+
const remaining = Math.ceil((blocked.expireAt - Date.now()) / 60000);
|
|
129
|
+
console.error(`[WAF 封禁拦截] IP:${clientIp} UA:${userAgent.slice(0, 50)} 剩余:${remaining}分钟`);
|
|
82
130
|
return res.status(403).json({
|
|
83
131
|
code: 403,
|
|
84
132
|
success: false,
|
|
85
|
-
msg: '
|
|
133
|
+
msg: '您的访问已被限制,请稍后再试',
|
|
134
|
+
retryAfter: Math.ceil(BLOCK_DURATION / 1000),
|
|
86
135
|
});
|
|
87
136
|
}
|
|
88
137
|
}
|
|
89
138
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
// 可信 IP 跳过限流(避免误限内网);非可信 IP 才执行限流
|
|
140
|
+
if (!isTrusted) {
|
|
141
|
+
await new Promise((resolve) => {
|
|
142
|
+
rateLimitMiddleware(req, res, resolve);
|
|
143
|
+
});
|
|
144
|
+
// 如果限流中间件已经响应(429),不再继续
|
|
145
|
+
if (res.headersSent) return;
|
|
146
|
+
}
|
|
93
147
|
|
|
148
|
+
// 收集待检查文本:路径 + query + body
|
|
94
149
|
let checkText = requestPath;
|
|
95
150
|
if (req.query && Object.keys(req.query).length > 0) {
|
|
96
151
|
const queryStr = Object.entries(req.query)
|
|
@@ -105,6 +160,7 @@ function createWafMiddleware(wafConfig) {
|
|
|
105
160
|
|
|
106
161
|
if (!isMultipart && req.body) {
|
|
107
162
|
try {
|
|
163
|
+
// 先判断 body 长度再决定是否序列化,避免不必要的 CPU 消耗
|
|
108
164
|
const bodyStr = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
109
165
|
if (bodyStr.length < 10000) {
|
|
110
166
|
bodyText = ` ${bodyStr}`;
|
|
@@ -116,6 +172,7 @@ function createWafMiddleware(wafConfig) {
|
|
|
116
172
|
|
|
117
173
|
const fullText = checkText + bodyText;
|
|
118
174
|
|
|
175
|
+
// 关键词检查(仅对非白名单路径)
|
|
119
176
|
if (!isWhitelistedPath(requestPath)) {
|
|
120
177
|
const matched = checkKeywords(fullText);
|
|
121
178
|
|
|
@@ -123,42 +180,42 @@ function createWafMiddleware(wafConfig) {
|
|
|
123
180
|
const { category, keyword } = matched;
|
|
124
181
|
console.error(`[WAF 拦截] IP:${clientIp} 路径:${requestPath} 关键词:${keyword} 类别:${category}`);
|
|
125
182
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return res.status(403).setHeader('Content-Type', 'text/html').send(htmlResponse);
|
|
183
|
+
// 记录命中次数,达到阈值则封禁
|
|
184
|
+
const strikeKey = `${STRIKE_KEY_PREFIX}${fingerprint}`;
|
|
185
|
+
const strikes = await store.incrAndExpire(strikeKey, STRIKE_WINDOW);
|
|
186
|
+
|
|
187
|
+
if (strikes >= STRIKE_THRESHOLD) {
|
|
188
|
+
// 封禁:写入封禁状态,TTL = BLOCK_DURATION
|
|
189
|
+
const blockKey = `${BLOCK_KEY_PREFIX}${fingerprint}`;
|
|
190
|
+
await store.set(blockKey, { ip: clientIp, reason: keyword, expireAt: Date.now() + BLOCK_DURATION }, BLOCK_DURATION);
|
|
191
|
+
console.error(`[WAF 封禁触发] IP:${clientIp} 命中次数:${strikes} 封禁时长:${BLOCK_DURATION / 60000}分钟`);
|
|
192
|
+
|
|
193
|
+
return res.status(403).json({
|
|
194
|
+
code: 403,
|
|
195
|
+
success: false,
|
|
196
|
+
msg: '检测到恶意访问,您的访问已被限制',
|
|
197
|
+
retryAfter: Math.ceil(BLOCK_DURATION / 1000),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 未达阈值,仅拦截本次请求
|
|
202
|
+
return res.status(403).json({
|
|
203
|
+
code: 403,
|
|
204
|
+
success: false,
|
|
205
|
+
msg: '检测到非法内容,请求已被拦截',
|
|
206
|
+
data: { strikes, threshold: STRIKE_THRESHOLD },
|
|
207
|
+
});
|
|
152
208
|
}
|
|
153
209
|
}
|
|
154
210
|
|
|
211
|
+
// XSS 过滤
|
|
155
212
|
try {
|
|
156
213
|
if (req.query && typeof req.query === 'object') {
|
|
157
214
|
const filteredQuery = filterXSS(req.query);
|
|
158
215
|
Object.keys(req.query).forEach(key => delete req.query[key]);
|
|
159
216
|
Object.assign(req.query, filteredQuery);
|
|
160
217
|
}
|
|
161
|
-
|
|
218
|
+
|
|
162
219
|
if (req.body && typeof req.body === 'object') {
|
|
163
220
|
req.body = filterXSS(req.body);
|
|
164
221
|
}
|
|
@@ -169,7 +226,12 @@ function createWafMiddleware(wafConfig) {
|
|
|
169
226
|
next();
|
|
170
227
|
} catch (error) {
|
|
171
228
|
console.error(`[WAF 异常] IP:${getIp(req)} 错误:${error.message}`, error.stack);
|
|
172
|
-
|
|
229
|
+
// 异常时拒绝请求,避免 WAF 故障时被绕过(fail-closed)
|
|
230
|
+
return res.status(403).json({
|
|
231
|
+
code: 403,
|
|
232
|
+
success: false,
|
|
233
|
+
msg: '安全检查异常,请求被拒绝',
|
|
234
|
+
});
|
|
173
235
|
}
|
|
174
236
|
};
|
|
175
237
|
}
|
|
@@ -180,17 +242,15 @@ function createWafMiddleware(wafConfig) {
|
|
|
180
242
|
* @param {Object} config - WAF 配置对象
|
|
181
243
|
* @param {boolean} config.enabled - 是否启用 WAF
|
|
182
244
|
* @param {Object} config.rateLimit - 限流配置
|
|
183
|
-
* @
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
245
|
+
* @param {Object} [config.block] - 封禁配置
|
|
246
|
+
* @param {number} [config.block.duration=1800000] - 封禁时长(毫秒),默认 30 分钟
|
|
247
|
+
* @param {number} [config.block.strikeThreshold=3] - 命中恶意关键词几次后封禁
|
|
248
|
+
* @param {number} [config.block.strikeWindow=3600000] - 命中累计窗口(毫秒)
|
|
187
249
|
* @example
|
|
188
250
|
* waf(app, {
|
|
189
251
|
* enabled: true,
|
|
190
|
-
* rateLimit: {
|
|
191
|
-
*
|
|
192
|
-
* max: 100
|
|
193
|
-
* }
|
|
252
|
+
* rateLimit: { windowMs: 60000, max: 100 },
|
|
253
|
+
* block: { duration: 1800000, strikeThreshold: 3, strikeWindow: 3600000 }
|
|
194
254
|
* });
|
|
195
255
|
*/
|
|
196
256
|
export const waf = function (app, config) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "chanjs",
|
|
4
|
-
"version": "2.7.
|
|
4
|
+
"version": "2.7.4",
|
|
5
5
|
"description": "chanjs基于express5 纯js研发的轻量级mvc框架。",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"module": "index.js",
|
|
@@ -20,20 +20,20 @@
|
|
|
20
20
|
"license": "ISC",
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"art-template": "^4.13.4",
|
|
23
|
-
"body-parser": "^2.2.2",
|
|
24
23
|
"cookie-parser": "^1.4.7",
|
|
25
24
|
"cors": "^2.8.6",
|
|
26
25
|
"dayjs": "^1.11.20",
|
|
27
26
|
"dotenv": "^17.4.2",
|
|
28
27
|
"express": "^5.2.1",
|
|
29
28
|
"express-art-template": "^1.0.1",
|
|
30
|
-
"ip2region": "^2.3.0",
|
|
31
29
|
"jsonwebtoken": "^9.0.3",
|
|
32
30
|
"knex": "^3.2.10",
|
|
33
31
|
"marked": "^18.0.3",
|
|
34
32
|
"morgan": "^1.10.1",
|
|
35
33
|
"mysql2": "^3.22.3",
|
|
36
34
|
"serve-favicon": "^2.5.1",
|
|
37
|
-
"
|
|
35
|
+
"ws": "^8.18.0",
|
|
36
|
+
"xss": "^1.0.15",
|
|
37
|
+
"ioredis": "^5.4.6"
|
|
38
38
|
}
|
|
39
39
|
}
|