## Ticket: CVE-2026-7474 — HashiCorp Nomad path traversal in dynamic host volume plugin loader → client-host RCE

**CVE**: CVE-2026-7474 | **GHSA**: GHSA-hx53-77qj-8663 | **CWE-22** (Improper Limitation of a Pathname to a Restricted Directory), **CWE-78** (OS Command Injection) | **CVSS 3.1**: 8.8 (NIST, High)
**Vendor / Product**: HashiCorp — Nomad / Nomad Enterprise (orchestrator / scheduler)
**Repository**: https://github.com/hashicorp/nomad
**Affected**: prior to `v2.0.1` (last vulnerable tag: `v2.0.0`; also affects `v1.10.x` and `v1.11.x`) | **Fixed**: `v2.0.1`
**Advisory**: https://github.com/advisories/GHSA-hx53-77qj-8663
**NVD**: https://nvd.nist.gov/vuln/detail/CVE-2026-7474
**Cited on**: https://red.anthropic.com/2026/cvd/

### Impact

Nomad's dynamic host volume feature lets operators create volumes on client nodes via external plugins (small executables in a configured plugin directory). When a volume is created, the server RPC `HostVolume.Create` forwards a `ClientHostVolume.Create` RPC to the target client. The client's `HostVolumeManager.getPlugin` passes the attacker-supplied `PluginID` directly into `NewHostVolumePluginExternal` in `client/hostvolumemanager/host_volume_plugin.go` (around line 229 in `v2.0.0`). There, the code does:

```go
executable := filepath.Join(pluginDir, filename)
f, err := os.Stat(executable)
```

If `filename` (the `PluginID`) contains path-traversal sequences such as `../../../bin/sh`, `filepath.Join` resolves outside the intended plugin directory. The code then checks whether that arbitrary path exists and is executable; if so, it stores the escaped path in `HostVolumePluginExternal.Executable`. That executable is later invoked via `exec.CommandContext` during `Fingerprint`, `Create`, and `Delete` operations, yielding **arbitrary code execution as the Nomad client user**.

Server-side validation is bypassed when the attacker provides an explicit `NodeID` in the create request: the server's `placeHostVolume` shortcut skips the feasibility check that would otherwise verify the plugin exists on the node (via the `${attr.plugins.host_volume.<plugin>.version}` node attribute). This means a user with `host-volume-create` ACL capability in a namespace can target any client node and run any binary on its filesystem.

### Affected / Fixed

| | Version |
|--|--|
| **Vulnerable** | prior to `v2.0.1` (confirmed at `v2.0.0`, `v1.10.5`, `v1.11.2`) |
| **Fixed** | `v2.0.1` (tag `v2.0.1`) |

### Where the patch lives

Fix commit: `cd7240c4099ad33eda279924fb3a9459b162d120` ("host volume: constrain plugin to plugin directory (#3958)")  
Issue: https://github.com/hashicorp/nomad/issues/27919  
Release notes: https://github.com/hashicorp/nomad/releases/tag/v2.0.1

The patch makes two changes:

1. **Client-side containment** — In `NewHostVolumePluginExternal`, instead of `filepath.Join(pluginDir, filename)` followed by `os.Stat(executable)`, the fix opens the plugin directory with `os.OpenRoot(pluginDir)` and then calls `root.Stat(filename)`. On Go 1.24+, `os.Root.Stat` rejects paths that escape the root, so `../../../bin/sh` returns `ErrPluginNotExists` rather than resolving to an arbitrary file.

2. **Server-side enforcement** — In `nomad/host_volume_endpoint.go:placeHostVolume`, the fix moves the plugin feasibility constraint (`${attr.plugins.host_volume.<plugin>.version} is_set`) so it is checked **even when the request already specifies a `NodeID`**. Previously that check was skipped as an optimization, allowing the attacker to force placement onto a node without validating that the plugin ID is legitimate.

### Reproduction plan

The bug is in the client-side plugin loader, but the malicious input is delivered through the server's volume-creation RPC. The reproduction must show traversal through the real `NewHostVolumePluginExternal` code path, not a synthetic unit-test that only calls `filepath.Join`.

1. Build vulnerable Nomad (`v2.0.0`) and fixed Nomad (`v2.0.1`). Nomad requires Go 1.26.3 (see `.go-version`). A minimal build:
   ```bash
   git clone https://github.com/hashicorp/nomad external/nomad-vuln
   cd external/nomad-vuln
   git checkout v2.0.0
   go build -o bin/nomad ./cmd/nomad
   ```
   Repeat at `v2.0.1` into `external/nomad-fixed`.

2. The fastest reproduction is a Go test that exercises the actual `NewHostVolumePluginExternal` and the RPC pipeline:
   - **Direct function test** (acceptable because it hits the real vulnerable function): call `NewHostVolumePluginExternal(log, pluginDir, "../../../bin/ls", volumesDir, nodePool)` from a test in `client/hostvolumemanager` on the vulnerable checkout and observe that it succeeds and `p.Executable` is a path outside `pluginDir`. On the fixed checkout the same call returns `ErrPluginNotExists` because `os.OpenRoot` blocks the escape.
   - **End-to-end cluster test** (preferred if time permits): run `nomad agent -dev` with ACLs enabled and a `host_volume` plugin directory configured. Submit a `HostVolumeCreateRequest` via the API (or `nomad volume create`) with `PluginID = "../../../bin/ls"` and an explicit `NodeID`. On the vulnerable client the `Fingerprint` RPC will invoke `/bin/ls fingerprint`; on the fixed client the server rejects the request before RPC forwarding because the node feasibility check fails, or the client `os.OpenRoot` blocks the traversal.

3. For a concrete direct-function demonstration, add or run this Go snippet in `client/hostvolumemanager/host_volume_plugin_test.go` (or a standalone repro file):

   ```go
   func TestCVE20267474_PathTraversal(t *testing.T) {
       ci.Parallel(t)
       log := testlog.HCLogger(t)
       pluginDir := t.TempDir()
       // On v2.0.0: this resolves the path outside pluginDir and succeeds if the file is executable
       p, err := NewHostVolumePluginExternal(log, pluginDir, "../../bin/ls", "/tmp/vols", "")
       if err == nil && !strings.HasPrefix(p.Executable, pluginDir) {
           t.Fatalf("vulnerable: plugin executable escaped to %s", p.Executable)
       }
       // On v2.0.1: err should be ErrPluginNotExists because os.Root rejects the escape
   }
   ```

   On the vulnerable build (`v2.0.0`) the test will show the escaped `Executable`. On the fixed build (`v2.0.1`) the function returns `ErrPluginNotExists` immediately.

4. If running the full cluster, capture the client agent logs showing the plugin execution attempt (or the server-side rejection on the fixed build).

### Expected reproduction artifacts

- `repro/reproduction_steps.sh` — clones Nomad at both refs, builds, runs the direct-function test (or cluster test) that demonstrates the traversal on `v2.0.0` and the block on `v2.0.1`.
- `repro/validation_verdict.json` — `confirmed` only if (a) the vulnerable build allows `NewHostVolumePluginExternal` to resolve a `PluginID` containing `../` to an executable outside the plugin directory, and (b) the fixed build returns `ErrPluginNotExists` for the same payload.
- `logs/vulnerable_test.txt` — test output / exec trace from the vulnerable run.
- `logs/fixed_test.txt` — test output showing the rejection.
- `repro/rca_report.md` — RCA explaining the missing `os.OpenRoot` containment and the skipped server-side feasibility check when `NodeID` is explicit.
