[GH-ISSUE #5642] Bump stat to lstat #5212

Closed
opened 2026-06-05 14:52:41 -04:00 by yindo · 0 comments
Owner

Originally created by @timothycarambat on GitHub (May 16, 2026).
Original GitHub issue: https://github.com/Mintplex-Labs/anything-llm/issues/5642

Original from @hariantara

Summary

The May 10 patch in 21ce030 (GHSA-vjrp-43mm-j7vw) changed fs.stat to fs.lstat in copy-file.js to stop the recursive copy from following symlinks. The exact same fs.stat anti-pattern still exists in list-directory.js. When the agent is asked to list a directory with includeSizes: true, each entry is fs.stat'd directly, so any symlink entry inside the allowed directory leaks its target's size and mtime, even if the target lives outside the allowed directory.

Details

I was reading the fix for GHSA-vjrp-43mm-j7vw and noticed the change was a one-liner: fs.stat to fs.lstat, plus a guard rejecting symlinks. I grepped the same plugin folder for the remaining fs.stat callers:

server/utils/agents/aibitat/plugins/filesystem/list-directory.js:104
server/utils/agents/aibitat/plugins/filesystem/lib.js:303
server/utils/agents/aibitat/plugins/filesystem/lib.js:483

lib.js:303 (#tailFileRaw) and lib.js:483 (getFileStats) are both called with paths that have already been run through filesystem.validatePath(), which uses fs.realpath and rejects any path whose resolved target falls outside the allowed dirs. Those two are safe.

list-directory.js:104 is different. It validates the parent directory once and then iterates over its entries without re-validating each one:

const validPath = await filesystem.validatePath(dirPath);
const entries = await fs.readdir(validPath, { withFileTypes: true });
// ...
const detailedEntries = await Promise.all(
  entries.map(async (entry) => {
    const entryPath = path.join(validPath, entry.name);
    try {
      const stats = await fs.stat(entryPath);   // follows symlinks
      return {
        name: entry.name,
        isDirectory: entry.isDirectory(),
        size: stats.size,                       // size of the symlink target
        mtime: stats.mtime,                     // mtime of the symlink target
      };
    } catch { ... }
  })
);

entry.isDirectory() from the Dirent uses lstat semantics, so the type flag reflects the symlink itself. But fs.stat(entryPath) follows the symlink and returns the target's stats. So if a symlink in the allowed dir points to e.g. /etc/passwd, the agent's response truthfully reports the size and mtime of /etc/passwd.

The threat model is the same one the May 10 advisory used: a pre-existing symlink in the configured filesystem-agent directory. That's common in Docker volume mounts, manually shared dirs, or any setup where another process placed a symlink. The agent tool then becomes a metadata oracle for files outside the allowed area.

Audit summary of the other ops in plugins/filesystem/:

copy-file.js -- already patched by GHSA-vjrp-43mm-j7vw
read-text-file.js, write-text-file.js, move-file.js, edit-file.js, get-file-info.js, read-multiple-files.js -- safe (validatePath resolves symlinks via realpath for every path that reaches fs.*)
list-directory.js -- this report
PoC
Pre-condition: one symlink inside the allowed dir pointing at something outside it. Same pre-condition as the parent advisory.

cd /path/to/your/anythingllm-allowed-dir
ln -s /etc/passwd leaked.txt
Then from a chat with the filesystem agent enabled:

List the current directory with sizes included.
The agent calls filesystem-list-directory with includeSizes: true. The response includes a line like:

[FILE] leaked.txt 3.5 KiB
That 3.5 KiB is the size of /etc/passwd on the server. mtime is also reported in the detailed-entry struct and shows up if you ask the agent to format that field. You can repeat the trick with any path the server process can read.

Impact
Information disclosure (CWE-200) of file metadata -- size, mtime, and isDirectory flag from stats -- for files outside the configured filesystem-agent directory. No file content leaks through this path. Same impact category and threat model as GHSA-vjrp-43mm-j7vw, which was rated Low.

Suggested fix is the same as the parent advisory, one line:

const stats = await fs.lstat(entryPath);
That returns the symlink's own stats instead of following it. Optional polish: when stats.isSymbolicLink() is true and entry.isDirectory() is false, mark it [LINK] in the formatted output so the agent sees it as a symlink instead of a regular file.

Originally created by @timothycarambat on GitHub (May 16, 2026). Original GitHub issue: https://github.com/Mintplex-Labs/anything-llm/issues/5642 Original from @hariantara ## Summary The May 10 patch in 21ce030 ([GHSA-vjrp-43mm-j7vw](https://github.com/Mintplex-Labs/anything-llm/security/advisories/GHSA-vjrp-43mm-j7vw)) changed fs.stat to fs.lstat in copy-file.js to stop the recursive copy from following symlinks. The exact same fs.stat anti-pattern still exists in list-directory.js. When the agent is asked to list a directory with includeSizes: true, each entry is fs.stat'd directly, so any symlink entry inside the allowed directory leaks its target's size and mtime, even if the target lives outside the allowed directory. ## Details I was reading the fix for [GHSA-vjrp-43mm-j7vw](https://github.com/Mintplex-Labs/anything-llm/security/advisories/GHSA-vjrp-43mm-j7vw) and noticed the change was a one-liner: fs.stat to fs.lstat, plus a guard rejecting symlinks. I grepped the same plugin folder for the remaining fs.stat callers: ``` server/utils/agents/aibitat/plugins/filesystem/list-directory.js:104 server/utils/agents/aibitat/plugins/filesystem/lib.js:303 server/utils/agents/aibitat/plugins/filesystem/lib.js:483 ``` lib.js:303 (#tailFileRaw) and lib.js:483 (getFileStats) are both called with paths that have already been run through filesystem.validatePath(), which uses fs.realpath and rejects any path whose resolved target falls outside the allowed dirs. Those two are safe. list-directory.js:104 is different. It validates the parent directory once and then iterates over its entries without re-validating each one: ``` const validPath = await filesystem.validatePath(dirPath); const entries = await fs.readdir(validPath, { withFileTypes: true }); // ... const detailedEntries = await Promise.all( entries.map(async (entry) => { const entryPath = path.join(validPath, entry.name); try { const stats = await fs.stat(entryPath); // follows symlinks return { name: entry.name, isDirectory: entry.isDirectory(), size: stats.size, // size of the symlink target mtime: stats.mtime, // mtime of the symlink target }; } catch { ... } }) ); ``` entry.isDirectory() from the Dirent uses lstat semantics, so the type flag reflects the symlink itself. But fs.stat(entryPath) follows the symlink and returns the target's stats. So if a symlink in the allowed dir points to e.g. /etc/passwd, the agent's response truthfully reports the size and mtime of /etc/passwd. The threat model is the same one the May 10 advisory used: a pre-existing symlink in the configured filesystem-agent directory. That's common in Docker volume mounts, manually shared dirs, or any setup where another process placed a symlink. The agent tool then becomes a metadata oracle for files outside the allowed area. Audit summary of the other ops in plugins/filesystem/: copy-file.js -- already patched by [GHSA-vjrp-43mm-j7vw](https://github.com/Mintplex-Labs/anything-llm/security/advisories/GHSA-vjrp-43mm-j7vw) read-text-file.js, write-text-file.js, move-file.js, edit-file.js, get-file-info.js, read-multiple-files.js -- safe (validatePath resolves symlinks via realpath for every path that reaches fs.*) list-directory.js -- this report PoC Pre-condition: one symlink inside the allowed dir pointing at something outside it. Same pre-condition as the parent advisory. cd /path/to/your/anythingllm-allowed-dir ln -s /etc/passwd leaked.txt Then from a chat with the filesystem agent enabled: List the current directory with sizes included. The agent calls filesystem-list-directory with includeSizes: true. The response includes a line like: [FILE] leaked.txt 3.5 KiB That 3.5 KiB is the size of /etc/passwd on the server. mtime is also reported in the detailed-entry struct and shows up if you ask the agent to format that field. You can repeat the trick with any path the server process can read. Impact Information disclosure (CWE-200) of file metadata -- size, mtime, and isDirectory flag from stats -- for files outside the configured filesystem-agent directory. No file content leaks through this path. Same impact category and threat model as [GHSA-vjrp-43mm-j7vw](https://github.com/Mintplex-Labs/anything-llm/security/advisories/GHSA-vjrp-43mm-j7vw), which was rated Low. Suggested fix is the same as the parent advisory, one line: const stats = await fs.lstat(entryPath); That returns the symlink's own stats instead of following it. Optional polish: when stats.isSymbolicLink() is true and entry.isDirectory() is false, mark it [LINK] in the formatted output so the agent sees it as a symlink instead of a regular file.
yindo closed this issue 2026-06-05 14:52:41 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Mintplex-Labs/anything-llm#5212