# Patch Analysis: CVE-2026-7474

## Fix Commit
- **Commit:** `cd7240c4099ad33eda279924fb3a9459b162d120`
- **Title:** "host volume: constrain plugin to plugin directory (#3958)"
- **Release:** v2.0.1

## What the Fix Changes

The fix introduces two independent defense-in-depth measures:

### 1. Client-side path containment (`client/hostvolumemanager/host_volume_plugin.go`)

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

**After (v2.0.1):**
```go
root, err := os.OpenRoot(pluginDir)
if err != nil {
    return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename)
}

f, err := root.Stat(filename)
if err != nil {
    return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename)
}
executable := filepath.Join(pluginDir, filename)
```

`os.OpenRoot` (Go 1.24+ Linux `openat2` with `RESOLVE_IN_ROOT`) forces path resolution to stay within the opened directory. `..` components, absolute paths, and symlinks pointing outside the root are all blocked.

### 2. Server-side feasibility check for explicit NodeID (`nomad/host_volume_endpoint.go`)

**Before:** `placeHostVolume` only ran feasibility constraints (`${attr.plugins.host_volume.<plugin>.version} is_set`) when the request did **not** include an explicit `NodeID`. When `NodeID` was provided, the shortcut skipped the constraint check entirely.

**After:** The feasibility checker is constructed unconditionally and is applied **even when `vol.NodeID != ""`** before returning the node.

## Fix Assumptions

1. **Client-side `os.OpenRoot` is robust:** The fix assumes `root.Stat` cannot be bypassed with `..`, absolute paths, or symlinks. Empirical testing confirms this assumption on Linux with kernel ≥5.6.
2. **Attacker cannot write into the plugin directory:** The commit message explicitly acknowledges that TOCTOU against `os.OpenRoot` is not an issue because the attacker can't write into the plugin directory.
3. **Server-side check prevents malicious PluginID from reaching the client:** By checking feasibility, the server ensures the claimed plugin actually exists on the target node as a detected fingerprint attribute.

## What the Fix Does NOT Cover

1. **Register → Delete chain:** The `HostVolume.Register` endpoint does **not** validate `PluginID` for path traversal, does **not** call `placeHostVolume`, and `HostVolume.ValidateUpdate` does **not** prevent `PluginID` changes. An attacker with `host-volume-register` and `host-volume-delete` ACLs could:
   - Register an update to an existing volume, changing its `PluginID` to a traversal payload.
   - Delete that volume, causing the server to send `ClientHostVolume.Delete` with the malicious `PluginID` to the client.
   - On the **fixed** version, this chain is blocked at the client by `os.OpenRoot`, so it does not constitute a bypass. However, if the client-side fix were ever reverted or bypassed, the Register endpoint would provide an unprotected path to the same sink.

2. **Stored-state restart (`restoreFromState`):** If client state somehow contains a volume with a malicious `PluginID` (e.g., pre-upgrade state or direct disk tampering), agent restart calls `restoreForCreate` → `getPlugin` → `NewHostVolumePluginExternal`. The `os.OpenRoot` fix blocks this, but the attacker must already have local disk access to inject the state.

3. **Hardlinks inside the plugin directory:** `os.OpenRoot.Stat` does not prevent a hardlink to an arbitrary executable that was placed inside the plugin directory by someone with filesystem write access. This is outside the stated threat model (remote authenticated user).

## Comparison of Behavior Before and After

| Input | v2.0.0 | v2.0.1 |
|-------|--------|--------|
| `PluginID = "../../bin/ls"` | Resolves to `/bin/ls`, executes if executable | Blocked by `root.Stat` → `ErrPluginNotExists` |
| `PluginID = "symlink_to_bin_ls"` (in plugin dir) | `os.Stat` follows symlink → executes `/bin/ls` | Blocked by `root.Stat` → `ErrPluginNotExists` |
| Create with explicit `NodeID` | Feasibility skipped → forwarded to client | Feasibility enforced → blocked at server if plugin missing |
| Register with traversal `PluginID` | Stored in raft, Delete forwards to client | Same, but client `os.OpenRoot` blocks execution |

## Verdict

The fix is **complete within its stated threat model** (remote authenticated attacker). The client-side `os.OpenRoot` defense is robust against path-traversal and symlink-based escapes. No true bypass that reaches the vulnerable sink on the fixed version was found. The Register endpoint represents a **latent variant path** that should be hardened with the same plugin-ID validation to provide defense-in-depth, but it does not currently allow exploitation because the client-side containment stops it.
