@maiyunnet/kebab 9.14.2 → 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/lib/undici.js CHANGED
@@ -453,7 +453,17 @@ export async function request(u, data, opt = {}) {
453
453
  return res;
454
454
  }
455
455
  // --- 哦,要追踪 ---
456
- headers['referer'] = u;
456
+ const nextUrl = lText.urlResolve(u, req.headers['location']);
457
+ if (lText.isSameOrigin(u, nextUrl)) {
458
+ headers['referer'] = u;
459
+ }
460
+ else {
461
+ // --- 跨域跳转不得携带来源站点凭据 ---
462
+ delete headers['authorization'];
463
+ delete headers['proxy-authorization'];
464
+ delete headers['cookie'];
465
+ delete headers['referer'];
466
+ }
457
467
  let nextMethod = method;
458
468
  let nextData = data;
459
469
  const status = res.headers['http-code'];
@@ -461,7 +471,12 @@ export async function request(u, data, opt = {}) {
461
471
  nextMethod = 'GET';
462
472
  nextData = undefined;
463
473
  }
464
- return request(lText.urlResolve(u, req.headers['location']), nextData, {
474
+ if (nextData instanceof stream.Readable) {
475
+ return res;
476
+ }
477
+ // --- 释放原响应体,避免跟随多次跳转时占用连接 ---
478
+ await req.body.dump();
479
+ return request(nextUrl, nextData, {
465
480
  ...opt,
466
481
  ...{
467
482
  'method': nextMethod,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "9.14.2",
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<{