@cjhyy/code-shell-server 0.8.9 → 0.8.10
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/README.md +11 -10
- package/dist/attachment-service.js +147 -13
- package/dist/mobile-remote/access-passcode.d.ts +13 -0
- package/dist/mobile-remote/access-passcode.js +147 -13
- package/dist/mobile-remote/mobile-attachments.d.ts +1 -5
- package/dist/mobile-remote/mobile-attachments.js +2 -4
- package/dist/mobile-remote/mobile-client-event-validator.d.ts +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +230 -0
- package/dist/mobile-remote/mobile-limits.d.ts +6 -0
- package/dist/mobile-remote/mobile-limits.js +6 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +2 -1
- package/dist/mobile-remote/mobile-upload-service.js +11 -5
- package/dist/mobile-remote/pairing.d.ts +11 -0
- package/dist/mobile-remote/pairing.js +47 -2
- package/dist/mobile-remote/remote-host-manager.d.ts +5 -3
- package/dist/mobile-remote/remote-host-manager.js +105 -16
- package/dist/mobile-remote/room-manager.d.ts +9 -1
- package/dist/mobile-remote/room-manager.js +388 -55
- package/dist/mobile-remote/trusted-device-store.d.ts +3 -0
- package/dist/mobile-remote/trusted-device-store.js +215 -58
- package/dist/serve/headless-server.d.ts +5 -0
- package/dist/serve/headless-server.js +112 -15
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -54,21 +54,22 @@ headless serve(本包 serve/)
|
|
|
54
54
|
agent-server-stdio worker(@cjhyy/code-shell-capability-coding)
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
-
|
|
57
|
+
- 浏览器使用受限的 core 协议投影:serve 自己处理会话列表/详情,只允许向 worker 转发 `agent/run`、`agent/approve`、`agent/cancel`,并在转发前校验 workspace 与 session 归属。配置修改、任意 workspace 切换等原始 RPC 不对 Web 开放。
|
|
58
|
+
- 每个 tab 的请求 ID 会在 host 内重写,响应只回到发起 tab;`agent/streamEvent` 等通知才广播给所有已认证 tab,避免多 tab 使用相同本地 ID 时串包。
|
|
58
59
|
- **访问控制只有 passcode + remember-cookie**(决策见 TODO 约束边界「服务端部署不做账号体系」):scrypt 哈希存储、防爆破锁定、轮换口令使所有旧 cookie 失效。无注册登录、无多用户。
|
|
59
60
|
- 默认只绑 `127.0.0.1`;`--host 0.0.0.0` 暴露到局域网是运维者的显式选择。公网建议走反向代理/tunnel 终结 TLS。
|
|
60
|
-
-
|
|
61
|
+
- 会话、设置与凭据持久在 serve 独占的 worker data root(默认 `<data-dir>/worker`),不再读取宿主机默认 `~/.code-shell`;serve 或 worker 重启后浏览器重连即可恢复列表。
|
|
61
62
|
|
|
62
63
|
### CLI 参数
|
|
63
64
|
|
|
64
|
-
| 参数 | 默认 | 说明
|
|
65
|
-
| --------------- | --------------------- |
|
|
66
|
-
| `--cwd` | 当前目录 | worker 的 workspace 根
|
|
67
|
-
| `--port` | 8790 | 监听端口
|
|
68
|
-
| `--host` | 127.0.0.1 | 监听地址
|
|
69
|
-
| `--passcode` | (生成) | 设置/轮换访问口令
|
|
70
|
-
| `--data-dir` | `~/.code-shell/serve` | access.json
|
|
71
|
-
| `--static-root` | 自动解析 web dist-app | 覆盖静态资源目录
|
|
65
|
+
| 参数 | 默认 | 说明 |
|
|
66
|
+
| --------------- | --------------------- | -------------------------------------------- |
|
|
67
|
+
| `--cwd` | 当前目录 | worker 的 workspace 根 |
|
|
68
|
+
| `--port` | 8790 | 监听端口 |
|
|
69
|
+
| `--host` | 127.0.0.1 | 监听地址 |
|
|
70
|
+
| `--passcode` | (生成) | 设置/轮换访问口令 |
|
|
71
|
+
| `--data-dir` | `~/.code-shell/serve` | access.json 与隔离 worker data root 的父目录 |
|
|
72
|
+
| `--static-root` | 自动解析 web dist-app | 覆盖静态资源目录 |
|
|
72
73
|
|
|
73
74
|
### Web 客户端开发
|
|
74
75
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { appendFile, lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
3
4
|
import { basename, extname, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { sanitizeImageBytes } from "./image-byte-probe.js";
|
|
5
6
|
const CODE_SHELL_DIR = ".code-shell";
|
|
@@ -10,6 +11,36 @@ export const DRAFT_ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
10
11
|
export const SENT_ATTACHMENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
12
|
export const MAX_STAGED_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
12
13
|
export const MAX_STAGED_FILE_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
const MAX_ATTACHMENT_NAME_CHARS = 1_024;
|
|
15
|
+
const MAX_ATTACHMENT_MIME_CHARS = 256;
|
|
16
|
+
const MAX_MANIFEST_LINE_BYTES = 128 * 1024;
|
|
17
|
+
const MAX_MANIFEST_SCAN_BYTES = 32 * 1024 * 1024;
|
|
18
|
+
const MAX_MANIFEST_RECORDS = 50_000;
|
|
19
|
+
const MAX_ATTACHMENT_SESSION_DIRS = 10_000;
|
|
20
|
+
const ATTACHMENT_ORIGINS = new Set([
|
|
21
|
+
"paste",
|
|
22
|
+
"os-drop",
|
|
23
|
+
"file-panel",
|
|
24
|
+
"picker",
|
|
25
|
+
"mention",
|
|
26
|
+
"generated",
|
|
27
|
+
"mobile",
|
|
28
|
+
"im-gateway",
|
|
29
|
+
"tool",
|
|
30
|
+
]);
|
|
31
|
+
function assertAttachmentLabels(input) {
|
|
32
|
+
if ((input.name !== undefined &&
|
|
33
|
+
(typeof input.name !== "string" ||
|
|
34
|
+
input.name.length > MAX_ATTACHMENT_NAME_CHARS ||
|
|
35
|
+
input.name.includes("\0"))) ||
|
|
36
|
+
(input.mime !== undefined &&
|
|
37
|
+
(typeof input.mime !== "string" ||
|
|
38
|
+
input.mime.length > MAX_ATTACHMENT_MIME_CHARS ||
|
|
39
|
+
input.mime.includes("\0"))) ||
|
|
40
|
+
!ATTACHMENT_ORIGINS.has(input.origin)) {
|
|
41
|
+
throw new Error("invalid attachment metadata");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
13
44
|
const IMAGE_MIME_EXT = {
|
|
14
45
|
"image/png": ".png",
|
|
15
46
|
"image/jpeg": ".jpg",
|
|
@@ -31,6 +62,7 @@ export async function stageImageDataUrl(input) {
|
|
|
31
62
|
}
|
|
32
63
|
/** Stage already-transferred bytes without expanding them back through base64. */
|
|
33
64
|
export async function stageImageBytes(input) {
|
|
65
|
+
assertAttachmentLabels(input);
|
|
34
66
|
const mime = normalizeMime(input.mime);
|
|
35
67
|
if (!ALLOWED_IMAGE_MIMES.has(mime))
|
|
36
68
|
throw new Error(`unsupported image MIME ${input.mime}`);
|
|
@@ -39,14 +71,25 @@ export async function stageImageBytes(input) {
|
|
|
39
71
|
}
|
|
40
72
|
let buffer;
|
|
41
73
|
if (input.sourceFile !== undefined) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
74
|
+
if (typeof input.sourceFile !== "string" ||
|
|
75
|
+
!input.sourceFile ||
|
|
76
|
+
input.sourceFile.length > 32_768 ||
|
|
77
|
+
input.sourceFile.includes("\0")) {
|
|
78
|
+
throw new Error("image source path is invalid");
|
|
79
|
+
}
|
|
80
|
+
const handle = await open(input.sourceFile, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
81
|
+
try {
|
|
82
|
+
const source = await handle.stat();
|
|
83
|
+
if (!source.isFile())
|
|
84
|
+
throw new Error("image source must be a regular file");
|
|
85
|
+
if (source.size > MAX_STAGED_IMAGE_BYTES) {
|
|
86
|
+
throw new Error(`image attachment exceeds size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
|
|
87
|
+
}
|
|
88
|
+
buffer = await handle.readFile();
|
|
45
89
|
}
|
|
46
|
-
|
|
47
|
-
|
|
90
|
+
finally {
|
|
91
|
+
await handle.close();
|
|
48
92
|
}
|
|
49
|
-
buffer = await readFile(input.sourceFile);
|
|
50
93
|
}
|
|
51
94
|
else {
|
|
52
95
|
buffer = Buffer.from(input.bytes);
|
|
@@ -54,6 +97,7 @@ export async function stageImageBytes(input) {
|
|
|
54
97
|
return stageImageBuffer(input, mime, buffer);
|
|
55
98
|
}
|
|
56
99
|
async function stageImageBuffer(input, mime, buffer) {
|
|
100
|
+
assertAttachmentLabels(input);
|
|
57
101
|
const cwd = await resolveExistingDirectory(input.cwd, "cwd");
|
|
58
102
|
assertSafeSessionId(input.sessionId);
|
|
59
103
|
if (buffer.byteLength > MAX_STAGED_IMAGE_BYTES) {
|
|
@@ -107,6 +151,7 @@ async function stageImageBuffer(input, mime, buffer) {
|
|
|
107
151
|
}
|
|
108
152
|
/** Stage an untrusted non-image file inside the canonical attachment root. */
|
|
109
153
|
export async function stageFileBytes(input) {
|
|
154
|
+
assertAttachmentLabels(input);
|
|
110
155
|
const cwd = await resolveExistingDirectory(input.cwd, "cwd");
|
|
111
156
|
assertSafeSessionId(input.sessionId);
|
|
112
157
|
const buffer = Buffer.from(input.bytes);
|
|
@@ -480,33 +525,121 @@ async function findExistingSessionFile(sessionDir, sha16) {
|
|
|
480
525
|
catch {
|
|
481
526
|
return null;
|
|
482
527
|
}
|
|
483
|
-
|
|
528
|
+
for (const entry of entries) {
|
|
529
|
+
if (!entry.startsWith(`${sha16}-`) || entry === MANIFEST_FILE)
|
|
530
|
+
continue;
|
|
531
|
+
const info = await lstat(join(sessionDir, entry));
|
|
532
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
533
|
+
throw new Error("existing attachment hash entry must be a regular file, not a symlink");
|
|
534
|
+
}
|
|
535
|
+
return entry;
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
484
538
|
}
|
|
485
539
|
async function appendManifest(sessionDir, record) {
|
|
486
540
|
const manifest = await safeJoin(sessionDir, MANIFEST_FILE, sessionDir);
|
|
487
|
-
|
|
541
|
+
const serialized = JSON.stringify(record);
|
|
542
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_MANIFEST_LINE_BYTES) {
|
|
543
|
+
throw new Error("attachment manifest record exceeds the size limit");
|
|
544
|
+
}
|
|
545
|
+
await appendFile(manifest, `${serialized}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
488
546
|
}
|
|
489
547
|
async function readManifest(sessionDir) {
|
|
490
548
|
try {
|
|
491
549
|
const manifest = await safeJoin(sessionDir, MANIFEST_FILE, sessionDir);
|
|
492
|
-
const
|
|
550
|
+
const handle = await open(manifest, "r");
|
|
551
|
+
let raw;
|
|
552
|
+
try {
|
|
553
|
+
const stat = await handle.stat();
|
|
554
|
+
if (!stat.isFile())
|
|
555
|
+
return [];
|
|
556
|
+
const length = Math.min(stat.size, MAX_MANIFEST_SCAN_BYTES);
|
|
557
|
+
const start = stat.size - length;
|
|
558
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
559
|
+
let total = 0;
|
|
560
|
+
while (total < length) {
|
|
561
|
+
const { bytesRead } = await handle.read(buffer, total, length - total, start + total);
|
|
562
|
+
if (bytesRead === 0)
|
|
563
|
+
break;
|
|
564
|
+
total += bytesRead;
|
|
565
|
+
}
|
|
566
|
+
let window = buffer.subarray(0, total);
|
|
567
|
+
if (start > 0) {
|
|
568
|
+
const newline = window.indexOf(0x0a);
|
|
569
|
+
if (newline < 0)
|
|
570
|
+
return [];
|
|
571
|
+
window = window.subarray(newline + 1);
|
|
572
|
+
}
|
|
573
|
+
raw = window.toString("utf8");
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
await handle.close();
|
|
577
|
+
}
|
|
493
578
|
return raw
|
|
494
579
|
.split("\n")
|
|
495
|
-
.filter(
|
|
580
|
+
.filter((line) => line.length > 0 && Buffer.byteLength(line, "utf8") <= MAX_MANIFEST_LINE_BYTES)
|
|
496
581
|
.map((line) => {
|
|
497
582
|
try {
|
|
498
|
-
return JSON.parse(line);
|
|
583
|
+
return parseManifestRecord(JSON.parse(line));
|
|
499
584
|
}
|
|
500
585
|
catch {
|
|
501
586
|
return null;
|
|
502
587
|
}
|
|
503
588
|
})
|
|
504
|
-
.filter((record) => !!record)
|
|
589
|
+
.filter((record) => !!record)
|
|
590
|
+
.slice(-MAX_MANIFEST_RECORDS);
|
|
505
591
|
}
|
|
506
592
|
catch {
|
|
507
593
|
return [];
|
|
508
594
|
}
|
|
509
595
|
}
|
|
596
|
+
function parseManifestRecord(value) {
|
|
597
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
598
|
+
return null;
|
|
599
|
+
const raw = value;
|
|
600
|
+
if ((raw.event !== "staged" &&
|
|
601
|
+
raw.event !== "sent" &&
|
|
602
|
+
raw.event !== "removedFromDraft" &&
|
|
603
|
+
raw.event !== "cleanup") ||
|
|
604
|
+
typeof raw.sessionId !== "string") {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
assertSafeSessionId(raw.sessionId);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
for (const field of [
|
|
614
|
+
"id",
|
|
615
|
+
"path",
|
|
616
|
+
"absPath",
|
|
617
|
+
"relPath",
|
|
618
|
+
"mime",
|
|
619
|
+
"sha256",
|
|
620
|
+
"originalName",
|
|
621
|
+
]) {
|
|
622
|
+
const item = raw[field];
|
|
623
|
+
if (item !== undefined && (typeof item !== "string" || item.length > 32_768 || item.includes("\0"))) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (raw.kind !== undefined &&
|
|
628
|
+
raw.kind !== "image" &&
|
|
629
|
+
raw.kind !== "file" &&
|
|
630
|
+
raw.kind !== "directory") {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
if (raw.origin !== undefined && !ATTACHMENT_ORIGINS.has(raw.origin)) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
for (const field of ["size", "createdAt", "sentAt", "removedAt", "cleanedAt"]) {
|
|
637
|
+
const item = raw[field];
|
|
638
|
+
if (item !== undefined && (typeof item !== "number" || !Number.isFinite(item)))
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
return raw;
|
|
642
|
+
}
|
|
510
643
|
async function listSafeSessionDirs(root) {
|
|
511
644
|
let entries;
|
|
512
645
|
try {
|
|
@@ -517,6 +650,7 @@ async function listSafeSessionDirs(root) {
|
|
|
517
650
|
}
|
|
518
651
|
return entries
|
|
519
652
|
.filter((entry) => entry.isDirectory())
|
|
653
|
+
.slice(0, MAX_ATTACHMENT_SESSION_DIRS)
|
|
520
654
|
.map((entry) => entry.name)
|
|
521
655
|
.filter((name) => {
|
|
522
656
|
try {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const MIN_ACCESS_PASSCODE_LENGTH = 4;
|
|
2
|
+
export declare const MAX_ACCESS_PASSCODE_LENGTH = 256;
|
|
1
3
|
/** Minimal structural views of node:http req/res so `gate` is unit-testable
|
|
2
4
|
* without a real server. */
|
|
3
5
|
interface GateRequest {
|
|
@@ -43,6 +45,7 @@ export declare class AccessPasscode {
|
|
|
43
45
|
private readonly lockoutMs;
|
|
44
46
|
private readonly tokenMaxAgeMs;
|
|
45
47
|
private failures;
|
|
48
|
+
private malformed;
|
|
46
49
|
private lockedUntil;
|
|
47
50
|
constructor(opts: AccessPasscodeOptions);
|
|
48
51
|
isSet(): boolean;
|
|
@@ -71,6 +74,16 @@ export declare class AccessPasscode {
|
|
|
71
74
|
* write). Used by the WS gate which cannot send a challenge page. */
|
|
72
75
|
allows(req: GateRequest): boolean;
|
|
73
76
|
private isLocked;
|
|
77
|
+
private recordFailure;
|
|
78
|
+
/**
|
|
79
|
+
* Count a request whose passcode could not even be a passcode.
|
|
80
|
+
*
|
|
81
|
+
* Kept separate from `recordFailure` so junk traffic cannot spend the
|
|
82
|
+
* credential-guessing budget and lock out the owner. The budget is a large
|
|
83
|
+
* multiple of `maxAttempts`: a determined flood is still throttled, but the
|
|
84
|
+
* handful of malformed submissions a real client produces never is.
|
|
85
|
+
*/
|
|
86
|
+
private recordMalformed;
|
|
74
87
|
private issueToken;
|
|
75
88
|
private sign;
|
|
76
89
|
private hashPasscode;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
5
|
+
/** Malformed submissions allowed per `maxAttempts` before throttling kicks in. */
|
|
6
|
+
const MALFORMED_ATTEMPT_MULTIPLIER = 20;
|
|
5
7
|
const DEFAULT_LOCKOUT_MS = 60_000;
|
|
6
8
|
const DEFAULT_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
9
|
const COOKIE_NAME = "cs_access";
|
|
8
10
|
const SCRYPT_KEYLEN = 32;
|
|
11
|
+
export const MIN_ACCESS_PASSCODE_LENGTH = 4;
|
|
12
|
+
export const MAX_ACCESS_PASSCODE_LENGTH = 256;
|
|
13
|
+
const MAX_REMEMBER_TOKEN_LENGTH = 128;
|
|
14
|
+
const MAX_ACCESS_RECORD_BYTES = 4 * 1024;
|
|
9
15
|
/**
|
|
10
16
|
* The public-tunnel access gate. A single shared passcode (set by the desktop
|
|
11
17
|
* user) guards the tunnel; a phone that enters it correctly receives a signed
|
|
@@ -25,8 +31,15 @@ export class AccessPasscode {
|
|
|
25
31
|
lockoutMs;
|
|
26
32
|
tokenMaxAgeMs;
|
|
27
33
|
failures = 0;
|
|
34
|
+
malformed = 0;
|
|
28
35
|
lockedUntil = 0;
|
|
29
36
|
constructor(opts) {
|
|
37
|
+
if (!opts || typeof opts.filePath !== "string" || !opts.filePath) {
|
|
38
|
+
throw new Error("Access passcode filePath is required");
|
|
39
|
+
}
|
|
40
|
+
assertPositiveSafeInteger("maxAttempts", opts.maxAttempts);
|
|
41
|
+
assertPositiveSafeInteger("lockoutMs", opts.lockoutMs);
|
|
42
|
+
assertPositiveSafeInteger("tokenMaxAgeMs", opts.tokenMaxAgeMs);
|
|
30
43
|
this.filePath = opts.filePath;
|
|
31
44
|
this.now = opts.now ?? Date.now;
|
|
32
45
|
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -38,6 +51,9 @@ export class AccessPasscode {
|
|
|
38
51
|
}
|
|
39
52
|
/** Set (or rotate) the passcode. Rotating `secret` invalidates old tokens. */
|
|
40
53
|
set(passcode) {
|
|
54
|
+
if (!isValidPasscode(passcode)) {
|
|
55
|
+
throw new Error(`Access passcode must be ${MIN_ACCESS_PASSCODE_LENGTH}-${MAX_ACCESS_PASSCODE_LENGTH} characters`);
|
|
56
|
+
}
|
|
41
57
|
const salt = randomBytes(16).toString("hex");
|
|
42
58
|
const hash = this.hashPasscode(passcode, salt);
|
|
43
59
|
const secret = randomBytes(32).toString("hex");
|
|
@@ -56,12 +72,21 @@ export class AccessPasscode {
|
|
|
56
72
|
return null;
|
|
57
73
|
if (this.isLocked())
|
|
58
74
|
return null;
|
|
75
|
+
// Malformed input is rejected before the expensive hash, but it must NOT
|
|
76
|
+
// spend the credential-guessing budget. A value outside the length bounds
|
|
77
|
+
// is not a candidate passcode — no 4..256 check can be "guessed" by a
|
|
78
|
+
// 1-character string — so counting it deterred no attack while handing an
|
|
79
|
+
// unauthenticated caller a free lockout: five `?passcode=1` requests locked
|
|
80
|
+
// the legitimate owner out for the whole window, repeatable indefinitely.
|
|
81
|
+
// It still costs an attempt against a much larger malformed-input budget,
|
|
82
|
+
// so a script hammering junk is not free either.
|
|
83
|
+
if (!isValidPasscode(passcode)) {
|
|
84
|
+
this.recordMalformed();
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
59
87
|
const candidate = this.hashPasscode(passcode, record.salt);
|
|
60
88
|
if (!safeEqualHex(candidate, record.hash)) {
|
|
61
|
-
this.
|
|
62
|
-
if (this.failures >= this.maxAttempts) {
|
|
63
|
-
this.lockedUntil = this.now() + this.lockoutMs;
|
|
64
|
-
}
|
|
89
|
+
this.recordFailure();
|
|
65
90
|
return null;
|
|
66
91
|
}
|
|
67
92
|
this.failures = 0;
|
|
@@ -73,6 +98,11 @@ export class AccessPasscode {
|
|
|
73
98
|
* issue timestamp must be within the validity window (so a stolen/synced
|
|
74
99
|
* cookie cannot grant access indefinitely). */
|
|
75
100
|
verifyToken(token) {
|
|
101
|
+
if (typeof token !== "string" ||
|
|
102
|
+
token.length === 0 ||
|
|
103
|
+
token.length > MAX_REMEMBER_TOKEN_LENGTH) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
76
106
|
const record = this.read();
|
|
77
107
|
if (!record)
|
|
78
108
|
return false;
|
|
@@ -89,7 +119,8 @@ export class AccessPasscode {
|
|
|
89
119
|
const issuedAt = Number(payload.slice(payload.indexOf(".") + 1));
|
|
90
120
|
if (!Number.isFinite(issuedAt))
|
|
91
121
|
return false;
|
|
92
|
-
|
|
122
|
+
const age = this.now() - issuedAt;
|
|
123
|
+
return age >= 0 && age <= this.tokenMaxAgeMs;
|
|
93
124
|
}
|
|
94
125
|
/**
|
|
95
126
|
* HTTP gate. Returns true (allow) when the request carries a valid remember
|
|
@@ -132,6 +163,10 @@ export class AccessPasscode {
|
|
|
132
163
|
res.end();
|
|
133
164
|
return false;
|
|
134
165
|
}
|
|
166
|
+
// Non-HTML/no-JS callers still use the historical GET fallback, so the
|
|
167
|
+
// passcode may appear in reverse-proxy access logs. Browser clients use
|
|
168
|
+
// the header-based fetch in challengePage and are redirected to a clean
|
|
169
|
+
// URL; deployments should redact the `passcode` query parameter in logs.
|
|
135
170
|
return true;
|
|
136
171
|
}
|
|
137
172
|
wrongAttempt = true;
|
|
@@ -172,6 +207,27 @@ export class AccessPasscode {
|
|
|
172
207
|
}
|
|
173
208
|
return true;
|
|
174
209
|
}
|
|
210
|
+
recordFailure() {
|
|
211
|
+
this.failures++;
|
|
212
|
+
if (this.failures >= this.maxAttempts) {
|
|
213
|
+
this.lockedUntil = this.now() + this.lockoutMs;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Count a request whose passcode could not even be a passcode.
|
|
218
|
+
*
|
|
219
|
+
* Kept separate from `recordFailure` so junk traffic cannot spend the
|
|
220
|
+
* credential-guessing budget and lock out the owner. The budget is a large
|
|
221
|
+
* multiple of `maxAttempts`: a determined flood is still throttled, but the
|
|
222
|
+
* handful of malformed submissions a real client produces never is.
|
|
223
|
+
*/
|
|
224
|
+
recordMalformed() {
|
|
225
|
+
this.malformed++;
|
|
226
|
+
if (this.malformed >= this.maxAttempts * MALFORMED_ATTEMPT_MULTIPLIER) {
|
|
227
|
+
this.malformed = 0;
|
|
228
|
+
this.lockedUntil = this.now() + this.lockoutMs;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
175
231
|
issueToken(record) {
|
|
176
232
|
const payload = `${randomBytes(12).toString("hex")}.${this.now()}`;
|
|
177
233
|
const sig = this.sign(payload, record.secret);
|
|
@@ -186,20 +242,94 @@ export class AccessPasscode {
|
|
|
186
242
|
read() {
|
|
187
243
|
if (!existsSync(this.filePath))
|
|
188
244
|
return undefined;
|
|
245
|
+
let fd;
|
|
189
246
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
247
|
+
fd = openSync(this.filePath, "r");
|
|
248
|
+
const stat = fstatSync(fd);
|
|
249
|
+
if (!stat.isFile() || stat.size > MAX_ACCESS_RECORD_BYTES)
|
|
250
|
+
return undefined;
|
|
251
|
+
const buffer = Buffer.allocUnsafe(MAX_ACCESS_RECORD_BYTES + 1);
|
|
252
|
+
let total = 0;
|
|
253
|
+
while (total < buffer.byteLength) {
|
|
254
|
+
const count = readSync(fd, buffer, total, buffer.byteLength - total, total);
|
|
255
|
+
if (count === 0)
|
|
256
|
+
break;
|
|
257
|
+
total += count;
|
|
258
|
+
}
|
|
259
|
+
if (total > MAX_ACCESS_RECORD_BYTES)
|
|
260
|
+
return undefined;
|
|
261
|
+
const parsed = JSON.parse(buffer.subarray(0, total).toString("utf8"));
|
|
262
|
+
if (isAccessRecord(parsed)) {
|
|
263
|
+
return parsed;
|
|
193
264
|
}
|
|
194
265
|
return undefined;
|
|
195
266
|
}
|
|
196
267
|
catch {
|
|
197
268
|
return undefined;
|
|
198
269
|
}
|
|
270
|
+
finally {
|
|
271
|
+
if (fd !== undefined)
|
|
272
|
+
closeSync(fd);
|
|
273
|
+
}
|
|
199
274
|
}
|
|
200
275
|
write(record) {
|
|
201
|
-
|
|
202
|
-
|
|
276
|
+
const parent = dirname(this.filePath);
|
|
277
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
278
|
+
try {
|
|
279
|
+
chmodSync(parent, 0o700);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Best effort on platforms/filesystems without POSIX permissions.
|
|
283
|
+
}
|
|
284
|
+
// access.json contains the HMAC signing secret. A direct overwrite can
|
|
285
|
+
// truncate the only valid record if the process crashes mid-write, and the
|
|
286
|
+
// default umask can make a newly-created file group/world-readable.
|
|
287
|
+
const tempPath = `${this.filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
288
|
+
try {
|
|
289
|
+
writeFileSync(tempPath, JSON.stringify(record, null, 2), {
|
|
290
|
+
encoding: "utf-8",
|
|
291
|
+
flag: "wx",
|
|
292
|
+
mode: 0o600,
|
|
293
|
+
});
|
|
294
|
+
renameSync(tempPath, this.filePath);
|
|
295
|
+
try {
|
|
296
|
+
chmodSync(this.filePath, 0o600);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// Best effort on platforms/filesystems without POSIX permissions.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
try {
|
|
304
|
+
rmSync(tempPath, { force: true });
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Never hide the original write/rename error with cleanup failure.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function isAccessRecord(value) {
|
|
313
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
314
|
+
return false;
|
|
315
|
+
const record = value;
|
|
316
|
+
return (typeof record.hash === "string" &&
|
|
317
|
+
/^[0-9a-f]{64}$/i.test(record.hash) &&
|
|
318
|
+
typeof record.salt === "string" &&
|
|
319
|
+
/^[0-9a-f]{32}$/i.test(record.salt) &&
|
|
320
|
+
typeof record.secret === "string" &&
|
|
321
|
+
/^[0-9a-f]{64}$/i.test(record.secret));
|
|
322
|
+
}
|
|
323
|
+
function isValidPasscode(value) {
|
|
324
|
+
return (typeof value === "string" &&
|
|
325
|
+
value.length >= MIN_ACCESS_PASSCODE_LENGTH &&
|
|
326
|
+
value.length <= MAX_ACCESS_PASSCODE_LENGTH);
|
|
327
|
+
}
|
|
328
|
+
function assertPositiveSafeInteger(name, value) {
|
|
329
|
+
if (value === undefined)
|
|
330
|
+
return;
|
|
331
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
332
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
203
333
|
}
|
|
204
334
|
}
|
|
205
335
|
function readCookie(cookieHeader, name) {
|
|
@@ -288,6 +418,10 @@ function challengePage(url, wrong, locked) {
|
|
|
288
418
|
: wrong
|
|
289
419
|
? "口令不正确,请重试。"
|
|
290
420
|
: "请输入访问口令以继续。";
|
|
421
|
+
const cleanLocation = JSON.stringify(path + (params.size ? `?${params.toString()}` : ""))
|
|
422
|
+
.replace(/</g, "\\u003c")
|
|
423
|
+
.replace(/\u2028/g, "\\u2028")
|
|
424
|
+
.replace(/\u2029/g, "\\u2029");
|
|
291
425
|
return `<!doctype html>
|
|
292
426
|
<html lang="zh"><head><meta charset="utf-8">
|
|
293
427
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -311,7 +445,7 @@ function challengePage(url, wrong, locked) {
|
|
|
311
445
|
<h1>手机遥控</h1>
|
|
312
446
|
<p class="${wrong || locked ? "err" : ""}">${note}</p>
|
|
313
447
|
${hidden}
|
|
314
|
-
<input id="passcode" type="password" name="passcode" placeholder="访问口令" autofocus required ${locked ? "disabled" : ""}>
|
|
448
|
+
<input id="passcode" type="password" name="passcode" placeholder="访问口令" minlength="${MIN_ACCESS_PASSCODE_LENGTH}" maxlength="${MAX_ACCESS_PASSCODE_LENGTH}" autofocus required ${locked ? "disabled" : ""}>
|
|
315
449
|
<button type="submit" ${locked ? "disabled" : ""}>进入</button>
|
|
316
450
|
</form>
|
|
317
451
|
<script>
|
|
@@ -333,7 +467,7 @@ function challengePage(url, wrong, locked) {
|
|
|
333
467
|
if (input) input.value = "";
|
|
334
468
|
return;
|
|
335
469
|
}
|
|
336
|
-
window.location.replace(${
|
|
470
|
+
window.location.replace(${cleanLocation});
|
|
337
471
|
} catch {
|
|
338
472
|
if (note) note.textContent = "连接失败,请重试。";
|
|
339
473
|
if (note) note.className = "err";
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { type InputAttachmentMeta } from "../attachment-service.js";
|
|
2
2
|
import type { MobileAttachmentSummary, MobileImageAttachment } from "./types.js";
|
|
3
3
|
import type { ClaimedMobileUpload, MobileUploadService } from "./mobile-upload-service.js";
|
|
4
|
-
export
|
|
5
|
-
export declare const MAX_MOBILE_INLINE_IMAGE_BYTES: number;
|
|
6
|
-
export declare const MAX_MOBILE_INLINE_TOTAL_BYTES: number;
|
|
7
|
-
export declare const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES: number;
|
|
4
|
+
export { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_ATTACHMENT_TOTAL_BYTES, MAX_MOBILE_INLINE_IMAGE_BYTES, MAX_MOBILE_INLINE_TOTAL_BYTES, } from "./mobile-limits.js";
|
|
8
5
|
interface MaterializeInput {
|
|
9
6
|
deviceId: string;
|
|
10
7
|
cwd: string;
|
|
@@ -18,4 +15,3 @@ export declare function materializeMobileAttachments(input: MaterializeInput): P
|
|
|
18
15
|
claims: ClaimedMobileUpload[];
|
|
19
16
|
summaries: MobileAttachmentSummary[];
|
|
20
17
|
}>;
|
|
21
|
-
export {};
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { stageImageBytes } from "../attachment-service.js";
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
export const MAX_MOBILE_INLINE_TOTAL_BYTES = 512 * 1024;
|
|
5
|
-
export const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
2
|
+
import { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_ATTACHMENT_TOTAL_BYTES, MAX_MOBILE_INLINE_IMAGE_BYTES, MAX_MOBILE_INLINE_TOTAL_BYTES, } from "./mobile-limits.js";
|
|
3
|
+
export { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_ATTACHMENT_TOTAL_BYTES, MAX_MOBILE_INLINE_IMAGE_BYTES, MAX_MOBILE_INLINE_TOTAL_BYTES, } from "./mobile-limits.js";
|
|
6
4
|
const ALLOWED_MIMES = new Set([
|
|
7
5
|
"image/png",
|
|
8
6
|
"image/jpeg",
|