diff --git a/README.md b/README.md index 4510932f..3022566f 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ services: devices: - /dev/fuse:/dev/fuse environment: - - TZ=Europe/Paris # Set your timezone here + - TZ=Europe/Paris # Set your timezone here volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -83,7 +83,7 @@ services: ports: - "4096:4096" environment: - - TZ=Europe/Paris # Set your timezone here + - TZ=Europe/Paris # Set your timezone here volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -91,6 +91,7 @@ services: ``` **Trade-offs:** + - ✅ Improved security by reducing container capabilities - ✅ Support for local directories - ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index e88a22ff..21bd6c5e 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -3364,7 +3364,6 @@ export type ExportFullConfigResponses = { createdAt?: number; hasDownloadedResticPassword?: boolean; id?: number; - passwordHash?: string; updatedAt?: number; }>; volumes?: Array; diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index 5ce3d51e..60250ac3 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -41,6 +41,7 @@ describe("system security", () => { const endpoints: { method: string; path: string }[] = [ { method: "GET", path: "/api/v1/system/info" }, { method: "POST", path: "/api/v1/system/restic-password" }, + { method: "POST", path: "/api/v1/system/export" }, ]; for (const { method, path } of endpoints) { @@ -86,4 +87,60 @@ describe("system security", () => { expect(body.message).toBe("Incorrect password"); }); }); + + test("should return 400 for invalid payload on full export", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/system/export", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); + + test("should return 401 for incorrect password on full export", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/system/export", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + includeMetadata: true, + password: "wrong-password", + }), + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Incorrect password"); + }); + + test("full export never exposes password hashes", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/system/export", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + includeMetadata: true, + password: "testpassword", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { users?: Array> }; + expect(Array.isArray(body.users)).toBe(true); + for (const user of body.users ?? []) { + expect(user.passwordHash).toBeUndefined(); + } + }); }); diff --git a/app/server/modules/system/system.dto.ts b/app/server/modules/system/system.dto.ts index 73cf5681..4732695e 100644 --- a/app/server/modules/system/system.dto.ts +++ b/app/server/modules/system/system.dto.ts @@ -98,7 +98,6 @@ const exportResponseSchema = type({ users: type({ id: "number?", username: "string", - passwordHash: "string?", createdAt: "number?", updatedAt: "number?", hasDownloadedResticPassword: "boolean?", diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index a024b72c..ad9396e6 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -187,13 +187,19 @@ const exportConfig = async (params: ExportParams) => { const backupSchedules = transformBackupSchedules(backupSchedulesRaw, scheduleNotifications, scheduleMirrors, params); - const [exportVolumes, exportRepositories, exportNotifications, exportUsers] = await Promise.all([ + const [exportVolumes, exportRepositories, exportNotifications, exportedUsersWithHash] = await Promise.all([ exportEntities(volumes, params), exportEntities(repositories, params), exportEntities(notifications, params), exportEntities(users, params), ]); + const exportUsers = exportedUsersWithHash.map((user) => { + const sanitizedUser = { ...user }; + delete sanitizedUser.passwordHash; + return sanitizedUser; + }); + return { version: 1, exportedAt: new Date().toISOString(),