Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ describe("isWithinDirectory", () => {
isWithinDirectory("/home/user/docs/../other/file.md", "/home/user/docs"),
).toBe(false);
});

test("returns false for sibling directory with shared prefix", () => {
expect(
isWithinDirectory("/home/user/docs-evil/file.md", "/home/user/docs"),
).toBe(false);
});
});

describe("validateRepoUrl", () => {
Expand Down Expand Up @@ -351,4 +357,56 @@ describe("getAllowedPaths / assertPathAllowed", () => {
assertPathAllowed("/tmp/allowed/../etc/passwd"),
).toThrow("outside the allowed directories");
});

test("assertPathAllowed rejects sibling directory with shared prefix", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mdfy-prefix-"));
try {
const allowed = path.join(tmp, "share");
const evilSibling = path.join(tmp, "share-evil");
fs.mkdirSync(allowed);
fs.mkdirSync(evilSibling);
const secret = path.join(evilSibling, "secret.txt");
fs.writeFileSync(secret, "top secret");
process.env.MD_ALLOWED_PATHS = allowed;
expect(() => assertPathAllowed(secret)).toThrow(
"outside the allowed directories",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

test("assertPathAllowed rejects symlink inside allowed dir that escapes", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mdfy-symlink-"));
try {
const allowed = path.join(tmp, "allowed");
const outside = path.join(tmp, "outside");
fs.mkdirSync(allowed);
fs.mkdirSync(outside);
const secret = path.join(outside, "secret.txt");
fs.writeFileSync(secret, "top secret");
const link = path.join(allowed, "link.txt");
fs.symlinkSync(secret, link);
process.env.MD_ALLOWED_PATHS = allowed;
expect(() => assertPathAllowed(link)).toThrow(
"outside the allowed directories",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

test("assertPathAllowed permits regular file inside allowed dir after realpath", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mdfy-happy-"));
try {
const allowed = path.join(tmp, "allowed");
fs.mkdirSync(allowed);
const file = path.join(allowed, "doc.pdf");
fs.writeFileSync(file, "%PDF-1.4");
process.env.MD_ALLOWED_PATHS = allowed;
expect(() => assertPathAllowed(file)).not.toThrow();
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
31 changes: 25 additions & 6 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,34 @@ export function getAllowedPaths(): string[] | null {
return dirs.length > 0 ? dirs : null;
}

function realpathOrAncestor(p: string): string {
let current = path.resolve(p);
const suffix: string[] = [];
while (true) {
try {
const realCurrent = fs.realpathSync.native(current);
return suffix.length === 0
? realCurrent
: path.join(realCurrent, ...suffix.slice().reverse());
} catch {
const parent = path.dirname(current);
if (parent === current) return path.resolve(p);
suffix.push(path.basename(current));
current = parent;
}
}
}

export function assertPathAllowed(filePath: string): void {
const allowed = getAllowedPaths();
if (!allowed) return;
const resolved = path.normalize(path.resolve(expandHome(filePath)));
if (!allowed.some((dir) => isWithinDirectory(resolved, dir))) {
const resolved = realpathOrAncestor(expandHome(filePath));
const allowedReal = allowed.map(realpathOrAncestor);
if (!allowedReal.some((dir) => isWithinDirectory(resolved, dir))) {
throw new Error(
`Path "${filePath}" is outside the allowed directories. ` +
`Set MD_ALLOWED_PATHS to a ${path.delimiter}-separated list that includes a parent directory ` +
`(currently allowed: ${allowed.join(path.delimiter)}).`,
`(currently allowed: ${allowedReal.join(path.delimiter)}).`,
);
}
}
Expand Down Expand Up @@ -104,7 +123,7 @@ export function isMarkdownFile(filePath: string): boolean {
}

export function isWithinDirectory(filePath: string, directory: string): boolean {
const normPath = path.normalize(path.resolve(filePath));
const normDir = path.normalize(path.resolve(directory));
return normPath.startsWith(normDir);
const rel = path.relative(path.resolve(directory), path.resolve(filePath));
if (rel === "") return true;
return !rel.startsWith("..") && !path.isAbsolute(rel);
}