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 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
- const DEFAULT_BLOCK = {
26
- BLOCK_DURATION: 30 * 60 * 1000,
27
- STRIKE_THRESHOLD: 3,
28
- STRIKE_WINDOW: 60 * 60 * 1000,
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
- const { blocked, strikes } = await handleStrike(fp, clientIp, keyword, blockCfg);
140
- if (blocked) return respondWaf(res, 403, "检测到恶意访问,您的访问已被限制", { retryAfter: Math.ceil(blockCfg.BLOCK_DURATION / 1000) });
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
- // 已登录用户的管理接口:跳过关键词检测(富文本/代码内容会误杀),仅做 XSS 过滤
186
- const isAuthedAdmin = req.user?.uid && bodySkipPrefixes.some(p => path.startsWith(p));
187
-
188
- if (!isAuthedAdmin) {
189
- // 序列化body文本,截断1w字符防绕过
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "chanjs",
4
- "version": "2.7.10",
4
+ "version": "2.7.11",
5
5
  "description": "chanjs基于express5 纯js研发的轻量级mvc框架。",
6
6
  "main": "index.js",
7
7
  "module": "index.js",
@@ -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] scope='body' 时跳过路径/文件特征类规则,降低正文误报
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 = scope === 'body'
28
- ? SORTED_CATEGORIES.filter(c => !BODY_EXCLUDED_CATEGORIES.includes(c))
29
- : SORTED_CATEGORIES;
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 match = keywordRegexCache[cat].merged.exec(text);
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
- * 样式采用 Tailwind CSS 原子类,紧凑精致布局
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) => `<li class="${i === current ? LI_CURRENT : LI_LINK}"><a class="${A_LINK}" href='${href}${i}.html${query}'>${text || i}</a></li>`;
30
- const navLink = (i, text) => i ? pageLink(i, text) : `<li class="${LI_DISABLED}">${text}</li>`;
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(`<li class="${LI_ELLIPSIS}">...</li>`, pageLink(totalPage));
42
+ items.push(ellipsis(), pageLink(totalPage));
42
43
  } else if (current >= totalPage - 3) {
43
- items.push(pageLink(1), `<li class="${LI_ELLIPSIS}">...</li>`);
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), `<li class="${LI_ELLIPSIS}">...</li>`);
47
+ items.push(pageLink(1), ellipsis());
47
48
  for (let i = current - 1; i <= current + 1; i++) items.push(pageLink(i));
48
- items.push(`<li class="${LI_ELLIPSIS}">...</li>`, pageLink(totalPage));
49
+ items.push(ellipsis(), pageLink(totalPage));
49
50
  }
50
51
 
51
52
  // 下一页