@sleep2agi/commhub-server 0.8.5 → 0.8.6-preview.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 +1 -1
- package/src/index.ts +257 -1
- package/src/uploads-http.test.ts +208 -0
- package/src/uploads.test.ts +294 -0
- package/src/uploads.ts +240 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6-preview.0",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,22 @@ import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
|
|
|
7
7
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
8
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
9
9
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
10
|
+
import {
|
|
11
|
+
FILE_ID_REGEX,
|
|
12
|
+
MAX_REQUEST_CONTENT_LENGTH,
|
|
13
|
+
MAX_UPLOAD_BYTES,
|
|
14
|
+
buildStoragePath,
|
|
15
|
+
generateFileId,
|
|
16
|
+
getUploadsRoot,
|
|
17
|
+
indexEntryPath,
|
|
18
|
+
isPathInsideUploadsRoot,
|
|
19
|
+
sanitizeExt,
|
|
20
|
+
sharedUploadRateLimiter,
|
|
21
|
+
validateAttachments,
|
|
22
|
+
validateIndexEntry,
|
|
23
|
+
} from "./uploads.js";
|
|
24
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
|
|
25
|
+
import { dirname as pathDirname } from "path";
|
|
10
26
|
|
|
11
27
|
const PORT = Number(process.env.PORT) || 9200;
|
|
12
28
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -380,6 +396,10 @@ const TaskSchema = z.object({
|
|
|
380
396
|
network_id: z.string().max(200).optional(),
|
|
381
397
|
parent_task_id: z.string().max(200).optional(),
|
|
382
398
|
ttl_seconds: z.number().min(1).max(86400).optional(),
|
|
399
|
+
// #221 — top-level attachments mirror the MCP send_task tool. They are
|
|
400
|
+
// merged into `meta.attachments` for persistence so the on-disk shape
|
|
401
|
+
// stays unified regardless of transport (REST or MCP).
|
|
402
|
+
attachments: z.any().optional(),
|
|
383
403
|
meta: z.any().optional(),
|
|
384
404
|
});
|
|
385
405
|
|
|
@@ -443,6 +463,13 @@ Bun.serve({
|
|
|
443
463
|
port: PORT,
|
|
444
464
|
hostname: HOST,
|
|
445
465
|
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
466
|
+
// #221 — defense-in-depth cap on the raw request body. The /api/upload
|
|
467
|
+
// handler also pre-checks Content-Length and post-checks parsed size
|
|
468
|
+
// against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
|
|
469
|
+
// simply guarantees Bun's own buffer won't grow past 13 MiB on any
|
|
470
|
+
// route. Default Bun ceiling is 128 MiB which is too permissive for
|
|
471
|
+
// a hub now exposed to the public internet.
|
|
472
|
+
maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
|
|
446
473
|
|
|
447
474
|
async fetch(req, server) {
|
|
448
475
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
@@ -1197,6 +1224,222 @@ Bun.serve({
|
|
|
1197
1224
|
}));
|
|
1198
1225
|
}
|
|
1199
1226
|
|
|
1227
|
+
// ── REST: file upload (#221) ──
|
|
1228
|
+
// POST multipart/form-data with a single `file` field. Bearer auth.
|
|
1229
|
+
// 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
|
|
1230
|
+
// verify). Returns { ok, file_id, path, url, size, mime }. The
|
|
1231
|
+
// file_id is server-generated (crypto.randomUUID with hyphens
|
|
1232
|
+
// stripped) so a client-supplied filename never enters the storage
|
|
1233
|
+
// path. The download URL `/api/files/<file_id>` requires the same
|
|
1234
|
+
// Bearer auth and forces Content-Disposition: attachment +
|
|
1235
|
+
// X-Content-Type-Options: nosniff so uploaded files can never be
|
|
1236
|
+
// executed or served as HTML.
|
|
1237
|
+
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
1238
|
+
const authErr = requireAuth(req);
|
|
1239
|
+
if (authErr) return withCors(req, authErr);
|
|
1240
|
+
|
|
1241
|
+
// Rate-limit key prefers the token id (so 60/h is per-token, not
|
|
1242
|
+
// per-IP); falls back to IP for legacy or anonymous-but-dev-open.
|
|
1243
|
+
const authCtx = resolveRequestAuth(req);
|
|
1244
|
+
const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
|
|
1245
|
+
const rate = sharedUploadRateLimiter.check(rateKey);
|
|
1246
|
+
if (!rate.allowed) {
|
|
1247
|
+
const headers: Record<string, string> = {
|
|
1248
|
+
"X-RateLimit-Limit": String(60),
|
|
1249
|
+
"X-RateLimit-Remaining": "0",
|
|
1250
|
+
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
1251
|
+
};
|
|
1252
|
+
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
1253
|
+
return withCors(req, new Response(
|
|
1254
|
+
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
1255
|
+
{ status: 429, headers: { ...headers, "Content-Type": "application/json" } },
|
|
1256
|
+
));
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Two-stage size cap per 通信龙 dispatch c38de7a9:
|
|
1260
|
+
// stage 1 — reject before reading body when Content-Length
|
|
1261
|
+
// exceeds the cap (saves bandwidth, fails fast)
|
|
1262
|
+
// stage 2 — re-verify the parsed File.size after Bun has parsed
|
|
1263
|
+
// the multipart envelope (defends against a missing
|
|
1264
|
+
// or lied Content-Length header)
|
|
1265
|
+
const contentLength = req.headers.get("Content-Length");
|
|
1266
|
+
if (!contentLength) {
|
|
1267
|
+
return withCors(req, Response.json(
|
|
1268
|
+
{ ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
|
|
1269
|
+
{ status: 411 },
|
|
1270
|
+
));
|
|
1271
|
+
}
|
|
1272
|
+
const declaredBytes = Number(contentLength);
|
|
1273
|
+
if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
|
|
1274
|
+
return withCors(req, Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
|
|
1275
|
+
}
|
|
1276
|
+
if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
|
|
1277
|
+
return withCors(req, Response.json(
|
|
1278
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
|
|
1279
|
+
{ status: 413 },
|
|
1280
|
+
));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
1284
|
+
if (!/^multipart\/form-data/i.test(contentType)) {
|
|
1285
|
+
return withCors(req, Response.json(
|
|
1286
|
+
{ ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
|
|
1287
|
+
{ status: 415 },
|
|
1288
|
+
));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
let form: FormData;
|
|
1292
|
+
try {
|
|
1293
|
+
form = await req.formData();
|
|
1294
|
+
} catch (e: any) {
|
|
1295
|
+
return withCors(req, Response.json(
|
|
1296
|
+
{ ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
|
|
1297
|
+
{ status: 400 },
|
|
1298
|
+
));
|
|
1299
|
+
}
|
|
1300
|
+
const file = form.get("file");
|
|
1301
|
+
if (!file || typeof file === "string") {
|
|
1302
|
+
return withCors(req, Response.json(
|
|
1303
|
+
{ ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
|
|
1304
|
+
{ status: 400 },
|
|
1305
|
+
));
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Stage 2 — re-verify parsed size against the documented cap.
|
|
1309
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
1310
|
+
return withCors(req, Response.json(
|
|
1311
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
|
|
1312
|
+
{ status: 413 },
|
|
1313
|
+
));
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const fileId = generateFileId();
|
|
1317
|
+
const ext = sanitizeExt(file.name);
|
|
1318
|
+
let storage;
|
|
1319
|
+
try {
|
|
1320
|
+
storage = buildStoragePath(fileId, ext);
|
|
1321
|
+
} catch (e: any) {
|
|
1322
|
+
// Should be unreachable — we generated the id ourselves — but
|
|
1323
|
+
// surface as 500 if our own primitives reject our own input.
|
|
1324
|
+
console.error("[/api/upload] buildStoragePath rejected:", e?.message);
|
|
1325
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
try {
|
|
1329
|
+
mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
|
|
1330
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
1331
|
+
writeFileSync(storage.absolutePath, buf);
|
|
1332
|
+
// Defense-in-depth: after writing, confirm the path still sits
|
|
1333
|
+
// inside the uploads root (catches a symlink swap or root env
|
|
1334
|
+
// poisoning across processes).
|
|
1335
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1336
|
+
// Refuse to acknowledge a write that landed outside the root.
|
|
1337
|
+
// Don't try to clean up — better to keep evidence on disk than
|
|
1338
|
+
// chase a symlink for cleanup.
|
|
1339
|
+
console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
|
|
1340
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Persist the index entry so /api/files/<file_id> can do an
|
|
1344
|
+
// O(1) lookup without scanning every date bucket.
|
|
1345
|
+
const idxPath = indexEntryPath(fileId);
|
|
1346
|
+
if (idxPath) {
|
|
1347
|
+
mkdirSync(pathDirname(idxPath), { recursive: true });
|
|
1348
|
+
writeFileSync(idxPath, JSON.stringify({
|
|
1349
|
+
file_id: fileId,
|
|
1350
|
+
date_bucket: storage.dateBucket,
|
|
1351
|
+
ext: storage.ext,
|
|
1352
|
+
name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
|
|
1353
|
+
mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
|
|
1354
|
+
size: file.size,
|
|
1355
|
+
owner: authCtx?.username ?? null,
|
|
1356
|
+
owner_id: authCtx?.userId ?? null,
|
|
1357
|
+
uploaded_at: new Date().toISOString(),
|
|
1358
|
+
}, null, 2));
|
|
1359
|
+
}
|
|
1360
|
+
} catch (e: any) {
|
|
1361
|
+
console.error("[/api/upload] write failed:", e?.message);
|
|
1362
|
+
return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return withCors(req, Response.json({
|
|
1366
|
+
ok: true,
|
|
1367
|
+
file_id: fileId,
|
|
1368
|
+
path: storage.absolutePath,
|
|
1369
|
+
url: `/api/files/${fileId}`,
|
|
1370
|
+
size: file.size,
|
|
1371
|
+
mime: typeof file.type === "string" ? file.type : "application/octet-stream",
|
|
1372
|
+
}));
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// ── REST: file download (#221) ──
|
|
1376
|
+
// GET /api/files/:file_id with Bearer auth. Always forces
|
|
1377
|
+
// Content-Disposition: attachment + X-Content-Type-Options: nosniff
|
|
1378
|
+
// so the served file can never be executed or rendered as HTML.
|
|
1379
|
+
// The file_id is validated against the same strict regex used at
|
|
1380
|
+
// generation time before any filesystem access.
|
|
1381
|
+
const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
|
|
1382
|
+
if (fileMatch && req.method === "GET") {
|
|
1383
|
+
const authErr = requireAuth(req);
|
|
1384
|
+
if (authErr) return withCors(req, authErr);
|
|
1385
|
+
|
|
1386
|
+
const fileId = decodeURIComponent(fileMatch[1]);
|
|
1387
|
+
if (!FILE_ID_REGEX.test(fileId)) {
|
|
1388
|
+
return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const idxPath = indexEntryPath(fileId);
|
|
1392
|
+
if (!idxPath || !existsSync(idxPath)) {
|
|
1393
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
let entry: any;
|
|
1397
|
+
try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
|
|
1398
|
+
return withCors(req, Response.json({ ok: false, error: "index_corrupt" }, { status: 500 }));
|
|
1399
|
+
}
|
|
1400
|
+
if (!validateIndexEntry(entry)) {
|
|
1401
|
+
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
let storage;
|
|
1405
|
+
try {
|
|
1406
|
+
storage = buildStoragePath(entry.file_id, entry.ext);
|
|
1407
|
+
} catch {
|
|
1408
|
+
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1409
|
+
}
|
|
1410
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1411
|
+
// Defence: should be unreachable since buildStoragePath rejected
|
|
1412
|
+
// malformed inputs, but a poisoned index could try to fool us.
|
|
1413
|
+
return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
|
|
1414
|
+
}
|
|
1415
|
+
if (!existsSync(storage.absolutePath)) {
|
|
1416
|
+
return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Defence: verify the on-disk size matches what the index claims
|
|
1420
|
+
// before serving — a tampered index could otherwise mislead the
|
|
1421
|
+
// caller about file integrity.
|
|
1422
|
+
const st = statSync(storage.absolutePath);
|
|
1423
|
+
if (st.size !== entry.size) {
|
|
1424
|
+
console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
|
|
1425
|
+
return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
|
|
1429
|
+
? entry.name
|
|
1430
|
+
: `${fileId}${entry.ext}`;
|
|
1431
|
+
const blob = Bun.file(storage.absolutePath);
|
|
1432
|
+
const responseHeaders: Record<string, string> = {
|
|
1433
|
+
...corsHeaders(req),
|
|
1434
|
+
"Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
|
|
1435
|
+
"Content-Length": String(entry.size),
|
|
1436
|
+
"Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
|
|
1437
|
+
"X-Content-Type-Options": "nosniff",
|
|
1438
|
+
"Cache-Control": "private, no-store",
|
|
1439
|
+
};
|
|
1440
|
+
return new Response(blob, { status: 200, headers: responseHeaders });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1200
1443
|
// ── REST: send task ──
|
|
1201
1444
|
if (url.pathname === "/api/task" && req.method === "POST") {
|
|
1202
1445
|
let raw: unknown;
|
|
@@ -1243,7 +1486,20 @@ Bun.serve({
|
|
|
1243
1486
|
const id = crypto.randomUUID();
|
|
1244
1487
|
const fromSession = body.from || "api";
|
|
1245
1488
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
1246
|
-
|
|
1489
|
+
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
1490
|
+
// the REST and MCP send_task transports produce identical
|
|
1491
|
+
// tasks.meta_json shape downstream. Top-level wins over any
|
|
1492
|
+
// duplicate `meta.attachments` the client supplied. Validation
|
|
1493
|
+
// matches the MCP-side schema (max 20 entries, file_id regex,
|
|
1494
|
+
// size cap, etc.).
|
|
1495
|
+
const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
|
|
1496
|
+
if (!attachmentsResult.ok) {
|
|
1497
|
+
return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
|
|
1498
|
+
}
|
|
1499
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1500
|
+
? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
|
|
1501
|
+
: (body as any).meta;
|
|
1502
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1247
1503
|
|
|
1248
1504
|
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
1249
1505
|
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// #221 — integration smoke for /api/upload + /api/files/:file_id + /api/task
|
|
2
|
+
// attachments. Spawns the real Bun.serve hub in a child process, hits it
|
|
3
|
+
// with real fetch() calls, asserts on the 6-item security checklist.
|
|
4
|
+
//
|
|
5
|
+
// Runs entirely under bun test — no Docker needed for the in-repo
|
|
6
|
+
// signal (Docker harness is documented in #221 for 测试马's UAT, but
|
|
7
|
+
// this file already exercises the same code path). The hub binds to
|
|
8
|
+
// an ephemeral port so multiple test runs don't collide.
|
|
9
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, existsSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { register, login } from "./auth.js";
|
|
14
|
+
|
|
15
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
|
|
16
|
+
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
|
|
17
|
+
const PORT = 19000 + Math.floor(Math.random() * 1000);
|
|
18
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
19
|
+
|
|
20
|
+
let server: any;
|
|
21
|
+
let userToken = "";
|
|
22
|
+
let userNetworkId = "";
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
// Bootstrap a real admin + token on the temp DB so the server can
|
|
26
|
+
// resolve it via requireAuth (we don't want to short-circuit auth —
|
|
27
|
+
// we want to validate the production code path).
|
|
28
|
+
process.env.COMMHUB_DB = SERVER_DB;
|
|
29
|
+
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
30
|
+
process.env.PORT = String(PORT);
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
|
|
33
|
+
// Use a unique username + strong password each run so we don't trip
|
|
34
|
+
// over "already taken" from a prior test in the same DB. Strong
|
|
35
|
+
// password keeps us past validatePasswordStrength even when we're
|
|
36
|
+
// not the first user in the DB.
|
|
37
|
+
const username = `upload_admin_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
38
|
+
const password = "BootstrapPw123Aa!";
|
|
39
|
+
let r = register(username, password, undefined, "seed");
|
|
40
|
+
if (!r.ok) {
|
|
41
|
+
console.error("[test bootstrap] register failed:", r.error);
|
|
42
|
+
}
|
|
43
|
+
if (r.token) {
|
|
44
|
+
userToken = r.token;
|
|
45
|
+
userNetworkId = r.network_id ?? "";
|
|
46
|
+
} else {
|
|
47
|
+
const lr = login(username, password);
|
|
48
|
+
if (lr.token) userToken = lr.token;
|
|
49
|
+
if (lr.network_id) userNetworkId = lr.network_id;
|
|
50
|
+
}
|
|
51
|
+
expect(userToken).toBeTruthy();
|
|
52
|
+
|
|
53
|
+
// Import the server module — its top-level `Bun.serve` call binds
|
|
54
|
+
// the port immediately. The import side effect IS the server start.
|
|
55
|
+
await import("./index.js");
|
|
56
|
+
// Tiny settle so the listener is ready.
|
|
57
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
// Best-effort cleanup. The Bun.serve handle is created at import
|
|
62
|
+
// time so we can't easily stop it — process exit handles that.
|
|
63
|
+
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
64
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function authHeaders(): Record<string, string> {
|
|
68
|
+
return { "Authorization": `Bearer ${userToken}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
|
|
72
|
+
const form = new FormData();
|
|
73
|
+
form.append("file", new Blob([content], { type: mime }), filename);
|
|
74
|
+
const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
|
|
75
|
+
// Note: fetch sets Content-Length automatically; only override when
|
|
76
|
+
// testing the missing-header path.
|
|
77
|
+
const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("POST /api/upload — 6-item security checklist (HTTP integration)", () => {
|
|
82
|
+
test("(item 5) anonymous request → 401", async () => {
|
|
83
|
+
const res = await uploadFile(new Uint8Array([1, 2, 3]), "tiny.bin", "application/octet-stream", { skipAuth: true });
|
|
84
|
+
expect(res.status).toBe(401);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("(item 5) bad bearer token → 401", async () => {
|
|
88
|
+
const form = new FormData();
|
|
89
|
+
form.append("file", new Blob([new Uint8Array([1, 2, 3])]), "tiny.bin");
|
|
90
|
+
const res = await fetch(`${BASE}/api/upload`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
body: form,
|
|
93
|
+
headers: { "Authorization": "Bearer this-is-not-a-real-token" },
|
|
94
|
+
});
|
|
95
|
+
expect(res.status).toBe(401);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("(items 1+2) successful upload returns server-generated file_id + url, blob lands inside uploads root", async () => {
|
|
99
|
+
const content = new Uint8Array(1024).fill(0xab);
|
|
100
|
+
const res = await uploadFile(content, "client-supplied-name.bin", "application/octet-stream");
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
const body = await res.json() as any;
|
|
103
|
+
expect(body.ok).toBe(true);
|
|
104
|
+
expect(typeof body.file_id).toBe("string");
|
|
105
|
+
expect(body.file_id.length).toBe(32);
|
|
106
|
+
expect(body.url).toBe(`/api/files/${body.file_id}`);
|
|
107
|
+
expect(body.size).toBe(1024);
|
|
108
|
+
// The path MUST live inside the configured uploads root.
|
|
109
|
+
expect(String(body.path).startsWith(UPLOADS_DIR)).toBe(true);
|
|
110
|
+
// The path MUST NOT contain the client-supplied filename.
|
|
111
|
+
expect(String(body.path).includes("client-supplied-name")).toBe(false);
|
|
112
|
+
// Blob really exists on disk.
|
|
113
|
+
expect(existsSync(body.path)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("(item 4 stage-2) 12 MiB cap — slightly over the limit is rejected with 413", async () => {
|
|
117
|
+
const overLimit = new Uint8Array(12 * 1024 * 1024 + 16).fill(0xcd);
|
|
118
|
+
const res = await uploadFile(overLimit, "over.bin", "application/octet-stream");
|
|
119
|
+
expect(res.status).toBe(413);
|
|
120
|
+
const body = await res.json() as any;
|
|
121
|
+
expect(body.ok).toBe(false);
|
|
122
|
+
expect(body.error).toBe("payload_too_large");
|
|
123
|
+
expect(body.limit_bytes).toBe(12 * 1024 * 1024);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("(item 4 stage-1) missing Content-Length → 411 Length Required", async () => {
|
|
127
|
+
// Forge a raw request with a body but no Content-Length. The
|
|
128
|
+
// simplest way to do this in Bun is to construct a Request with a
|
|
129
|
+
// streaming ReadableStream body — Bun then doesn't set
|
|
130
|
+
// Content-Length on outgoing fetch.
|
|
131
|
+
const stream = new ReadableStream({
|
|
132
|
+
start(controller) {
|
|
133
|
+
controller.enqueue(new TextEncoder().encode("not actually multipart"));
|
|
134
|
+
controller.close();
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const res = await fetch(`${BASE}/api/upload`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: stream,
|
|
140
|
+
headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
|
|
141
|
+
// @ts-expect-error — Bun-specific to force streaming upload
|
|
142
|
+
duplex: "half",
|
|
143
|
+
});
|
|
144
|
+
expect(res.status).toBe(411);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("(extra defense) non-multipart body is rejected (415 or 400)", async () => {
|
|
148
|
+
// We accept either 415 (Content-Type !multipart) or 400 (body
|
|
149
|
+
// failed to parse as multipart). Both mean "we refused to accept
|
|
150
|
+
// this body" which is the security property we care about; the
|
|
151
|
+
// exact code depends on whether Bun's multipart parser bails before
|
|
152
|
+
// or after our Content-Type pre-check.
|
|
153
|
+
const res = await fetch(`${BASE}/api/upload`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
body: JSON.stringify({ nope: 1 }),
|
|
156
|
+
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
157
|
+
});
|
|
158
|
+
expect([400, 415]).toContain(res.status);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("GET /api/files/:file_id — download safety", () => {
|
|
163
|
+
let fileId = "";
|
|
164
|
+
|
|
165
|
+
beforeAll(async () => {
|
|
166
|
+
const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
|
|
167
|
+
const res = await uploadFile(content, "greeting.txt", "text/plain");
|
|
168
|
+
const body = await res.json() as any;
|
|
169
|
+
fileId = body.file_id;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
|
|
173
|
+
const res = await fetch(`${BASE}/api/files/${fileId}`, { headers: authHeaders() });
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
|
|
176
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
177
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
expect(text).toBe("hello-world-from-#221-integration-test");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("anonymous download is 401", async () => {
|
|
183
|
+
const res = await fetch(`${BASE}/api/files/${fileId}`);
|
|
184
|
+
expect(res.status).toBe(401);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("traversal attempt in path is rejected at the regex layer with 400", async () => {
|
|
188
|
+
const res = await fetch(`${BASE}/api/files/..%2Fetc%2Fpasswd`, { headers: authHeaders() });
|
|
189
|
+
expect(res.status).toBe(400);
|
|
190
|
+
const body = await res.json() as any;
|
|
191
|
+
expect(body.error).toBe("bad_file_id");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("unknown file_id is 404", async () => {
|
|
195
|
+
const res = await fetch(`${BASE}/api/files/0123456789abcdef0123456789abcdef`, { headers: authHeaders() });
|
|
196
|
+
expect(res.status).toBe(404);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// NOTE on /api/task attachments coverage: the validation logic
|
|
201
|
+
// (validateAttachments) is exercised end-to-end in
|
|
202
|
+
// uploads.test.ts with 6 cases covering happy path / too-many /
|
|
203
|
+
// bad type / bad file_id / size cap / non-array. The HTTP wire-up
|
|
204
|
+
// (server/src/index.ts inside the /api/task handler) is a one-line
|
|
205
|
+
// merge that surfaces `validateAttachments`'s structured error as a
|
|
206
|
+
// 400 `bad_attachments` to the caller — see the #221 contract in
|
|
207
|
+
// the issue comment for the full request/response shape that N站马
|
|
208
|
+
// APP will consume.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// #221 — unit tests for the upload primitives. The HTTP wire-up + Bun
|
|
2
|
+
// multipart parsing lives in server/src/index.ts; this file pins the
|
|
3
|
+
// path / regex / rate-limit / attachment-schema invariants that the
|
|
4
|
+
// 6-item security checklist depends on.
|
|
5
|
+
//
|
|
6
|
+
// Coverage matrix:
|
|
7
|
+
// 1. file_id regex accepts server-generated ids + rejects traversal attempts
|
|
8
|
+
// 2. sanitizeExt strips client-side filename paths, lowercases ext
|
|
9
|
+
// 3. buildStoragePath rejects malformed id / ext, places file under date bucket
|
|
10
|
+
// 4. isPathInsideUploadsRoot defends against symlink / traversal escape
|
|
11
|
+
// 5. UploadRateLimiter — first call allowed, exhausting bucket blocks, window expiry resets
|
|
12
|
+
// 6. validateAttachments accepts MCP-compatible shape, rejects too-many / bad type / bad file_id
|
|
13
|
+
// 7. validateIndexEntry refuses tampered shapes
|
|
14
|
+
// 8. indexEntryPath rejects malformed file_id
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
FILE_ID_REGEX,
|
|
18
|
+
MAX_UPLOAD_BYTES,
|
|
19
|
+
UploadRateLimiter,
|
|
20
|
+
UPLOAD_RATE_MAX_PER_WINDOW,
|
|
21
|
+
UPLOAD_RATE_WINDOW_MS,
|
|
22
|
+
buildStoragePath,
|
|
23
|
+
generateFileId,
|
|
24
|
+
getDateBucket,
|
|
25
|
+
indexEntryPath,
|
|
26
|
+
isPathInsideUploadsRoot,
|
|
27
|
+
sanitizeExt,
|
|
28
|
+
validateAttachments,
|
|
29
|
+
validateIndexEntry,
|
|
30
|
+
} from "./uploads";
|
|
31
|
+
|
|
32
|
+
describe("FILE_ID_REGEX", () => {
|
|
33
|
+
test("accepts the server-generated uuidv4-without-hyphens shape (32 hex chars)", () => {
|
|
34
|
+
for (let i = 0; i < 20; i++) {
|
|
35
|
+
const id = generateFileId();
|
|
36
|
+
expect(FILE_ID_REGEX.test(id)).toBe(true);
|
|
37
|
+
expect(id.length).toBe(32);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rejects every directory-traversal attempt a client might try", () => {
|
|
42
|
+
const traversals = [
|
|
43
|
+
"../foo",
|
|
44
|
+
"..%2Ffoo",
|
|
45
|
+
"foo/bar",
|
|
46
|
+
"foo\\bar",
|
|
47
|
+
"foo.bar",
|
|
48
|
+
".bashrc",
|
|
49
|
+
"/etc/passwd",
|
|
50
|
+
"foo bar",
|
|
51
|
+
"",
|
|
52
|
+
"abc", // too short
|
|
53
|
+
"x".repeat(65), // too long
|
|
54
|
+
];
|
|
55
|
+
for (const t of traversals) {
|
|
56
|
+
expect(FILE_ID_REGEX.test(t)).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("sanitizeExt", () => {
|
|
62
|
+
test("returns a leading-dot lowercase extension for a normal filename", () => {
|
|
63
|
+
expect(sanitizeExt("photo.PNG")).toBe(".png");
|
|
64
|
+
expect(sanitizeExt("report.pdf")).toBe(".pdf");
|
|
65
|
+
expect(sanitizeExt("archive.tar.gz")).toBe(".gz");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("strips any client-supplied directory prefix before reading the ext", () => {
|
|
69
|
+
// The handler must NEVER use the client filename for storage paths,
|
|
70
|
+
// but the ext is allowed through for nicer URLs. We still strip any
|
|
71
|
+
// path separator the client tries to sneak in.
|
|
72
|
+
expect(sanitizeExt("../../etc/passwd.jpg")).toBe(".jpg");
|
|
73
|
+
expect(sanitizeExt("..\\..\\windows\\boot.ini")).toBe(".ini");
|
|
74
|
+
expect(sanitizeExt("foo/bar/baz.html")).toBe(".html");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns empty string for missing / weird inputs", () => {
|
|
78
|
+
expect(sanitizeExt(undefined)).toBe("");
|
|
79
|
+
expect(sanitizeExt(null)).toBe("");
|
|
80
|
+
expect(sanitizeExt("")).toBe("");
|
|
81
|
+
expect(sanitizeExt("noext")).toBe("");
|
|
82
|
+
// ".bashrc" → ".bashrc" is acceptable: storage uses the server-generated
|
|
83
|
+
// file_id, so the file lands as "<file_id>.bashrc". The download handler
|
|
84
|
+
// forces Content-Disposition: attachment + X-Content-Type-Options: nosniff,
|
|
85
|
+
// so the ext can't be leveraged to execute / mis-render the file.
|
|
86
|
+
expect(sanitizeExt(".bashrc")).toBe(".bashrc");
|
|
87
|
+
// ext > 16 chars is rejected; "extensiontoolong1" is 17 chars.
|
|
88
|
+
expect(sanitizeExt("foo.extensiontoolong1")).toBe("");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects exts with non-alphanumeric characters", () => {
|
|
92
|
+
expect(sanitizeExt("payload.exe;.png")).toBe(".png");
|
|
93
|
+
expect(sanitizeExt("foo.<script>")).toBe("");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("buildStoragePath", () => {
|
|
98
|
+
test("places the file under a YYYY-MM-DD bucket with file_id+ext as the name", () => {
|
|
99
|
+
const now = new Date("2026-06-11T03:00:00Z");
|
|
100
|
+
const r = buildStoragePath("abcdef1234567890abcdef1234567890", ".png", {
|
|
101
|
+
uploadsRoot: "/srv/uploads",
|
|
102
|
+
now,
|
|
103
|
+
});
|
|
104
|
+
expect(r.dateBucket).toBe("2026-06-11");
|
|
105
|
+
expect(r.relativePath).toBe("2026-06-11/abcdef1234567890abcdef1234567890.png");
|
|
106
|
+
expect(r.absolutePath).toBe("/srv/uploads/2026-06-11/abcdef1234567890abcdef1234567890.png");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects malformed file_id", () => {
|
|
110
|
+
expect(() => buildStoragePath("../etc/passwd", ".png", { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
111
|
+
expect(() => buildStoragePath("ok", ".png", { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects malformed ext", () => {
|
|
115
|
+
expect(() => buildStoragePath(generateFileId(), "../passwd", { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
116
|
+
expect(() => buildStoragePath(generateFileId(), ".x".repeat(40), { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("allows empty ext", () => {
|
|
120
|
+
const r = buildStoragePath(generateFileId(), "", { uploadsRoot: "/srv" });
|
|
121
|
+
expect(r.relativePath.endsWith("/")).toBe(false);
|
|
122
|
+
expect(r.relativePath.includes(".")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("getDateBucket", () => {
|
|
127
|
+
test("uses UTC so the bucket is consistent across hub timezones", () => {
|
|
128
|
+
expect(getDateBucket(new Date("2026-06-11T23:59:59Z"))).toBe("2026-06-11");
|
|
129
|
+
expect(getDateBucket(new Date("2026-06-12T00:00:01Z"))).toBe("2026-06-12");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("isPathInsideUploadsRoot", () => {
|
|
134
|
+
test("true for a normal nested path", () => {
|
|
135
|
+
expect(isPathInsideUploadsRoot("/srv/uploads/2026-06-11/abc.png", "/srv/uploads")).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("false for an upward traversal that lands outside the root", () => {
|
|
139
|
+
expect(isPathInsideUploadsRoot("/srv/uploads/../etc/passwd", "/srv/uploads")).toBe(false);
|
|
140
|
+
expect(isPathInsideUploadsRoot("/etc/passwd", "/srv/uploads")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("false for the root itself (must be a file underneath, not the root dir)", () => {
|
|
144
|
+
expect(isPathInsideUploadsRoot("/srv/uploads", "/srv/uploads")).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("UploadRateLimiter", () => {
|
|
149
|
+
test("first N calls within window are allowed; the (N+1)th is blocked", () => {
|
|
150
|
+
const lim = new UploadRateLimiter(60_000, 3); // 3 per minute
|
|
151
|
+
const t0 = 1_700_000_000_000;
|
|
152
|
+
const a1 = lim.check("token-x", t0);
|
|
153
|
+
const a2 = lim.check("token-x", t0 + 100);
|
|
154
|
+
const a3 = lim.check("token-x", t0 + 200);
|
|
155
|
+
const a4 = lim.check("token-x", t0 + 300);
|
|
156
|
+
expect(a1.allowed).toBe(true);
|
|
157
|
+
expect(a2.allowed).toBe(true);
|
|
158
|
+
expect(a3.allowed).toBe(true);
|
|
159
|
+
expect(a4.allowed).toBe(false);
|
|
160
|
+
if (!a4.allowed) {
|
|
161
|
+
expect(a4.retryAfterMs).toBeGreaterThan(0);
|
|
162
|
+
expect(a4.retryAfterMs).toBeLessThanOrEqual(60_000);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("different keys are isolated", () => {
|
|
167
|
+
const lim = new UploadRateLimiter(60_000, 1);
|
|
168
|
+
expect(lim.check("alice", 1).allowed).toBe(true);
|
|
169
|
+
expect(lim.check("alice", 2).allowed).toBe(false);
|
|
170
|
+
expect(lim.check("bob", 3).allowed).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("window expiry resets the bucket", () => {
|
|
174
|
+
const lim = new UploadRateLimiter(60_000, 1);
|
|
175
|
+
const t0 = 1_700_000_000_000;
|
|
176
|
+
expect(lim.check("token-x", t0).allowed).toBe(true);
|
|
177
|
+
expect(lim.check("token-x", t0 + 30_000).allowed).toBe(false);
|
|
178
|
+
expect(lim.check("token-x", t0 + 60_001).allowed).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("default constructor matches documented public defaults", () => {
|
|
182
|
+
const lim = new UploadRateLimiter();
|
|
183
|
+
expect(UPLOAD_RATE_WINDOW_MS).toBe(60 * 60 * 1000);
|
|
184
|
+
expect(UPLOAD_RATE_MAX_PER_WINDOW).toBe(60);
|
|
185
|
+
for (let i = 0; i < UPLOAD_RATE_MAX_PER_WINDOW; i++) {
|
|
186
|
+
expect(lim.check("token-y", 1 + i).allowed).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
expect(lim.check("token-y", 1000).allowed).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("validateAttachments", () => {
|
|
193
|
+
test("undefined / null input → empty array", () => {
|
|
194
|
+
const a = validateAttachments(undefined);
|
|
195
|
+
expect(a.ok).toBe(true);
|
|
196
|
+
if (a.ok) expect(a.attachments).toEqual([]);
|
|
197
|
+
const b = validateAttachments(null);
|
|
198
|
+
expect(b.ok).toBe(true);
|
|
199
|
+
if (b.ok) expect(b.attachments).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("rejects non-array input", () => {
|
|
203
|
+
expect(validateAttachments("nope").ok).toBe(false);
|
|
204
|
+
expect(validateAttachments({}).ok).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("accepts a well-formed attachment matching MCP send_task shape", () => {
|
|
208
|
+
const fileId = generateFileId();
|
|
209
|
+
const res = validateAttachments([
|
|
210
|
+
{ type: "file", file_id: fileId, name: "photo.png", mime: "image/png", size: 12345 },
|
|
211
|
+
]);
|
|
212
|
+
expect(res.ok).toBe(true);
|
|
213
|
+
if (res.ok) {
|
|
214
|
+
expect(res.attachments[0].file_id).toBe(fileId);
|
|
215
|
+
expect(res.attachments[0].mime).toBe("image/png");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("rejects too many attachments (>20)", () => {
|
|
220
|
+
const fileId = generateFileId();
|
|
221
|
+
const many = Array.from({ length: 21 }, () => ({ type: "file" as const, file_id: fileId }));
|
|
222
|
+
const res = validateAttachments(many);
|
|
223
|
+
expect(res.ok).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("rejects invalid file_id (path traversal)", () => {
|
|
227
|
+
const res = validateAttachments([{ type: "file", file_id: "../etc/passwd" }]);
|
|
228
|
+
expect(res.ok).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("rejects size larger than MAX_UPLOAD_BYTES", () => {
|
|
232
|
+
const fileId = generateFileId();
|
|
233
|
+
const res = validateAttachments([{ type: "file", file_id: fileId, size: MAX_UPLOAD_BYTES + 1 }]);
|
|
234
|
+
expect(res.ok).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects unexpected type", () => {
|
|
238
|
+
const res = validateAttachments([{ type: "image", file_id: generateFileId() }]);
|
|
239
|
+
expect(res.ok).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("validateIndexEntry", () => {
|
|
244
|
+
test("accepts a well-formed entry", () => {
|
|
245
|
+
const id = generateFileId();
|
|
246
|
+
expect(
|
|
247
|
+
validateIndexEntry({
|
|
248
|
+
file_id: id,
|
|
249
|
+
date_bucket: "2026-06-11",
|
|
250
|
+
ext: ".png",
|
|
251
|
+
name: "anything",
|
|
252
|
+
mime: "image/png",
|
|
253
|
+
size: 12345,
|
|
254
|
+
uploaded_at: "2026-06-11T03:00:00Z",
|
|
255
|
+
}),
|
|
256
|
+
).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("rejects bad date_bucket (anti-tamper)", () => {
|
|
260
|
+
expect(
|
|
261
|
+
validateIndexEntry({ file_id: generateFileId(), date_bucket: "../etc", ext: ".png", size: 1 }),
|
|
262
|
+
).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("rejects bad ext", () => {
|
|
266
|
+
expect(
|
|
267
|
+
validateIndexEntry({ file_id: generateFileId(), date_bucket: "2026-06-11", ext: "/etc/passwd", size: 1 }),
|
|
268
|
+
).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("rejects size beyond MAX_UPLOAD_BYTES (anti-tamper)", () => {
|
|
272
|
+
expect(
|
|
273
|
+
validateIndexEntry({
|
|
274
|
+
file_id: generateFileId(),
|
|
275
|
+
date_bucket: "2026-06-11",
|
|
276
|
+
ext: "",
|
|
277
|
+
size: MAX_UPLOAD_BYTES + 1,
|
|
278
|
+
}),
|
|
279
|
+
).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("indexEntryPath", () => {
|
|
284
|
+
test("returns null on malformed file_id", () => {
|
|
285
|
+
expect(indexEntryPath("../etc/passwd", { uploadsRoot: "/srv" })).toBeNull();
|
|
286
|
+
expect(indexEntryPath("", { uploadsRoot: "/srv" })).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("places the index file under <uploadsRoot>/.index/", () => {
|
|
290
|
+
const id = generateFileId();
|
|
291
|
+
const p = indexEntryPath(id, { uploadsRoot: "/srv/up" });
|
|
292
|
+
expect(p).toBe(`/srv/up/.index/${id}.json`);
|
|
293
|
+
});
|
|
294
|
+
});
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// #221 — commhub-server file upload primitives.
|
|
2
|
+
//
|
|
3
|
+
// Hub upload surface for the mobile APP (#220). The hub started reverse-
|
|
4
|
+
// proxying over HTTPS to the public internet last night, so the upload
|
|
5
|
+
// endpoint is a brand-new public attack surface. The six security items
|
|
6
|
+
// in the issue are HARD verification items, encoded here:
|
|
7
|
+
//
|
|
8
|
+
// 1. file_id is server-generated (uuidv4, alphanumeric only), client-
|
|
9
|
+
// supplied filenames never enter the storage path.
|
|
10
|
+
// 2. Storage root sits in $HOME/.anet/server/uploads (outside any
|
|
11
|
+
// web-served directory); downloads go through an explicit handler.
|
|
12
|
+
// 3. Download handler forces Content-Disposition: attachment +
|
|
13
|
+
// X-Content-Type-Options: nosniff — uploaded files are never
|
|
14
|
+
// executed or served as HTML.
|
|
15
|
+
// 4. 12 MB cap enforced two-stage per 通信龙: Content-Length header
|
|
16
|
+
// pre-check (reject before reading body) + post-parse size verify.
|
|
17
|
+
// 5. Auth is the existing requireAuth — anonymous + bad tokens 401.
|
|
18
|
+
// 6. Naive per-token rate limit: 60 uploads/hour per token (or IP for
|
|
19
|
+
// legacy/dev mode).
|
|
20
|
+
//
|
|
21
|
+
// This module stays pure-ish: file I/O lives in the HTTP handlers, the
|
|
22
|
+
// path / id / rate-limit primitives are unit-testable.
|
|
23
|
+
|
|
24
|
+
import { homedir } from "os";
|
|
25
|
+
import { join, relative, isAbsolute } from "path";
|
|
26
|
+
|
|
27
|
+
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
// Allow ~1 MiB of multipart envelope overhead on top of the 12 MiB
|
|
30
|
+
// payload limit when checking Content-Length. The post-parse check is
|
|
31
|
+
// still done against MAX_UPLOAD_BYTES so the actual binary payload
|
|
32
|
+
// never exceeds the documented cap.
|
|
33
|
+
export const MAX_REQUEST_CONTENT_LENGTH = MAX_UPLOAD_BYTES + 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
// Per-token rate-limit constants. 60 uploads/hour matches the issue
|
|
36
|
+
// "如 60 req/h" suggestion and is enforced in-memory (one process holds
|
|
37
|
+
// the source of truth; resets on restart).
|
|
38
|
+
export const UPLOAD_RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
39
|
+
export const UPLOAD_RATE_MAX_PER_WINDOW = 60;
|
|
40
|
+
|
|
41
|
+
// Strict file_id regex. Generated server-side via crypto.randomUUID()
|
|
42
|
+
// with hyphens stripped, so the natural output is 32 hex chars. The
|
|
43
|
+
// regex is intentionally permissive on the upper bound (64 chars) to
|
|
44
|
+
// allow future scheme changes (e.g. prefix), but it rejects ANY of `.`
|
|
45
|
+
// `/` `\` `..` so the same regex used for path generation can also be
|
|
46
|
+
// used to validate a /api/files/:file_id lookup against directory
|
|
47
|
+
// traversal.
|
|
48
|
+
export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
|
|
49
|
+
|
|
50
|
+
export type GeneratedFile = {
|
|
51
|
+
fileId: string;
|
|
52
|
+
ext: string;
|
|
53
|
+
dateBucket: string;
|
|
54
|
+
relativePath: string;
|
|
55
|
+
absolutePath: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function getUploadsRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
59
|
+
return env.COMMHUB_UPLOADS_DIR || join(homedir(), ".anet/server/uploads");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getDateBucket(now: Date = new Date()): string {
|
|
63
|
+
// YYYY-MM-DD partitioning per issue spec — keeps a single directory
|
|
64
|
+
// from accumulating millions of entries while leaving the lookup
|
|
65
|
+
// path predictable from the disk index (file_id → date bucket).
|
|
66
|
+
return now.toISOString().slice(0, 10);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function generateFileId(): string {
|
|
70
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract a safe file extension from a client-supplied filename.
|
|
75
|
+
* Server NEVER uses the rest of the filename. Returns "" if no
|
|
76
|
+
* acceptable extension is present.
|
|
77
|
+
*
|
|
78
|
+
* Accepts: alphanumeric extensions of 1–16 chars (`.png`, `.tar`,
|
|
79
|
+
* `.docx`, `.tar.gz` returns only `.gz`). Rejects: anything containing
|
|
80
|
+
* path separators, dots in unusual positions, or non-ASCII characters.
|
|
81
|
+
*/
|
|
82
|
+
export function sanitizeExt(filename: string | undefined | null): string {
|
|
83
|
+
if (!filename || typeof filename !== "string") return "";
|
|
84
|
+
// Defense: strip any leading directory the client might have shoved
|
|
85
|
+
// into "name", then take only the last extension.
|
|
86
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
87
|
+
const match = base.match(/\.([A-Za-z0-9]{1,16})$/);
|
|
88
|
+
return match ? "." + match[1].toLowerCase() : "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the per-upload storage path. The file_id and ext are validated
|
|
93
|
+
* against strict regexes; any path-separator or `..` segment from a
|
|
94
|
+
* malicious client gets rejected at the regex layer before any I/O.
|
|
95
|
+
*/
|
|
96
|
+
export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
|
|
97
|
+
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
98
|
+
if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
99
|
+
const dateBucket = getDateBucket(opts.now);
|
|
100
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
101
|
+
const relativePath = join(dateBucket, fileId + ext);
|
|
102
|
+
const absolutePath = join(root, relativePath);
|
|
103
|
+
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type UploadIndexEntry = {
|
|
107
|
+
file_id: string;
|
|
108
|
+
date_bucket: string; // YYYY-MM-DD
|
|
109
|
+
ext: string; // ".png" / ".pdf" / etc., including the leading dot, lowercase
|
|
110
|
+
name: string; // original filename — stored as metadata only, NEVER used in paths
|
|
111
|
+
mime: string; // best-effort mime from the multipart Content-Type
|
|
112
|
+
size: number; // byte length of the binary
|
|
113
|
+
owner?: string; // upload token id / username, for traceability
|
|
114
|
+
uploaded_at: string; // ISO 8601
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Path to the index entry file for a given file_id. Lives in
|
|
118
|
+
* `<uploadsRoot>/.index/<file_id>.json` so /api/files/:file_id can do
|
|
119
|
+
* an O(1) lookup without scanning every date bucket. */
|
|
120
|
+
export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } = {}): string | null {
|
|
121
|
+
if (!FILE_ID_REGEX.test(fileId)) return null;
|
|
122
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
123
|
+
return join(root, ".index", fileId + ".json");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Validate an index entry shape before serving a download. Defends
|
|
127
|
+
* against a malformed/tampered .index file by re-checking the same
|
|
128
|
+
* invariants the upload handler enforces. */
|
|
129
|
+
export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
|
|
130
|
+
if (!entry || typeof entry !== "object") return false;
|
|
131
|
+
const e: any = entry;
|
|
132
|
+
return (
|
|
133
|
+
typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
|
|
134
|
+
typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
|
|
135
|
+
typeof e.ext === "string" && (e.ext === "" || /^\.[A-Za-z0-9]{1,16}$/.test(e.ext)) &&
|
|
136
|
+
typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Defence-in-depth: confirm a resolved absolute path falls within
|
|
142
|
+
* the uploads root. The HTTP handler calls this after constructing
|
|
143
|
+
* the download path; if it ever reports `false` we hard-fail the
|
|
144
|
+
* request rather than serve whatever the resolved path points at.
|
|
145
|
+
*/
|
|
146
|
+
export function isPathInsideUploadsRoot(absPath: string, uploadsRoot?: string): boolean {
|
|
147
|
+
const root = uploadsRoot ?? getUploadsRoot();
|
|
148
|
+
const rel = relative(root, absPath);
|
|
149
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Rate limit (in-memory, per token/IP) ────────────────────────────
|
|
153
|
+
|
|
154
|
+
type UploadRateState = { count: number; resetAt: number };
|
|
155
|
+
|
|
156
|
+
export class UploadRateLimiter {
|
|
157
|
+
private state = new Map<string, UploadRateState>();
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
private readonly windowMs: number = UPLOAD_RATE_WINDOW_MS,
|
|
161
|
+
private readonly maxPerWindow: number = UPLOAD_RATE_MAX_PER_WINDOW,
|
|
162
|
+
) {}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Attempt to consume one upload quota slot for `key` (typically the
|
|
166
|
+
* caller's token id, falling back to IP for legacy auth). Returns
|
|
167
|
+
* `{ allowed: true }` when the upload may proceed and
|
|
168
|
+
* `{ allowed: false, retryAfterMs }` when the caller is rate-limited.
|
|
169
|
+
* Window is rolling on first touch (not aligned to wall-clock hours).
|
|
170
|
+
*/
|
|
171
|
+
check(key: string, nowMs: number = Date.now()): { allowed: boolean; remaining: number; resetAt: number; retryAfterMs?: number } {
|
|
172
|
+
const entry = this.state.get(key);
|
|
173
|
+
if (!entry || nowMs >= entry.resetAt) {
|
|
174
|
+
const resetAt = nowMs + this.windowMs;
|
|
175
|
+
this.state.set(key, { count: 1, resetAt });
|
|
176
|
+
return { allowed: true, remaining: this.maxPerWindow - 1, resetAt };
|
|
177
|
+
}
|
|
178
|
+
if (entry.count >= this.maxPerWindow) {
|
|
179
|
+
return { allowed: false, remaining: 0, resetAt: entry.resetAt, retryAfterMs: entry.resetAt - nowMs };
|
|
180
|
+
}
|
|
181
|
+
entry.count++;
|
|
182
|
+
return { allowed: true, remaining: this.maxPerWindow - entry.count, resetAt: entry.resetAt };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Visible for tests — clear all rate-limit state. */
|
|
186
|
+
reset(): void {
|
|
187
|
+
this.state.clear();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Visible for tests — current bucket count. */
|
|
191
|
+
size(): number {
|
|
192
|
+
return this.state.size;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Module-level singleton shared by the HTTP /api/upload handler. */
|
|
197
|
+
export const sharedUploadRateLimiter = new UploadRateLimiter();
|
|
198
|
+
|
|
199
|
+
// ── Attachment schema (shared between REST /api/task and MCP send_task) ───
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Canonical shape for the `attachments` field on tasks. Both the REST
|
|
203
|
+
* /api/task body and the MCP send_task tool accept this same array;
|
|
204
|
+
* server persistence routes through `tasks.meta_json` so the storage
|
|
205
|
+
* layout is identical regardless of transport.
|
|
206
|
+
*/
|
|
207
|
+
export type TaskAttachment = {
|
|
208
|
+
type: "file";
|
|
209
|
+
file_id: string;
|
|
210
|
+
name?: string;
|
|
211
|
+
mime?: string;
|
|
212
|
+
size?: number;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Light validator usable from the REST handler. Returns null on success or an error message string. */
|
|
216
|
+
export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
|
|
217
|
+
if (input === undefined || input === null) return { ok: true, attachments: [] };
|
|
218
|
+
if (!Array.isArray(input)) return { ok: false, error: "attachments must be an array" };
|
|
219
|
+
if (input.length > 20) return { ok: false, error: "too many attachments (max 20)" };
|
|
220
|
+
const out: TaskAttachment[] = [];
|
|
221
|
+
for (let i = 0; i < input.length; i++) {
|
|
222
|
+
const a: any = input[i];
|
|
223
|
+
if (!a || typeof a !== "object") return { ok: false, error: `attachments[${i}] is not an object` };
|
|
224
|
+
if (a.type !== "file") return { ok: false, error: `attachments[${i}].type must be "file"` };
|
|
225
|
+
if (typeof a.file_id !== "string" || !FILE_ID_REGEX.test(a.file_id)) {
|
|
226
|
+
return { ok: false, error: `attachments[${i}].file_id is invalid` };
|
|
227
|
+
}
|
|
228
|
+
if (a.name !== undefined && (typeof a.name !== "string" || a.name.length > 255)) {
|
|
229
|
+
return { ok: false, error: `attachments[${i}].name must be a string ≤ 255 chars` };
|
|
230
|
+
}
|
|
231
|
+
if (a.mime !== undefined && (typeof a.mime !== "string" || a.mime.length > 100)) {
|
|
232
|
+
return { ok: false, error: `attachments[${i}].mime must be a string ≤ 100 chars` };
|
|
233
|
+
}
|
|
234
|
+
if (a.size !== undefined && (typeof a.size !== "number" || a.size < 0 || a.size > MAX_UPLOAD_BYTES)) {
|
|
235
|
+
return { ok: false, error: `attachments[${i}].size must be a non-negative number ≤ ${MAX_UPLOAD_BYTES}` };
|
|
236
|
+
}
|
|
237
|
+
out.push({ type: "file", file_id: a.file_id, name: a.name, mime: a.mime, size: a.size });
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, attachments: out };
|
|
240
|
+
}
|