From 3acf565ccd9b76108a816c7df0921f68c386d9a7 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 31 Mar 2026 20:23:45 +0200 Subject: [PATCH] fix: force user to provide known hosts values in sftp volume / repository creation --- .../components/create-repository-form.tsx | 30 ++++++++++++------- .../volumes/components/create-volume-form.tsx | 26 +++++++++++----- app/schemas/volumes.ts | 2 +- .../modules/backends/sftp/sftp-backend.ts | 4 ++- .../helpers/__tests__/build-env.test.ts | 6 ++-- packages/core/src/restic/helpers/build-env.ts | 4 ++- packages/core/src/restic/schemas.ts | 2 +- 7 files changed, 49 insertions(+), 25 deletions(-) diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index a8ce3c35..da67ee25 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -52,16 +52,26 @@ const formBaseFields = { compressionMode: z.enum(COMPRESSION_MODES).optional(), }; -export const formSchema = z.discriminatedUnion("backend", [ - localRepositoryConfigSchema.extend(formBaseFields), - s3RepositoryConfigSchema.extend(formBaseFields), - r2RepositoryConfigSchema.extend(formBaseFields), - gcsRepositoryConfigSchema.extend(formBaseFields), - azureRepositoryConfigSchema.extend(formBaseFields), - rcloneRepositoryConfigSchema.extend(formBaseFields), - restRepositoryConfigSchema.extend(formBaseFields), - sftpRepositoryConfigSchema.extend(formBaseFields), -]); +export const formSchema = z + .discriminatedUnion("backend", [ + localRepositoryConfigSchema.extend(formBaseFields), + s3RepositoryConfigSchema.extend(formBaseFields), + r2RepositoryConfigSchema.extend(formBaseFields), + gcsRepositoryConfigSchema.extend(formBaseFields), + azureRepositoryConfigSchema.extend(formBaseFields), + rcloneRepositoryConfigSchema.extend(formBaseFields), + restRepositoryConfigSchema.extend(formBaseFields), + sftpRepositoryConfigSchema.extend(formBaseFields), + ]) + .superRefine((value, ctx) => { + if (value.backend === "sftp" && !value.skipHostKeyCheck && !value.knownHosts?.trim()) { + ctx.addIssue({ + code: "custom", + message: "Known hosts are required unless host key verification is skipped", + path: ["knownHosts"], + }); + } + }); export type RepositoryFormValues = z.input; diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx index 0b1d51f9..ba48e117 100644 --- a/app/client/modules/volumes/components/create-volume-form.tsx +++ b/app/client/modules/volumes/components/create-volume-form.tsx @@ -32,14 +32,24 @@ import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms"; -export const formSchema = z.discriminatedUnion("backend", [ - directoryConfigSchema.extend({ name: z.string().min(2).max(32) }), - nfsConfigSchema.extend({ name: z.string().min(2).max(32) }), - smbConfigSchema.extend({ name: z.string().min(2).max(32) }), - webdavConfigSchema.extend({ name: z.string().min(2).max(32) }), - rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }), - sftpConfigSchema.extend({ name: z.string().min(2).max(32) }), -]); +export const formSchema = z + .discriminatedUnion("backend", [ + directoryConfigSchema.extend({ name: z.string().min(2).max(32) }), + nfsConfigSchema.extend({ name: z.string().min(2).max(32) }), + smbConfigSchema.extend({ name: z.string().min(2).max(32) }), + webdavConfigSchema.extend({ name: z.string().min(2).max(32) }), + rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }), + sftpConfigSchema.extend({ name: z.string().min(2).max(32) }), + ]) + .superRefine((value, ctx) => { + if (value.backend === "sftp" && !value.skipHostKeyCheck && !value.knownHosts?.trim()) { + ctx.addIssue({ + code: "custom", + message: "Known hosts are required unless host key verification is skipped", + path: ["knownHosts"], + }); + } + }); export type FormValues = z.input; diff --git a/app/schemas/volumes.ts b/app/schemas/volumes.ts index cec43538..60946f63 100644 --- a/app/schemas/volumes.ts +++ b/app/schemas/volumes.ts @@ -82,7 +82,7 @@ export const sftpConfigSchema = z.object({ privateKey: z.string().optional(), path: z.string().min(1), readOnly: z.boolean().optional(), - skipHostKeyCheck: z.boolean().default(true), + skipHostKeyCheck: z.boolean().default(false), knownHosts: z.string().optional(), }); diff --git a/app/server/modules/backends/sftp/sftp-backend.ts b/app/server/modules/backends/sftp/sftp-backend.ts index bf553b14..93e87c5b 100644 --- a/app/server/modules/backends/sftp/sftp-backend.ts +++ b/app/server/modules/backends/sftp/sftp-backend.ts @@ -60,12 +60,14 @@ const mount = async (config: BackendConfig, mountPath: string) => { `gid=${gid}`, ]; - if (config.skipHostKeyCheck || !config.knownHosts) { + if (config.skipHostKeyCheck) { options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null"); } else if (config.knownHosts) { const knownHostsPath = getKnownHostsPath(mountPath); await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes"); + } else { + options.push("StrictHostKeyChecking=yes"); } if (config.readOnly) { diff --git a/packages/core/src/restic/helpers/__tests__/build-env.test.ts b/packages/core/src/restic/helpers/__tests__/build-env.test.ts index d5bc857d..e54afcfd 100644 --- a/packages/core/src/restic/helpers/__tests__/build-env.test.ts +++ b/packages/core/src/restic/helpers/__tests__/build-env.test.ts @@ -317,11 +317,11 @@ describe("buildEnv", () => { expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); }); - test("uses StrictHostKeyChecking=no when knownHosts is absent", async () => { + test("uses StrictHostKeyChecking=yes when knownHosts is absent", async () => { const env = await buildEnvForTest({ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, "org-1"); - expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=no"); - expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); + expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=yes"); + expect(env._SFTP_SSH_ARGS).not.toContain("UserKnownHostsFile=/dev/null"); }); test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => { diff --git a/packages/core/src/restic/helpers/build-env.ts b/packages/core/src/restic/helpers/build-env.ts index 9f50adc1..ae6ed50e 100644 --- a/packages/core/src/restic/helpers/build-env.ts +++ b/packages/core/src/restic/helpers/build-env.ts @@ -114,13 +114,15 @@ export const buildEnv = async ( keyPath, ]; - if (config.skipHostKeyCheck || !config.knownHosts) { + if (config.skipHostKeyCheck) { sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); } else if (config.knownHosts) { const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`); await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); + } else { + sshArgs.push("-o", "StrictHostKeyChecking=yes"); } if (config.port && config.port !== 22) { diff --git a/packages/core/src/restic/schemas.ts b/packages/core/src/restic/schemas.ts index ee73f05a..c38dfc1f 100644 --- a/packages/core/src/restic/schemas.ts +++ b/packages/core/src/restic/schemas.ts @@ -116,7 +116,7 @@ export const sftpRepositoryConfigSchema = z user: z.string().min(1), path: z.string().min(1), privateKey: z.string().min(1), - skipHostKeyCheck: z.boolean().default(true), + skipHostKeyCheck: z.boolean().default(false), knownHosts: z.string().optional(), }) .extend(baseRepositoryConfigSchema.shape);