fix: harden attachment access and file paths
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
38
src/lib/boss-attachment-access.ts
Normal file
38
src/lib/boss-attachment-access.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user