Detecting n8n's Git Node RCE Vulnerability with CodeQL (CVE-2026-21877)

Fresh off our analysis of CVE-2026-21858, another critical n8n vulnerability landed with a CVSS 10.0 score. CVE-2026-21877 is an authenticated RCE via the Git node that highlights a subtle but critical security pattern: when external libraries bypass your framework's built-in protections.

The Vulnerability: Arbitrary File Write via Git Node

CVE-2026-21877 allows an authenticated user to write arbitrary files to the n8n server by exploiting the Git node's repositoryPath parameter.

Affected Versions: >= 0.123.0 < 1.121.3 Fix Commit: f4b009d in PR #22253

The Attack Chain

The Git node lets users perform git operations (clone, add, commit, push) on a specified directory. The vulnerable code accepted any path:

// VULNERABLE CODE - Before fix
const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '');
const resolvedRepositoryPath = await this.helpers.resolvePath(repositoryPath);
 
// NO VALIDATION - path goes directly to simpleGit
const git: SimpleGit = simpleGit({ baseDir: resolvedRepositoryPath });
 
if (operation === 'clone') {
    await git.clone(sourceRepository, '.');
}

An attacker with workflow creation access could:

  1. Set repositoryPath to ~/.n8n/nodes/ (n8n's custom nodes directory)
  2. Set sourceRepository to a malicious git repo containing a backdoored node
  3. Execute the clone operation
  4. On restart, n8n loads the malicious node and executes attacker code
User Input (repositoryPath) → simpleGit({ baseDir }) → git clone →
Malicious files in ~/.n8n/nodes/ → n8n restart → RCE

Why This Achieved CVSS 10.0

  • Authenticated but low privilege: Any user who can create workflows can exploit this
  • Direct path to RCE: No complex chains required - write file, restart, code execution
  • Affects cloud and self-hosted: Both n8n Cloud and self-hosted deployments were vulnerable

The Fix: Path Validation Before External Library Use

The fix adds a single validation check:

// FIXED CODE - After PR #22253
const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '');
const resolvedRepositoryPath = await this.helpers.resolvePath(repositoryPath);
 
// NEW: Validate path before using it
const isFilePathBlocked = this.helpers.isFilePathBlocked(resolvedRepositoryPath);
if (isFilePathBlocked) {
    throw new NodeOperationError(
        this.getNode(),
        'Access to the repository path is not allowed',
    );
}
 
const git: SimpleGit = simpleGit({ baseDir: resolvedRepositoryPath });

The isFilePathBlocked function checks against:

  • n8n's configuration directory (~/.n8n)
  • Cache directories
  • Binary data storage paths
  • Custom extension paths
  • Any paths outside an allowlist (if N8N_RESTRICT_FILE_ACCESS_TO is set)

Why the Git Node Was Different

n8n has robust file access controls built into its helper functions. When you use this.helpers.createReadStream() or this.helpers.writeContentToFile(), these functions internally call isFilePathBlocked():

// Inside n8n's createReadStream helper (simplified)
async createReadStream(resolvedFilePath) {
    // Security check is BUILT-IN
    if (isFilePathBlocked(resolvedFilePath)) {
        throw new NodeOperationError(node, 'Access to the file is not allowed.');
    }
 
    return createReadStream(resolvedFilePath, {
        flags: constants.O_RDONLY | constants.O_NOFOLLOW  // Also prevents symlink attacks
    });
}

The Git node was vulnerable because simpleGit bypasses n8n's helper functions entirely. It's an external library that directly performs filesystem operations:

// This BYPASSES n8n's built-in file access controls
const git: SimpleGit = simpleGit({ baseDir: userControlledPath });
await git.clone(sourceRepo, '.');  // Direct filesystem write

This is a common pattern in security vulnerabilities: the framework has protections, but integrations with external libraries create gaps.

Writing a CodeQL Query for This Pattern

The vulnerability pattern is: user input flows to an external library that performs filesystem operations, bypassing the framework's built-in protections.

/**
 * @name Git operations with unvalidated user-controlled path
 * @description Detects when user input flows to simpleGit or git operations
 *              without path validation. This is the CVE-2026-21877 pattern.
 * @kind problem
 * @problem.severity error
 * @security-severity 9.0
 * @precision high
 * @id js/git-node-path-injection
 * @tags security
 *       external/cwe/cwe-22
 *       external/cwe/cwe-73
 */
 
import javascript
 
/**
 * User input from n8n's getNodeParameter
 */
class NodeParameterSource extends DataFlow::CallNode {
  string paramName;
 
  NodeParameterSource() {
    this.getCalleeName() = "getNodeParameter" and
    paramName = this.getArgument(0).getStringValue()
  }
 
  string getParameterName() { result = paramName }
}
 
/**
 * simpleGit instantiation with baseDir option - the dangerous sink
 */
