Skip to main content
interlace
Plugin: node-securityRules

no-toctou-vulnerability

Detects Time-of-Check-Time-of-Use (TOCTOU) race condition vulnerabilities in file system operations.

Quick Summary

AspectDetails
SeverityError
CWECWE-367
OWASPA01:2021
Auto-Fix❌ No
Suggestions✅ 3 available
CategorySecurity

Vulnerability and Risk

Vulnerability: A Time-of-Check to Time-of-Use (TOCTOU) vulnerability relies on the timing window between checking a condition (e.g., "does this file exist?") and using the result (e.g., "read the file").

Risk: An attacker can alter the state of the system (e.g., delete or replace the file with a symbolic link) during this window, causing the application to perform actions on the wrong resource. This can lead to privilege escalation, data corruption, or denial of service.

Rule Details

TOCTOU vulnerabilities occur when a program checks a condition (like file existence) and later performs an action based on that check, but the condition may have changed between the check and the action.

A race needs somewhere for the attacker to stand

Winning a TOCTOU race means creating or replacing the name between the check and the use, and that requires write permission on the containing directory. In /tmp — a namespace every local account can write — that is trivial. Under $HOME, $XDG_CACHE_HOME or %LOCALAPPDATA% no other unprivileged user can create the name at all, and the process is already running as the user whose directory it is, so there is no privilege transition either.

The rule therefore stays silent when the path resolves to a per-user root — os.homedir(), os.userInfo().homedir, or one of HOME, USERPROFILE, LOCALAPPDATA, APPDATA, XDG_{CACHE,CONFIG,DATA,STATE}_HOME. The resolution follows const bindings, a file-local helper's single top-level return, platform ternaries, ?? fallbacks and path.join, which is how a cache path is normally assembled:

// Not reported — Shopify/cli bin/update-observe.js:402
function cookieCachePath() {
  const cacheRoot =
    platform() === 'darwin'
      ? join(homedir(), 'Library', 'Caches')
      : process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache');
  return join(cacheRoot, 'shopify-cli', 'observe-cookie');
}
function clearCachedCookie() {
  const p = cookieCachePath();
  if (existsSync(p)) unlinkSync(p);   // no shared namespace, no window
}

The exemption is scoped to the absence of a shared namespace, not to the absence of a race in general. A per-user path reached through a directory an attacker can already write is out of scope for any rule that does not resolve the filesystem.

Why This Matters

IssueImpactSolution
🔒 Race ConditionAttackers can exploit timing windowUse atomic operations
🐛 Data IntegrityFile state may change unexpectedlyUse fs.promises API
ReliabilityOperations may fail or produce wrong resultsHandle errors properly

Configuration

interface Options {
  /** Ignore in test files. Default: true */
  ignoreInTests?: boolean;

  /** File system methods to check. Default: ['fs.existsSync', 'fs.statSync', 'fs.accessSync'] */
  fsMethods?: string[];
}

Examples

❌ Incorrect

// TOCTOU vulnerability: file state can change between check and use
if (fs.existsSync('config.json')) {
  const data = fs.readFileSync('config.json'); // ⚠️ File may no longer exist
}

// Stat-then-use pattern
const stats = fs.statSync('file.txt');
if (stats.isFile()) {
  fs.unlinkSync('file.txt'); // ⚠️ File may have changed
}

✅ Correct

// Use try-catch with fs.promises (atomic behavior)
try {
  const data = await fs.promises.readFile('config.json');
  // Process data
} catch (err) {
  if (err.code === 'ENOENT') {
    // File doesn't exist - handle appropriately
  }
}

// Use proper locking for concurrent access
import { lock } from 'proper-lockfile';

const release = await lock('file.txt');
try {
  const data = await fs.promises.readFile('file.txt');
  await fs.promises.writeFile('file.txt', modifiedData);
} finally {
  await release();
}

Configuration Examples

Basic Usage

// eslint.config.mjs
export default [
  {
    rules: {
      'node-security/no-toctou-vulnerability': 'error',
    },
  },
];

Custom Configuration

// eslint.config.mjs
export default [
  {
    rules: {
      'node-security/no-toctou-vulnerability': [
        'error',
        {
          ignoreInTests: true,
          fsMethods: ['fs.existsSync', 'fs.statSync', 'fs.accessSync'],
        },
      ],
    },
  },
];

LLM-Optimized Output

🔒 CWE-367 OWASP:A01-Broken-Access-Control CVSS:7.0 | TOCTOU vulnerability | HIGH
   Fix: Use atomic operations or fs.promises for file operations | https://cwe.mitre.org/data/definitions/367.html

Suggestions

When this rule reports an error, it provides three suggestions:

  1. Use Atomic Operations - Use atomic file operations like fs.promises.access() then fs.promises.readFile()
  2. Use fs.promises - Use the Promise-based fs API for better error handling
  3. Add Proper Locking - Use a file locking library like proper-lockfile for concurrent access

Known False Negatives

The following patterns are not detected due to static analysis limitations:

A per-user path an attacker can already reach

The per-user exemption above assumes the resolved root really is private. A world-writable $HOME, a shared service account, or a setuid binary running with a different real and effective user all break that assumption, and the rule reports nothing in those cases.

Values from Variables

Why: Values stored in variables are not traced.

// ❌ NOT DETECTED - Value from variable
const value = userInput;
dangerousOperation(value);

Mitigation: Validate all user inputs.

Wrapper Functions

Why: Custom wrappers not recognized.

// ❌ NOT DETECTED - Wrapper
myWrapper(userInput); // Uses dangerous API internally

Mitigation: Apply rule to wrapper implementations.

Dynamic Invocation

Why: Dynamic calls not analyzed.

// ❌ NOT DETECTED - Dynamic
obj[method](userInput);

Mitigation: Avoid dynamic method invocation.

Further Reading

⚙️ Options

OptionTypeDefaultDescription
ignoreInTestsbooleantrueSkip this rule in *.test.* / *.spec.* files
fsMethodsstring[]["fs.existsSync","fs.statSync","fs.accessSync","fs.exists","fs.stat","fs.access"]Filesystem check calls that create a time-of-check window. Replaces the built-in list. Only the final dotted segment is compared, so fs.existsSync and existsSync are the same entry.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.