Skip to content

fix(security): validate run ID in rollback to prevent path traversal [MEDIUM]#1559

Merged
ericksoa merged 5 commits intoNVIDIA:mainfrom
Joshua-Medvinsky:fix/path-traversal-rollback-runid
Apr 9, 2026
Merged

fix(security): validate run ID in rollback to prevent path traversal [MEDIUM]#1559
ericksoa merged 5 commits intoNVIDIA:mainfrom
Joshua-Medvinsky:fix/path-traversal-rollback-runid

Conversation

@Joshua-Medvinsky
Copy link
Copy Markdown
Contributor

@Joshua-Medvinsky Joshua-Medvinsky commented Apr 7, 2026

Security Finding: Path Traversal in Rollback via Unsanitized Run ID

Severity: MEDIUM
Reported by: FailSafe Security Researcher
Component: nemoclaw/src/blueprint/runner.tsactionRollback()

Description

The actionRollback() function accepts a --run-id CLI argument and concatenates it directly into a path.join() call without sanitization or validation. Node.js path.join() resolves .. path components, allowing the resulting directory to escape the intended .nemoclaw/state/runs/ directory tree.

The function then performs three filesystem operations against the attacker-controlled path: a directory listing (readdirSync), a file read (readFileSync for plan.json), and a file write (writeFileSync for a rolled_back marker file). The sandbox_name parsed from the traversed plan.json is subsequently passed to openshell sandbox stop and openshell sandbox remove commands.

The rid value is taken directly from the CLI argument with no filtering:

case "--run-id":
  runId = requireValue("--run-id", ++i);

Fix

  • Add regex validation: run IDs must match ^[a-zA-Z0-9_-]+$ (alphanumeric, hyphens, underscores only)
  • Add resolved-path prefix check as defense-in-depth: verify the resolved stateDir starts with the runsRoot directory

Test plan

  • Verify nemoclaw rollback --run-id nc-20260406-abc12345 still works
  • Verify nemoclaw rollback --run-id "../../etc" is rejected with "Invalid run ID"
  • Verify nemoclaw rollback --run-id "../../../tmp/evil" is rejected
  • Verify nemoclaw rollback --run-id "valid-but-nonexistent" still reports "Run not found"

Summary by CodeRabbit

  • Bug Fixes

    • Enforced stricter validation for run identifiers and added safeguards when resolving run-state directories to prevent path traversal, absolute/invalid paths, and ensure operations remain within the intended runs area.
  • Tests

    • Added tests that reject malformed or malicious run IDs (path traversal, absolute paths, nulls, dots, empty) and verify valid run IDs are accepted and located correctly.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Added run-ID validation and a safe run-directory resolver to nemoclaw/src/blueprint/runner.ts. validateRunId enforces ^[a-zA-Z0-9_-]+$; safeRunDir joins the run path and verifies it remains inside the runs root using sep. actionStatus and actionRollback now use safeRunDir. Tests added for rejection/acceptance of IDs.

Changes

Cohort / File(s) Summary
Runner: validation & path-hardening
nemoclaw/src/blueprint/runner.ts
Added validateRunId(rid) (whitelist ^[a-zA-Z0-9_-]+$), added safeRunDir(runsDir, rid) which performs path.join then checks startsWith(runsDir + sep) to prevent traversal; replaced direct join usage in actionStatus and actionRollback; imported sep.
Tests: run-id rejection/acceptance
nemoclaw/src/blueprint/runner.test.ts
Added tests asserting actionStatus and actionRollback throw on malicious/invalid run_id inputs (path traversal, absolute paths, null bytes, dot-containing strings, empty) and a positive case accepting a hyphenated run ID that reads plan.json.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through folders, nose alert and bright,
A little regex gate kept every path polite.
I joined the trail and checked the border tight,
No sneaky dots or nulls to give me fright.
Now every run sleeps safe through the night. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main security fix: validating run ID to prevent path traversal attacks, which is the primary objective of the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
nemoclaw/src/blueprint/runner.ts (2)

337-338: ⚠️ Potential issue | 🔴 Critical

actionStatus has the same path traversal vulnerability that remains unfixed.

The rid parameter in actionStatus is used directly in join(runsDir, rid) at line 338 without validation, then passed to readFileSync at line 355. This allows the same path traversal attack pattern (e.g., --run-id ../../etc) to read arbitrary files.

Apply the same validation used in actionRollback:

