check / check (push) Successful in 37s
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
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
// File names built from server-supplied metadata.
|
|
//
|
|
// quak does not trust the server. A file's title and a collection's name are
|
|
// decrypted from data the server hands us, and a hostile server (or a
|
|
// compromised account) can set them to anything. quak uses them to name files
|
|
// on disk: `quak get` without `--out`, `downloadFile` without `outPath`, the
|
|
// backup's symlink and collection directories, and the extension of every file
|
|
// in the originals cache. Each of those goes through `sanitizeFileName` or
|
|
// `safeExtension`, so a title can only ever name one file inside the directory
|
|
// the caller chose.
|
|
//
|
|
// A path the user supplies (`--out`, `outPath`) is never sanitized: the caller
|
|
// is trusted, the server is not.
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { safeExtension, sanitizeFileName } from "../../src/filename.js";
|
|
|
|
const FALLBACK = "file-42";
|
|
|
|
describe("sanitizeFileName", () => {
|
|
it("passes a normal title through unchanged", () => {
|
|
expect(sanitizeFileName("IMG_0001.HEIC", FALLBACK)).toBe(
|
|
"IMG_0001.HEIC",
|
|
);
|
|
expect(sanitizeFileName("Holiday 2024 (1).jpg", FALLBACK)).toBe(
|
|
"Holiday 2024 (1).jpg",
|
|
);
|
|
expect(sanitizeFileName("café.jpg", FALLBACK)).toBe("café.jpg");
|
|
});
|
|
|
|
it("cannot climb out of the directory with ../", () => {
|
|
// Without sanitizing, this would overwrite the user's SSH keys.
|
|
expect(sanitizeFileName("../../.ssh/authorized_keys", FALLBACK)).toBe(
|
|
"__.._.ssh_authorized_keys",
|
|
);
|
|
expect(sanitizeFileName("..", FALLBACK)).toBe("_");
|
|
expect(sanitizeFileName("..\\..\\x", FALLBACK)).toBe("__.._x");
|
|
});
|
|
|
|
it("cannot name an absolute path", () => {
|
|
expect(sanitizeFileName("/etc/passwd", FALLBACK)).toBe("_etc_passwd");
|
|
expect(sanitizeFileName("C:\\Windows\\x.dll", FALLBACK)).toBe(
|
|
"C__Windows_x.dll",
|
|
);
|
|
});
|
|
|
|
it("replaces embedded separators, so the name stays one file", () => {
|
|
expect(sanitizeFileName("a/b\\c.jpg", FALLBACK)).toBe("a_b_c.jpg");
|
|
});
|
|
|
|
it("replaces NUL and other control characters", () => {
|
|
// A NUL truncates the path in C code and makes Node's fs throw.
|
|
expect(sanitizeFileName("evil\0.jpg", FALLBACK)).toBe("evil_.jpg");
|
|
expect(sanitizeFileName("line\nbreak\x7f.jpg", FALLBACK)).toBe(
|
|
"line_break_.jpg",
|
|
);
|
|
});
|
|
|
|
it("does not produce a hidden file", () => {
|
|
expect(sanitizeFileName(".bashrc", FALLBACK)).toBe("_bashrc");
|
|
});
|
|
|
|
it("does not produce a Windows device name", () => {
|
|
expect(sanitizeFileName("CON", FALLBACK)).toBe("_CON");
|
|
expect(sanitizeFileName("nul.txt", FALLBACK)).toBe("_nul.txt");
|
|
expect(sanitizeFileName("LPT1", FALLBACK)).toBe("_LPT1");
|
|
// Only the exact names are reserved.
|
|
expect(sanitizeFileName("console.jpg", FALLBACK)).toBe("console.jpg");
|
|
});
|
|
|
|
it("falls back to the given name for an empty title", () => {
|
|
expect(sanitizeFileName("", FALLBACK)).toBe(FALLBACK);
|
|
});
|
|
});
|
|
|
|
describe("safeExtension", () => {
|
|
it("keeps a normal extension", () => {
|
|
expect(safeExtension("IMG_0001.HEIC")).toBe(".HEIC");
|
|
expect(safeExtension("clip.mp4")).toBe(".mp4");
|
|
});
|
|
|
|
it("uses .bin when there is no extension", () => {
|
|
expect(safeExtension("")).toBe(".bin");
|
|
expect(safeExtension("README")).toBe(".bin");
|
|
});
|
|
|
|
it("uses .bin when the extension holds anything but letters and digits", () => {
|
|
expect(safeExtension("x.j\\..\\pg")).toBe(".bin");
|
|
expect(safeExtension("x.jp g")).toBe(".bin");
|
|
expect(safeExtension("x.jpg\0")).toBe(".bin");
|
|
});
|
|
});
|