@maiyunnet/kebab 9.14.3 → 9.15.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "9.14.3",
3
+ "version": "9.15.0",
4
4
  "description": "Simple, easy-to-use, and fully-featured Node.js framework that is ready-to-use out of the box.",
5
5
  "type": "module",
6
6
  "keywords": [
package/sys/child.js CHANGED
@@ -243,14 +243,6 @@ async function requestHandler(req, res, https) {
243
243
  // --- 不能用 req.socket.destroy() 可能会导致底层复用(如 CDN) 的连接被直接断开 ---
244
244
  res.end('403 Forbidden');
245
245
  return;
246
- /*
247
- const text = '<h1>Kebab: No permissions</h1>host: ' + (req.headers[':authority'] as string | undefined ?? req.headers['host'] ?? '') + '<br>url: ' + (lText.htmlescape(req.url ?? ''));
248
- res.setHeader('content-type', 'text/html; charset=utf-8');
249
- res.setHeader('content-length', Buffer.byteLength(text));
250
- res.writeHead(403);
251
- res.end(text);
252
- return;
253
- */
254
246
  }
255
247
  /** --- 请求的路径部分,前导带 / 末尾不一定,用户怎么请求就是什么 --- */
256
248
  let path = uri.pathname ?? '/';
package/sys/ctr.d.ts CHANGED
@@ -344,6 +344,12 @@ export declare class Ctr {
344
344
  'allowedExts'?: string[];
345
345
  /** --- 单个字段(非文件)最大字节数,默认 1 MB --- */
346
346
  'maxFieldSize'?: number;
347
+ /** --- 整体请求最大字节数,不设置或设为 0 则不限制 --- */
348
+ 'maxTotalSize'?: number;
349
+ /** --- 单个 multipart 段头部最大字节数,默认 16 KB --- */
350
+ 'maxHeaderSize'?: number;
351
+ /** --- multipart 字段与文件总数量,默认 1000 --- */
352
+ 'maxParts'?: number;
347
353
  /** --- 整体请求超时时间(毫秒),默认 5 分钟,设为 0 禁用超时 --- */
348
354
  'timeout'?: number;
349
355
  }): Promise<boolean>;
package/sys/master.js CHANGED
@@ -7,6 +7,7 @@ import cluster from 'cluster';
7
7
  import * as os from 'os';
8
8
  import * as fs from 'fs';
9
9
  import * as http from 'http';
10
+ import * as path from 'path';
10
11
  // --- 库和定义 ---
11
12
  import * as kebab from '#kebab/index.js';
12
13
  import * as sRoute from '#kebab/sys/route.js';
@@ -18,6 +19,70 @@ import * as lTime from '#kebab/lib/time.js';
18
19
  import * as lZip from '#kebab/lib/zip.js';
19
20
  /** --- 当前运行中的子进程列表 --- */
20
21
  const workerList = {};