🔒 Proposed fix
 export function actionStatus(rid?: string): void {
   emitRunId();
   const runsDir = join(homedir(), ".nemoclaw", "state", "runs");

   let runDir: string;
   if (rid) {
+    if (!/^[a-zA-Z0-9_-]+$/.test(rid)) {
+      throw new Error(`Invalid run ID: '${rid}'`);
+    }
     runDir = join(runsDir, rid);
+    if (!runDir.startsWith(runsDir + sep)) {
+      throw new Error("Run ID resolves outside expected directory");
+    }
   } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/blueprint/runner.ts` around lines 337 - 338, The actionStatus
handler currently uses rid directly in join(runsDir, rid) and then reads files,
creating a path traversal risk; replicate the validation from actionRollback:
validate the incoming rid before using it (e.g., ensure path.basename(rid) ===
rid or match a safe regex like alphanumeric/[-_], and/or normalize+resolve the
candidate path and assert it startsWith path.resolve(runsDir)),
reject/throw/return an error for invalid rid, then compute runDir =
join(runsDir, rid) and proceed to readFileSync only after the check passes;
reference actionStatus, rid, runDir, runsDir, join, and readFileSync when
applying the fix.

362-377: ⚠️ Potential issue | 🔴 Critical

Add test coverage for path traversal rejection in actionRollback.

The test suite covers valid-but-nonexistent run IDs and valid format acceptance, but lacks tests for the critical path traversal validation. The code explicitly checks !stateDir.startsWith(runsRoot + sep) to prevent directory traversal attacks, but this security check has no corresponding tests. Add tests for payloads like ../../etc, ../tmp, and other traversal attempts to ensure the validation properly rejects them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/blueprint/runner.ts` around lines 362 - 377, Add unit tests for
actionRollback to assert that path-traversal run IDs are rejected: call
actionRollback with payloads like "../../etc", "../tmp", "..\\..\\windows", and
similar traversal strings and assert it throws the "Run ID resolves outside
expected directory" error (or at least rejects) because stateDir must start with
runsRoot + sep; locate the function actionRollback and the runsRoot/stateDir
logic in runner.ts and write tests that trigger and verify that branch rather
than only testing valid-format and nonexistent IDs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@nemoclaw/src/blueprint/runner.ts`:
- Around line 337-338: The actionStatus handler currently uses rid directly in
join(runsDir, rid) and then reads files, creating a path traversal risk;
replicate the validation from actionRollback: validate the incoming rid before
using it (e.g., ensure path.basename(rid) === rid or match a safe regex like
alphanumeric/[-_], and/or normalize+resolve the candidate path and assert it
startsWith path.resolve(runsDir)), reject/throw/return an error for invalid rid,
then compute runDir = join(runsDir, rid) and proceed to readFileSync only after
the check passes; reference actionStatus, rid, runDir, runsDir, join, and
readFileSync when applying the fix.
- Around line 362-377: Add unit tests for actionRollback to assert that
path-traversal run IDs are rejected: call actionRollback with payloads like
"../../etc", "../tmp", "..\\..\\windows", and similar traversal strings and
assert it throws the "Run ID resolves outside expected directory" error (or at
least rejects) because stateDir must start with runsRoot + sep; locate the
function actionRollback and the runsRoot/stateDir logic in runner.ts and write
tests that trigger and verify that branch rather than only testing valid-format
and nonexistent IDs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a0ea9aca-4696-4c12-8086-a1197b925296

📥 Commits

Reviewing files that changed from the base of the PR and between 6119baf and 5b066c7.

📒 Files selected for processing (1)
  • nemoclaw/src/blueprint/runner.ts

@wscurran wscurran added security Something isn't secure priority: high Important issue that should be resolved in the next release fix labels Apr 8, 2026
@wscurran
Copy link
Copy Markdown
Contributor

wscurran commented Apr 8, 2026

✨ Thanks for submitting this fix, which proposes a way to prevent path traversal in the rollback command by validating and sanitizing run IDs.

@cv cv added the v0.0.10 Release target label Apr 8, 2026
@cv cv self-assigned this Apr 8, 2026
Copy link
Copy Markdown
Contributor

@cv cv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review — WARNING

The fix applied to actionRollback is well-implemented — the two-layer defense (regex ^[a-zA-Z0-9_-]+$ + prefix check) is a best practice. However, the PR is incomplete and needs changes before merge.

Required Changes

1. actionStatus has the same vulnerability (HIGH)

actionStatus accepts rid from the same --run-id CLI argument and passes it unsanitized to join(runsDir, rid)readFileSync(join(runDir, "plan.json")). This allows arbitrary file read via path traversal (e.g., --run-id ../../etc would attempt to read /etc/plan.json).

Please apply the identical two-layer validation (regex + prefix check) to actionStatus.

2. No test coverage for path traversal rejection (MEDIUM)

Please add tests covering malicious run IDs for both functions:

  • ../../etc → should throw "Invalid run ID"
  • ../tmp → should throw "Invalid run ID"
  • valid.with.dots → should throw "Invalid run ID"
  • foo\x00bar → should throw "Invalid run ID"
  • nc-20260406-abc12345 → should still work

3. Error message echoes unsanitized input (LOW, optional)

throw new Error(\Invalid run ID: '${rid}'`)echoes attacker-controlled input. Consider using a generic message or truncating/sanitizingrid`.

What's Good

  • Regex character class is correct and complete — blocks dots, slashes, null bytes, empty strings
  • Prefix check with + sep suffix prevents prefix-matching bypass
  • Defense-in-depth approach is exactly the right pattern

The core fix is solid — just needs to be applied consistently. Thanks for the contribution!

… traversal [MEDIUM]

Both actionRollback() and actionStatus() accepted --run-id from CLI
arguments and passed it unsanitized to path.join(), which resolves
".." components. A crafted run ID like "../../../../etc" could escape
the .nemoclaw/state/runs/ directory, enabling arbitrary directory
listing, file reads (plan.json), and file writes (rolled_back marker).

Add validateRunId() (regex whitelist: alphanumeric, hyphens, underscores)
and safeRunDir() (prefix containment check) applied to both functions.

Reported-by: FailSafe Security Researcher
Co-Authored-By: Joshua Medvinsky <joshua-medvinsky@users.noreply.github.com>
@Joshua-Medvinsky Joshua-Medvinsky force-pushed the fix/path-traversal-rollback-runid branch from 5b066c7 to 3c630a4 Compare April 8, 2026 23:29
@Joshua-Medvinsky
Copy link
Copy Markdown
Contributor Author

Updated per review feedback:

  • Applied the same fix to actionStatus (same vulnerability — unsanitized rid in path.join)
  • Extracted shared validateRunId() (regex whitelist) and safeRunDir() (prefix containment) helpers used by both functions
  • Both actionStatus and actionRollback now validated

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nemoclaw/src/blueprint/runner.ts`:
- Around line 343-356: Add focused unit tests that explicitly exercise the
security validation functions: write tests for validateRunId to assert it throws
for traversal/punctuation payloads (e.g., "../", "//", strings with spaces or
special chars) and accepts valid alphanumerics/underscores/hyphens; write tests
for safeRunDir to call with a legitimate runsDir and malicious rid values (e.g.,
"../evil", "sub/../escape", "//absolute") and assert it throws when the resolved
path would be outside runsDir; and add an actionRollback unit test that passes
malicious run IDs to ensure the higher-level handler rejects them (asserting
error/HTTP 4xx behavior). Use the existing validateRunId, safeRunDir, and
actionRollback symbols to locate code and assert that errors are thrown (or
appropriate status returned) for each bad input.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f25db62e-e59f-41c3-aedf-59d6e0052543

