Skip to content

feat(windows): security hardening and cross-platform tests#173

Open
JF10R wants to merge 11 commits intojustrach:mainfrom
JF10R:windows/security-tests
Open

feat(windows): security hardening and cross-platform tests#173
JF10R wants to merge 11 commits intojustrach:mainfrom
JF10R:windows/security-tests

Conversation

@JF10R
Copy link
Copy Markdown

@JF10R JF10R commented Apr 6, 2026

Closes #184
Depends on #170, #171, #172

Summary

Hardens path safety and root policy for Windows: isPathSafe rejects backslash traversal and drive letters, root_policy blocks Windows temp directories, and tests are cross-platform.

Changes

File Change
src/mcp.zig isPathSafe splits on both / and \; rejects \ prefix, drive letters (C:\), ..\ traversal; restores NUL byte check
src/server.zig Delegate isPathSafe to mcp.zig (deduplicate)
src/root_policy.zig Case-insensitive isExactOrChildCaseInsensitive helper; reject C:\Windows\Temp and AppData\Local\Temp with boundary check (no false positives on TempProject); guard std.posix.getenv with comptime Windows check
src/tests.zig New isPathSafe: rejects Windows-style paths test; cross-platform issue-77 temp root test (%TEMP% on Windows, /private/tmp on macOS)

Tests

$ zig build test --summary all
Build Summary: 7/7 steps succeeded; 300/304 tests passed; 4 skipped

All tests pass. 4 skipped are POSIX-only (mmap, pipe) correctly guarded with SkipZigTest.

Before/After

  • Before: isPathSafe("C:\Windows\System32")true (bypass). isPathSafe("..\..\secret")true (bypass). Root policy allows C:\Windows\Temp.
  • After: All three correctly rejected. NUL byte injection blocked. AppData\Local\TempProject not falsely rejected.

Compliance

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7cc819792

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1302 to +1304
const sep = for (rest, 0..) |c, i| {
if (c == '/' or c == '\\') break i;
} else rest.len;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve sensitive-file blocking for backslash paths

isPathSafe now accepts relative paths that use \ separators, but the subsequent sensitive-file gate in watcher.isSensitivePath still parses only / separators. On Windows this allows requests like .ssh\id_rsa or secrets\credentials.json to pass traversal checks and evade the secret filter in codedb_read/codedb_edit, exposing files that were previously blocked when backslashes were rejected entirely.

Useful? React with 👍 / 👎.

Comment on lines +176 to +177
const cmd = std.fmt.bufPrint(&cmd_buf, "curl -sf -X POST {s} -H 'Content-Type: application/json' --data-binary @{s} >/dev/null 2>&1 && : > {s}", .{ CLOUD_URL, path, path }) catch return;
var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, std.heap.page_allocator);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid shell interpolation when syncing telemetry

This change regresses from argv-based curl execution to shell command strings with {path} interpolated directly. Because path derives from environment-backed directories (HOME/USERPROFILE), shell metacharacters or even spaces in the path can alter parsing, fail uploads, or execute unintended shell fragments; the previous implementation avoided this class entirely by passing arguments as an argv array.

Useful? React with 👍 / 👎.

child.stdin_behavior = .Ignore;
child.stdout_behavior = .Ignore;
child.stderr_behavior = .Ignore;
_ = child.spawn() catch return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wait for telemetry sync children to exit

syncToCloud now calls spawn() and returns immediately without wait/spawnAndWait. Since this path is invoked repeatedly during a session, completed children are not reaped by this code path, which can accumulate process/handle leaks over long-lived runs (especially visible as zombies on Unix).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR is the final step in the Windows support series, focusing on security hardening for Windows path handling and making the test suite more cross-platform while reducing Windows stack pressure.

Changes:

  • Harden path validation (isPathSafe) and root indexing policy to reject Windows-specific unsafe paths and temp directories.
  • Reduce Windows stack usage by introducing compat.path_buf_size and moving large buffers/queues to heap allocation.
  • Update/add tests to cover Windows-style path attacks and make temp-directory tests cross-platform.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/compat.zig Introduces path_buf_size to cap Windows stack buffer sizes.
