fix(dump): content-disposition header with special characters

This commit is contained in:
Nicolas Meienberger 2026-03-12 19:17:32 +01:00
parent 1fb4f90d50
commit 469f0d6c4e
6 changed files with 76 additions and 4 deletions

View file

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

View file

@ -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 () => {

View file

@ -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",

View file

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

View file

@ -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",
},
});

View file

@ -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({