📥 Commits

Reviewing files that changed from the base of the PR and between 5b066c7 and 3c630a4.

📒 Files selected for processing (1)
  • nemoclaw/src/blueprint/runner.ts

… message

Address remaining review feedback on the run ID validation fix:

- Add test coverage for malicious run IDs in both actionStatus and
  actionRollback: ../../etc, ../tmp, dots, null bytes, absolute paths,
  and empty strings all throw "Invalid run ID"
- Add positive test confirming legitimate hyphenated IDs still work
- Sanitize error message to not echo attacker-controlled input — use
  a generic description of valid characters instead

Reported-by: FailSafe Security Researcher
Co-Authored-By: Joshua Medvinsky <joshua-medvinsky@users.noreply.github.com>
@Joshua-Medvinsky
Copy link
Copy Markdown
Contributor Author

Pushed additional commit addressing remaining feedback:

  • Added path traversal rejection tests for both actionStatus and actionRollback: ../../etc, ../tmp, valid.with.dots, null bytes, absolute paths, and empty strings all throw
  • Added positive test confirming nc-20260406-abc12345 still works
  • Sanitized error message — no longer echoes raw rid input, uses generic description instead

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
nemoclaw/src/blueprint/runner.test.ts (1)

556-565: Also pin the sanitized error text in these rejection cases.

These assertions prove the payload is rejected, but they do not prove the error stays non-reflective. A regression back to Invalid run ID: ${rid} would still pass, which leaves part of the security fix unguarded.

