Sanitize file names taken from server metadata (closes #9)
check / check (push) Successful in 30s
check / check (push) Successful in 30s
A file title or album name decrypted from server data could name a path outside the chosen directory (`../../.ssh/authorized_keys`). One module, src/filename.ts, now makes such names safe for `quak get`/`get-thumb` without `--out`, downloadFile/downloadThumbnail without outPath, and the backup and metadata backup trees. Originals-cache extensions are limited to letters and digits. A user-supplied path is still used as is. decryptFile reads a missing or non-string title as "" and rejects metadata that is not a JSON object. Model: opus-5-5
This commit is contained in:
+53
-2
@@ -36,6 +36,7 @@ import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
@@ -107,6 +108,29 @@ class MockClient {
|
||||
}
|
||||
}
|
||||
|
||||
// A server that names an album and a file so as to climb out of the backup
|
||||
// directory.
|
||||
class HostileClient extends MockClient {
|
||||
override async collectionsSince(): Promise<CollectionsPage> {
|
||||
const page = await super.collectionsSince();
|
||||
return {
|
||||
...page,
|
||||
collections: page.collections.length
|
||||
? [collection(3, "../escape")]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
override async filesSince(args: {
|
||||
collectionID: number;
|
||||
}): Promise<FilesPage> {
|
||||
const files =
|
||||
args.collectionID === 3
|
||||
? [file(300, 3, "../../.ssh/authorized_keys")]
|
||||
: [];
|
||||
return { files, deleted: [], cursor: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// A content source that writes byte buffers of the expected length and can be
|
||||
// told to fail one fileID's original, to exercise per-file resilience.
|
||||
interface StubSource extends ContentSource {
|
||||
@@ -136,9 +160,12 @@ const stubSource = (): StubSource => {
|
||||
|
||||
let root: string;
|
||||
|
||||
const openLibrary = (source: ContentSource): Promise<Library> =>
|
||||
const openLibrary = (
|
||||
source: ContentSource,
|
||||
client: MockClient = new MockClient(),
|
||||
): Promise<Library> =>
|
||||
Library.open({
|
||||
client: new MockClient(),
|
||||
client,
|
||||
cacheDirectory: join(root, "cache"),
|
||||
contentSource: source,
|
||||
refreshIntervalSeconds: 3600,
|
||||
@@ -243,6 +270,30 @@ describe("lib.backup", () => {
|
||||
lib.close();
|
||||
});
|
||||
|
||||
it("keeps server-supplied album and file names inside the backup", async () => {
|
||||
const lib = await openLibrary(stubSource(), new HostileClient());
|
||||
const outDir = join(root, "backup");
|
||||
|
||||
const result = await lib.backup({ downloadDirectory: outDir });
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
// The title has no usable extension, so the original is `.bin`.
|
||||
expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true);
|
||||
const link = join(
|
||||
outDir,
|
||||
"collections",
|
||||
"__escape",
|
||||
"__.._.ssh_authorized_keys",
|
||||
);
|
||||
expect(lstatSync(link).isSymbolicLink()).toBe(true);
|
||||
expect(existsSync(join(outDir, "collections", "__escape.json"))).toBe(
|
||||
true,
|
||||
);
|
||||
// Nothing landed beside or above the backup directory.
|
||||
expect(readdirSync(root).sort()).toEqual(["backup", "cache"]);
|
||||
lib.close();
|
||||
});
|
||||
|
||||
it("is an idempotent no-op when every original is already present", async () => {
|
||||
const source = stubSource();
|
||||
const lib = await openLibrary(source);
|
||||
|
||||
Reference in New Issue
Block a user