Skip to content

Bug: LocalMediaAccessError on Windows due to fstat/lstat dev mismatch in openVerifiedLocalFile #21989

@rpelizza

Description

@rpelizza

Bug description

On Windows (NTFS), all local media file reads fail with LocalMediaAccessError: Local media path is not safe to read due to a device number (dev) mismatch between handle.stat() (fstat) and fs.lstat() (path-based lstat) in openVerifiedLocalFile.

This breaks TTS audio delivery, image sending, and any feature that relies on readLocalFileSafely / loadWebMedia with local file paths.

Root cause

In src/infra/fs-safe.ts, openVerifiedLocalFile performs a TOCTOU/symlink safety check:

const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]);
// ...
if (stat.ino !== lstat.ino || stat.dev !== lstat.dev)
  throw new SafeOpenError("path-mismatch", "path changed during read");

On Windows/NTFS with Node.js:

  • handle.stat() (fstat via file descriptor) returns the actual NTFS volume serial number (e.g., 708139836)
  • fs.lstat() / fs.stat() (path-based) returns dev: 0

This is a documented Node.js behavior on Windows — path-based stat calls return 0 for dev, while handle-based stat returns the real volume serial number.

The same issue affects the second check:

const realStat = await fs.stat(realPath);
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev)
  throw new SafeOpenError("path-mismatch", "path mismatch");

Reproduction

const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');

async function reproduce() {
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tts-test-'));
  const testFile = path.join(tmpDir, 'voice-test.mp3');
  fs.writeFileSync(testFile, Buffer.from('test'));

  const handle = await fsp.open(testFile, fs.constants.O_RDONLY);
  const [stat, lstat] = await Promise.all([handle.stat(), fsp.lstat(testFile)]);

  console.log('handle.stat().dev:', stat.dev);   // e.g. 708139836
  console.log('fs.lstat().dev:',    lstat.dev);   // 0
  console.log('dev mismatch:',      stat.dev !== lstat.dev); // true on Windows

  await handle.close();
  fs.unlinkSync(testFile);
  fs.rmdirSync(tmpDir);
}

reproduce();

Output on Windows 10 (Node 22.16.0):

handle.stat().dev: 708139836
fs.lstat().dev:    0
dev mismatch:      true

Error log

[telegram] telegram tool reply failed: LocalMediaAccessError: Local media path is not safe to read: C:\Users\<user>\AppData\Local\Temp\tts-fCMZWl\voice-1771602803431.mp3

This error repeats for every TTS audio delivery attempt.

Suggested fix

Skip the dev comparison on Windows, where it is known to be unreliable. The ino (file index) comparison remains valid on Windows/NTFS and provides sufficient TOCTOU protection:

const IS_WIN32 = process.platform === "win32";

// In openVerifiedLocalFile:
if (stat.ino !== lstat.ino || (!IS_WIN32 && stat.dev !== lstat.dev))
  throw new SafeOpenError("path-mismatch", "path changed during read");

// ...

if (stat.ino !== realStat.ino || (!IS_WIN32 && stat.dev !== realStat.dev))
  throw new SafeOpenError("path-mismatch", "path mismatch");

This is consistent with the existing Windows-aware pattern already used in the same file:

const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in constants;

Environment

  • OS: Windows 10 (build 26200)
  • Node.js: v22.16.0 (via nvm)
  • OpenClaw: 2026.2.19-2 (latest npm release)
  • Channel: Telegram (but affects all channels using local media)
  • TTS provider: Edge TTS (pt-BR-AntonioNeural)

Affected files (dist bundles)

All fs-safe-*.js bundles in dist/ contain the same vulnerable check:

  • dist/fs-safe-BY9kuZ3o.js
  • dist/fs-safe-EcGbY0tj.js
  • dist/fs-safe-CWVBGzz3.js
  • dist/fs-safe-Blsk2yxR.js
  • dist/plugin-sdk/fs-safe-PV2bjJll.js

Impact

  • Severity: High — completely breaks all local media delivery on Windows
  • Affected features: TTS audio, local image/video sending, any loadWebMedia with local paths
  • Workaround: Manual patch of fs-safe-*.js files in node_modules (overwritten on update)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions