diff --git a/scripts/verify-attachment-security.mjs b/scripts/verify-attachment-security.mjs new file mode 100644 index 0000000..eda08bf --- /dev/null +++ b/scripts/verify-attachment-security.mjs @@ -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 }); +} diff --git a/src/app/api/v1/attachments/[attachmentId]/download/route.ts b/src/app/api/v1/attachments/[attachmentId]/download/route.ts index ae174aa..68ac275 100644 --- a/src/app/api/v1/attachments/[attachmentId]/download/route.ts +++ b/src/app/api/v1/attachments/[attachmentId]/download/route.ts @@ -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 { diff --git a/src/app/api/v1/projects/[projectId]/attachments/route.ts b/src/app/api/v1/projects/[projectId]/attachments/route.ts index fa9f7df..c9089b8 100644 --- a/src/app/api/v1/projects/[projectId]/attachments/route.ts +++ b/src/app/api/v1/projects/[projectId]/attachments/route.ts @@ -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"); diff --git a/src/lib/boss-attachment-access.ts b/src/lib/boss-attachment-access.ts new file mode 100644 index 0000000..dfb22ac --- /dev/null +++ b/src/lib/boss-attachment-access.ts @@ -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, + project: Pick, +) { + 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; +} diff --git a/src/lib/boss-storage-server-file.ts b/src/lib/boss-storage-server-file.ts index 0e117e9..33d76a2 100644 --- a/src/lib/boss-storage-server-file.ts +++ b/src/lib/boss-storage-server-file.ts @@ -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 { 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); }