22
+ /**
23
+ * --- 判断候选路径是否位于指定根目录内 ---
24
+ * @param rootPath 已解析的真实根目录
25
+ * @param candidate 候选绝对路径
26
+ */
27
+ function isPathInside(rootPath, candidate) {
28
+ const relative = path.relative(rootPath, candidate);
29
+ return (relative === '') || (!relative.startsWith(`..${path.sep}`) && (relative !== '..') && !path.isAbsolute(relative));
30
+ }
31
+ /**
32
+ * --- 解析根目录内的路径,并阻止通过绝对路径、.. 或符号链接逃逸 ---
33
+ * @param rootPath 限定的根目录
34
+ * @param inputPath 用户输入的相对路径
35
+ */
36
+ async function resolvePathInside(rootPath, inputPath) {
37
+ if ((typeof inputPath !== 'string') || inputPath.includes('\0')) {
38
+ return null;
39
+ }
40
+ const rootReal = await fs.promises.realpath(rootPath);
41
+ const normalizedInput = inputPath.replace(/\\/g, '/').replace(/^\/+/, '');
42
+ if (/^[a-zA-Z]:/.test(normalizedInput)) {
43
+ return null;
44
+ }
45
+ const candidate = path.resolve(rootReal, normalizedInput);
46
+ if (!isPathInside(rootReal, candidate)) {
47
+ return null;
48
+ }
49
+ /** --- 向上找到已存在的最近父路径,用 realpath 校验其中的符号链接 --- */
50
+ let existingPath = candidate;
51
+ const missingParts = [];
52
+ while (true) {
53
+ try {
54
+ const existingReal = await fs.promises.realpath(existingPath);
55
+ const resolved = path.resolve(existingReal, ...missingParts);
56
+ return isPathInside(rootReal, resolved) ? resolved : null;
57
+ }
58
+ catch {
59
+ const parent = path.dirname(existingPath);
60
+ if (parent === existingPath) {
61
+ return null;
62
+ }
63
+ missingParts.unshift(path.basename(existingPath));
64
+ existingPath = parent;
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * --- 规范化 ZIP 条目路径,返回带前导斜杠的安全相对路径 ---
70
+ * @param archivePath ZIP 内的原始路径
71
+ */
72
+ function normalizeArchivePath(archivePath) {
73
+ const normalizedSlashes = archivePath.replace(/\\/g, '/').replace(/^\/+/, '');
74
+ if (!normalizedSlashes ||
75
+ normalizedSlashes.includes('\0') ||
76
+ /^[a-zA-Z]:/.test(normalizedSlashes) ||
77
+ normalizedSlashes.split('/').some(part => !part || (part === '.') || (part === '..'))) {
78
+ return null;
79
+ }
80
+ const normalized = path.posix.normalize(normalizedSlashes);
81
+ if (normalized.startsWith('../') || (normalized === '..') || path.posix.isAbsolute(normalized)) {
82
+ return null;
83
+ }
84
+ return `/${normalized}`;
85
+ }
21
86
  /**
22
87
  * --- 等待指定 worker 开始监听端口就绪 ---
23
88
  * @param worker 要等待的 worker 对象
@@ -128,7 +193,7 @@ function createRpcListener() {
128
193
  res.end('Failed');
129
194
  return;
130
195
  }
131
- if (msg.time < time - 5) {
196
+ if ((typeof msg.time !== 'number') || (Math.abs(msg.time - time) > 5)) {
132
197
  res.end('Timeout');
133
198
  return;
134
199
  }
@@ -226,28 +291,16 @@ function createRpcListener() {
226
291
  res.end('Invalid staticVer');
227
292
  return;
228
293
  }
229
- let path = msg.path;
230
- if (path.startsWith('/')) {
231
- path = path.slice(1);
232
- }
233
- if (path.endsWith('/')) {
234
- path = path.slice(0, -1);
235
- }
236
- // --- 拒绝路径穿越,防止跳出 ROOT_CWD ---
237
- if (path.includes('..')) {
294
+ const to = await resolvePathInside(kebab.ROOT_CWD, msg.path);
295
+ if (!to) {
238
296
  res.end('Invalid path');
239
297
  return;
240
298
  }
241
- /** --- 最终的项目根目录,以 / 结尾,但用户传入的无所谓 --- */
242
- let to = kebab.ROOT_CWD + path;
243
- if (!to.endsWith('/')) {
244
- to += '/';
245
- }
246
299
  if (!await lFs.isDir(to)) {
247
300
  res.end('[project] Path not found: ' + to);
248
301
  return;
249
302
  }
250
- const projectFile = to + 'kebab.json';
303
+ const projectFile = path.join(to, 'kebab.json');
251
304
  if (!await lFs.isFile(projectFile)) {
252
305
  res.end('kebab.json not found in project path');
253
306
  return;
@@ -303,18 +356,20 @@ function createRpcListener() {
303
356
  await sRoute.unlinkUploadFiles(rtn.files);
304
357
  return;
305
358
  }
306
- let path = rtn.post['path'];
307
- if (path.startsWith('/')) {
308
- path = path.slice(1);
309
- }
310
- if (path.endsWith('/')) {
311
- path = path.slice(0, -1);
359
+ const inputPath = rtn.post['path'];
360
+ if (typeof inputPath !== 'string') {
361
+ res.end('Invalid path');
362
+ await sRoute.unlinkUploadFiles(rtn.files);
363
+ return;
312
364
  }
313
- /** --- 最终更新的根目录,以 / 结尾,但用户传入的无所谓 --- */
314
- let to = kebab.ROOT_CWD + path;
315
- if (!to.endsWith('/')) {
316
- to += '/';
365
+ /** --- 最终更新的根目录 --- */
366
+ const targetPath = await resolvePathInside(kebab.ROOT_CWD, inputPath);
367
+ if (!targetPath) {
368
+ res.end('Invalid path');
369
+ await sRoute.unlinkUploadFiles(rtn.files);
370
+ return;
317
371
  }
372
+ const to = targetPath + path.sep;
318
373
  if (!await lFs.isDir(to)) {
319
374
  if (rtn.post['strict'] === '1') {
320
375
  res.end(`[code][0] [${rtn.post['strict']}] Path not found: ${to}`);
@@ -335,7 +390,28 @@ function createRpcListener() {
335
390
  await sRoute.unlinkUploadFiles(rtn.files);
336
391
  return;
337
392
  }
338
- const ls = await zip.getList();
393
+ const archiveEntries = zip.readDir('/', {
394
+ 'hasChildren': true,
395
+ 'hasDir': false,
396
+ });
397
+ const archiveSize = archiveEntries.reduce((total, item) => total + item.uncompressedSize, 0);
398
+ if ((archiveEntries.length > 10_000) || (archiveSize > 512 * 1024 * 1024)) {
399
+ res.end('Archive limit exceeded');
400
+ await sRoute.unlinkUploadFiles(rtn.files);
401
+ return;
402
+ }
403
+ const rawList = await zip.getList();
404
+ /** --- 经过路径校验并统一为带前导斜杠的 ZIP 文件列表 --- */
405
+ const ls = {};
406
+ for (const archivePath in rawList) {
407
+ const normalizedPath = normalizeArchivePath(archivePath);
408
+ if (!normalizedPath || (ls[normalizedPath] !== undefined)) {
409
+ res.end('Invalid archive path');
410
+ await sRoute.unlinkUploadFiles(rtn.files);
411
+ return;
412
+ }
413
+ ls[normalizedPath] = rawList[archivePath];
414
+ }
339
415
  // --- 预扫描:收集 .cga 锁定目录和 kebab 子项目目录 ---
340
416
  /** --- .cga 锁定的目录集合,key 格式为"父路径/目录名/"(不含开头/,含尾部/),例如 "www/pika/" --- */
341
417
  const cgaLockedDirs = new Set();
@@ -401,9 +477,9 @@ function createRpcListener() {
401
477
  'tmp',
402
478
  'temp',
403
479
  ]);
404
- for (const path in ls) {
480
+ for (const archivePath in ls) {
405
481
  /** --- 带 / 开头的 zip 中文件完整路径,例如 "/www/pika/ctr/api.js" --- */
406
- const fpath = path.startsWith('/') ? path : '/' + path;
482
+ const fpath = archivePath.startsWith('/') ? archivePath : '/' + archivePath;
407
483
  /** --- 纯路径中最后一个 / 的位置索引 --- */
408
484
  const lio = fpath.lastIndexOf('/');
409
485
  /** --- 纯路径,不以 / 开头,以 / 结尾,若是根路径就是空字符串,例如 "www/pika/ctr/" --- */
@@ -473,21 +549,33 @@ function createRpcListener() {
473
549
  }
474
550
  }
475
551
  // --- 看文件夹是否存在 ---
476
- if (pat && !await lFs.isDir(to + pat)) {
552
+ const targetDir = await resolvePathInside(to, pat);
553
+ if (!targetDir) {
554
+ res.end('Invalid archive directory');
555
+ await sRoute.unlinkUploadFiles(rtn.files);
556
+ return;
557
+ }
558
+ if (pat && !await lFs.isDir(targetDir)) {
477
559
  if (rtn.post['strict'] === '1') {
478
- res.end(`[code][1] [${rtn.post['strict']}] Path not found: ${to + pat}`);
560
+ res.end(`[code][1] [${rtn.post['strict']}] Path not found: ${targetDir}`);
479
561
  await sRoute.unlinkUploadFiles(rtn.files);
480
562
  return;
481
563
  }
482
- await lFs.mkdir(to + pat);
564
+ await lFs.mkdir(targetDir);
565
+ }
566
+ const targetFile = await resolvePathInside(to, pat + fname);
567
+ if (!targetFile) {
568
+ res.end('Invalid archive file');
569
+ await sRoute.unlinkUploadFiles(rtn.files);
570
+ return;
483
571
  }
484
572
  // --- 覆盖或创建文件 ---
485
- if ((rtn.post['strict'] === '1') && !await lFs.isFile(to + pat + fname)) {
486
- res.end(`[code][2] [${rtn.post['strict']}] Path not found: ${to + pat + fname}`);
573
+ if ((rtn.post['strict'] === '1') && !await lFs.isFile(targetFile)) {
574
+ res.end(`[code][2] [${rtn.post['strict']}] Path not found: ${targetFile}`);
487
575
  await sRoute.unlinkUploadFiles(rtn.files);
488
576
  return;
489
577
  }
490
- await lFs.putContent(to + pat + fname, ls[path]);
578
+ await lFs.putContent(targetFile, ls[archivePath]);
491
579
  }
492
580
  await sRoute.unlinkUploadFiles(rtn.files);
493
581
  // --- 检查是否更新 config ---
@@ -529,25 +617,47 @@ function createRpcListener() {
529
617
  // --- 获取日志信息 ---
530
618
  const format = lCore.globalConfig.logFormat ?? 'jsonl';
531
619
  const ext = format === 'jsonl' ? '.jsonl' : '.csv';
532
- const path = kebab.LOG_CWD + msg.hostname + (msg.fend ?? '') + '/' + msg.path + ext;
533
- if (!await lFs.isFile(path)) {
620
+ const fend = msg.fend ?? '';
621
+ if ((typeof msg.hostname !== 'string') ||
622
+ (typeof msg.path !== 'string') ||
623
+ !/^[\w.-]+$/.test(msg.hostname) ||
624
+ !/^(?:|-[\w-]+)$/.test(fend) ||
625
+ !/^[\w./-]+$/.test(msg.path)) {
626
+ res.end('Invalid log path');
627
+ return;
628
+ }
629
+ const logPath = await resolvePathInside(kebab.LOG_CWD, `${msg.hostname}${fend}/${msg.path}${ext}`);
630
+ if (!logPath) {
631
+ res.end('Invalid log path');
632
+ return;
633
+ }
634
+ if (!await lFs.isFile(logPath)) {
534
635
  res.end(lText.stringifyJson({
535
636
  'result': 1,
536
637
  'data': null,
537
638
  }));
538
639
  return;
539
640
  }
540
- let limit = msg.limit ?? 100;
541
- const offset = msg.offset ?? 0;
641
+ let limit = Number.isSafeInteger(msg.limit) ? msg.limit : 100;
642
+ const offset = Number.isSafeInteger(msg.offset) ? msg.offset : 0;
643
+ limit = Math.min(Math.max(limit, 1), 1000);
644
+ if (offset < 0) {
645
+ res.end('Invalid offset');
646
+ return;
647
+ }
542
648
  let total = 0;
543
649
  if (msg.search) {
650
+ if ((typeof msg.search !== 'string') || (msg.search.length > 500) || msg.search.includes('\0')) {
651
+ res.end('Invalid search');
652
+ return;
653
+ }
544
654
  // === 搜索模式:total 为匹配行数,offset/limit 均基于匹配行 ===
545
655
  // --- shell 单引号安全转义(防止特殊字符破坏命令)---
546
656
  const escaped = msg.search.replace(/'/g, "'\\''");
547
657
  // --- 获取匹配行总数(grep -c 无匹配时退出码 1,|| echo 0 兜底)---
548
658
  const countCmd = format === 'csv'
549
- ? `tail -n +2 "${path}" | grep -F -c '${escaped}' || echo 0`
550
- : `grep -F -c '${escaped}' "${path}" || echo 0`;
659
+ ? `tail -n +2 "${logPath}" | grep -F -c '${escaped}' || echo 0`
660
+ : `grep -F -c '${escaped}' "${logPath}" || echo 0`;
551
661
  const countRtn = await lCore.exec(countCmd);
552
662
  if (countRtn !== false) {
553
663
  total = parseInt(countRtn.trim()) || 0;
@@ -556,8 +666,8 @@ function createRpcListener() {
556
666
  const from = offset + 1;
557
667
  const to = offset + limit;
558
668
  const dataCmd = format === 'csv'
559
- ? `tail -n +2 "${path}" | grep -F '${escaped}' | sed -n '${from},${to}p'`
560
- : `grep -F '${escaped}' "${path}" | sed -n '${from},${to}p'`;
669
+ ? `tail -n +2 "${logPath}" | grep -F '${escaped}' | sed -n '${from},${to}p'`
670
+ : `grep -F '${escaped}' "${logPath}" | sed -n '${from},${to}p'`;
561
671
  const dataRtn = await lCore.exec(dataCmd);
562
672
  if (dataRtn === false) {
563
673
  res.end(lText.stringifyJson({
@@ -619,7 +729,7 @@ function createRpcListener() {
619
729
  return;
620
730
  }
621
731
  // === 无搜索模式:wc -l 获取总行数,grep -b '^' 定位字节偏移直接跳至 offset ===
622
- const wclRtn = await lCore.exec(`wc -l "${path}"`);
732
+ const wclRtn = await lCore.exec(`wc -l "${logPath}"`);
623
733
  if (wclRtn !== false) {
624
734
  const wclMatch = /^\s*(\d+)/.exec(wclRtn);
625
735
  if (wclMatch) {
@@ -641,7 +751,7 @@ function createRpcListener() {
641
751
  if (offset > 0) {
642
752
  const skipLines = format === 'jsonl' ? offset : offset + 1;
643
753
  // --- sed -n '${N}{p;q}' 找到第 N 行后立即退出,使 grep 收到 SIGPIPE 提前终止 ---
644
- const grepRtn = await lCore.exec(`grep -b '^' "${path}" | sed -n '${skipLines + 1}{p;q}'`);
754
+ const grepRtn = await lCore.exec(`grep -b '^' "${logPath}" | sed -n '${skipLines + 1}{p;q}'`);
645
755
  if (grepRtn !== false) {
646
756
  const grepMatch = /^(\d+):/.exec(grepRtn.trim());
647
757
  if (grepMatch) {
@@ -671,7 +781,7 @@ function createRpcListener() {
671
781
  let line = 0;
672
782
  /** --- 当前行数据 --- */
673
783
  let packet = '';
674
- lFs.createReadStream(path, {
784
+ lFs.createReadStream(logPath, {
675
785
  'encoding': 'utf8',
676
786
  'start': startByte,
677
787
  }).on('data', buf => {
@@ -759,10 +869,10 @@ function createRpcListener() {
759
869
  res.end('Invalid path');
760
870
  return;
761
871
  }
762
- const path = lText.urlResolve(kebab.ROOT_CWD, msg.path, true);
872
+ const targetPath = lText.urlResolve(kebab.ROOT_CWD, msg.path, true);
763
873
  res.end(lText.stringifyJson({
764
874
  'result': 1,
765
- 'data': (await lFs.readDir(path, msg.encoding)).map(item => ({
875
+ 'data': (await lFs.readDir(targetPath, msg.encoding)).map(item => ({
766
876
  'isFile': item.isFile(),
767
877
  'isDirectory': item.isDirectory(),
768
878
  'isSymbolicLink': item.isSymbolicLink(),
package/sys/route.d.ts CHANGED
@@ -79,6 +79,12 @@ export declare function getFormData(req: http2.Http2ServerRequest | http.Incomin
79
79
  'allowedExts'?: string[];
80
80
  /** --- 单个字段(非文件)最大字节数,默认 1 MB --- */
81
81
  'maxFieldSize'?: number;
82
+ /** --- 整体请求最大字节数,不设置或设为 0 则不限制 --- */
83
+ 'maxTotalSize'?: number;
84
+ /** --- 单个 multipart 段头部最大字节数,默认 16 KB --- */
85
+ 'maxHeaderSize'?: number;
86
+ /** --- multipart 字段与文件总数量,默认 1000 --- */
87
+ 'maxParts'?: number;
82
88
  /** --- 整体请求超时时间(毫秒),默认 5 分钟,设为 0 禁用超时 --- */
83
89
  'timeout'?: number;
84
90
  }): Promise<{
package/sys/route.js CHANGED
@@ -41,6 +41,20 @@ function respond500(res) {
41
41
  res.end(content);
42
42
  }
43
43
  }
44
+ /**
45
+ * --- 安全写入外部输入键,避免 __proto__ 等特殊键改变对象原型 ---
46
+ * @param target 目标对象
47
+ * @param key 键
48
+ * @param value 值
49
+ */
50
+ function setInputValue(target, key, value) {
51
+ Object.defineProperty(target, key, {
52
+ 'configurable': true,
53
+ 'enumerable': true,
54
+ 'value': value,
55
+ 'writable': true,
56
+ });
57
+ }
44
58
  /**
45
59
  * --- 输出 404 错误响应 ---
46
60
  * @param res 响应对象
@@ -58,7 +72,7 @@ function respond404(res, config, path) {
58
72
  }
59
73
  return;
60
74
  }
61
- const content = '[Error] Controller not found, path: ' + path + '.';
75
+ const content = '[Error] Controller not found, path: ' + lText.htmlescape(path) + '.';
62
76
  if (!res.headersSent) {
63
77
  res.setHeader('content-type', 'text/html; charset=utf-8');
64
78
  res.setHeader('content-length', Buffer.byteLength(content));
@@ -245,17 +259,17 @@ export async function run(data) {
245
259
  const key = cookie.slice(0, eqIndex).trim();
246
260
  const rawVal = cookie.slice(eqIndex + 1);
247
261
  try {
248
- cookies[key] = decodeURIComponent(rawVal);
262
+ setInputValue(cookies, key, decodeURIComponent(rawVal));
249
263
  }
250
264
  catch {
251
- cookies[key] = rawVal;
265
+ setInputValue(cookies, key, rawVal);
252
266
  }
253
267
  }
254
268
  }
255
269
  // --- 处理 headers ---
256
270
  const headers = {};
257
271
  for (const key in data.req.headers) {
258
- headers[key.toLowerCase()] = data.req.headers[key];
272
+ setInputValue(headers, key.toLowerCase(), data.req.headers[key]);
259
273
  }
260
274
  headers['authorization'] ??= '';
261
275
  /** --- 开发者返回值 --- */
@@ -922,9 +936,9 @@ export function getFormData(req, events = {}, limits = {}) {
922
936
  resolve({ 'post': {}, 'files': {} });
923
937
  return;
924
938
  }
925
- /** --- boundary 位置 --- */
926
- const clio = ct.lastIndexOf('boundary=');
927
- if (clio === -1) {
939
+ /** --- 获取 boundary,兼容带引号及后续参数的标准写法 --- */
940
+ const boundaryMatch = /(?:^|;)\s*boundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i.exec(ct);
941
+ if (!boundaryMatch) {
928
942
  resolve({ 'post': {}, 'files': {} });
929
943
  return;
930
944
  }
@@ -934,7 +948,11 @@ export function getFormData(req, events = {}, limits = {}) {
934
948
  'files': {}
935
949
  };
936
950
  /** --- 获取的 boundary 文本 --- */
937
- const boundary = ct.slice(clio + 9);
951
+ const boundary = boundaryMatch[1] ?? boundaryMatch[2];
952
+ if (!boundary || (boundary.length > 200) || /[\r\n]/.test(boundary)) {
953
+ resolve(false);
954
+ return;
955
+ }
938
956
  // --- 超时护盾:防止网络静默断开时 Promise 永不 resolve ---
939
957
  /** --- 超时定时器 --- */
940
958
  let timer;
@@ -978,6 +996,10 @@ export function getFormData(req, events = {}, limits = {}) {
978
996
  let readEnd = false;
979
997
  /** --- 是否有文件被限制拒绝(整体返回 false) --- */
980
998
  let rejected = false;
999
+ /** --- 已接收的请求体字节数 --- */
1000
+ let totalSize = 0;
1001
+ /** --- 已解析的字段与文件数量 --- */
1002
+ let partCount = 0;
981
1003
  /** --- 清理 rtn.files 中所有已写入的临时文件 --- */
982
1004
  function cleanupFiles() {
983
1005
  for (const key in rtn.files) {
@@ -990,6 +1012,16 @@ export function getFormData(req, events = {}, limits = {}) {
990
1012
  }
991
1013
  }
992
1014
  }
1015
+ /** --- 清理仍在写入、尚未加入 rtn.files 的临时文件 --- */
1016
+ function cleanupActiveFile() {
1017
+ if ((state !== EState.FILE) || !ftmpName) {
1018
+ return;
1019
+ }
1020
+ ftmpStream.destroy();
1021
+ lFs.unlink(kebab.FTMP_CWD + ftmpName).catch(() => { });
1022
+ ftmpName = '';
1023
+ --writeFileLength;
1024
+ }
993
1025
  // --- 启动超时定时器(在所有变量声明之后,确保回调闭包可引用) ---
994
1026
  const timeoutMs = limits.timeout ?? 300_000;
995
1027
  if (timeoutMs > 0) {
@@ -998,9 +1030,7 @@ export function getFormData(req, events = {}, limits = {}) {
998
1030
  return;
999
1031
  }
1000
1032
  finished = true;
1001
- if ((state === EState.FILE) && ftmpName && ftmpStream) {
1002
- ftmpStream.destroy();
1003
- }
1033
+ cleanupActiveFile();
1004
1034
  lCore.debug('[ROUTE][GETFORMDATA] formdata request timeout');
1005
1035
  lCore.log({}, '[ROUTE][GETFORMDATA] formdata request timeout after ' + timeoutMs + 'ms', '-error');
1006
1036
  cleanupFiles();
@@ -1032,6 +1062,16 @@ export function getFormData(req, events = {}, limits = {}) {
1032
1062
  }
1033
1063
  // --- 开始读取 ---
1034
1064
  req.on('data', function (chunk) {
1065
+ if (finished || rejected) {
1066
+ return;
1067
+ }
1068
+ totalSize += chunk.length;
1069
+ if ((limits.maxTotalSize !== undefined) && (limits.maxTotalSize > 0) && (totalSize > limits.maxTotalSize)) {
1070
+ rejected = true;
1071
+ cleanupActiveFile();
1072
+ buffer = Buffer.from('');
1073
+ return;
1074
+ }
1035
1075
  buffer = Buffer.concat([buffer, chunk], buffer.length + chunk.length);
1036
1076
  while (true) {
1037
1077
  switch (state) {
@@ -1039,9 +1079,19 @@ export function getFormData(req, events = {}, limits = {}) {
1039
1079
  /** --- 中断符位置 --- */
1040
1080
  const io = buffer.indexOf('\r\n\r\n');
1041
1081
  if (io === -1) {
1082
+ if (buffer.length > (limits.maxHeaderSize ?? 16 * 1024)) {
1083
+ rejected = true;
1084
+ buffer = Buffer.from('');
1085
+ }
1042
1086
  return;
1043
1087
  }
1044
1088
  // --- 头部已经读取完毕 ---
1089
+ ++partCount;
1090
+ if (partCount > (limits.maxParts ?? 1000)) {
1091
+ rejected = true;
1092
+ buffer = Buffer.from('');
1093
+ return;
1094
+ }
1045
1095
  const head = buffer.subarray(0, io).toString();
1046
1096
  // --- 除头部外剩下的 buffer ---
1047
1097
  buffer = buffer.subarray(io + 4);
@@ -1084,8 +1134,20 @@ export function getFormData(req, events = {}, limits = {}) {
1084
1134
  date.getUTCDate().toString().padStart(2, '0') +
1085
1135
  date.getUTCHours().toString().padStart(2, '0') +
1086
1136
  date.getUTCMinutes().toString().padStart(2, '0') + '_' + lCore.random() + '.ftmp';
1087
- ftmpStream = lFs.createWriteStream(kebab.FTMP_CWD + ftmpName);
1088
- ftmpStream.on('error', () => { });
1137
+ const activeName = ftmpName;
1138
+ const activePath = kebab.FTMP_CWD + activeName;
1139
+ const activeStream = lFs.createWriteStream(activePath);
1140
+ ftmpStream = activeStream;
1141
+ activeStream.on('error', (error) => {
1142
+ rejected = true;
1143
+ lFs.unlink(activePath).catch(() => { });
1144
+ if (ftmpStream === activeStream) {
1145
+ ftmpName = '';
1146
+ }
1147
+ --writeFileLength;
1148
+ req.resume();
1149
+ lCore.log({}, `[ROUTE][GETFORMDATA] temporary file error: ${error.message}`, '-error');
1150
+ });
1089
1151
  ftmpSize = 0;
1090
1152
  }
1091
1153
  else {
@@ -1102,13 +1164,14 @@ export function getFormData(req, events = {}, limits = {}) {
1102
1164
  const maxField = limits.maxFieldSize ?? 1_048_576;
1103
1165
  if (buffer.length > maxField + boundary.length + 4) {
1104
1166
  rejected = true;
1167
+ buffer = Buffer.from('');
1105
1168
  return;
1106
1169
  }
1107
1170
  return;
1108
1171
  }
1109
1172
  // --- 找到结束标语,写入 POST ---
1110
1173
  const val = buffer.subarray(0, io).toString();
1111
- if (rtn.post[name]) {
1174
+ if (Object.hasOwn(rtn.post, name)) {
1112
1175
  if (Array.isArray(rtn.post[name])) {
1113
1176
  rtn.post[name].push(val);
1114
1177
  }
@@ -1117,7 +1180,7 @@ export function getFormData(req, events = {}, limits = {}) {
1117
1180
  }
1118
1181
  }
1119
1182
  else {
1120
- rtn.post[name] = val;
1183
+ setInputValue(rtn.post, name, val);
1121
1184
  }
1122
1185
  // --- 重置状态机 ---
1123
1186
  state = EState.WAIT;
@@ -1138,7 +1201,10 @@ export function getFormData(req, events = {}, limits = {}) {
1138
1201
  rejectFile();
1139
1202
  }
1140
1203
  else {
1141
- ftmpStream.write(writeBuffer);
1204
+ if (!ftmpStream.write(writeBuffer)) {
1205
+ req.pause();
1206
+ ftmpStream.once('drain', () => { req.resume(); });
1207
+ }
1142
1208
  ftmpSize += Buffer.byteLength(writeBuffer);
1143
1209
  }
1144
1210
  }
@@ -1188,7 +1254,7 @@ export function getFormData(req, events = {}, limits = {}) {
1188
1254
  'size': ftmpSize,
1189
1255
  'path': kebab.FTMP_CWD + ftmpName
1190
1256
  };
1191
- if (rtn.files[name]) {
1257
+ if (Object.hasOwn(rtn.files, name)) {
1192
1258
  if (Array.isArray(rtn.files[name])) {
1193
1259
  rtn.files[name].push(val);
1194
1260
  }
@@ -1197,7 +1263,7 @@ export function getFormData(req, events = {}, limits = {}) {
1197
1263
  }
1198
1264
  }
1199
1265
  else {
1200
- rtn.files[name] = val;
1266
+ setInputValue(rtn.files, name, val);
1201
1267
  }
1202
1268
  }
1203
1269
  }
@@ -1223,9 +1289,7 @@ export function getFormData(req, events = {}, limits = {}) {
1223
1289
  }
1224
1290
  finished = true;
1225
1291
  clearTimer();
1226
- if ((state === EState.FILE) && ftmpName) {
1227
- ftmpStream.destroy();
1228
- }
1292
+ cleanupActiveFile();
1229
1293
  lCore.debug('[ROUTE][GETFORMDATA] request error before getFormData: ' + e.message);
1230
1294
  lCore.log({}, '[ROUTE][GETFORMDATA] request error before getFormData: ' + (e.stack ?? ''), '-error');
1231
1295
  cleanupFiles();
@@ -1237,12 +1301,10 @@ export function getFormData(req, events = {}, limits = {}) {
1237
1301
  return;
1238
1302
  }
1239
1303
  // --- 若数据未读完且连接已关闭,视为传输中断(多数是用户主动取消,无需记录错误) ---
1240
- if (!readEnd && writeFileLength > 0) {
1304
+ if (!readEnd) {
1241
1305
  finished = true;
1242
1306
  clearTimer();
1243
- if ((state === EState.FILE) && ftmpName && ftmpStream) {
1244
- ftmpStream.destroy();
1245
- }
1307
+ cleanupActiveFile();
1246
1308
  lCore.debug('[ROUTE][GETFORMDATA] connection closed before formdata complete');
1247
1309
  cleanupFiles();
1248
1310
  resolve(false);