src/watcher.zig Uses compat.path_buf_size, heap-allocates EventQueue, improves separator handling, and makes notify path Windows-aware.
src/index.zig Heap-allocates large frequency-table buffers to avoid Windows stack overflow.
src/main.zig Adds Windows guardrails (disable serve), switches to noinline mainImpl, updates queue init API usage, and uses path_buf_size.
src/mcp.zig Uses path_buf_size, improves Windows support (HOME/USERPROFILE), and hardens isPathSafe traversal logic.
src/server.zig Deduplicates isPathSafe by delegating to mcp.zig.
src/root_policy.zig Adds Windows temp directory denial and case-insensitive matching.
src/snapshot.zig Adds USERPROFILE fallback for Windows.
src/store.zig Adds Windows file locking implementation for cross-process log safety.
src/telemetry.zig Uses path_buf_size and changes cloud sync execution strategy.
src/tests.zig Updates queue tests for new init/deinit API; adds Windows-style isPathSafe tests; makes issue-77 temp-root test cross-platform.
build.zig Sets Windows executable stack size to 8MB.

Comment on lines +165 to +169
if (comptime @import("builtin").os.tag == .windows) {
// On Windows, use cmd.exe to run curl and truncate the file
var cmd_buf: [2048]u8 = undefined;
const cmd = std.fmt.bufPrint(&cmd_buf, "curl -sf -X POST {s} -H \"Content-Type: application/json\" --data-binary @{s} >NUL 2>&1 && type nul > {s}", .{ CLOUD_URL, path, path }) catch return;
var child = std.process.Child.init(&.{ "cmd.exe", "/C", cmd }, std.heap.page_allocator);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

syncToCloud builds a shell command by string-interpolating path and CLOUD_URL. This reintroduces command-injection risk and is also fragile for legitimate paths containing spaces/quotes (common on Windows user profiles), and the fixed 2048-byte buffer can truncate/disable sync for long paths. Prefer argv-based std.process.Child.run/spawnAndWait with explicit args, and truncate the file via Zig file APIs after confirming success (no shell/redirection).

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +173
var child = std.process.Child.init(&.{ "cmd.exe", "/C", cmd }, std.heap.page_allocator);
child.stdin_behavior = .Ignore;
child.stdout_behavior = .Ignore;
child.stderr_behavior = .Ignore;
_ = child.spawn() catch return;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

child.spawn() is called without any subsequent wait()/spawnAndWait(). On POSIX this can leave zombie processes until the parent exits, and on all platforms it drops any signal of whether the sync/truncate succeeded. Consider spawnAndWait() (or Child.run) if you need success semantics, or an explicit detached/background reaping strategy.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +32
if (builtin.os.tag == .windows) {
if (isExactOrChildCaseInsensitive(path, "C:\\Windows\\Temp")) return false;
if (std.ascii.indexOfIgnoreCase(path, "\\AppData\\Local\\Temp")) |_| return false;
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

indexOfIgnoreCase(path, "\\AppData\\Local\\Temp") matches any occurrence, so it will also deny paths like C:\Users\dev\AppData\Local\TempProject that are not actually under the Temp directory. Consider enforcing a boundary (end-of-string or path separator) after the match so only ...\Temp and its children are rejected.

Copilot uses AI. Check for mistakes.
src/tests.zig Outdated
const tmp_name = try std.fmt.bufPrint(&tmp_name_buf, "codedb-issue-77-{d}", .{std.time.microTimestamp()});
const tmp_root = try std.fs.path.join(testing.allocator, &.{ "/private/tmp", tmp_name });
const tmp_base = if (comptime @import("builtin").os.tag == .windows)
std.process.getEnvVarOwned(testing.allocator, "TEMP") catch return
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

On Windows, if TEMP is missing this test returns early and silently passes. Prefer return error.SkipZigTest (or fall back to TMP) so CI doesn’t accidentally stop exercising the behavior when env vars are unavailable.

Suggested change
std.process.getEnvVarOwned(testing.allocator, "TEMP") catch return
std.process.getEnvVarOwned(testing.allocator, "TEMP") catch
std.process.getEnvVarOwned(testing.allocator, "TMP") catch return error.SkipZigTest

Copilot uses AI. Check for mistakes.
if (std.mem.indexOfScalar(u8, path, '\\') != null) return false;
var it = std.mem.splitScalar(u8, path, '/');
while (it.next()) |component| {
if (path[0] == '/' or path[0] == '\\') return false;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

isPathSafe no longer rejects embedded NUL bytes. That check is important to prevent path-truncation style issues when paths flow into OS/file APIs. Add back an explicit indexOfScalar(u8, path, 0) rejection before component parsing.

Suggested change
if (path[0] == '/' or path[0] == '\\') return false;
if (path[0] == '/' or path[0] == '\\') return false;
if (std.mem.indexOfScalar(u8, path, 0) != null) return false;

Copilot uses AI. Check for mistakes.
src/store.zig Outdated
const max_off: i64 = std.math.maxInt(i64);
const zero_off: i64 = 0;
var iosb: std.os.windows.IO_STATUS_BLOCK = undefined;
_ = ntdll.NtLockFile(log.handle, null, null, null, &iosb, &zero_off, &max_off, null, 0, 1);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The Windows advisory lock attempt ignores the return status and unconditionally defers an unlock. If the lock fails, writes proceed without cross-process safety (and unlocking a lock you don’t hold may also fail). Consider checking the status and only unlocking when the lock was acquired (mirroring the previous locked boolean pattern).

Suggested change
_ = ntdll.NtLockFile(log.handle, null, null, null, &iosb, &zero_off, &max_off, null, 0, 1);
const lock_status = ntdll.NtLockFile(log.handle, null, null, null, &iosb, &zero_off, &max_off, null, 0, 1);
if (lock_status != 0) return error.Unexpected;

Copilot uses AI. Check for mistakes.
@JF10R JF10R force-pushed the windows/security-tests branch 2 times, most recently from 0598521 to 7433590 Compare April 6, 2026 15:05
@JF10R
Copy link
Copy Markdown
Author

JF10R commented Apr 6, 2026

Addressing reviewer feedback

✅ Fixed: mcp.zig:1296 — NUL byte check missing (P1)

Restored std.mem.indexOfScalar(u8, path, 0) != null rejection. This was accidentally dropped when rewriting isPathSafe for cross-platform separators. NUL bytes in paths can cause truncation when passed to OS APIs. (commit 7433590)

✅ Fixed: root_policy.zig:32AppData\Local\Temp false positive (P2)

indexOfIgnoreCase now checks that the match is followed by end-of-string, \, or /. Paths like C:\Users\dev\AppData\Local\TempProject are no longer incorrectly rejected. (commit 7433590)

✅ Fixed: tests.zig:3581 — silent early return when TEMP missing

Changed catch return to catch return error.SkipZigTest so CI reports the test as skipped rather than silently passing. (commit 7433590)

Not applicable: mcp.zig:1304 — sensitive-file blocking for backslash paths (P1)

This PR does not modify isSensitivePath — that function exists in upstream's watcher.zig and is called by upstream's handleRead/handleEdit. Our isPathSafe change is orthogonal: it validates path structure (no traversal, no absolute paths), while isSensitivePath blocks specific filenames (.env, credentials.json, etc.). Both checks are preserved in upstream's code. The reviewer may have been looking at an earlier diff where isSensitivePath appeared removed — that was from our local branch's unrelated changes, not from this PR.

Fixed in #171: store.zig:88 — NtLockFile status

The NtLockFile return status check was fixed in PR #171 (commit ef0ef34). This PR inherits that fix via the merge.

Fixed in #172: telemetry.zig — shell injection + zombies

The telemetry shell interpolation was fixed in PR #172 (commit a81c763). This PR inherits that fix via the merge.

@justrach
Copy link
Copy Markdown
Owner

justrach commented Apr 6, 2026

Hey @JF10R — thanks for the Windows work! Before we can review and merge, could you please:

  1. Rebase onto current main — we've landed several changes (mmap trigram index, fuzzy search, process lifecycle fixes, root policy updates) that may conflict
  2. Follow the requirements in CONTRIBUTING.md — each PR needs:
    • Linked issue number
    • Summary of exact changes + files touched
    • Tests run (include zig build test output)
    • Failing test before fix / passing test after
    • Confirmation branch is rebased onto current main
    • No generated artifacts committed
  3. Keep PRs tightly scoped — the foundation → I/O → MCP → security chain looks good, just make sure each one compiles independently after rebase

We'll get Codex to do a review pass once rebased. Thanks!

Copy link
Copy Markdown
Owner

@justrach justrach left a comment

Choose a reason for hiding this comment

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

Requesting rebase onto current main and CONTRIBUTING.md compliance before review. See comment above.

JF10R added 10 commits April 6, 2026 19:37
Add Windows platform support foundations that all subsequent Windows PRs
depend on. No behavioral change on Linux/macOS.

- compat.zig: add path_buf_size (1024 on Windows vs max_path_bytes)
- build.zig: set 8MB stack for Windows (matches Linux default)
- main.zig: replace thread trampoline with noinline mainImpl to avoid
  LLVM frame merging (~128MB) that exceeds Windows guard page stride
- main.zig: fix double scan_thread.join() (UB on all platforms)
- watcher.zig: heap-allocate EventQueue (avoids ~4MB stack array)
- watcher.zig: use compat.path_buf_size in FsEvent
- index.zig: heap-allocate frequency table buffers (~640KB each)
- tests.zig: update EventQueue construction to use init()/deinit()
Upstream issue-148 added stdin HUP detection via std.posix.poll() and
pipe tests. These use POSIX APIs unavailable on Windows. Guard the poll
block with comptime os check (Windows uses idle timeout only) and skip
pipe tests on Windows.
Upstream's mmap trigram index (justrach#167) uses std.posix.mmap/munmap which
are unavailable on Windows. Guard initFromDisk to return null on Windows
(falling back to heap-based index) and skip mmap-dependent tests.
Platform-specific I/O changes for Windows file system operations.

- store.zig: use NtLockFile/NtUnlockFile on Windows (POSIX flock on others)
- watcher.zig: add isSep() to handle both / and \ path separators
- watcher.zig: backslash-aware shouldSkip() for Windows paths
- watcher.zig: use %TEMP%/%TMP% for codedb-notify path on Windows
- watcher.zig: use setEndPos() instead of ftruncate (cross-platform)
- watcher.zig: trim both / and \ when making paths relative to root
- store.zig: check NtLockFile return status; only unlock if lock
  succeeded; skip write-under-lock if lock fails (matches upstream's
  advisory lock pattern)
- watcher.zig: normalize backslash paths to forward slashes in
  drainNotifyFile to avoid duplicate entries in the explorer (walker
  uses / convention, notify file may contain \ on Windows)
Remove barriers preventing MCP and telemetry from working on Windows.

- main.zig: add is_windows const; block HTTP serve with error message
  directing users to mcp mode; HOME→USERPROFILE fallback in getDataDir
- mcp.zig: import compat; noinline on run/dispatch to prevent LLVM frame
  merging; never_inline for heavy handlers (handleRemote, handleIndex,
  handleProjects); HOME→USERPROFILE fallback; path_buf_size; pass cwd
  to Child.run in handleIndex
- snapshot.zig: HOME→USERPROFILE fallback in writeSnapshotDual
- telemetry.zig: use cmd.exe /C for cloud sync on Windows; use
  compat.path_buf_size for buffer declarations
Revert from shell command string interpolation (cmd.exe /C, /bin/sh -c)
to argv-based curl execution. This:
- Eliminates command injection risk from path interpolation
- Avoids zombie processes (spawnAndWait instead of spawn)
- Works on both Windows (curl ships with Win10+) and POSIX
- Truncates telemetry file after successful sync via createFile
Windows-aware path safety checks and test updates.

- mcp.zig: isPathSafe now rejects backslash prefix, drive letters (C:\),
  and ..\ traversal — splits on both / and \ separators
- server.zig: delegate isPathSafe to mcp.zig (single canonical impl)
- root_policy.zig: add case-insensitive matching for Windows paths;
  reject C:\Windows\Temp and AppData\Local\Temp as indexable roots
- tests.zig: add isPathSafe Windows path tests; make issue-77 test
  cross-platform (%TEMP% on Windows, /private/tmp on macOS)
- mcp.zig: restore NUL byte rejection in isPathSafe (prevents path
  truncation attacks)
- root_policy.zig: add boundary check after AppData\Local\Temp match
  to avoid false positives like TempProject
- tests.zig: use error.SkipZigTest instead of silent return when
  TEMP env var is missing on Windows
@JF10R JF10R force-pushed the windows/security-tests branch from 7433590 to a54145a Compare April 6, 2026 23:45
@JF10R
Copy link
Copy Markdown
Author

JF10R commented Apr 6, 2026

Rebased onto current main (d7e3bfb), updated PR description per CONTRIBUTING.md.

Resolved rebase conflict in root_policy.zig: merged upstream home directory blocking (#174) with our Windows temp directory rejection. Added boundary check so AppData\Local\TempProject is not falsely rejected.

Additional fix: guarded upstream's std.posix.getenv("HOME") in root_policy.zig with comptime builtin.os.tag != .windows — this API is unavailable on Windows (env strings are WTF-16). Windows home dir blocking still works via the pattern matching below (/home/user, /Users/user, /root).

zig build test output

$ zig build test --summary all
Build Summary: 7/7 steps succeeded; 300/304 tests passed; 4 skipped
test success
+- run test 255 passed 4 skipped
+- run test success
+- run test 45 passed

All tests pass. 0 failures. 300/304 passed, 4 skipped.

4 skipped (correctly guarded with SkipZigTest on Windows):

  • issue-148: POLLHUP detects closed pipe — uses std.posix.pipe() / std.posix.poll()
  • issue-164: mmap trigram index returns same candidates as heap index — uses std.posix.mmap
  • issue-164: mmap binary search on sorted lookup table — uses std.posix.mmap
  • issue-164: AnyTrigramIndex dispatches to mmap variant — uses std.posix.mmap

Red-to-green

Before (without this PR):

isPathSafe("C:\Windows\System32")  → true  (drive letter bypass)
isPathSafe("..\..\..\secret")      → true  (backslash traversal bypass)
isPathSafe("file\x00.txt")        → true  (NUL injection)
isIndexableRoot("C:\Windows\Temp") → true  (Windows temp allowed)

After:

isPathSafe("C:\Windows\System32")  → false (drive letter rejected)
isPathSafe("..\..\..\secret")      → false (backslash traversal rejected)
isPathSafe("file\x00.txt")        → false (NUL byte rejected)
isIndexableRoot("C:\Windows\Temp") → false (Windows temp rejected)

New test isPathSafe: rejects Windows-style paths exercises all these cases and passes. Boundary check verified: isIndexableRoot("C:\Users\dev\AppData\Local\TempProject")true (not falsely rejected).

Nearby non-regression checks

All 300 passing tests include the full upstream test suite. Specifically:

  • All existing isPathSafe tests pass (POSIX paths unaffected)
  • All existing root_policy tests pass (issue-80, issue-174 upstream tests)
  • issue-77: temp root test now cross-platform (%TEMP% on Windows, /private/tmp on macOS)
  • server.zig delegates to mcp.isPathSafe — all server path validation tests pass
  • isExactOrChild now handles both separators — upstream /tmp and /private/tmp tests still pass
  • issue-150: --help and issue-150: -h both pass (binary available after build)

Upstream's home directory blocking (justrach#174) uses std.posix.getenv("HOME")
which is unavailable on Windows (env strings are WTF-16). Guard with
comptime check — Windows home dir blocking still works via the pattern
matching below (/home/user, /Users/user, /root).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Windows support — security hardening and cross-platform tests

3 participants