From 469f0d6c4e6d8d3466a609e115900bdc43ff863b Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 12 Mar 2026 19:17:32 +0100 Subject: [PATCH] fix(dump): content-disposition header with special characters --- README.md | 2 ++ .../__tests__/repositories.controller.test.ts | 32 +++++++++++++++++++ .../__tests__/repositories.service.test.ts | 30 +++++++++++++++-- .../modules/repositories/helpers/dump.ts | 9 ++++++ .../repositories/repositories.controller.ts | 5 ++- packages/core/src/restic/restic-dto.ts | 2 +- 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cdbdaac1..71d72777 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,8 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.): - The `BASE_URL` must start with `https://` for secure cookies to be enabled - Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically +- `TRUSTED_ORIGINS` only allows additional origins for auth-related requests. It does not disable secure cookies or make authenticated HTTP access work when `BASE_URL` is HTTPS. +- If `BASE_URL` is HTTPS, browsers will only send Zerobyte's auth cookies over HTTPS. Plain HTTP access may still show the login page, but authenticated flows will fail because no session cookie is available. ## Troubleshooting diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 25655f49..ed8d0b75 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -384,6 +384,38 @@ describe("repositories updates", () => { dumpSnapshotSpy.mockRestore(); } }); + + test("returns a valid content-disposition header for non-ascii filenames", async () => { + const { headers, organizationId } = await createTestSession(); + const repository = await createRepositoryRecord(organizationId); + const { repositoriesService } = await import("~/server/modules/repositories/repositories.service"); + + const stream = new PassThrough(); + const dumpSnapshotSpy = spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({ + stream, + completion: Promise.resolve(), + abort: () => { + stream.destroy(new Error("download aborted")); + }, + filename: "möte.txt", + contentType: "application/octet-stream", + }); + + try { + stream.end("downloaded snapshot contents"); + + const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { + headers, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Disposition")).toBe( + `attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`, + ); + } finally { + dumpSnapshotSpy.mockRestore(); + } + }); }); test("GET marks provisioned repositories as managed", async () => { diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index cf0d67ff..7d553c58 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -203,7 +203,15 @@ describe("repositoriesService.dumpSnapshot", () => { abort: () => {}, }); - const setupDumpSnapshotScenario = async ({ snapshotId, basePath }: { snapshotId: string; basePath: string }) => { + const setupDumpSnapshotScenario = async ({ + snapshotId, + basePath, + snapshotPaths, + }: { + snapshotId: string; + basePath: string; + snapshotPaths?: string[]; + }) => { const { organizationId, user } = await createTestSession(); const shortId = generateShortId(); @@ -226,7 +234,7 @@ describe("repositoriesService.dumpSnapshot", () => { id: snapshotId, short_id: snapshotId, time: new Date().toISOString(), - paths: [basePath], + paths: snapshotPaths ?? [basePath], hostname: "host", }, ]); @@ -295,6 +303,24 @@ describe("repositoriesService.dumpSnapshot", () => { expect(result.contentType).toBe("application/octet-stream"); }); + test("downloads a selected parent directory when snapshot paths point to a nested file", async () => { + const parentPath = "/var/lib/zerobyte/volumes/vol123/_data/documents"; + const { organizationId, userId, shortId, dumpMock } = await setupDumpSnapshotScenario({ + snapshotId: "snapshot-parent-dir", + basePath: `${parentPath}/report.txt`, + snapshotPaths: [`${parentPath}/report.txt`], + }); + + await withContext({ organizationId, userId }, () => + repositoriesService.dumpSnapshot(shortId, "snapshot-parent-dir", parentPath, "dir"), + ); + + expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-parent-dir:${parentPath}`, { + organizationId, + path: "/", + }); + }); + test("rejects path downloads without a kind", async () => { const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({ snapshotId: "snapshot-no-kind", diff --git a/app/server/modules/repositories/helpers/dump.ts b/app/server/modules/repositories/helpers/dump.ts index 5e8caa78..66752ce6 100644 --- a/app/server/modules/repositories/helpers/dump.ts +++ b/app/server/modules/repositories/helpers/dump.ts @@ -34,6 +34,15 @@ export const prepareSnapshotDump = (params: { }; } + const relativeFromRequested = path.posix.relative(normalizedRequestedPath, basePath); + if (relativeFromRequested !== ".." && !relativeFromRequested.startsWith("../")) { + return { + snapshotRef: `${snapshotId}:${normalizedRequestedPath}`, + path: "/", + filename: archiveFilename, + }; + } + const relativeFromBase = path.posix.relative(basePath, normalizedRequestedPath); if (relativeFromBase === ".." || relativeFromBase.startsWith("../")) { throw new BadRequestError("Requested path is outside the snapshot base path"); diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 0da2630b..efa4881a 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -217,12 +217,15 @@ export const repositoriesController = new Hono() await reader.cancel(reason).catch(() => {}); }, }); + const filename = dumpStream.filename || "snapshot.tar"; return new Response(webStream, { status: 200, headers: { "Content-Type": dumpStream.contentType, - "Content-Disposition": contentDisposition(dumpStream.filename || "snapshot.tar"), + "Content-Disposition": contentDisposition(filename, { + fallback: filename.replace(/[^\x20-\x7E]/g, "?"), + }), "X-Content-Type-Options": "nosniff", }, }); diff --git a/packages/core/src/restic/restic-dto.ts b/packages/core/src/restic/restic-dto.ts index a5043966..fd60c5ac 100644 --- a/packages/core/src/restic/restic-dto.ts +++ b/packages/core/src/restic/restic-dto.ts @@ -51,7 +51,7 @@ export const resticRestoreOutputSchema = z.object({ files_skipped: z.number(), total_bytes: z.number().optional(), bytes_restored: z.number().optional(), - bytes_skipped: z.number(), + bytes_skipped: z.number().optional(), }); export const resticStatsSchema = z.object({