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

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