chanjs 2.7.1 → 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 +137 -78
- 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
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,61 +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
|
-
res.setHeader("
|
|
74
|
-
res.setHeader("X-Frame-Options", "
|
|
119
|
+
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
|
|
120
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
75
121
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
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}分钟`);
|
|
83
130
|
return res.status(403).json({
|
|
84
131
|
code: 403,
|
|
85
132
|
success: false,
|
|
86
|
-
msg: '
|
|
133
|
+
msg: '您的访问已被限制,请稍后再试',
|
|
134
|
+
retryAfter: Math.ceil(BLOCK_DURATION / 1000),
|
|
87
135
|
});
|
|
88
136
|
}
|
|
89
137
|
}
|
|
90
138
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
147
|
|
|
148
|
+
// 收集待检查文本:路径 + query + body
|
|
95
149
|
let checkText = requestPath;
|
|
96
150
|
if (req.query && Object.keys(req.query).length > 0) {
|
|
97
151
|
const queryStr = Object.entries(req.query)
|
|
@@ -106,6 +160,7 @@ function createWafMiddleware(wafConfig) {
|
|
|
106
160
|
|
|
107
161
|
if (!isMultipart && req.body) {
|
|
108
162
|
try {
|
|
163
|
+
// 先判断 body 长度再决定是否序列化,避免不必要的 CPU 消耗
|
|
109
164
|
const bodyStr = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
110
165
|
if (bodyStr.length < 10000) {
|
|
111
166
|
bodyText = ` ${bodyStr}`;
|
|
@@ -117,6 +172,7 @@ function createWafMiddleware(wafConfig) {
|
|
|
117
172
|
|
|
118
173
|
const fullText = checkText + bodyText;
|
|
119
174
|
|
|
175
|
+
// 关键词检查(仅对非白名单路径)
|
|
120
176
|
if (!isWhitelistedPath(requestPath)) {
|
|
121
177
|
const matched = checkKeywords(fullText);
|
|
122
178
|
|
|
@@ -124,42 +180,42 @@ function createWafMiddleware(wafConfig) {
|
|
|
124
180
|
const { category, keyword } = matched;
|
|
125
181
|
console.error(`[WAF 拦截] IP:${clientIp} 路径:${requestPath} 关键词:${keyword} 类别:${category}`);
|
|
126
182
|
|
|
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
|
-
|
|
152
|
-
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
|
+
});
|
|
153
208
|
}
|
|
154
209
|
}
|
|
155
210
|
|
|
211
|
+
// XSS 过滤
|
|
156
212
|
try {
|
|
157
213
|
if (req.query && typeof req.query === 'object') {
|
|
158
214
|
const filteredQuery = filterXSS(req.query);
|
|
159
215
|
Object.keys(req.query).forEach(key => delete req.query[key]);
|
|
160
216
|
Object.assign(req.query, filteredQuery);
|
|
161
217
|
}
|
|
162
|
-
|
|
218
|
+
|
|
163
219
|
if (req.body && typeof req.body === 'object') {
|
|
164
220
|
req.body = filterXSS(req.body);
|
|
165
221
|
}
|
|
@@ -170,7 +226,12 @@ function createWafMiddleware(wafConfig) {
|
|
|
170
226
|
next();
|
|
171
227
|
} catch (error) {
|
|
172
228
|
console.error(`[WAF 异常] IP:${getIp(req)} 错误:${error.message}`, error.stack);
|
|
173
|
-
|
|
229
|
+
// 异常时拒绝请求,避免 WAF 故障时被绕过(fail-closed)
|
|
230
|
+
return res.status(403).json({
|
|
231
|
+
code: 403,
|
|
232
|
+
success: false,
|
|
233
|
+
msg: '安全检查异常,请求被拒绝',
|
|
234
|
+
});
|
|
174
235
|
}
|
|
175
236
|
};
|
|
176
237
|
}
|
|
@@ -181,17 +242,15 @@ function createWafMiddleware(wafConfig) {
|
|
|
181
242
|
* @param {Object} config - WAF 配置对象
|
|
182
243
|
* @param {boolean} config.enabled - 是否启用 WAF
|
|
183
244
|
* @param {Object} config.rateLimit - 限流配置
|
|
184
|
-
* @
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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] - 命中累计窗口(毫秒)
|
|
188
249
|
* @example
|
|
189
250
|
* waf(app, {
|
|
190
251
|
* enabled: true,
|
|
191
|
-
* rateLimit: {
|
|
192
|
-
*
|
|
193
|
-
* max: 100
|
|
194
|
-
* }
|
|
252
|
+
* rateLimit: { windowMs: 60000, max: 100 },
|
|
253
|
+
* block: { duration: 1800000, strikeThreshold: 3, strikeWindow: 3600000 }
|
|
195
254
|
* });
|
|
196
255
|
*/
|
|
197
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.3",
|
|
5
5
|
"description": "chanjs基于express5 纯js研发的轻量级mvc框架。",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"module": "index.js",
|
|
@@ -20,20 +20,19 @@
|
|
|
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",
|
|
35
|
+
"ws": "^8.18.0",
|
|
37
36
|
"xss": "^1.0.15"
|
|
38
37
|
}
|
|
39
38
|
}
|