fix: harden attachment access and file paths

This commit is contained in:
kris
2026-03-29 15:47:07 +08:00
parent aa75506364
commit de23a6e921
5 changed files with 370 additions and 6 deletions

View 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 });
}

View File

@@ -3,7 +3,8 @@ import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getAttachmentById } from "@/lib/boss-data";
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
import { getAttachmentById, readState } from "@/lib/boss-data";
import { buildAttachmentDownloadHeaders } from "@/lib/boss-attachments";
import { resolveServerFileAttachmentAbsolutePath } from "@/lib/boss-storage-server-file";
@@ -23,6 +24,10 @@ export async function GET(
if (!record) {
return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
}
const state = await readState();
if (!canSessionAccessAttachmentProject(state, session, record.project)) {
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
if (record.attachment.storageBackend !== "server_file") {
return NextResponse.json(
@@ -31,7 +36,12 @@ export async function GET(
);
}
const absolutePath = resolveServerFileAttachmentAbsolutePath(record.attachment.storagePath);
let absolutePath: string;
try {
absolutePath = resolveServerFileAttachmentAbsolutePath(record.attachment.storagePath);
} catch {
return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 });
}
try {
await stat(absolutePath);
} catch {

View File

@@ -1,6 +1,7 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
import {
appendAttachmentMessage,
getAttachmentStorageConfig,
@@ -31,6 +32,9 @@ export async function POST(
if (!project) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
if (!canSessionAccessAttachmentProject(state, session, project)) {
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const form = await request.formData();
const file = form.get("file");

View File

@@ -0,0 +1,38 @@
import type { AuthSession, BossState, Project } from "@/lib/boss-data";
function getAccountOwnedDeviceIds(state: BossState, account: string) {
return new Set(
state.devices
.filter((device) => device.account === account)
.map((device) => device.id),
);
}
export function canSessionAccessAttachmentProject(
state: BossState,
session: Pick<AuthSession, "account" | "role">,
project: Pick<Project, "deviceIds" | "groupMembers">,
) {
if (session.role === "highest_admin") {
return true;
}
const ownedDeviceIds = getAccountOwnedDeviceIds(state, session.account);
if (ownedDeviceIds.size === 0) {
return false;
}
for (const deviceId of project.deviceIds) {
if (ownedDeviceIds.has(deviceId)) {
return true;
}
}
for (const member of project.groupMembers) {
if (ownedDeviceIds.has(member.deviceId)) {
return true;
}
}
return false;
}

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage";
@@ -30,19 +31,44 @@ function resolveRuntimeRoot() {
return detectRuntimeRoot(process.cwd());
}
function getUploadsRoot() {
return path.resolve(resolveRuntimeRoot(), "data", "uploads");
}
function accountStorageSegment(account: string) {
const normalized = account.normalize("NFKC").trim();
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
return `acct-${digest}`;
}
function normalizeUploadsRelativePath(storagePath: string) {
const normalized = storagePath.replace(/\\/g, "/").replace(/^\/+/, "");
return normalized.replace(/^data\/uploads\/+/, "");
}
function resolvePathWithinUploadsRoot(storagePath: string) {
const uploadsRoot = getUploadsRoot();
const candidate = path.resolve(uploadsRoot, normalizeUploadsRelativePath(storagePath));
const relative = path.relative(uploadsRoot, candidate);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("ATTACHMENT_STORAGE_PATH_OUTSIDE_UPLOADS_ROOT");
}
return candidate;
}
export async function storeServerFileAttachment(
params: StoreAttachmentParams,
): Promise<StoredAttachmentRecord> {
const now = new Date();
const relativePath = path.join(
const relativePath = path.posix.join(
"data",
"uploads",
params.account,
accountStorageSegment(params.account),
String(now.getUTCFullYear()),
String(now.getUTCMonth() + 1).padStart(2, "0"),
`${params.messageId}-${sanitizeFileName(params.fileName)}`,
);
const absolutePath = path.join(resolveRuntimeRoot(), relativePath);
const absolutePath = resolvePathWithinUploadsRoot(relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, params.buffer);
return {
@@ -52,5 +78,5 @@ export async function storeServerFileAttachment(
}
export function resolveServerFileAttachmentAbsolutePath(storagePath: string) {
return path.join(resolveRuntimeRoot(), storagePath);
return resolvePathWithinUploadsRoot(storagePath);
}