class SimpleGitSink extends DataFlow::Node {
  SimpleGitSink() {
    exists(DataFlow::CallNode call, DataFlow::ObjectLiteralNode opts |
      call.getCalleeName() = "simpleGit" and
      opts.flowsTo(call.getArgument(0)) and
      this = opts.getAPropertyWrite("baseDir").getRhs()
    )
  }
}
 
/**
 * isFilePathBlocked check that guards a code path.
 * Matches: if (isFilePathBlocked(path)) { throw ... }
 */
class PathBlockedGuard extends DataFlow::CallNode {
  ConditionGuardNode guard;
 
  PathBlockedGuard() {
    exists(DataFlow::PropRead pr |
      pr.getPropertyName() = "isFilePathBlocked" and
      this.getCalleeNode().getALocalSource() = pr
    ) and
    guard.getTest() = this.asExpr()
  }
 
  /** Gets a node that is guarded by this check (in the false branch) */
  DataFlow::Node getAGuardedNode() {
    guard.dominates(result.getBasicBlock()) and
    guard.getOutcome() = false
  }
}
 
/**
 * Taint tracking configuration
 */
module GitPathInjectionConfig implements DataFlow::ConfigSig {
  predicate isSource(DataFlow::Node source) {
    exists(NodeParameterSource nps |
      nps.getParameterName().toLowerCase().matches("%path%") and
      source = nps
    )
  }
 
  predicate isSink(DataFlow::Node sink) {
    sink instanceof SimpleGitSink
  }
 
  predicate isBarrier(DataFlow::Node node) {
    // Block flow if the node is guarded by an isFilePathBlocked check
    // that throws when the path is blocked (i.e., sink is in the "safe" branch)
    exists(PathBlockedGuard check | node = check.getAGuardedNode())
  }
}
 
module GitPathInjectionFlow = TaintTracking::Global<GitPathInjectionConfig>;
 
from DataFlow::Node source, DataFlow::Node sink
where GitPathInjectionFlow::flow(source, sink)
select sink,
    "User input from getNodeParameter flows to simpleGit baseDir without " +
    "isFilePathBlocked validation, enabling arbitrary file write (CVE-2026-21877 pattern)."

The barrier detection uses CodeQL's ConditionGuardNode to verify that isFilePathBlocked actually guards the sink in control flow. The sink must be in the false branch (meaning isFilePathBlocked returned false, indicating the path is allowed) for the flow to be blocked. If there's no such guard, or the sink is reachable without passing through the check, the query flags it.

Query Components

ComponentWhat It MatchesPurpose
SourcegetNodeParameter('*path*')User-controlled path input
SinksimpleGit({ baseDir: ... })External library that bypasses framework protections
BarrierisFilePathBlocked() calln8n's path validation function

Validating the Query

I created test fixtures for both vulnerable and fixed versions:

Vulnerable Version:

// Git.node.ts - VULNERABLE
const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '');
const resolvedRepositoryPath = await this.helpers.resolvePath(repositoryPath);
 
// Direct use without validation
const git: SimpleGit = simpleGit({ baseDir: resolvedRepositoryPath });

Fixed Version:

// Git.node.ts - FIXED
const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '');
const resolvedRepositoryPath = await this.helpers.resolvePath(repositoryPath);
 
// Validation before use
const isFilePathBlocked = this.helpers.isFilePathBlocked(resolvedRepositoryPath);
if (isFilePathBlocked) {
    throw new NodeOperationError(this.getNode(), 'Access to the repository path is not allowed');
}
 
const git: SimpleGit = simpleGit({ baseDir: resolvedRepositoryPath });

Results

VersionisFilePathBlocked CheckCodeQL Result
VulnerableMissing1 finding - CVE detected
FixedPresent0 findings - Barrier blocks flow
Current n8n codebasePresent0 findings - Patched

Broader Detection: Finding Similar Patterns

I also wrote a broader query to find any user input flowing to filesystem operations without validation. This found 8 potential issues in n8n, but investigation revealed they were all false positives:

FindingLocationWhy It's a False Positive
mkdir/rmdirFtp.node.tsRemote FTP server operations, not local filesystem
createReadStreamReadBinaryFile.node.tsUses this.helpers.createReadStream() which has built-in isFilePathBlocked
mkdircompression.util.tsProtected by sanitizePath() which prevents path traversal

n8n's helper functions have built-in security. The Git node was vulnerable specifically because simpleGit bypasses these helpers.

The Practical Reality

Writing custom CodeQL queries requires deep knowledge of both the vulnerability pattern and the framework's security architecture. Most teams don't have time to build this expertise for every framework they use.

This is exactly why we built Waclaude. When CVE-2026-21877 dropped, teams using Waclaude with SCA scanning were alerted immediately if they were running vulnerable n8n versions - no custom queries required.

When reviewing code that integrates external libraries, ask: does this library bypass our framework's security controls? If yes, explicit validation is mandatory.


This is the second in a series on n8n vulnerabilities. See also: CVE-2026-21858: Content-Type Confusion.