fix: force user to provide known hosts values in sftp volume / repository creation
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled

This commit is contained in:
Nicolas Meienberger 2026-03-31 20:23:45 +02:00
parent aa7eaa0929
commit 3acf565ccd
7 changed files with 49 additions and 25 deletions

View file

@ -52,16 +52,26 @@ const formBaseFields = {
compressionMode: z.enum(COMPRESSION_MODES).optional(), compressionMode: z.enum(COMPRESSION_MODES).optional(),
}; };
export const formSchema = z.discriminatedUnion("backend", [ export const formSchema = z
localRepositoryConfigSchema.extend(formBaseFields), .discriminatedUnion("backend", [
s3RepositoryConfigSchema.extend(formBaseFields), localRepositoryConfigSchema.extend(formBaseFields),
r2RepositoryConfigSchema.extend(formBaseFields), s3RepositoryConfigSchema.extend(formBaseFields),
gcsRepositoryConfigSchema.extend(formBaseFields), r2RepositoryConfigSchema.extend(formBaseFields),
azureRepositoryConfigSchema.extend(formBaseFields), gcsRepositoryConfigSchema.extend(formBaseFields),
rcloneRepositoryConfigSchema.extend(formBaseFields), azureRepositoryConfigSchema.extend(formBaseFields),
restRepositoryConfigSchema.extend(formBaseFields), rcloneRepositoryConfigSchema.extend(formBaseFields),
sftpRepositoryConfigSchema.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<typeof formSchema>; export type RepositoryFormValues = z.input<typeof formSchema>;

View file

@ -32,14 +32,24 @@ import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error"; import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms"; import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = z.discriminatedUnion("backend", [ export const formSchema = z
directoryConfigSchema.extend({ name: z.string().min(2).max(32) }), .discriminatedUnion("backend", [
nfsConfigSchema.extend({ name: z.string().min(2).max(32) }), directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
smbConfigSchema.extend({ name: z.string().min(2).max(32) }), nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
webdavConfigSchema.extend({ name: z.string().min(2).max(32) }), smbConfigSchema.extend({ name: z.string().min(2).max(32) }),
rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }), webdavConfigSchema.extend({ name: z.string().min(2).max(32) }),
sftpConfigSchema.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<typeof formSchema>; export type FormValues = z.input<typeof formSchema>;

View file

@ -82,7 +82,7 @@ export const sftpConfigSchema = z.object({
privateKey: z.string().optional(), privateKey: z.string().optional(),
path: z.string().min(1), path: z.string().min(1),
readOnly: z.boolean().optional(), readOnly: z.boolean().optional(),
skipHostKeyCheck: z.boolean().default(true), skipHostKeyCheck: z.boolean().default(false),
knownHosts: z.string().optional(), knownHosts: z.string().optional(),
}); });

View file

@ -60,12 +60,14 @@ const mount = async (config: BackendConfig, mountPath: string) => {
`gid=${gid}`, `gid=${gid}`,
]; ];
if (config.skipHostKeyCheck || !config.knownHosts) { if (config.skipHostKeyCheck) {
options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null"); options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) { } else if (config.knownHosts) {
const knownHostsPath = getKnownHostsPath(mountPath); const knownHostsPath = getKnownHostsPath(mountPath);
await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite);
options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes"); options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes");
} else {
options.push("StrictHostKeyChecking=yes");
} }
if (config.readOnly) { if (config.readOnly) {

View file

@ -317,11 +317,11 @@ describe("buildEnv", () => {
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); 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"); 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("StrictHostKeyChecking=yes");
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); expect(env._SFTP_SSH_ARGS).not.toContain("UserKnownHostsFile=/dev/null");
}); });
test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => { test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => {

View file

@ -114,13 +114,15 @@ export const buildEnv = async (
keyPath, keyPath,
]; ];
if (config.skipHostKeyCheck || !config.knownHosts) { if (config.skipHostKeyCheck) {
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) { } else if (config.knownHosts) {
const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`); const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`);
await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite);
env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath;
sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`);
} else {
sshArgs.push("-o", "StrictHostKeyChecking=yes");
} }
if (config.port && config.port !== 22) { if (config.port && config.port !== 22) {

View file

@ -116,7 +116,7 @@ export const sftpRepositoryConfigSchema = z
user: z.string().min(1), user: z.string().min(1),
path: z.string().min(1), path: z.string().min(1),
privateKey: z.string().min(1), privateKey: z.string().min(1),
skipHostKeyCheck: z.boolean().default(true), skipHostKeyCheck: z.boolean().default(false),
knownHosts: z.string().optional(), knownHosts: z.string().optional(),
}) })
.extend(baseRepositoryConfigSchema.shape); .extend(baseRepositoryConfigSchema.shape);