Merge pull request #1 from mowdep/copilot/enforce-chmod-600-on-ssh-key
fix: enforce 0o600 on SFTP key files and retry mount on permission failure
This commit is contained in:
commit
4d437e2ff1
4 changed files with 102 additions and 4 deletions
|
|
@ -4,7 +4,7 @@ import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
import { FILE_MODES, logger, writeFileWithMode } from "@zerobyte/core/node";
|
import { FILE_MODES, ensureFileMode, logger, writeFileWithMode } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants";
|
import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants";
|
||||||
import { getMountForPath } from "../fs";
|
import { getMountForPath } from "../fs";
|
||||||
|
|
@ -142,7 +142,30 @@ const mount = async (config: BackendConfig, mountPath: string) => {
|
||||||
const args = [`${config.username}@${config.host}:${config.path || ""}`, mountPath, "-o", options.join(",")];
|
const args = [`${config.username}@${config.host}:${config.path || ""}`, mountPath, "-o", options.join(",")];
|
||||||
if (config.password) args.push("-o", "password_stdin");
|
if (config.password) args.push("-o", "password_stdin");
|
||||||
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
|
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
|
||||||
await runSshfs(args, config.password);
|
|
||||||
|
try {
|
||||||
|
await runSshfs(args, config.password);
|
||||||
|
} catch (sshfsError) {
|
||||||
|
// On some Docker bind-mount filesystems (e.g. Synology NAS) the chmod call
|
||||||
|
// inside writeFileWithMode may not have taken effect due to ACL inheritance.
|
||||||
|
// SSH refuses to use key files whose permissions are too open, so check and
|
||||||
|
// re-apply 0o600 on all sensitive files written above, then retry once.
|
||||||
|
const [keyFixed, knownHostsFixed] = await Promise.all([
|
||||||
|
config.privateKey
|
||||||
|
? ensureFileMode(getPrivateKeyPath(mountPath), FILE_MODES.ownerReadWrite)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
config.knownHosts
|
||||||
|
? ensureFileMode(getKnownHostsPath(mountPath), FILE_MODES.ownerReadWrite)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (keyFixed || knownHostsFixed) {
|
||||||
|
logger.warn("SFTP mount failed and key file permissions were incorrect; permissions have been fixed. Retrying mount...");
|
||||||
|
await runSshfs(args, config.password);
|
||||||
|
} else {
|
||||||
|
throw sshfsError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`SFTP volume at ${mountPath} mounted successfully.`);
|
logger.info(`SFTP volume at ${mountPath} mounted successfully.`);
|
||||||
return { status: "mounted" as const };
|
return { status: "mounted" as const };
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { FILE_MODES, writeFileWithMode } from "../fs";
|
import { FILE_MODES, ensureFileMode, writeFileWithMode } from "../fs";
|
||||||
|
|
||||||
const tempDirectories = new Set<string>();
|
const tempDirectories = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -15,7 +15,57 @@ afterEach(async () => {
|
||||||
tempDirectories.clear();
|
tempDirectories.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ensureFileMode", () => {
|
||||||
|
test("returns false and leaves the file unchanged when mode is already correct", async () => {
|
||||||
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-"));
|
||||||
|
tempDirectories.add(tempDirectory);
|
||||||
|
|
||||||
|
const filePath = path.join(tempDirectory, "identity");
|
||||||
|
await fs.writeFile(filePath, "content");
|
||||||
|
await fs.chmod(filePath, 0o600);
|
||||||
|
|
||||||
|
const fixed = await ensureFileMode(filePath, FILE_MODES.ownerReadWrite);
|
||||||
|
|
||||||
|
expect(fixed).toBe(false);
|
||||||
|
expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true and applies the correct mode when permissions are wrong", async () => {
|
||||||
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-"));
|
||||||
|
tempDirectories.add(tempDirectory);
|
||||||
|
|
||||||
|
const filePath = path.join(tempDirectory, "identity");
|
||||||
|
await fs.writeFile(filePath, "content");
|
||||||
|
await fs.chmod(filePath, 0o755);
|
||||||
|
|
||||||
|
const fixed = await ensureFileMode(filePath, FILE_MODES.ownerReadWrite);
|
||||||
|
|
||||||
|
expect(fixed).toBe(true);
|
||||||
|
expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when the file does not exist", async () => {
|
||||||
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-"));
|
||||||
|
tempDirectories.add(tempDirectory);
|
||||||
|
|
||||||
|
const fixed = await ensureFileMode(path.join(tempDirectory, "nonexistent.key"), FILE_MODES.ownerReadWrite);
|
||||||
|
expect(fixed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("writeFileWithMode", () => {
|
describe("writeFileWithMode", () => {
|
||||||
|
test("applies the requested mode when creating a new file", async () => {
|
||||||
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-"));
|
||||||
|
tempDirectories.add(tempDirectory);
|
||||||
|
|
||||||
|
const filePath = path.join(tempDirectory, "identity");
|
||||||
|
|
||||||
|
await writeFileWithMode(filePath, "content", FILE_MODES.ownerReadWrite);
|
||||||
|
|
||||||
|
expect(await fs.readFile(filePath, "utf8")).toBe("content");
|
||||||
|
expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
test("applies the requested mode even when rewriting an existing file", async () => {
|
test("applies the requested mode even when rewriting an existing file", async () => {
|
||||||
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-"));
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-"));
|
||||||
tempDirectories.add(tempDirectory);
|
tempDirectories.add(tempDirectory);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,32 @@ export const FILE_MODES = {
|
||||||
|
|
||||||
type FileMode = (typeof FILE_MODES)[keyof typeof FILE_MODES];
|
type FileMode = (typeof FILE_MODES)[keyof typeof FILE_MODES];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an existing file has the expected mode and, if not, applies chmod.
|
||||||
|
* Returns true when the mode was corrected, false when the file already had the
|
||||||
|
* correct mode or does not exist.
|
||||||
|
*/
|
||||||
|
export const ensureFileMode = async (filePath: string, mode: FileMode): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if ((stat.mode & 0o777) !== mode) {
|
||||||
|
await fs.chmod(filePath, mode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File does not exist or cannot be stat'd — nothing to fix.
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
export const writeFileWithMode = async (filePath: string, data: string, mode: FileMode) => {
|
export const writeFileWithMode = async (filePath: string, data: string, mode: FileMode) => {
|
||||||
|
// Remove any existing file first so the mode option on writeFile is always applied
|
||||||
|
// on a fresh file creation (mode is ignored for existing files). This also avoids
|
||||||
|
// inheriting incorrect permissions from a previously-created file on filesystems
|
||||||
|
// where chmod may not behave as expected (e.g. Docker bind-mounts on some NAS systems).
|
||||||
|
await fs.unlink(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
});
|
||||||
await fs.writeFile(filePath, data, { mode });
|
await fs.writeFile(filePath, data, { mode });
|
||||||
await fs.chmod(filePath, mode);
|
await fs.chmod(filePath, mode);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@ export { safeSpawn, safeExec } from "./spawn.js";
|
||||||
export type { SafeSpawnParams, SafeSpawnParamsLines, SafeSpawnParamsRaw, SpawnResult } from "./spawn.js";
|
export type { SafeSpawnParams, SafeSpawnParamsLines, SafeSpawnParamsRaw, SpawnResult } from "./spawn.js";
|
||||||
export { logger } from "./logger.js";
|
export { logger } from "./logger.js";
|
||||||
export { sanitizeSensitiveData } from "../utils/sanitize.js";
|
export { sanitizeSensitiveData } from "../utils/sanitize.js";
|
||||||
export { FILE_MODES, writeFileWithMode } from "./fs.js";
|
export { FILE_MODES, writeFileWithMode, ensureFileMode } from "./fs.js";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue