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
|
- 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
|
- 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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,38 @@ describe("repositories updates", () => {
|
||||||
dumpSnapshotSpy.mockRestore();
|
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 () => {
|
test("GET marks provisioned repositories as managed", async () => {
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,15 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
abort: () => {},
|
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 { organizationId, user } = await createTestSession();
|
||||||
const shortId = generateShortId();
|
const shortId = generateShortId();
|
||||||
|
|
||||||
|
|
@ -226,7 +234,7 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
id: snapshotId,
|
id: snapshotId,
|
||||||
short_id: snapshotId,
|
short_id: snapshotId,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
paths: [basePath],
|
paths: snapshotPaths ?? [basePath],
|
||||||
hostname: "host",
|
hostname: "host",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -295,6 +303,24 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
expect(result.contentType).toBe("application/octet-stream");
|
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 () => {
|
test("rejects path downloads without a kind", async () => {
|
||||||
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
||||||
snapshotId: "snapshot-no-kind",
|
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);
|
const relativeFromBase = path.posix.relative(basePath, normalizedRequestedPath);
|
||||||
if (relativeFromBase === ".." || relativeFromBase.startsWith("../")) {
|
if (relativeFromBase === ".." || relativeFromBase.startsWith("../")) {
|
||||||
throw new BadRequestError("Requested path is outside the snapshot base path");
|
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(() => {});
|
await reader.cancel(reason).catch(() => {});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const filename = dumpStream.filename || "snapshot.tar";
|
||||||
|
|
||||||
return new Response(webStream, {
|
return new Response(webStream, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": dumpStream.contentType,
|
"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",
|
"X-Content-Type-Options": "nosniff",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export const resticRestoreOutputSchema = z.object({
|
||||||
files_skipped: z.number(),
|
files_skipped: z.number(),
|
||||||
total_bytes: z.number().optional(),
|
total_bytes: z.number().optional(),
|
||||||
bytes_restored: z.number().optional(),
|
bytes_restored: z.number().optional(),
|
||||||
bytes_skipped: z.number(),
|
bytes_skipped: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticStatsSchema = z.object({
|
export const resticStatsSchema = z.object({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue