chanjs 2.7.10 → 2.7.11
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/middleware/waf.js +19 -83
- package/package.json +1 -1
- package/security/checker.js +14 -7
- package/utils/pages.js +13 -12
package/middleware/waf.js
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
1
|
import { getIp } from "../utils/ip.js";
|
|
3
2
|
import { checkKeywords } from "../security/checker.js";
|
|
4
3
|
import { filterXSS } from "../security/xss-filter.js";
|
|
5
4
|
import { createRateLimitMiddleware } from "../security/rate-limit.js";
|
|
6
|
-
import { store } from "../storage/store.js";
|
|
7
5
|
import { CODE_BLOCKED } from "../response/code.js";
|
|
8
6
|
import logger from "../utils/logger.js";
|
|
9
7
|
|
|
10
|
-
const BLOCK_KEY_PREFIX = "waf:block:";
|
|
11
|
-
const STRIKE_KEY_PREFIX = "waf:strike:";
|
|
12
|
-
|
|
13
|
-
// 放行路径白名单
|
|
14
8
|
const WAF_PATH_WHITELIST = [
|
|
15
9
|
"/.well-known/appspecific/",
|
|
16
10
|
"/.well-known/change-password",
|
|
@@ -22,14 +16,13 @@ const WAF_PATH_WHITELIST = [
|
|
|
22
16
|
|
|
23
17
|
const TRUSTED_IPS = new Set(["127.0.0.1", "::1"]);
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
};
|
|
19
|
+
// URL/query 关键词检测保留的分类——只保留真正危险、几乎不会在正常 URL 中出现的攻击特征。
|
|
20
|
+
// 去掉 wholeWord / extensions / directories / sensitiveIdentifiers / commandInjection,
|
|
21
|
+
// 这些在正常 URL(?debug=1、/internal/、/.well-known/、wget/scp/dd 等)极易误伤正常请求。
|
|
22
|
+
const URL_SCAN_CATEGORIES = ["sqlInjection", "xss", "pathTraversal", "encoding"];
|
|
30
23
|
|
|
31
24
|
/**
|
|
32
|
-
* 判断本机回环可信IP:仅 127.0.0.1 / ::1 无条件放行 WAF
|
|
25
|
+
* 判断本机回环可信IP:仅 127.0.0.1 / ::1 无条件放行 WAF 的 query 关键词检测与 XSS 净化。
|
|
33
26
|
* 不再放行整个内网段(如 10.x、192.168.x、172.16-31.x),
|
|
34
27
|
* 否则当 Nginx 部署在另一台内网机、Express 未解析 XFF 时,全站 WAF 会静默失效。
|
|
35
28
|
*/
|
|
@@ -43,12 +36,6 @@ const isTrustedIp = ip => {
|
|
|
43
36
|
*/
|
|
44
37
|
const isWhitelistedPath = path => WAF_PATH_WHITELIST.some(p => path === p || path.startsWith(p));
|
|
45
38
|
|
|
46
|
-
/**
|
|
47
|
-
* IP+UA指纹 sha256(前32位)
|
|
48
|
-
*/
|
|
49
|
-
const buildFingerprint = (ip, ua = "") =>
|
|
50
|
-
crypto.createHash("sha256").update(`${ip}|${ua}`).digest("hex").slice(0, 32);
|
|
51
|
-
|
|
52
39
|
/**
|
|
53
40
|
* 覆盖Express5只读req.query
|
|
54
41
|
*/
|
|
@@ -68,22 +55,8 @@ const respondWaf = (res, status, msg, data) =>
|
|
|
68
55
|
res.status(status).json({ code: CODE_BLOCKED, success: false, msg, data });
|
|
69
56
|
|
|
70
57
|
/**
|
|
71
|
-
*
|
|
58
|
+
* 限流,返回是否已响应
|
|
72
59
|
*/
|
|
73
|
-
async function handleStrike(fingerprint, clientIp, keyword, blockCfg) {
|
|
74
|
-
const strikeKey = `${STRIKE_KEY_PREFIX}${fingerprint}`;
|
|
75
|
-
const strikes = await store.incrAndExpire(strikeKey, blockCfg.STRIKE_WINDOW);
|
|
76
|
-
|
|
77
|
-
if (strikes >= blockCfg.STRIKE_THRESHOLD) {
|
|
78
|
-
const blockKey = `${BLOCK_KEY_PREFIX}${fingerprint}`;
|
|
79
|
-
await store.set(blockKey, { ip: clientIp, reason: keyword, expireAt: Date.now() + blockCfg.BLOCK_DURATION }, blockCfg.BLOCK_DURATION);
|
|
80
|
-
logger.error(`[WAF封禁] IP:${clientIp} 累计命中:${strikes}次 封禁${blockCfg.BLOCK_DURATION / 60000}分钟`);
|
|
81
|
-
return { blocked: true, strikes };
|
|
82
|
-
}
|
|
83
|
-
return { blocked: false, strikes };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** 执行限流,返回是否已响应 */
|
|
87
60
|
const runRateLimit = (rateLimit, req, res) =>
|
|
88
61
|
new Promise(resolve => rateLimit(req, res, resolve)).then(() => res.headersSent);
|
|
89
62
|
|
|
@@ -92,7 +65,6 @@ const runRateLimit = (rateLimit, req, res) =>
|
|
|
92
65
|
*/
|
|
93
66
|
const createWafMiddleware = wafConfig => {
|
|
94
67
|
const rateLimit = createRateLimitMiddleware(wafConfig.rateLimit);
|
|
95
|
-
const blockCfg = { ...DEFAULT_BLOCK, ...wafConfig.block };
|
|
96
68
|
|
|
97
69
|
return async (req, res, next) => {
|
|
98
70
|
try {
|
|
@@ -100,8 +72,6 @@ const createWafMiddleware = wafConfig => {
|
|
|
100
72
|
|
|
101
73
|
const clientIp = getIp(req);
|
|
102
74
|
const path = req.path || "";
|
|
103
|
-
const ua = req.headers["user-agent"] || "";
|
|
104
|
-
const fp = buildFingerprint(clientIp, ua);
|
|
105
75
|
const whitePath = isWhitelistedPath(path);
|
|
106
76
|
|
|
107
77
|
// 可信IP直接放行,仅做query XSS过滤
|
|
@@ -113,32 +83,24 @@ const createWafMiddleware = wafConfig => {
|
|
|
113
83
|
// 限流
|
|
114
84
|
if (await runRateLimit(rateLimit, req, res)) return;
|
|
115
85
|
|
|
116
|
-
// 已封禁直接拦截
|
|
117
|
-
const blockInfo = await store.get(`${BLOCK_KEY_PREFIX}${fp}`);
|
|
118
|
-
if (blockInfo) {
|
|
119
|
-
logger.error(`[WAF拦截封禁] IP:${clientIp} UA:${ua.slice(0, 50)}`);
|
|
120
|
-
return respondWaf(res, 403, "检测到恶意访问,您的访问已被限制", { retryAfter: Math.ceil(blockCfg.BLOCK_DURATION / 1000) });
|
|
121
|
-
}
|
|
122
|
-
|
|
123
86
|
// 白名单路径跳过关键词检测
|
|
124
87
|
if (whitePath) {
|
|
125
88
|
if (req.query && Object.keys(req.query).length) overrideQuery(req, filterXSS(req.query));
|
|
126
89
|
return next();
|
|
127
90
|
}
|
|
128
91
|
|
|
129
|
-
// 路径+query
|
|
92
|
+
// 路径+query拼接检测恶意关键词(仅保留真正危险分类,避免 debug/secret/internal/.env 等正常词误伤)
|
|
130
93
|
let checkText = path;
|
|
131
94
|
if (Object.keys(req.query ?? {}).length) {
|
|
132
95
|
const queryStr = Object.entries(req.query).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
133
96
|
checkText += ` ${queryStr}`;
|
|
134
97
|
}
|
|
135
|
-
const hit = checkKeywords(checkText);
|
|
98
|
+
const hit = checkKeywords(checkText, { categories: URL_SCAN_CATEGORIES });
|
|
136
99
|
if (hit) {
|
|
137
100
|
const { keyword, category } = hit;
|
|
138
101
|
logger.error(`[WAF拦截-URL] IP:${clientIp} Path:${path} Key:${keyword} Type:${category}`);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return respondWaf(res, 403, "检测到非法内容,请求已被拦截", { strikes, threshold: blockCfg.STRIKE_THRESHOLD });
|
|
102
|
+
// 仅当次拦截,不再累计封禁 IP(避免误报把整 IP 锁 30 分钟)
|
|
103
|
+
return respondWaf(res, 403, "检测到非法内容,请求已被拦截");
|
|
142
104
|
}
|
|
143
105
|
|
|
144
106
|
// query XSS净化
|
|
@@ -156,19 +118,10 @@ const createWafMiddleware = wafConfig => {
|
|
|
156
118
|
};
|
|
157
119
|
};
|
|
158
120
|
|
|
159
|
-
/**
|
|
160
|
-
* 已登录用户的管理接口路径前缀(跳过 body 关键词检测,避免误杀富文本/代码内容)
|
|
161
|
-
* 可通过 waf 配置 bodySkipPrefixes 字段自定义,默认包含常见管理模块前缀
|
|
162
|
-
*/
|
|
163
|
-
const DEFAULT_BODY_SKIP_PREFIXES = ["/cms/", "/base/", "/member/", "/book/", "/oss/", "/vip/"];
|
|
164
|
-
|
|
165
121
|
/**
|
|
166
122
|
* Body层WAF中间件(body解析后)
|
|
167
123
|
*/
|
|
168
124
|
const createWafBodyMiddleware = wafConfig => {
|
|
169
|
-
const blockCfg = { ...DEFAULT_BLOCK, ...wafConfig.block };
|
|
170
|
-
const bodySkipPrefixes = wafConfig.bodySkipPrefixes ?? DEFAULT_BODY_SKIP_PREFIXES;
|
|
171
|
-
|
|
172
125
|
return async (req, res, next) => {
|
|
173
126
|
try {
|
|
174
127
|
if (!wafConfig.enabled) return next();
|
|
@@ -182,35 +135,18 @@ const createWafBodyMiddleware = wafConfig => {
|
|
|
182
135
|
// 空body直接放行
|
|
183
136
|
if (!req.body || (typeof req.body === "object" && !Object.keys(req.body).length)) return next();
|
|
184
137
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
let bodyText = "";
|
|
191
|
-
try {
|
|
192
|
-
bodyText = (typeof req.body === "string" ? req.body : JSON.stringify(req.body)).slice(0, 10000);
|
|
193
|
-
} catch (e) {
|
|
194
|
-
logger.error(`[WAF警告] body序列化失败 IP:${clientIp} Err:${e.message}`);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// body关键词检测拦截(scope='body' 跳过路径/文件特征类规则,降低正文误报)
|
|
198
|
-
if (bodyText) {
|
|
199
|
-
const hit = checkKeywords(bodyText, { scope: 'body' });
|
|
200
|
-
if (hit) {
|
|
201
|
-
const { keyword, category } = hit;
|
|
202
|
-
const fp = buildFingerprint(clientIp, req.headers["user-agent"] || "");
|
|
203
|
-
logger.error(`[WAF拦截-Body] IP:${clientIp} Path:${path} Key:${keyword} Type:${category}`);
|
|
204
|
-
const { blocked, strikes } = await handleStrike(fp, clientIp, keyword, blockCfg);
|
|
205
|
-
if (blocked) return respondWaf(res, 403, "检测到恶意访问,您的访问已被限制", { retryAfter: Math.ceil(blockCfg.BLOCK_DURATION / 1000) });
|
|
206
|
-
return respondWaf(res, 403, "检测到非法内容,请求已被拦截", { strikes, threshold: blockCfg.STRIKE_THRESHOLD });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
138
|
+
// 请求体关键词拦截已移除:
|
|
139
|
+
// 项目 DB 全部走 knex 参数化查询(天然防 SQL 注入),后端也无任何
|
|
140
|
+
// 把用户输入交给 shell 执行的路径;而关键词扫描对留言/评论/注册等正常正文
|
|
141
|
+
// 误报极高(drop / select / alert / onclick 等普通词都会被拦),代价远大于收益。
|
|
142
|
+
// XSS 防护由下方 filterXSS 负责(输出编码),SQL 注入由参数化查询负责。
|
|
210
143
|
|
|
211
144
|
// body XSS过滤
|
|
145
|
+
// 例外:base/config 系统配置写入接口跳过 XSS 过滤——sys_config 配置值须原样存储
|
|
146
|
+
// (含「名称 <邮箱>」发件人格式、HTML 邮件模板等),且该接口仅管理员可访问,
|
|
147
|
+
// 配置值由后端逻辑消费而非直接反射到页面,无 XSS 风险。
|
|
212
148
|
try {
|
|
213
|
-
if (typeof req.body === "object") req.body = filterXSS(req.body);
|
|
149
|
+
if (typeof req.body === "object" && !path.startsWith("/base/config")) req.body = filterXSS(req.body);
|
|
214
150
|
} catch (e) {
|
|
215
151
|
logger.error(`[WAF警告] body过滤失败 IP:${clientIp} Err:${e.message}`);
|
|
216
152
|
}
|
package/package.json
CHANGED
package/security/checker.js
CHANGED
|
@@ -15,21 +15,28 @@ const BODY_EXCLUDED_CATEGORIES = [
|
|
|
15
15
|
];
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* 恶意关键词检测:单次遍历分类列表,利用捕获组直接定位命中关键词
|
|
19
19
|
* @param {string} fullText 待检测文本
|
|
20
|
-
* @param {{scope?: 'body'|'url'}} [options]
|
|
20
|
+
* @param {{scope?: 'body'|'url', categories?: string[]}} [options]
|
|
21
|
+
* - scope='body' 时跳过路径/文件特征类规则,降低正文误报;
|
|
22
|
+
* - categories 显式指定要扫描的分类(用于 URL 场景,只保留真正危险分类,避免正常词误伤);
|
|
23
|
+
* - 二者都不传则扫描全部分类。
|
|
21
24
|
* @returns {{category: string; keyword: string}|null}
|
|
22
25
|
*/
|
|
23
|
-
export const checkKeywords = (fullText, { scope } = {}) => {
|
|
26
|
+
export const checkKeywords = (fullText, { scope, categories } = {}) => {
|
|
24
27
|
const text = fullText?.trim();
|
|
25
28
|
if (!text) return null;
|
|
26
29
|
|
|
27
|
-
const cats =
|
|
28
|
-
?
|
|
29
|
-
:
|
|
30
|
+
const cats = Array.isArray(categories)
|
|
31
|
+
? categories
|
|
32
|
+
: (scope === 'body'
|
|
33
|
+
? SORTED_CATEGORIES.filter(c => !BODY_EXCLUDED_CATEGORIES.includes(c))
|
|
34
|
+
: SORTED_CATEGORIES);
|
|
30
35
|
|
|
31
36
|
for (const cat of cats) {
|
|
32
|
-
const
|
|
37
|
+
const re = keywordRegexCache[cat]?.merged;
|
|
38
|
+
if (!re) continue;
|
|
39
|
+
const match = re.exec(text);
|
|
33
40
|
if (!match) continue;
|
|
34
41
|
// capture[0]是整体匹配,capture[1..]是每个关键词的捕获组
|
|
35
42
|
const idx = match.findIndex((g, i) => i > 0 && g);
|
package/utils/pages.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 分页工具函数
|
|
3
|
-
*
|
|
3
|
+
* 输出无 ul/li 的干净 HTML:普通页码直接用 <a>,当前页/禁用/省略号用 <span class="current|disabled|ellipsis">
|
|
4
|
+
* 样式由项目 CSS(如 .blince-pagination)统一维护,不依赖 Tailwind 等第三方框架
|
|
4
5
|
*/
|
|
5
|
-
const LI_DISABLED = 'inline-flex items-center px-3 py-1.5 text-sm text-slate-400 cursor-not-allowed rounded-md border border-slate-100 bg-slate-50';
|
|
6
|
-
const LI_ELLIPSIS = 'inline-flex items-center px-2 py-1.5 text-sm text-slate-400';
|
|
7
|
-
const LI_CURRENT = 'inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-lime-600 border border-lime-600 rounded-md';
|
|
8
|
-
const LI_LINK = 'inline-flex items-center text-sm text-slate-700 bg-white border border-slate-200 rounded-md hover:border-lime-600 hover:text-lime-600 transition-colors';
|
|
9
|
-
const A_LINK = 'block px-3 py-1.5';
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* 渲染分页HTML
|
|
@@ -26,8 +22,13 @@ export function pages(current, total, pageSize, href, query = '') {
|
|
|
26
22
|
if (totalPage <= 1) return '';
|
|
27
23
|
if (current > totalPage) current = totalPage;
|
|
28
24
|
|
|
29
|
-
const pageLink = (i, text) =>
|
|
30
|
-
|
|
25
|
+
const pageLink = (i, text) => i === current
|
|
26
|
+
? `<span class="current">${text || i}</span>`
|
|
27
|
+
: `<a href="${href}${i}.html${query}">${text || i}</a>`;
|
|
28
|
+
const navLink = (i, text) => i
|
|
29
|
+
? pageLink(i, text)
|
|
30
|
+
: `<span class="disabled">${text}</span>`;
|
|
31
|
+
const ellipsis = () => `<span class="ellipsis">...</span>`;
|
|
31
32
|
const items = [];
|
|
32
33
|
|
|
33
34
|
// 上一页
|
|
@@ -38,14 +39,14 @@ export function pages(current, total, pageSize, href, query = '') {
|
|
|
38
39
|
for (let i = 1; i <= totalPage; i++) items.push(pageLink(i));
|
|
39
40
|
} else if (current <= 4) {
|
|
40
41
|
for (let i = 1; i <= 5; i++) items.push(pageLink(i));
|
|
41
|
-
items.push(
|
|
42
|
+
items.push(ellipsis(), pageLink(totalPage));
|
|
42
43
|
} else if (current >= totalPage - 3) {
|
|
43
|
-
items.push(pageLink(1),
|
|
44
|
+
items.push(pageLink(1), ellipsis());
|
|
44
45
|
for (let i = totalPage - 4; i <= totalPage; i++) items.push(pageLink(i));
|
|
45
46
|
} else {
|
|
46
|
-
items.push(pageLink(1),
|
|
47
|
+
items.push(pageLink(1), ellipsis());
|
|
47
48
|
for (let i = current - 1; i <= current + 1; i++) items.push(pageLink(i));
|
|
48
|
-
items.push(
|
|
49
|
+
items.push(ellipsis(), pageLink(totalPage));
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// 下一页
|