fix: retry protected file reads with sudo (#1349)

This commit is contained in:
ZacharyZcR
2026-08-27 06:39:16 +08:00
committed by GitHub
parent 6323459af2
commit 19d4d91eee
3 changed files with 298 additions and 155 deletions
+72 -117
View File
@@ -3,6 +3,7 @@ import Busboy from "busboy";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js"; import { fileLogger } from "../../utils/logger.js";
import { import {
execBuffer,
execChannel, execChannel,
execWithSudo, execWithSudo,
execWithSudoBuffer, execWithSudoBuffer,
@@ -262,7 +263,7 @@ export function registerFileContentRoutes(
* 500: * 500:
* description: Failed to read file. * description: Failed to read file.
*/ */
app.get("/ssh/file_manager/ssh/readFile", (req, res) => { app.get("/ssh/file_manager/ssh/readFile", async (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const filePath = decodeURIComponent(req.query.path as string); const filePath = decodeURIComponent(req.query.path as string);
@@ -295,49 +296,53 @@ export function registerFileContentRoutes(
const MAX_READ_SIZE = 500 * 1024 * 1024; const MAX_READ_SIZE = 500 * 1024 * 1024;
const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const escapedPath = filePath.replace(/'/g, "'\"'\"'");
execChannel( const isPermissionDenied = (message: string) =>
message.toLowerCase().includes("permission denied");
const isFileNotFound = (message: string) => {
const lower = message.toLowerCase();
return (
lower.includes("no such file or directory") ||
lower.includes("cannot access") ||
lower.includes("not found") ||
lower.includes("resource not found")
);
};
try {
let sizeResult = await execBuffer(
sshConn, sshConn,
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
(sizeErr, sizeStream) => { );
if (sizeErr) { let sizeError = sizeResult.stderr || sizeResult.stdout.toString("utf8");
fileLogger.error("SSH file size check error:", sizeErr);
return res.status(500).json({ error: sizeErr.message }); if (
sizeResult.code !== 0 &&
isPermissionDenied(sizeError) &&
sshConn.sudoPassword
) {
sizeResult = await execWithSudoBuffer(
sshConn,
`stat -c%s '${escapedPath}'`,
sshConn.sudoPassword,
);
sizeError = sizeResult.stderr || sizeResult.stdout.toString("utf8");
} }
let sizeData = ""; if (sizeResult.code !== 0) {
let sizeErrorData = ""; const missing = isFileNotFound(sizeError);
const permissionDenied = isPermissionDenied(sizeError);
sizeStream.on("data", (chunk: Buffer) => { fileLogger.error(`File size check failed: ${sizeError}`);
sizeData += chunk.toString(); return res.status(missing ? 404 : permissionDenied ? 403 : 500).json({
}); error: `Cannot check file size: ${sizeError}`,
fileNotFound: missing,
sizeStream.stderr.on("data", (chunk: Buffer) => { needsSudo: permissionDenied,
sizeErrorData += chunk.toString();
});
sizeStream.on("close", (sizeCode) => {
if (sizeCode !== 0) {
const errorLower = sizeErrorData.toLowerCase();
const isFileNotFound =
errorLower.includes("no such file or directory") ||
errorLower.includes("cannot access") ||
errorLower.includes("not found") ||
errorLower.includes("resource not found");
fileLogger.error(`File size check failed: ${sizeErrorData}`);
return res.status(isFileNotFound ? 404 : 500).json({
error: `Cannot check file size: ${sizeErrorData}`,
fileNotFound: isFileNotFound,
}); });
} }
const fileSize = parseInt(sizeData.trim(), 10); const fileSize = parseInt(sizeResult.stdout.toString("utf8").trim(), 10);
if (isNaN(fileSize)) { if (isNaN(fileSize)) {
fileLogger.error("Invalid file size response:", sizeData); fileLogger.error("Invalid file size response:", sizeResult.stdout);
return res return res.status(500).json({ error: "Cannot determine file size" });
.status(500)
.json({ error: "Cannot determine file size" });
} }
if (fileSize > MAX_READ_SIZE) { if (fileSize > MAX_READ_SIZE) {
@@ -356,106 +361,56 @@ export function registerFileContentRoutes(
}); });
} }
execChannel(sshConn, `cat '${escapedPath}'`, (err, stream) => { let contentResult = await execBuffer(sshConn, `cat '${escapedPath}'`);
if (err) { let contentError =
fileLogger.error("SSH readFile error:", err); contentResult.stderr || contentResult.stdout.toString("utf8");
return res.status(500).json({ error: err.message });
}
let binaryData = Buffer.alloc(0); if (
let errorData = ""; contentResult.code !== 0 &&
isPermissionDenied(contentError) &&
stream.on("data", (chunk: Buffer) => { sshConn.sudoPassword
binaryData = Buffer.concat([binaryData, chunk]); ) {
}); contentResult = await execWithSudoBuffer(
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied = errorData
.toLowerCase()
.includes("permission denied");
if (isPermissionDenied && sshConn.sudoPassword) {
execWithSudoBuffer(
sshConn, sshConn,
`cat '${escapedPath}'`, `cat '${escapedPath}'`,
sshConn.sudoPassword, sshConn.sudoPassword,
)
.then((result) => {
if (result.code !== 0) {
return res.status(403).json({
error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
needsSudo: true,
});
}
const sudoData = result.stdout;
const isBinary = detectBinary(sudoData);
res.json({
content: isBinary
? sudoData.toString("base64")
: sudoData.toString("utf8"),
isBinary,
size: sudoData.length,
});
})
.catch(() => {
res
.status(403)
.json({ error: "Permission denied", needsSudo: true });
});
return;
}
fileLogger.error(
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
); );
contentError =
contentResult.stderr || contentResult.stdout.toString("utf8");
}
const isFileNotFound = if (contentResult.code !== 0) {
errorData.includes("No such file or directory") || const missing = isFileNotFound(contentError);
errorData.includes("cannot access") || const permissionDenied = isPermissionDenied(contentError);
errorData.includes("not found"); fileLogger.error(
`SSH readFile command failed with code ${contentResult.code}: ${contentError.replace(/\n/g, " ").trim()}`,
return res.status(isFileNotFound ? 404 : 500).json({ );
error: `Command failed: ${errorData}`, return res.status(missing ? 404 : permissionDenied ? 403 : 500).json({
fileNotFound: isFileNotFound, error: `Command failed: ${contentError}`,
fileNotFound: missing,
needsSudo: permissionDenied,
}); });
} }
const isBinary = detectBinary(binaryData); const isBinary = detectBinary(contentResult.stdout);
fileLogger.success("File read successfully", { fileLogger.success("File read successfully", {
operation: "file_read_success", operation: "file_read_success",
sessionId, sessionId,
userId, userId,
path: filePath, path: filePath,
bytes: binaryData.length, bytes: contentResult.stdout.length,
}); });
return res.json({
if (isBinary) { content: contentResult.stdout.toString(isBinary ? "base64" : "utf8"),
const base64Content = binaryData.toString("base64");
res.json({
content: base64Content,
path: filePath, path: filePath,
encoding: "base64", encoding: isBinary ? "base64" : "utf8",
});
} else {
const textContent = binaryData.toString("utf8");
res.json({
content: textContent,
path: filePath,
encoding: "utf8",
}); });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
fileLogger.error("SSH readFile error:", error);
return res.status(500).json({ error: message });
} }
}); });
});
});
},
);
});
/** /**
* @openapi * @openapi
+34
View File
@@ -120,6 +120,40 @@ export function execWithSudoBuffer(
}); });
} }
export function execBuffer(
session: SSHSession,
command: string,
): Promise<{ stdout: Buffer; stderr: string; code: number }> {
return new Promise((resolve) => {
execChannel(session, command, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stderr = "";
let settled = false;
const finish = (code: number) => {
if (settled) return;
settled = true;
resolve({ stdout: Buffer.concat(stdoutChunks), stderr, code });
};
stream.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => finish(code || 0));
stream.on("error", (streamErr: Error) => {
stderr = stderr || streamErr.message;
finish(1);
});
});
});
}
export function getSessionSftp( export function getSessionSftp(
session: SSHSession, session: SSHSession,
): Promise<import("ssh2").SFTPWrapper> { ): Promise<import("ssh2").SFTPWrapper> {
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const commandMocks = vi.hoisted(() => ({
execBuffer: vi.fn(),
execWithSudoBuffer: vi.fn(),
}));
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../hosts/file-manager/session.js")
>()),
execBuffer: commandMocks.execBuffer,
execWithSudoBuffer: commandMocks.execWithSudoBuffer,
}));
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
type RouteHandler = (request: Request, response: Response) => Promise<unknown>;
function setupReadRoute() {
const routes = new Map<string, RouteHandler>();
const app = {
get: (path: string, handler: RouteHandler) => routes.set(path, handler),
post: vi.fn(),
} as unknown as Express;
const session = {
isConnected: true,
lastActive: 0,
sudoPassword: "sudo-secret",
} as SSHSession;
registerFileContentRoutes(app, {
sshSessions: { session: session },
verifySessionOwnership: () => true,
});
const handler = routes.get("/ssh/file_manager/ssh/readFile");
if (!handler) throw new Error("readFile route was not registered");
const request = {
query: { sessionId: "session", path: "/root/secret.txt" },
userId: "user",
} as unknown as Request;
const response = {
status: vi.fn(),
json: vi.fn(),
} as unknown as Response;
vi.mocked(response.status).mockReturnValue(response);
vi.mocked(response.json).mockReturnValue(response);
return { handler, request, response, session };
}
describe("file manager readFile", () => {
beforeEach(() => {
commandMocks.execBuffer.mockReset();
commandMocks.execWithSudoBuffer.mockReset();
});
it("falls back to sudo for both the size check and content read", async () => {
const { handler, request, response, session } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
});
commandMocks.execWithSudoBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("6\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("secret"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
1,
session,
"stat -c%s '/root/secret.txt'",
"sudo-secret",
);
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/root/secret.txt'",
"sudo-secret",
);
expect(response.json).toHaveBeenCalledWith({
content: "secret",
path: "/root/secret.txt",
encoding: "utf8",
});
});
it("keeps readable files on the unprivileged path", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("hello"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execWithSudoBuffer).not.toHaveBeenCalled();
expect(response.json).toHaveBeenCalledWith({
content: "hello",
path: "/root/secret.txt",
encoding: "utf8",
});
});
it("rejects oversized sudo-only files before reading their content", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
});
commandMocks.execWithSudoBuffer.mockResolvedValueOnce({
stdout: Buffer.from(String(501 * 1024 * 1024)),
stderr: "",
code: 0,
});
await handler(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ tooLarge: true }),
);
expect(commandMocks.execBuffer).toHaveBeenCalledTimes(1);
expect(commandMocks.execWithSudoBuffer).toHaveBeenCalledTimes(1);
});
});