refactor(config-export): do not include user's password hashes

This commit is contained in:
Nicolas Meienberger 2026-01-04 17:14:28 +01:00
parent 99ccfa4305
commit 1d49f59ebc
5 changed files with 67 additions and 5 deletions

View file

@ -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)

View file

@ -3364,7 +3364,6 @@ export type ExportFullConfigResponses = {
createdAt?: number;
hasDownloadedResticPassword?: boolean;
id?: number;
passwordHash?: string;
updatedAt?: number;
}>;
volumes?: Array<unknown>;

View file

@ -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<Record<string, unknown>> };
expect(Array.isArray(body.users)).toBe(true);
for (const user of body.users ?? []) {
expect(user.passwordHash).toBeUndefined();
}
});
});

View file

@ -98,7 +98,6 @@ const exportResponseSchema = type({
users: type({
id: "number?",
username: "string",
passwordHash: "string?",
createdAt: "number?",
updatedAt: "number?",
hasDownloadedResticPassword: "boolean?",

View file

@ -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(),