fix(file-transfer): denyPaths does not deny listing the denied directory itself (#109233)

* fix(file-transfer): deny listing the directory a denyPaths rule names

A denyPaths entry like "**/.ssh/**" did not deny dir.list on the denied
directory itself, so listing /home/me/.ssh returned its full contents,
including filenames such as id_rsa and authorized_keys. Only paths strictly
under the directory were denied.

The deny gate matched the requested path against the globs as a plain string,
and a trailing /** requires something after the separator, so the bare
directory never matched. Every command routes its path through
evaluateFilePolicy, but dir.list is where it is observable, since its payload
is the directory's own contents. dir.fetch was incidentally safe because it
rechecks each returned entry, and those entries do match; dir.list has no
such recheck.

Match the deny globs against the directory form of the path at that one gate.
The check stays deny-side only, so it can fail closed but never open.

* test(file-transfer): cover denied directory path forms

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Yuval Dinodia
2026-07-16 22:22:01 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 44b79d44ae
commit f6582dfb5e
2 changed files with 42 additions and 1 deletions
@@ -201,6 +201,40 @@ describe("evaluateFilePolicy — denyPaths always wins", () => {
);
});
it.each([
{
label: "the bare denied directory",
requestedPath: path.join(os.homedir(), ".ssh"),
expected: { ok: false, code: "POLICY_DENIED", askable: false },
},
{
label: "the denied directory with a trailing separator",
requestedPath: `${path.join(os.homedir(), ".ssh")}/`,
expected: { ok: false, code: "POLICY_DENIED", askable: false },
},
{
label: "the bare denied directory on Windows",
requestedPath: "C:\\Users\\me\\.ssh",
expected: { ok: false, code: "POLICY_DENIED", askable: false },
},
{
label: "a sibling sharing only the denied directory prefix",
requestedPath: path.join(os.homedir(), ".sshrc"),
expected: { ok: true },
},
])("handles $label", ({ requestedPath, expected }) => {
withConfig({
n1: {
allowReadPaths: ["/**"],
denyPaths: ["**/.ssh/**"],
},
});
expectResultFields(
evaluateFilePolicy({ nodeId: "n1", kind: "read", path: requestedPath }),
expected,
);
});
it("denies even with ask=always (denyPaths is hard)", () => {
withConfig({
n1: {
@@ -156,6 +156,13 @@ function matchesAny(target: string, patterns: string[]): boolean {
return false;
}
function matchesAnyDeny(target: string, patterns: string[]): boolean {
if (matchesAny(target, patterns)) {
return true;
}
return matchesAny(`${target.replace(/[\\/]+$/u, "")}/`, patterns);
}
function resolveNodePolicy(
config: FilePolicyConfig,
nodeId: string,
@@ -262,7 +269,7 @@ export function evaluateFilePolicy(input: {
// 1. Deny patterns always win.
const denyPatterns = normalizeGlobs(nodeConfig.denyPaths);
if (matchesAny(input.path, denyPatterns)) {
if (matchesAnyDeny(input.path, denyPatterns)) {
return {
ok: false,
code: "POLICY_DENIED",