Suggested assertion upgrade
     it.each([
       "../../etc",
       "../tmp",
       "valid.with.dots",
       "foo\x00bar",
       "/absolute/path",
       "",
     ])("rejects malicious run ID: %j", (rid) => {
-      expect(() => actionStatus(rid)).toThrow(/Invalid run ID/);
+      expect(() => actionStatus(rid)).toThrow(/Invalid run ID/);
+      try {
+        actionStatus(rid);
+      } catch (error) {
+        expect((error as Error).message).not.toContain(rid);
+      }
     });
     it.each([
       "../../etc",
       "../tmp",
       "valid.with.dots",
       "foo\x00bar",
       "/absolute/path",
       "",
     ])("rejects malicious run ID: %j", async (rid) => {
-      await expect(actionRollback(rid)).rejects.toThrow(/Invalid run ID/);
+      const error = await actionRollback(rid).catch((err: unknown) => err as Error);
+      expect(error.message).toMatch(/Invalid run ID/);
+      expect(error.message).not.toContain(rid);
     });

As per coding guidelines, "Security-sensitive code paths in isolation/sandbox features must have extra test coverage to prevent credential leaks and sandbox escapes."

Also applies to: 630-639

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/blueprint/runner.test.ts` around lines 556 - 565, Tests
currently only assert that actionStatus(rid) throws, but not that the thrown
message is sanitized; update the two test blocks (including the case at
actionStatus in this file and the similar block around lines 630-639) to assert
the exact non-reflective error text instead of a generic /Invalid run ID/ match
— e.g., expect(() => actionStatus(rid)).toThrowError(new Error("Invalid run
ID")) or toThrow(/^Invalid run ID$/) — so the spec pins the sanitized message
and will fail if the implementation regresses to reflecting the input run ID.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@nemoclaw/src/blueprint/runner.test.ts`:
- Around line 556-565: Tests currently only assert that actionStatus(rid)
throws, but not that the thrown message is sanitized; update the two test blocks
(including the case at actionStatus in this file and the similar block around
lines 630-639) to assert the exact non-reflective error text instead of a
generic /Invalid run ID/ match — e.g., expect(() =>
actionStatus(rid)).toThrowError(new Error("Invalid run ID")) or
toThrow(/^Invalid run ID$/) — so the spec pins the sanitized message and will
fail if the implementation regresses to reflecting the input run ID.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2262236a-472f-4d34-b34f-84740f7a9372

📥 Commits

Reviewing files that changed from the base of the PR and between 3c630a4 and b0efa81.

📒 Files selected for processing (2)
  • nemoclaw/src/blueprint/runner.test.ts
  • nemoclaw/src/blueprint/runner.ts

Copy link
Copy Markdown
Contributor

@cv cv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all three original findings addressed.

  1. actionStatus patched — now uses shared safeRunDir() + validateRunId()
  2. Tests addedit.each blocks covering traversal payloads for both functions
  3. Error message sanitized — no longer echoes attacker-controlled input

Minor note for follow-up: test assertions use /Invalid run ID/ which matches both old and new error text — consider tightening to /must contain only alphanumeric/ or adding expect(error.message).not.toContain(rid) to guard against sanitization regression. Not blocking.

No concerns.

@ericksoa
Copy link
Copy Markdown
Contributor

ericksoa commented Apr 9, 2026

Thanks for addressing all the review feedback — the safeRunDir() approach with regex + prefix check is clean, and the sanitized error messages are correct. I pushed a small test fix for the empty-string edge case.

One thing still needed before we can merge:

DCO signoff missing
None of the commits have Signed-off-by: lines, which will fail the dco-check CI job. You can fix this with:

git rebase --signoff HEAD~2
git push --force-with-lease

Once that's done we'll merge. Thanks for the contribution!

…cases

actionStatus(rid) only calls validateRunId when rid is truthy.
Empty string is falsy in JS, so it takes the "no rid" codepath
instead of throwing "Invalid run ID". This caused a false test
expectation. The empty string test case remains in actionRollback
where rid is always validated.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Copy link
Copy Markdown
Contributor

@ericksoa ericksoa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review feedback addressed. Path traversal validation applied to both actionStatus and actionRollback, tests cover malicious inputs, error messages sanitized. LGTM.

@ericksoa ericksoa merged commit 57580bd into NVIDIA:main Apr 9, 2026
6 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix priority: high Important issue that should be resolved in the next release security Something isn't secure v0.0.10 Release target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants