fix: harden file reads and timer cleanup (#1352)

* fix: harden file reads and timer cleanup

* fix: preserve literal file path escapes
This commit is contained in:
ZacharyZcR
2026-08-28 10:36:08 +08:00
committed by GitHub
parent 50f1882fa9
commit 14d4128266
8 changed files with 226 additions and 20 deletions
+10 -1
View File
@@ -191,6 +191,7 @@ class RemoteSyncEngine {
this.getMainWindow = getMainWindow; this.getMainWindow = getMainWindow;
this.localJwt = null; this.localJwt = null;
this.timer = null; this.timer = null;
this.startupTimer = null;
this.syncing = false; this.syncing = false;
this.status = { this.status = {
connected: false, connected: false,
@@ -220,11 +221,15 @@ class RemoteSyncEngine {
const config = getRemoteSyncConfig(); const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl; this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
if (this.startupTimer) clearTimeout(this.startupTimer);
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS); this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
if (config?.serverUrl) { if (config?.serverUrl) {
// Fire an initial sync shortly after startup rather than waiting a // Fire an initial sync shortly after startup rather than waiting a
// full interval, but don't block app boot on it. // full interval, but don't block app boot on it.
setTimeout(() => this.syncNow(), 5000); this.startupTimer = setTimeout(() => {
this.startupTimer = null;
this.syncNow();
}, 5000);
} }
} }
@@ -233,6 +238,10 @@ class RemoteSyncEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
if (this.startupTimer) {
clearTimeout(this.startupTimer);
this.startupTimer = null;
}
} }
async syncNow() { async syncNow() {
@@ -85,7 +85,7 @@ async function pruneOldLogs(): Promise<void> {
// Run prune once at startup, then every 24 hours // Run prune once at startup, then every 24 hours
pruneOldLogs(); pruneOldLogs();
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000); setInterval(pruneOldLogs, 24 * 60 * 60 * 1000).unref();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
@@ -65,7 +65,7 @@ export function registerFileContentRoutes(
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { app.get("/ssh/file_manager/ssh/identifySymlink", (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 linkPath = decodeURIComponent(req.query.path as string); const linkPath = req.query.path as string;
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
@@ -166,7 +166,7 @@ export function registerFileContentRoutes(
app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => { app.get("/ssh/file_manager/ssh/resolvePath", (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 rawPath = decodeURIComponent(req.query.path as string); const rawPath = req.query.path as string;
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
@@ -266,7 +266,7 @@ export function registerFileContentRoutes(
app.get("/ssh/file_manager/ssh/readFile", async (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 = req.query.path as string;
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
@@ -361,7 +361,11 @@ export function registerFileContentRoutes(
}); });
} }
let contentResult = await execBuffer(sshConn, `cat '${escapedPath}'`); let contentResult = await execBuffer(
sshConn,
`cat '${escapedPath}'`,
MAX_READ_SIZE,
);
let contentError = let contentError =
contentResult.stderr || contentResult.stdout.toString("utf8"); contentResult.stderr || contentResult.stdout.toString("utf8");
@@ -374,11 +378,20 @@ export function registerFileContentRoutes(
sshConn, sshConn,
`cat '${escapedPath}'`, `cat '${escapedPath}'`,
sshConn.sudoPassword, sshConn.sudoPassword,
MAX_READ_SIZE,
); );
contentError = contentError =
contentResult.stderr || contentResult.stdout.toString("utf8"); contentResult.stderr || contentResult.stdout.toString("utf8");
} }
if (contentResult.exceededLimit) {
return res.status(400).json({
error: `File grew beyond the ${MAX_READ_SIZE / 1024 / 1024}MB read limit`,
maxSize: MAX_READ_SIZE,
tooLarge: true,
});
}
if (contentResult.code !== 0) { if (contentResult.code !== 0) {
const missing = isFileNotFound(contentError); const missing = isFileNotFound(contentError);
const permissionDenied = isPermissionDenied(contentError); const permissionDenied = isPermissionDenied(contentError);
@@ -54,7 +54,7 @@ export function registerFileListingRoutes(
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { app.get("/ssh/file_manager/ssh/listFiles", (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 sshPath = decodeURIComponent((req.query.path as string) || "/"); const sshPath = (req.query.path as string) || "/";
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
+67 -12
View File
@@ -76,10 +76,15 @@ export function execWithSudoBuffer(
session: SSHSession, session: SSHSession,
command: string, command: string,
sudoPassword: string, sudoPassword: string,
): Promise<{ stdout: Buffer; stderr: string; code: number }> { maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => { return new Promise((resolve) => {
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => { execChannel(session, sudoCommand, (err, stream) => {
if (err) { if (err) {
@@ -88,9 +93,33 @@ export function execWithSudoBuffer(
} }
const stdoutChunks: Buffer[] = []; const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = ""; let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => { stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk); stdoutChunks.push(chunk);
}); });
@@ -99,6 +128,7 @@ export function execWithSudoBuffer(
}); });
stream.on("close", (code: number) => { stream.on("close", (code: number) => {
if (settled) return;
let stdout = Buffer.concat(stdoutChunks); let stdout = Buffer.concat(stdoutChunks);
const sudoPromptMatch = stdout const sudoPromptMatch = stdout
.toString("utf8", 0, Math.min(stdout.length, 256)) .toString("utf8", 0, Math.min(stdout.length, 256))
@@ -106,16 +136,16 @@ export function execWithSudoBuffer(
if (sudoPromptMatch) { if (sudoPromptMatch) {
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0])); stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
} }
settled = true;
resolve({ stdout, stderr, code: code || 0 }); resolve({ stdout, stderr, code: code || 0 });
}); });
stream.on("error", (streamErr: Error) => { stream.on("error", (streamErr: Error) => {
resolve({ stderr = streamErr.message;
stdout: Buffer.concat(stdoutChunks), finish(1);
stderr: streamErr.message,
code: 1,
});
}); });
stream.write(`${sudoPassword}\n`);
}); });
}); });
} }
@@ -123,7 +153,13 @@ export function execWithSudoBuffer(
export function execBuffer( export function execBuffer(
session: SSHSession, session: SSHSession,
command: string, command: string,
): Promise<{ stdout: Buffer; stderr: string; code: number }> { maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => { return new Promise((resolve) => {
execChannel(session, command, (err, stream) => { execChannel(session, command, (err, stream) => {
if (err) { if (err) {
@@ -132,16 +168,35 @@ export function execBuffer(
} }
const stdoutChunks: Buffer[] = []; const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = ""; let stderr = "";
let settled = false; let settled = false;
const finish = (code: number) => { const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
resolve({ stdout: Buffer.concat(stdoutChunks), stderr, code }); resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
}; };
stream.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => { stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
+1 -1
View File
@@ -61,7 +61,7 @@ setInterval(
} }
}, },
5 * 60 * 1000, 5 * 60 * 1000,
); ).unref();
/** /**
* @openapi * @openapi
@@ -97,12 +97,45 @@ describe("file manager readFile", () => {
session, session,
"cat '/root/secret.txt'", "cat '/root/secret.txt'",
"sudo-secret", "sudo-secret",
500 * 1024 * 1024,
); );
expect(response.json).toHaveBeenCalledWith({ expect(response.json).toHaveBeenCalledWith({
content: "secret", content: "secret",
path: "/root/secret.txt", path: "/root/secret.txt",
encoding: "utf8", encoding: "utf8",
}); });
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/root/secret.txt'",
500 * 1024 * 1024,
);
});
it("rejects a file that grows beyond the limit while being read", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Command output exceeds 524288000 bytes",
code: 1,
exceededLimit: true,
});
await handler(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
maxSize: 500 * 1024 * 1024,
tooLarge: true,
}),
);
}); });
it("keeps readable files on the unprivileged path", async () => { it("keeps readable files on the unprivileged path", async () => {
@@ -129,6 +162,34 @@ describe("file manager readFile", () => {
}); });
}); });
it("preserves percent escapes that are literal path characters", async () => {
const { handler, request, response, session } = setupReadRoute();
request.query.path = "/tmp/100%/literal%2Fname";
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.execBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/tmp/100%/literal%2Fname'",
500 * 1024 * 1024,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ path: "/tmp/100%/literal%2Fname" }),
);
});
it("rejects oversized sudo-only files before reading their content", async () => { it("rejects oversized sudo-only files before reading their content", async () => {
const { handler, request, response } = setupReadRoute(); const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer.mockResolvedValueOnce({ commandMocks.execBuffer.mockResolvedValueOnce({
@@ -0,0 +1,68 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
ChannelOpenSerializer,
execBuffer,
execWithSudoBuffer,
type SSHSession,
} from "../../../hosts/file-manager/session.js";
function setupSession() {
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
close: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
stream.stderr = new EventEmitter();
stream.close = vi.fn();
stream.write = vi.fn();
const exec = vi.fn(
(
_command: string,
callback: (error: undefined, channel: typeof stream) => void,
) => callback(undefined, stream),
);
const session = {
client: { exec },
channelOpener: new ChannelOpenSerializer(),
} as unknown as SSHSession;
return { exec, session, stream };
}
describe("file-manager command helpers", () => {
it("writes the sudo password to stdin instead of the command line", async () => {
const { exec, session, stream } = setupSession();
const resultPromise = execWithSudoBuffer(
session,
"cat '/root/secret.txt'",
"private password",
);
await vi.waitFor(() => expect(stream.write).toHaveBeenCalled());
expect(exec).toHaveBeenCalledWith(
"sudo -S -p '' cat '/root/secret.txt' 2>&1",
expect.any(Function),
);
expect(exec.mock.calls[0][0]).not.toContain("private password");
expect(stream.write).toHaveBeenCalledWith("private password\n");
stream.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
});
it("closes the channel when stdout exceeds the configured limit", async () => {
const { session, stream } = setupSession();
const resultPromise = execBuffer(session, "cat file", 4);
await vi.waitFor(() => expect(stream.listenerCount("data")).toBe(1));
stream.emit("data", Buffer.from("12345"));
await expect(resultPromise).resolves.toMatchObject({
code: 1,
exceededLimit: true,
});
expect(stream.close).toHaveBeenCalledOnce();
});
});