fix(dump): content-disposition header with special characters
This commit is contained in:
parent
1fb4f90d50
commit
469f0d6c4e
6 changed files with 76 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue