fix: harden attachment access and file paths
This commit is contained in:
286
scripts/verify-attachment-security.mjs
Normal file
286
scripts/verify-attachment-security.mjs
Normal file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function hashPassword(password) {
|
||||
const normalized = password.normalize("NFKC");
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(normalized, `boss:${salt}`, 64).toString("hex");
|
||||
return `scrypt$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function projectTemplate(id, name, deviceId) {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
pinned: false,
|
||||
deviceIds: [deviceId],
|
||||
preview: "",
|
||||
updatedAt: timestamp,
|
||||
lastMessageAt: timestamp,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: id,
|
||||
threadId: `thread-${id}`,
|
||||
threadDisplayName: name,
|
||||
folderName: name,
|
||||
activityIconCount: 1,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForServer(baseUrl, child, getServerLogs) {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`);
|
||||
}
|
||||
|
||||
async function login(baseUrl, account, password) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${account}:${response.status}`);
|
||||
}
|
||||
const cookie = (response.headers.get("set-cookie") || "").split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error(`COOKIE_MISSING:${account}`);
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "boss-attachment-security-"));
|
||||
const runtimeRoot = path.join(tmpRoot, "runtime");
|
||||
const dataDir = path.join(runtimeRoot, "data");
|
||||
const uploadsDir = path.join(dataDir, "uploads");
|
||||
const stateFile = path.join(dataDir, "boss-state.json");
|
||||
const outsideSecretPath = path.join(runtimeRoot, "secret-outside-uploads.txt");
|
||||
const port = "3104";
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
let server;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(runtimeRoot, "public", "downloads"), { recursive: true });
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const baseState = JSON.parse(await readFile(path.join(repoRoot, "data", "boss-state.json"), "utf8"));
|
||||
const timestamp = nowIso();
|
||||
const memberAccount = "18800000001";
|
||||
const memberPassword = "member-pass-123";
|
||||
const memberDeviceId = "member-device-1";
|
||||
const memberProject = projectTemplate("member-project", "成员项目", memberDeviceId);
|
||||
const adminProject = projectTemplate("admin-only-project", "管理员项目", "mac-studio");
|
||||
|
||||
const adminAttachmentPath = path.join("data", "uploads", "seeded-admin", "2026", "03", "admin-note.txt");
|
||||
const adminAbsolutePath = path.join(runtimeRoot, adminAttachmentPath);
|
||||
await mkdir(path.dirname(adminAbsolutePath), { recursive: true });
|
||||
await writeFile(adminAbsolutePath, "admin only attachment\n", "utf8");
|
||||
|
||||
const traversalAttachmentId = "att-traversal";
|
||||
const adminAttachmentId = "att-admin-only";
|
||||
await writeFile(outsideSecretPath, "outside uploads secret\n", "utf8");
|
||||
|
||||
memberProject.messages.push({
|
||||
id: "msg-traversal",
|
||||
sender: "user",
|
||||
senderLabel: "成员用户",
|
||||
body: "traversal probe",
|
||||
sentAt: timestamp,
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: traversalAttachmentId,
|
||||
fileName: "secret.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 23,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: outsideSecretPath,
|
||||
previewAvailable: false,
|
||||
uploadedAt: timestamp,
|
||||
uploadedBy: memberAccount,
|
||||
analysisState: "ready_manual",
|
||||
},
|
||||
],
|
||||
});
|
||||
memberProject.preview = "traversal probe";
|
||||
|
||||
adminProject.messages.push({
|
||||
id: "msg-admin-attachment",
|
||||
sender: "user",
|
||||
senderLabel: "Boss 超级管理员",
|
||||
body: "管理员附件",
|
||||
sentAt: timestamp,
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: adminAttachmentId,
|
||||
fileName: "admin-note.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 22,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: adminAttachmentPath,
|
||||
previewAvailable: false,
|
||||
uploadedAt: timestamp,
|
||||
uploadedBy: "17600003315",
|
||||
analysisState: "ready_manual",
|
||||
},
|
||||
],
|
||||
});
|
||||
adminProject.preview = "管理员附件";
|
||||
|
||||
baseState.authSessions = [];
|
||||
baseState.authAccounts.push({
|
||||
id: `account-${memberAccount}`,
|
||||
account: memberAccount,
|
||||
passwordHash: hashPassword(memberPassword),
|
||||
displayName: "成员用户",
|
||||
role: "member",
|
||||
primaryDeviceId: memberDeviceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
baseState.devices.push({
|
||||
id: memberDeviceId,
|
||||
name: "Member Device",
|
||||
avatar: "M",
|
||||
account: memberAccount,
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [memberProject.id],
|
||||
quota5h: 10,
|
||||
quota7d: 20,
|
||||
lastSeenAt: timestamp,
|
||||
});
|
||||
baseState.projects.push(memberProject, adminProject);
|
||||
baseState.userAttachmentStorageConfigs = [
|
||||
...(Array.isArray(baseState.userAttachmentStorageConfigs) ? baseState.userAttachmentStorageConfigs : []),
|
||||
{
|
||||
account: memberAccount,
|
||||
mode: "server_file",
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
await writeFile(stateFile, JSON.stringify(baseState, null, 2), "utf8");
|
||||
|
||||
server = spawn("node", [".next/standalone/server.js"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: port,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
BOSS_RUNTIME_ROOT: runtimeRoot,
|
||||
BOSS_STATE_FILE: stateFile,
|
||||
BOSS_AUTH_AUTO_LOGIN: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let serverLogs = "";
|
||||
server.stdout.on("data", (chunk) => {
|
||||
serverLogs += chunk.toString();
|
||||
});
|
||||
server.stderr.on("data", (chunk) => {
|
||||
serverLogs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl, server, () => serverLogs);
|
||||
|
||||
const memberCookie = await login(baseUrl, memberAccount, memberPassword);
|
||||
const adminCookie = await login(baseUrl, "17600003315", "boss123456");
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append("file", new File([Buffer.from("blocked upload\n")], "blocked.txt", { type: "text/plain" }));
|
||||
|
||||
const uploadDenied = await fetch(`${baseUrl}/api/v1/projects/admin-only-project/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
body: uploadForm,
|
||||
});
|
||||
if (uploadDenied.status !== 403) {
|
||||
throw new Error(`EXPECTED_UPLOAD_403:${uploadDenied.status}`);
|
||||
}
|
||||
|
||||
const adminDownload = await fetch(`${baseUrl}/api/v1/attachments/${adminAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
});
|
||||
if (adminDownload.status !== 403) {
|
||||
throw new Error(`EXPECTED_DOWNLOAD_403:${adminDownload.status}`);
|
||||
}
|
||||
|
||||
const happyAdminDownload = await fetch(`${baseUrl}/api/v1/attachments/${adminAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: adminCookie,
|
||||
},
|
||||
});
|
||||
if (!happyAdminDownload.ok) {
|
||||
throw new Error(`ADMIN_DOWNLOAD_FAILED:${happyAdminDownload.status}`);
|
||||
}
|
||||
|
||||
const traversalDownload = await fetch(`${baseUrl}/api/v1/attachments/${traversalAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
});
|
||||
if (traversalDownload.status !== 404) {
|
||||
throw new Error(`EXPECTED_TRAVERSAL_404:${traversalDownload.status}`);
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (server && server.exitCode === null) {
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user