diff --git a/app/server/modules/backups/__tests__/backups.patterns.test.ts b/app/server/modules/backups/__tests__/backups.patterns.test.ts index 0e9694c0..a276a1bb 100644 --- a/app/server/modules/backups/__tests__/backups.patterns.test.ts +++ b/app/server/modules/backups/__tests__/backups.patterns.test.ts @@ -100,6 +100,21 @@ describe("executeBackup - include / exclude patterns", () => { expect(processPattern("!*.log", "/volume")).toBe("!*.log"); }); + test("rejects include patterns that escape the volume root", () => { + const volumePath = "/var/lib/zerobyte/volumes/vol123/_data"; + const signal = new AbortController().signal; + + expect(() => + createBackupOptions( + createSchedule({ + includePatterns: ["../../../../etc/shadow", "/../etc/passwd", "!/../../secrets.txt"], + }), + volumePath, + signal, + ), + ).toThrow("Include pattern escapes volume root"); + }); + test("anchors relative glob include patterns to the volume path", () => { const volumePath = "/var/lib/zerobyte/volumes/vol123/_data"; const schedule = createSchedule({ diff --git a/app/server/modules/backups/backup.helpers.ts b/app/server/modules/backups/backup.helpers.ts index edf99ac3..077e8e98 100644 --- a/app/server/modules/backups/backup.helpers.ts +++ b/app/server/modules/backups/backup.helpers.ts @@ -23,12 +23,26 @@ export const processPattern = (pattern: string, volumePath: string, relative = f const isNegated = pattern.startsWith("!"); const p = isNegated ? pattern.slice(1) : pattern; + const ensurePatternIsWithinVolume = (candidate: string) => { + const resolvedVolumePath = path.resolve(volumePath); + const resolvedCandidatePath = path.resolve(volumePath, candidate); + const relativePath = path.relative(resolvedVolumePath, resolvedCandidatePath); + + if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { + throw new Error(`Include pattern escapes volume root: ${pattern}`); + } + }; + if (!p.startsWith("/")) { if (!relative) return pattern; + ensurePatternIsWithinVolume(p); const processed = path.join(volumePath, p); return isNegated ? `!${processed}` : processed; } + if (relative) { + ensurePatternIsWithinVolume(p.slice(1)); + } const processed = path.join(volumePath, p.slice(1)); return isNegated ? `!${processed}` : processed; };