add restrictions on file handler
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { handleFileDownloadRoutes } from "./file-download-routes";
|
||||
import { getFileTransferLimits } from "../file-transfer-state";
|
||||
|
||||
const PASSWORD = "Aa1!RouteUploadTestPass123";
|
||||
|
||||
@@ -58,6 +59,114 @@ async function removeTempDataDir(dir: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe("file upload route flow", () => {
|
||||
test("consumes an upload intent atomically when staging requests race", async () => {
|
||||
const auth = await createAdminToken();
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId, lastSeen: Date.now(), role: "client", ws: { send: () => {} },
|
||||
});
|
||||
const previousDisableAgentAuth = process.env.OVERLORD_DISABLE_AGENT_AUTH;
|
||||
process.env.OVERLORD_DISABLE_AGENT_AUTH = "true";
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ clientId, path: "C:\\Games\\race.bin", fileName: "race.bin" }),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
const requestJson = await requestRes!.json() as any;
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const makeStageRequest = () => new Request(stageUrl, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: new TextEncoder().encode("race payload"),
|
||||
});
|
||||
const responses = await Promise.all([
|
||||
handleFileDownloadRoutes(makeStageRequest(), stageUrl, mockServer, deps),
|
||||
handleFileDownloadRoutes(makeStageRequest(), stageUrl, mockServer, deps),
|
||||
]);
|
||||
|
||||
expect(responses.map((response) => response!.status).sort()).toEqual([200, 404]);
|
||||
const successful = responses.find((response) => response!.status === 200)!;
|
||||
const staged = await successful!.json() as any;
|
||||
const pullUrl = new URL(staged.pullUrl, "https://localhost");
|
||||
const pullResponse = await handleFileDownloadRoutes(
|
||||
new Request(pullUrl, { headers: { "x-overlord-client-id": clientId } }),
|
||||
pullUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
expect(await pullResponse!.text()).toBe("race payload");
|
||||
} finally {
|
||||
if (previousDisableAgentAuth === undefined) delete process.env.OVERLORD_DISABLE_AGENT_AUTH;
|
||||
else process.env.OVERLORD_DISABLE_AGENT_AUTH = previousDisableAgentAuth;
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an oversized staged upload before writing a payload file", async () => {
|
||||
const auth = await createAdminToken();
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({ clientId, path: "C:\\Games\\too-big.iso", fileName: "too-big.iso" }),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
const requestJson = await requestRes!.json() as any;
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const stageRes = await handleFileDownloadRoutes(
|
||||
new Request(stageUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
"Content-Length": String(getFileTransferLimits().maxFileBytes + 1),
|
||||
},
|
||||
}),
|
||||
stageUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(stageRes!.status).toBe(413);
|
||||
const uploadEntries = await readdir(join(dataDir, "uploads")).catch(() => [] as string[]);
|
||||
expect(uploadEntries).toHaveLength(0);
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("stages and serves one-time upload payload for matching client", async () => {
|
||||
const auth = await createAdminToken();
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
@@ -586,6 +695,52 @@ describe("file route feature_browser enforcement", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("upload stage PUT rechecks client access after intent creation", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId, lastSeen: Date.now(), role: "client", ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ clientId, path: "C:\\Games\\blocked.bin", fileName: "blocked.bin" }),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
const requestJson = await requestRes!.json() as any;
|
||||
setUserClientAccessScope(auth.userId, "none");
|
||||
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const stageRes = await handleFileDownloadRoutes(
|
||||
new Request(stageUrl, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: new TextEncoder().encode("must not be staged"),
|
||||
}),
|
||||
stageUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(stageRes!.status).toBe(403);
|
||||
const uploadEntries = await readdir(join(dataDir, "uploads")).catch(() => [] as string[]);
|
||||
expect(uploadEntries).toHaveLength(0);
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("download serve returns 403 when file_browser is revoked after intent creation", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
|
||||
@@ -10,9 +10,8 @@ import { getConfig } from "../../config";
|
||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
|
||||
import {
|
||||
FILE_UPLOAD_INTENT_TTL_MS,
|
||||
FILE_UPLOAD_PULL_TTL_MS,
|
||||
UUID_TOKEN_RE,
|
||||
getFileTransferLimits,
|
||||
isSafeRemotePath,
|
||||
makeIncrementalPullStream,
|
||||
notifyPullWaiters,
|
||||
@@ -37,6 +36,26 @@ type FileDownloadRouteDeps = {
|
||||
downloadIntents: Map<string, DownloadIntent>;
|
||||
};
|
||||
|
||||
class UploadStagingLimitError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function activeStagedPulls() {
|
||||
const now = Date.now();
|
||||
return [...uploadPulls.values()].filter((pull) =>
|
||||
pull.deleteFile && pull.expiresAt >= now,
|
||||
);
|
||||
}
|
||||
|
||||
function reservedStagedBytes(excludePullId = ""): number {
|
||||
return activeStagedPulls().reduce((total, pull) => {
|
||||
if (pull.id === excludePullId) return total;
|
||||
return total + Math.max(0, pull.expectedTotal || 0, pull.state?.size || 0, pull.size || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function serveDownloadById(
|
||||
req: Request,
|
||||
downloadId: string,
|
||||
@@ -155,6 +174,7 @@ async function serveDownloadById(
|
||||
}
|
||||
firstChunkReject(new Error("Download timed out"));
|
||||
reject(new Error("Download timed out"));
|
||||
try { target.ws.send(encodeMessage({ type: "command_abort", commandId } as any)); } catch {}
|
||||
}, 5 * 60_000);
|
||||
|
||||
deps.pendingHttpDownloads.set(commandId, {
|
||||
@@ -309,11 +329,19 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Client offline", { status: 404 });
|
||||
}
|
||||
|
||||
const limits = getFileTransferLimits();
|
||||
const activeForUser = [...uploadIntents.values()]
|
||||
.filter((intent) => intent.userId === user.userId && intent.expiresAt >= Date.now()).length
|
||||
+ activeStagedPulls().filter((pull) => pull.userId === user.userId).length;
|
||||
if (activeForUser >= limits.maxActivePerUser) {
|
||||
return new Response("Too many pending uploads", { status: 429, headers: { "Retry-After": "10" } });
|
||||
}
|
||||
|
||||
const uploadId = uuidv4();
|
||||
const expiresAt = Date.now() + FILE_UPLOAD_INTENT_TTL_MS;
|
||||
const expiresAt = Date.now() + limits.uploadIntentTtlMs;
|
||||
const timeout = setTimeout(() => {
|
||||
uploadIntents.delete(uploadId);
|
||||
}, FILE_UPLOAD_INTENT_TTL_MS);
|
||||
}, limits.uploadIntentTtlMs);
|
||||
|
||||
uploadIntents.set(uploadId, {
|
||||
id: uploadId,
|
||||
@@ -372,25 +400,42 @@ export async function handleFileDownloadRoutes(
|
||||
if (!intent || intent.userId !== user.userId || intent.expiresAt < Date.now()) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
try {
|
||||
requireClientAccess(user, intent.clientId);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const uploadDir = path.join(deps.DATA_DIR, "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const pullId = uuidv4();
|
||||
const tmpPath = path.join(uploadDir, `${pullId}.bin`);
|
||||
const limits = getFileTransferLimits();
|
||||
const activePulls = activeStagedPulls();
|
||||
if (activePulls.length >= limits.maxActiveGlobal
|
||||
|| activePulls.filter((pull) => pull.userId === user.userId).length >= limits.maxActivePerUser) {
|
||||
return new Response("Too many active staged uploads", { status: 429, headers: { "Retry-After": "10" } });
|
||||
}
|
||||
|
||||
const contentLength = Number(req.headers.get("content-length") || 0);
|
||||
const expectedTotal = Number.isFinite(contentLength) && contentLength >= 0 ? contentLength : 0;
|
||||
if (expectedTotal > limits.maxFileBytes) {
|
||||
return new Response("Upload exceeds the server staging limit", { status: 413 });
|
||||
}
|
||||
if (reservedStagedBytes() + expectedTotal > limits.maxStagedBytes) {
|
||||
return new Response("Server upload staging quota exceeded", { status: 429, headers: { "Retry-After": "30" } });
|
||||
}
|
||||
|
||||
const uploadDir = path.join(deps.DATA_DIR, "uploads");
|
||||
const pullId = uuidv4();
|
||||
const tmpPath = path.join(uploadDir, `${pullId}.bin`);
|
||||
const canStream = !!req.body && expectedTotal > 0;
|
||||
|
||||
const pullExpiresAt = Date.now() + FILE_UPLOAD_PULL_TTL_MS;
|
||||
const pullExpiresAt = Date.now() + limits.uploadPullTtlMs;
|
||||
const pullTimeout = setTimeout(() => {
|
||||
const stale = uploadPulls.get(pullId);
|
||||
uploadPulls.delete(pullId);
|
||||
if (stale && stale.deleteFile) {
|
||||
void fs.unlink(stale.tmpPath).catch(() => {});
|
||||
}
|
||||
}, FILE_UPLOAD_PULL_TTL_MS);
|
||||
}, limits.uploadPullTtlMs);
|
||||
|
||||
const state: StreamingPullState | undefined = canStream
|
||||
? { size: 0, done: false, error: null, waiters: [] }
|
||||
@@ -408,6 +453,7 @@ export async function handleFileDownloadRoutes(
|
||||
deleteFile: true,
|
||||
state,
|
||||
expectedTotal,
|
||||
userId: user.userId,
|
||||
});
|
||||
|
||||
uploadIntents.delete(uploadId);
|
||||
@@ -416,14 +462,27 @@ export async function handleFileDownloadRoutes(
|
||||
const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||
|
||||
let stagedSize = 0;
|
||||
const fileHandle = await fs.open(tmpPath, "w");
|
||||
let fileHandle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
||||
try {
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
fileHandle = await fs.open(tmpPath, "w");
|
||||
if (req.body) {
|
||||
const reader = req.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
const currentPull = uploadPulls.get(pullId);
|
||||
if (!currentPull || currentPull.expiresAt < Date.now()) {
|
||||
throw new UploadStagingLimitError("Upload staging expired", 408);
|
||||
}
|
||||
if (stagedSize + value.byteLength > limits.maxFileBytes) {
|
||||
throw new UploadStagingLimitError("Upload exceeds the server staging limit", 413);
|
||||
}
|
||||
if (reservedStagedBytes(pullId) + stagedSize + value.byteLength > limits.maxStagedBytes) {
|
||||
throw new UploadStagingLimitError("Server upload staging quota exceeded", 429);
|
||||
}
|
||||
currentPull.size = Math.max(currentPull.size, stagedSize + value.byteLength);
|
||||
await fileHandle.write(value, 0, value.byteLength, stagedSize);
|
||||
stagedSize += value.byteLength;
|
||||
if (state) {
|
||||
@@ -434,6 +493,17 @@ export async function handleFileDownloadRoutes(
|
||||
} else {
|
||||
const bytes = new Uint8Array(await req.arrayBuffer());
|
||||
if (bytes.byteLength > 0) {
|
||||
if (bytes.byteLength > limits.maxFileBytes) {
|
||||
throw new UploadStagingLimitError("Upload exceeds the server staging limit", 413);
|
||||
}
|
||||
if (reservedStagedBytes(pullId) + bytes.byteLength > limits.maxStagedBytes) {
|
||||
throw new UploadStagingLimitError("Server upload staging quota exceeded", 429);
|
||||
}
|
||||
const currentPull = uploadPulls.get(pullId);
|
||||
if (!currentPull || currentPull.expiresAt < Date.now()) {
|
||||
throw new UploadStagingLimitError("Upload staging expired", 408);
|
||||
}
|
||||
currentPull.size = Math.max(currentPull.size, bytes.byteLength);
|
||||
await fileHandle.write(bytes, 0, bytes.byteLength, 0);
|
||||
stagedSize = bytes.byteLength;
|
||||
if (state) {
|
||||
@@ -452,10 +522,16 @@ export async function handleFileDownloadRoutes(
|
||||
state.done = true;
|
||||
notifyPullWaiters(state);
|
||||
}
|
||||
await fileHandle.close();
|
||||
if (fileHandle) await fileHandle.close().catch(() => {});
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
uploadPulls.delete(pullId);
|
||||
clearTimeout(pullTimeout);
|
||||
if (err instanceof UploadStagingLimitError) {
|
||||
return new Response(err.message, {
|
||||
status: err.status,
|
||||
...(err.status === 429 ? { headers: { "Retry-After": "30" } } : {}),
|
||||
});
|
||||
}
|
||||
return new Response("Upload staging failed", { status: 500 });
|
||||
}
|
||||
await fileHandle.close();
|
||||
@@ -467,6 +543,9 @@ export async function handleFileDownloadRoutes(
|
||||
const finalPull = uploadPulls.get(pullId);
|
||||
if (finalPull) {
|
||||
finalPull.size = stagedSize;
|
||||
} else {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return new Response("Upload staging expired", { status: 408 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http upload staged bytes", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig, updateFileTransfersConfig } from "../../config";
|
||||
import {
|
||||
saveBrandingImage,
|
||||
getClientMetricsSummary,
|
||||
@@ -670,6 +670,40 @@ export async function handleMiscRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/file-transfers") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try { requirePermission(user, "system:security"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
|
||||
|
||||
if (req.method === "GET") {
|
||||
return Response.json({ fileTransfers: getConfig().fileTransfers }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const updated = await updateFileTransfersConfig({
|
||||
maxFileBytes: body?.maxFileBytes !== undefined ? Number(body.maxFileBytes) : undefined,
|
||||
maxStagedBytes: body?.maxStagedBytes !== undefined ? Number(body.maxStagedBytes) : undefined,
|
||||
maxActiveGlobal: body?.maxActiveGlobal !== undefined ? Number(body.maxActiveGlobal) : undefined,
|
||||
maxActivePerUser: body?.maxActivePerUser !== undefined ? Number(body.maxActivePerUser) : undefined,
|
||||
uploadIntentTtlMs: body?.uploadIntentTtlMs !== undefined ? Number(body.uploadIntentTtlMs) : undefined,
|
||||
uploadPullTtlMs: body?.uploadPullTtlMs !== undefined ? Number(body.uploadPullTtlMs) : undefined,
|
||||
});
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip: deps.requestIP?.(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: "Updated file transfer limits",
|
||||
success: true,
|
||||
});
|
||||
return Response.json({ ok: true, fileTransfers: updated }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/tls") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
|
||||
Reference in New Issue
Block a user