diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f61f817..5f4f5bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ ### Security and Correctness +- **Security:** Resolve symlink aliases component-by-component before applying later `..` segments in the exported root-path validator, and validate raw `root()` inputs before normalized I/O, preventing alias-dependent traversal from being approved under a different lexical path. +- Prefer macOS 15.4's `O_RESOLVE_BENEATH` for native opens, retain the guarded component walk on older kernels, and apply an `F_GETPATH` post-open escape detector to both routes without claiming rename-race atomicity. +- Report open containment explicitly: native `openBeneath()` returns `{ fd, containment }` with `kernel-atomic` on Linux and `best-effort` on macOS/Windows, while JavaScript root open/read/writable results report `best-effort`. - Serialize async `jsonStore` writes and read-modify-write updates in-process by canonical store path before taking the cross-process sidecar lock, preventing overlapping `write`, `update`, and `updateOr` calls from silently losing updates; reject nested same-path mutations with typed `store-reentrant-update` errors. Thanks @yetval for reporting this. - Create `append`, `openWritable`, and fallback `copyIn` parents through guarded per-component walks and continue I/O through the resolved in-root parent, preventing symlink-swap races from creating directories outside the root while preserving valid in-root symlink parents. Thanks @yetval for reporting this. - Add pinned-destination hardlink rejection and bounded original-content restoration to `replaceFileAtomic()` and its sync variant, including typed `restored` / `restore-failed` receipts for torn copy-fallback writes. diff --git a/README.md b/README.md index 50d2e0e1..51ae6b17 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Capability-style filesystem roots for Node.js apps that handle untrusted relative paths. -Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. Hand `root()` a trusted directory and you get back a handle whose every method resolves relative paths against it and refuses to escape — through `..`, symlink swaps, hardlink aliases, or TOCTOU rename races between check and use. +Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. Hand `root()` a trusted directory and you get back a handle whose every method resolves relative paths against it and defends against `..`, symlink swaps, hardlink aliases, and TOCTOU rename races. The exact containment strength is reported per mechanism: Linux native opens are kernel-atomic; macOS, Windows, and JavaScript paths are best-effort. ```ts import { root } from "@openclaw/fs-safe"; @@ -45,11 +45,11 @@ The same idea has landed in other languages. Go [added `os.Root` and `OpenInRoot | `path.resolve().startsWith()` | string check only | – | – | – | – | | [`write-file-atomic`](https://www.npmjs.com/package/write-file-atomic) | – | ✓ | – | – | – | | Go [`os.Root`](https://go.dev/blog/osroot) / Rust [`cap-std`](https://github.com/bytecodealliance/cap-std) | ✓ | platform | ✓ | ✓ | – | -| **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **✓ (POSIX fd-relative)** | **✓ (ZIP/TAR; native zstd/bzip2)** | +| **`@openclaw/fs-safe`** | **✓** | **✓** | **✓** | **Linux atomic; others best-effort** | **✓ (ZIP/TAR; native zstd/bzip2)** | ## Not a sandbox -This is a **library-level guardrail**, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. If your threat model is a hostile process, you need OS isolation; if your threat model is "an agent, plugin, upload handler, or CLI will eventually be tricked into writing somewhere it shouldn't," `fs-safe` catches that. +This is a **library-level guardrail**, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. If your threat model is a hostile process, you need OS isolation; if your threat model is "an agent, plugin, upload handler, or CLI will eventually be tricked into writing somewhere it shouldn't," `fs-safe` catches that. The [security model](docs/security-model.md) describes the exact Linux, macOS, Windows, and JavaScript fallback guarantees and race boundaries. ## Install @@ -79,6 +79,10 @@ helper policy](docs/native-helper.md) for the exact boundary and deployment tradeoff, and [native architecture](docs/native.md) for the platform mechanisms and policy ownership model. +Open results report the mechanism's containment class as `"kernel-atomic"` or +`"best-effort"`. Linux native `openBeneath()` is kernel-atomic; macOS, Windows, +and guarded JavaScript results are best-effort. See the [security model](docs/security-model.md#containment-guarantees-by-platform) before using that fact in higher-level policy. + ## Migrating from the Python helper Version 0.5 replaces the persistent Python worker with optional prebuilt native @@ -184,7 +188,7 @@ const locked = await root("/srv/workspace", { await locked.write(".env", "token"); // FsSafeError code "denied-path" ``` -`stat()`, `exists()`, and `list()` are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` for operations that must be race-resistant at the point of use. +`stat()`, `exists()`, and `list()` are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` for operation-local identity checks, and inspect `containment` when the platform distinction matters. ## Subpaths diff --git a/docs/archive.md b/docs/archive.md index 875012da..20cf8af1 100644 --- a/docs/archive.md +++ b/docs/archive.md @@ -146,7 +146,7 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and - **Path traversal:** entries with `..`, absolute paths, or Windows drive prefixes are rejected (`ArchiveSecurityError`). - **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them. -- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. A symlink swap in the destination tree mid-merge is caught. +- **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform). - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk. - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun. - **Metadata bombs:** a fixed-header pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 size fields without interpreting metadata content. diff --git a/docs/native-helper.md b/docs/native-helper.md index 0580c45b..a25646cc 100644 --- a/docs/native-helper.md +++ b/docs/native-helper.md @@ -36,7 +36,7 @@ layer owns policy, retries, filters, budgets, modes, cleanup, error normalization, and the decision to fall back. - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS` and `renameat2(RENAME_NOREPLACE)`. -- macOS resolves components with `O_NOFOLLOW`, restarts in-root symlinks from the pinned root descriptor, and uses `renameatx_np(RENAME_EXCL)`. +- macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. No-replace publication uses `renameatx_np(RENAME_EXCL)`. - Windows uses handle-relative `NtCreateFile`, rejects reparse points, and uses `FileRenameInfoEx` with replacement disabled. Native primitives back create-only pinned writes, async sidecar creation, @@ -45,6 +45,11 @@ Equivalent JavaScript paths remain available for documented fallback-capable features. See [Native architecture](native.md#javascript-fallback-guarantees-and-delta) for the exact difference. +`openBeneath()` returns `{ fd, containment }`. `containment` is +`"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and +Windows. Public JavaScript root open/read/writable results also expose the +field and report `"best-effort"`; the label reports mechanism, not policy. + ## Migration from the Python helper Version 0.5 removes the Python worker and interpreter-path selection. The mode diff --git a/docs/native.md b/docs/native.md index efd5428d..90e3aa2c 100644 --- a/docs/native.md +++ b/docs/native.md @@ -40,8 +40,11 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable. - Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative `mkdirat`/`linkat`/`renameat2`, `FICLONE`, and `copy_file_range`. -- macOS walks components with `openat(O_NOFOLLOW)`, restarts in-root symlinks - from the pinned root, uses `renameatx_np(RENAME_EXCL)`, and permits +- macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk + components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the + pinned root. Both routes apply an `F_GETPATH` post-open containment detector, + but directory rename races mean the result remains `best-effort`, not + race-atomic. macOS uses `renameatx_np(RENAME_EXCL)` and permits `fclonefileat` in an owned, non-shared parent. The clone is normalized inside a private staging directory: flags, ACLs, extended attributes, and broad mode bits are cleared before no-replace publication. @@ -94,7 +97,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability: | Capability | Native path | Guarded JavaScript path | |---|---|---| -| Root-relative opens/mutations | Descriptor-relative beneath operations; Linux uses `openat2`, Windows rejects reparse traversal in the object-manager call. | Lexical + canonical checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. | +| Root-relative opens/mutations | Descriptor-relative beneath operations. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. | | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | JSZip/node-tar into a private stage, then the same guarded merge policy. | | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. | | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. | diff --git a/docs/quickstart.md b/docs/quickstart.md index c4734721..5e11a609 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -67,7 +67,7 @@ const names = await fs.list("state"); // string[] const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[] ``` -`exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For race-resistant reads or writes, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` — they pin the path identity at the point of use. +`exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. Linux native beneath opens are kernel-atomic; other mechanisms remain best-effort as documented in the [security model](security-model.md#containment-guarantees-by-platform). ## 6. Catch escapes diff --git a/docs/reading.md b/docs/reading.md index 6fa72327..f4416756 100644 --- a/docs/reading.md +++ b/docs/reading.md @@ -3,7 +3,7 @@ The `Root` handle exposes five read shapes. Pick the narrowest one that gives you what you need — narrower shapes do less work and surface fewer footguns. ```ts -const result = await fs.read("notes/today.txt"); // { buffer, realPath, stat } +const result = await fs.read("notes/today.txt"); // { buffer, containment, realPath, stat } const text = await fs.readText("notes/today.txt"); // string const bytes = await fs.readBytes("image.png"); // Buffer const json = await fs.readJson("config.json"); // T @@ -30,7 +30,7 @@ Regardless of shape, every read goes through the same boundary checks: The full result. Use it when you need both the bytes and the verified `realPath` or `stat`: ```ts -const { buffer, realPath, stat } = await fs.read("notes/today.txt"); +const { buffer, containment, realPath, stat } = await fs.read("notes/today.txt"); console.log(`${stat.size} bytes at ${realPath}`); ``` @@ -63,7 +63,7 @@ For tighter control over malformed-or-missing JSON, use the standalone helpers i ### `fs.open(rel, options?)` -Returns a `FileHandle` plus the verified `realPath` and `stat`. Use this for streaming or partial reads, and **always close the handle**: +Returns a `FileHandle` plus `containment: "best-effort"`, the verified `realPath`, and `stat`. Use this for streaming or partial reads, and **always close the handle**: ```ts const opened = await fs.open("large.log"); @@ -121,7 +121,7 @@ if (await fs.exists("notes/today.txt")) { } ``` -A symlink swap between `exists` and `readText` is caught by the read; the boundary is per-call. +A symlink swap between `exists` and `readText` is checked again by the read; the boundary and its documented race window are per-call. ## Streaming patterns diff --git a/docs/root.md b/docs/root.md index 7123bd5d..d38d1f0e 100644 --- a/docs/root.md +++ b/docs/root.md @@ -44,11 +44,11 @@ Every method on the returned handle accepts paths relative to the root and rejec ### Reads ```ts -fs.read(rel, options?) // { buffer, realPath, stat } +fs.read(rel, options?) // { buffer, containment, realPath, stat } fs.readBytes(rel, options?) // Buffer fs.readText(rel, options?) // string fs.readJson(rel, options?) // parsed T -fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] } +fs.open(rel, options?) // { handle, containment, realPath, stat, [Symbol.asyncDispose] } fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root fs.reader(options?) // (path) => Promise; useful for loader APIs fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kind, size }> @@ -79,6 +79,10 @@ await using opened = await fs.open("large.log"); } ``` +`open()`, `read()`, and `openWritable()` results include +`containment: "best-effort"`. The field reports the mechanism used; see the +[security model](security-model.md#containment-guarantees-by-platform). + ### Writes ```ts diff --git a/docs/security-model.md b/docs/security-model.md index f6bb751e..85835e53 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -31,7 +31,7 @@ If you need full sandboxing, run the worker under reduced privileges (uid, conta ### Path traversal and absolute paths -Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Inputs containing `..`, leading `/` (without `pathScope` opt-in), or that resolve outside the root throw `outside-workspace`. +Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal that escapes, leading `/` (without `pathScope` opt-in), or any canonical result outside the root throws `outside-workspace`. ### Symlinks (read side) @@ -49,7 +49,7 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink > ### TOCTOU between resolve and use -`resolve()`, `exists()`, `stat()`, and `list()` are explicitly **not** race-resistant — they answer a question and return. To act on a path with race resistance, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. They re-pin the path identity at the point of use. +`resolve()`, `exists()`, `stat()`, and `list()` are explicitly advisory — they answer a question and return. To act on a path with operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. The containment table below states which opens are kernel-atomic and which remain best-effort. ### Denied mutations @@ -84,13 +84,20 @@ A library cannot revoke its own caller's authority. If your code chooses to bypa The library does not modify or constrain the global Node.js `fs` namespace, and it does not patch the runtime. Other code in the same process retains its normal filesystem authority. -## Platform notes +## Containment guarantees by platform -- **Linux:** Native opens use `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` and no-replace publication uses `renameat2(RENAME_NOREPLACE)`; guarded JavaScript implementations remain for operations outside the native surface. -- **macOS:** Native opens walk with `O_NOFOLLOW` and re-resolve in-root symlinks from the pinned root descriptor; no-replace publication uses `renameatx_np(RENAME_EXCL)`. -- **Windows:** Native opens are handle-relative and reject reparse points; no-replace publication uses `FileRenameInfoEx` with replacement disabled. Other operations use the guarded Node implementation. +`openBeneath()` and JavaScript open results report one of two factual containment classes: -The library does not advertise different security guarantees per platform — it advertises the same surface and relies on the strongest mechanism the platform offers. +| Mechanism | Reported containment | Boundary | +|---|---|---| +| Linux native | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. | +| macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. | +| Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. | +| JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. | + +The macOS `F_GETPATH` verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after `O_RESOLVE_BENEATH` or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains `best-effort` on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires. + +The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label. ## Limitations to keep in mind @@ -99,7 +106,7 @@ The library does not advertise different security guarantees per platform — it | Not ambient authority removal | Code that can import `node:fs` can still bypass the handle. Keep caller-controlled path operations behind `root()` by convention, review, and tests. | | Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. | | Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. | -| Per-call, not per-session | Another process with the same privileges can still mutate the tree between two separate calls. Use one verb method for the operation you need to make race-resistant. | +| Per-call, not per-session | Another process with the same privileges can still mutate the tree between calls, and best-effort mechanisms retain documented same-call race windows. Use one verb method to minimize the window and inspect its reported containment class. | | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. | | Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. | | Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. | diff --git a/docs/types.md b/docs/types.md index b6fea0c0..98fe6fb5 100644 --- a/docs/types.md +++ b/docs/types.md @@ -65,18 +65,20 @@ Returned by `Root.open()` and `Root.read()`: ```ts type OpenResult = { handle: import("node:fs/promises").FileHandle; + containment: "kernel-atomic" | "best-effort"; realPath: string; stat: import("node:fs").Stats; }; type ReadResult = { buffer: Buffer; + containment: "kernel-atomic" | "best-effort"; realPath: string; stat: import("node:fs").Stats; }; ``` -`realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result. +`realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result. Public root results currently report `containment: "best-effort"`; the union also describes direct native `openBeneath()` results, which report `"kernel-atomic"` on Linux. See the [security model](security-model.md#containment-guarantees-by-platform). ## `RootDefaults` / `RootOptions` diff --git a/docs/writing.md b/docs/writing.md index b94769ec..a2bb0904 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -17,7 +17,7 @@ await fs.mkdir("snapshots/2026/05"); 1. Resolve the relative target against the canonical root and reject anything that escapes (`outside-workspace`). 2. If `mkdir: true`, create missing parent directories with the parent fd pinned. -3. Open the parent directory by fd. Subsequent rename/unlink uses the parent fd, not the path string, so a parent-directory symlink swap mid-call cannot divert the write. +3. Pin or guard the parent directory for the selected mechanism. Native operations use a parent fd; guarded JavaScript verifies directory identity before and after mutation. Linux beneath opens are kernel-atomic, while macOS, Windows, and JavaScript routes retain the best-effort race boundaries in the [security model](security-model.md#containment-guarantees-by-platform). 4. Write data to a sibling temp file in the same directory. 5. Atomically rename the temp file over the destination. 6. Stat the resulting fd and verify identity. diff --git a/native/index.d.ts b/native/index.d.ts index 0a3bfdc7..62d6dd83 100644 --- a/native/index.d.ts +++ b/native/index.d.ts @@ -55,7 +55,12 @@ export interface NativeCopyResult { errorMessage?: string } -export declare function openBeneath(rootFd: number, relPath: string, flags: number): number +export declare function openBeneath(rootFd: number, relPath: string, flags: number): OpenBeneathResult + +export interface OpenBeneathResult { + fd: number + containment: 'kernel-atomic' | 'best-effort' +} export declare function readArchiveEntryNative(path: string, kind: string, requested: string, maxBytes: number, maxEntries: number, maxMetaEntryBytes: number, signal: AbortSignal): Promise diff --git a/native/src/lib.rs b/native/src/lib.rs index d1607f56..cbe9818c 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -24,6 +24,12 @@ pub struct FileIdentity { pub is_symbolic_link: bool, } +#[napi(object)] +pub struct OpenBeneathResult { + pub fd: i32, + pub containment: String, +} + pub(crate) type NativeResult = std::result::Result>; pub(crate) fn native_error(code: impl Into, message: impl Into) -> Error { @@ -68,12 +74,23 @@ fn into_napi(env: Env, result: NativeResult) -> Result { } #[napi(js_name = "openBeneath")] -pub fn open_beneath(env: Env, root_fd: i32, rel_path: String, flags: i32) -> Result { - into_napi( - env, - validate_relative_path(&rel_path, true) - .and_then(|()| platform::open_beneath(root_fd, &rel_path, flags)), - ) +pub fn open_beneath( + env: Env, + root_fd: i32, + rel_path: String, + flags: i32, +) -> Result { + let result = validate_relative_path(&rel_path, true) + .and_then(|()| platform::open_beneath(root_fd, &rel_path, flags)) + .map(|fd| OpenBeneathResult { + fd, + containment: if cfg!(target_os = "linux") { + "kernel-atomic".to_owned() + } else { + "best-effort".to_owned() + }, + }); + into_napi(env, result) } #[napi(js_name = "mkdirBeneath")] diff --git a/native/src/unix.rs b/native/src/unix.rs index 9685632c..8ea561df 100644 --- a/native/src/unix.rs +++ b/native/src/unix.rs @@ -631,10 +631,13 @@ mod macos { use std::collections::VecDeque; use std::ffi::{CStr, CString}; use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd}; + use std::sync::OnceLock; use crate::{NativeResult, native_error}; const MAX_SYMLINKS: usize = 40; + const O_RESOLVE_BENEATH: i32 = 0x0000_1000; + static RESOLVE_BENEATH_AVAILABLE: OnceLock = OnceLock::new(); fn last_error(operation: &str) -> napi::Error { let error = std::io::Error::last_os_error(); @@ -672,6 +675,58 @@ mod macos { .into_owned()) } + pub(super) fn resolve_beneath_available() -> bool { + *RESOLVE_BENEATH_AVAILABLE.get_or_init(probe_resolve_beneath_availability) + } + + fn probe_resolve_beneath_availability() -> bool { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: uname initializes the supplied utsname on success. + if unsafe { libc::uname(info.as_mut_ptr()) } != 0 { + return false; + } + // SAFETY: uname succeeded, so info is initialized and release is NUL-terminated. + let info = unsafe { info.assume_init() }; + let release = unsafe { CStr::from_ptr(info.release.as_ptr()) }.to_string_lossy(); + let mut parts = release + .split('.') + .filter_map(|part| part.parse::().ok()); + let major = parts.next().unwrap_or(0); + let minor = parts.next().unwrap_or(0); + major > 24 || (major == 24 && minor >= 4) + } + + fn verify_opened_beneath(root_fd: RawFd, opened: OwnedFd) -> NativeResult { + let root = root_path(root_fd)?; + let opened_path = root_path(opened.as_raw_fd())?; + if !std::path::Path::new(&opened_path).starts_with(std::path::Path::new(&root)) { + return Err(native_error( + "EXDEV", + format!("opened path escaped root: {opened_path}"), + )); + } + Ok(opened.into_raw_fd()) + } + + fn open_with_resolve_beneath(root_fd: RawFd, rel_path: &str, flags: i32) -> NativeResult { + let path = CString::new(rel_path.as_bytes()) + .map_err(|_| native_error("EINVAL", "path contains a NUL byte"))?; + // SAFETY: root_fd is borrowed for this call and path is NUL-terminated. + let opened = unsafe { + libc::openat( + root_fd, + path.as_ptr(), + flags | libc::O_CLOEXEC | O_RESOLVE_BENEATH, + 0o600, + ) + }; + if opened < 0 { + return Err(last_error("open path with O_RESOLVE_BENEATH")); + } + // SAFETY: openat returned a new descriptor owned by this call. + verify_opened_beneath(root_fd, unsafe { OwnedFd::from_raw_fd(opened) }) + } + fn read_link(fd: RawFd, name: &CString) -> NativeResult { let mut buffer = vec![0_u8; libc::PATH_MAX as usize]; // SAFETY: pointers are valid for this call and the buffer is writable. @@ -717,7 +772,10 @@ mod macos { pub fn open_beneath(root_fd: RawFd, rel_path: &str, flags: i32) -> NativeResult { if rel_path.is_empty() || rel_path == "." { - return Ok(duplicate(root_fd)?.into_raw_fd()); + return verify_opened_beneath(root_fd, duplicate(root_fd)?); + } + if resolve_beneath_available() { + return open_with_resolve_beneath(root_fd, rel_path, flags); } let mut queue: VecDeque = rel_path .split('/') @@ -742,7 +800,8 @@ mod macos { unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), open_flags, 0o600) }; if opened >= 0 { if is_final { - return Ok(opened); + // SAFETY: openat returned a new descriptor owned by this call. + return verify_opened_beneath(root_fd, unsafe { OwnedFd::from_raw_fd(opened) }); } // SAFETY: opened is a new owned directory descriptor. current = unsafe { OwnedFd::from_raw_fd(opened) }; @@ -872,4 +931,44 @@ mod tests { drop(unsafe { std::fs::File::from_raw_fd(fd) }); fs::remove_dir_all(root).unwrap(); } + + #[cfg(target_os = "macos")] + #[test] + fn resolve_beneath_flag_blocks_static_escape_and_allows_in_root_symlink() { + use std::os::unix::fs::symlink; + if !macos::resolve_beneath_available() { + return; + } + + let base = temp_root("resolve-beneath"); + let root = base.join("root"); + let outside = base.join("outside"); + fs::create_dir(&root).unwrap(); + fs::create_dir(root.join("sub")).unwrap(); + fs::create_dir(root.join("real")).unwrap(); + fs::create_dir(&outside).unwrap(); + fs::write(root.join("real/file"), b"ok").unwrap(); + fs::write(outside.join("secret.txt"), b"outside").unwrap(); + symlink("..", root.join("sub/up")).unwrap(); + symlink("real", root.join("alias")).unwrap(); + let root_handle = OpenOptions::new().read(true).open(&root).unwrap(); + + assert!( + macos::open_beneath( + root_handle.as_raw_fd(), + "sub/up/../outside/secret.txt", + OFlags::RDONLY.bits() as i32, + ) + .is_err() + ); + let fd = macos::open_beneath( + root_handle.as_raw_fd(), + "alias/file", + OFlags::RDONLY.bits() as i32, + ) + .unwrap(); + // SAFETY: open_beneath returned a fresh descriptor owned by this test. + drop(unsafe { std::fs::File::from_raw_fd(fd) }); + fs::remove_dir_all(base).unwrap(); + } } diff --git a/scripts/harden-native-loader.mjs b/scripts/harden-native-loader.mjs index b0ba2325..4a9f983b 100644 --- a/scripts/harden-native-loader.mjs +++ b/scripts/harden-native-loader.mjs @@ -34,6 +34,10 @@ const hardenedTypes = generatedTypes .replace( /copyFileRangeExclusive\(([^)]*)\): Promise/, "copyFileRangeExclusive($1): Promise", + ) + .replace( + /containment: string/, + "containment: 'kernel-atomic' | 'best-effort'", ); if (hardenedTypes !== generatedTypes) { writeFileSync(typesPath, hardenedTypes); diff --git a/src/containment.ts b/src/containment.ts new file mode 100644 index 00000000..f72f652a --- /dev/null +++ b/src/containment.ts @@ -0,0 +1 @@ +export type ContainmentGuarantee = "kernel-atomic" | "best-effort"; diff --git a/src/index.ts b/src/index.ts index ed7b5cee..398223f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export { type WritableOpenMode, type WritableOpenResult, } from "./root.js"; +export type { ContainmentGuarantee } from "./containment.js"; export { configureFsSafePython, configureFsSafeNative, diff --git a/src/native-operations.ts b/src/native-operations.ts index 18398a1f..d3226e0e 100644 --- a/src/native-operations.ts +++ b/src/native-operations.ts @@ -1,11 +1,13 @@ import fsSync, { type Stats } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import type { ContainmentGuarantee } from "./containment.js"; import { sameFileIdentity } from "./file-identity.js"; import { getNativeBinding, type NativeBinding } from "./native.js"; export type NativeFileHandle = { readonly fd: number; + readonly containment: ContainmentGuarantee; close(): Promise; stat(): Promise; writeFile(data: string | Buffer, encoding?: BufferEncoding): Promise; @@ -31,10 +33,11 @@ function writeAll(fd: number, data: Buffer): void { } } -function wrapNativeFd(fd: number): NativeFileHandle { +function wrapNativeFd(fd: number, containment: ContainmentGuarantee): NativeFileHandle { let open = true; return { fd, + containment, async close() { if (open) { open = false; @@ -98,16 +101,17 @@ export async function createNativeExclusiveFile( let fd: number | undefined; let created: Stats | undefined; try { - fd = binding.openBeneath( + const opened = binding.openBeneath( parent.fd, basename, nativeOpenFlags( fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, ), ); + fd = opened.fd; fsSync.fchmodSync(fd, mode); created = fsSync.fstatSync(fd); - return wrapNativeFd(fd); + return wrapNativeFd(fd, opened.containment); } catch (error) { if (fd !== undefined) { try { diff --git a/src/native-pinned-write.ts b/src/native-pinned-write.ts index 4ffa2b27..19845c66 100644 --- a/src/native-pinned-write.ts +++ b/src/native-pinned-write.ts @@ -87,7 +87,7 @@ export async function runPinnedWriteNative( const parentFlags = fsSync.constants.O_RDONLY | (typeof fsSync.constants.O_DIRECTORY === "number" ? fsSync.constants.O_DIRECTORY : 0); - parentFd = binding.openBeneath(root.fd, params.relativeParentPath, parentFlags); + parentFd = binding.openBeneath(root.fd, params.relativeParentPath, parentFlags).fd; parentPath = await fs.realpath( params.relativeParentPath ? path.join(params.rootPath, ...params.relativeParentPath.split("/")) @@ -115,7 +115,7 @@ export async function runPinnedWriteNative( nativeOpenFlags( fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, ), - ); + ).fd; fsSync.fchmodSync(tempFd, params.mode || 0o600); writeNativeFd(tempFd, data); syncNativeFileBestEffort(tempFd); @@ -126,7 +126,7 @@ export async function runPinnedWriteNative( parentFd, params.basename, nativeOpenFlags(fsSync.constants.O_RDONLY), - ); + ).fd; const targetIdentity = binding.fstatIdentity(targetFd); if (!targetIdentity.isFile || !sameNativeIdentity(tempIdentity, targetIdentity)) { throw new FsSafeError("path-mismatch", "native write target changed after rename"); diff --git a/src/path-policy.ts b/src/path-policy.ts index 814598f4..f732a90a 100644 --- a/src/path-policy.ts +++ b/src/path-policy.ts @@ -28,7 +28,7 @@ export async function assertNoPathAliasEscape(params: { return; } await assertNoHardlinkedFinalPath({ - filePath: resolved.absolutePath, + filePath: resolved.canonicalPath, root: resolved.rootPath, boundaryLabel: params.boundaryLabel, allowFinalHardlinkForUnlink: params.policy?.allowFinalHardlinkForUnlink, diff --git a/src/read-opened-file.ts b/src/read-opened-file.ts index 79f38440..d657f309 100644 --- a/src/read-opened-file.ts +++ b/src/read-opened-file.ts @@ -1,16 +1,19 @@ import type { Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; +import type { ContainmentGuarantee } from "./containment.js"; import { readFileHandleBounded } from "./bounded-read.js"; import { FsSafeError } from "./errors.js"; export type ReadResult = { buffer: Buffer; + containment: ContainmentGuarantee; realPath: string; stat: Stats; }; type OpenedFile = { handle: FileHandle; + containment: ContainmentGuarantee; realPath: string; stat: Stats; }; @@ -31,6 +34,7 @@ export async function readOpenedFileSafely(params: { : await readFileHandleBounded(params.opened.handle, params.maxBytes); return { buffer, + containment: params.opened.containment, realPath: params.opened.realPath, stat: params.opened.stat, }; diff --git a/src/root-context.ts b/src/root-context.ts index a4c070f1..14a07c9b 100644 --- a/src/root-context.ts +++ b/src/root-context.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { FsSafeError } from "./errors.js"; import { expandHomePrefix } from "./home-dir.js"; import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js"; +import { ROOT_PATH_ALIAS_POLICIES, resolveRootPath } from "./root-path.js"; export type RootContext = { rootDir: string; @@ -62,6 +63,10 @@ export async function resolveRootContext(rootDir: string): Promise export async function resolvePathInRoot( root: RootContext, relativePath: string, + options?: { + aliasErrorCode?: "outside-workspace" | "path-alias"; + allowFinalSymlink?: boolean; + }, ): Promise<{ rootReal: string; rootWithSep: string; resolved: string }> { assertValidRootRelativePath(relativePath); const expanded = await expandRelativePathWithHome(relativePath); @@ -69,6 +74,27 @@ export async function resolvePathInRoot( if (!isPathInside(root.rootWithSep, resolved)) { throw new FsSafeError("outside-workspace", "file is outside workspace root"); } + const rawAbsolutePath = path.isAbsolute(expanded) + ? expanded + : `${root.rootWithSep}${expanded}`; + try { + await resolveRootPath({ + absolutePath: rawAbsolutePath, + rootPath: root.rootReal, + rootCanonicalPath: root.rootReal, + boundaryLabel: "root", + policy: options?.allowFinalSymlink ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined, + }); + } catch (error) { + const code = options?.aliasErrorCode ?? "outside-workspace"; + throw new FsSafeError( + code, + code === "path-alias" ? "path alias escape blocked" : "file is outside workspace root", + { + cause: error instanceof Error ? error : undefined, + }, + ); + } return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved }; } diff --git a/src/root-impl.ts b/src/root-impl.ts index 65807b4a..87665fc3 100644 --- a/src/root-impl.ts +++ b/src/root-impl.ts @@ -4,6 +4,7 @@ import { constants as fsConstants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; +import type { ContainmentGuarantee } from "./containment.js"; import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js"; import { FsSafeError } from "./errors.js"; import { syncDirectoryBestEffort } from "./fsync.js"; @@ -60,6 +61,7 @@ export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js"; export type { ReadResult } from "./read-opened-file.js"; export type OpenResult = { handle: FileHandle; + containment: ContainmentGuarantee; realPath: string; stat: Stats; [Symbol.asyncDispose](): Promise; @@ -164,6 +166,7 @@ function openResult(params: { }): OpenResult { return { handle: params.handle, + containment: "best-effort", realPath: params.realPath, stat: params.stat, [Symbol.asyncDispose]: () => params.handle.close().catch(() => undefined), @@ -359,7 +362,9 @@ class RootHandle implements Root { } async resolve(relativePath: string): Promise { - return (await resolvePathInRoot(this.context, relativePath)).resolved; + return ( + await resolvePathInRoot(this.context, relativePath, { allowFinalSymlink: true }) + ).resolved; } async open(relativePath: string, options: RootOpenOptions = {}): Promise { @@ -620,7 +625,9 @@ async function openFileInRoot( symlinks?: SymlinkPolicy; }, ): Promise { - const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath); + const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath, { + allowFinalSymlink: true, + }); let opened: OpenResult; try { @@ -709,6 +716,7 @@ export async function openLocalFileSafely(params: { filePath: string }): Promise export type WritableOpenResult = { handle: FileHandle; + containment: ContainmentGuarantee; createdForWrite: boolean; realPath: string; stat: Stats; @@ -792,6 +800,7 @@ async function openWritableFileInRoot( const { rootReal, rootWithSep, resolved } = await resolvePathInRoot( root, params.relativePath, + { aliasErrorCode: "path-alias" }, ); await assertMutationNotDenied(resolved, params.denyMutations); try { @@ -897,6 +906,7 @@ async function openWritableFileInRoot( } return { handle, + containment: "best-effort", createdForWrite, realPath, stat, @@ -1156,7 +1166,9 @@ async function resolvePinnedWriteTargetInRoot( requestedMode?: number, denyMutations?: DenyMutationPolicy, ): Promise { - const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath); + const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath, { + aliasErrorCode: "path-alias", + }); await assertMutationNotDenied(resolved, denyMutations); try { await assertNoPathAliasEscape({ @@ -1291,8 +1303,11 @@ async function resolvePinnedRootPathInRoot( const rootReal = root.rootReal; let resolved; try { + const expandedPath = await expandRelativePathWithHome(params.relativePath); resolved = await resolveRootPath({ - absolutePath: path.resolve(rootReal, await expandRelativePathWithHome(params.relativePath)), + absolutePath: path.isAbsolute(expandedPath) + ? expandedPath + : `${ensureTrailingSep(rootReal)}${expandedPath}`, rootPath: rootReal, rootCanonicalPath: rootReal, boundaryLabel: "root", @@ -1376,9 +1391,15 @@ async function assertMoveMutationAllowed( denyMutations?: DenyMutationPolicy; }, ): Promise { - const source = await resolvePathInRoot(root, params.fromRelative); + const source = await resolvePathInRoot(root, params.fromRelative, { + aliasErrorCode: "path-alias", + allowFinalSymlink: true, + }); await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true }); - const target = await resolvePathInRoot(root, params.toRelative); + const target = await resolvePathInRoot(root, params.toRelative, { + aliasErrorCode: "path-alias", + allowFinalSymlink: true, + }); await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true }); } @@ -1391,13 +1412,19 @@ async function movePathFallback( overwrite: boolean; }, ): Promise { - const source = await resolvePathInRoot(root, params.fromRelative); + const source = await resolvePathInRoot(root, params.fromRelative, { + aliasErrorCode: "path-alias", + allowFinalSymlink: true, + }); await assertMutationNotDenied(source.resolved, params.denyMutations, { protectAncestors: true }); await resolvePinnedRootPathInRoot(root, { relativePath: params.fromRelative, policy: PATH_ALIAS_POLICIES.strict, }); - const target = await resolvePathInRoot(root, params.toRelative); + const target = await resolvePathInRoot(root, params.toRelative, { + aliasErrorCode: "path-alias", + allowFinalSymlink: true, + }); await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true }); await resolvePinnedRootPathInRoot(root, { relativePath: params.toRelative, @@ -1553,7 +1580,9 @@ async function writeMissingFileFallback( denyMutations?: DenyMutationPolicy; }, ): Promise { - const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath); + const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath, { + aliasErrorCode: "path-alias", + }); await assertMutationNotDenied(resolved, params.denyMutations); try { await assertNoPathAliasEscape({ diff --git a/src/root-path-existing.ts b/src/root-path-existing.ts new file mode 100644 index 00000000..2962b419 --- /dev/null +++ b/src/root-path-existing.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { isNotFoundPathError } from "./path.js"; + +function isFilesystemRoot(candidate: string): boolean { + return path.parse(candidate).root === candidate; +} + +async function pathExists(targetPath: string): Promise { + try { + await fsp.lstat(targetPath); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } +} + +export async function resolvePathViaExistingAncestor(targetPath: string): Promise { + const normalized = path.resolve(targetPath); + let cursor = normalized; + const missingSuffix: string[] = []; + + while (!isFilesystemRoot(cursor) && !(await pathExists(cursor))) { + missingSuffix.unshift(path.basename(cursor)); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + if (!(await pathExists(cursor))) { + return normalized; + } + + try { + const resolvedAncestor = path.resolve(await fsp.realpath(cursor)); + return missingSuffix.length === 0 + ? resolvedAncestor + : path.resolve(resolvedAncestor, ...missingSuffix); + } catch { + return normalized; + } +} + +export function resolvePathViaExistingAncestorSync(targetPath: string): string { + const normalized = path.resolve(targetPath); + let cursor = normalized; + const missingSuffix: string[] = []; + + while (!isFilesystemRoot(cursor) && !fs.existsSync(cursor)) { + missingSuffix.unshift(path.basename(cursor)); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + if (!fs.existsSync(cursor)) { + return normalized; + } + + try { + const resolvedAncestor = path.resolve(fs.realpathSync(cursor)); + return missingSuffix.length === 0 + ? resolvedAncestor + : path.resolve(resolvedAncestor, ...missingSuffix); + } catch { + return normalized; + } +} diff --git a/src/root-path.ts b/src/root-path.ts index 9ccb6adc..d432caf3 100644 --- a/src/root-path.ts +++ b/src/root-path.ts @@ -3,6 +3,12 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js"; +import { + resolvePathViaExistingAncestor, + resolvePathViaExistingAncestorSync, +} from "./root-path-existing.js"; + +export { resolvePathViaExistingAncestorSync } from "./root-path-existing.js"; type RootPathIntent = "read" | "write" | "create" | "delete" | "stat"; @@ -143,9 +149,11 @@ function createLexicalTraversalState(params: { rootCanonicalPath: string; absolutePath: string; }): LexicalTraversalState { - const relative = path.relative(params.rootPath, params.absolutePath); + const rawAbsolutePath = params.params.absolutePath; + const rawRelativePath = rawPathRelativeToRoot(params.rootPath, rawAbsolutePath); + const relative = rawRelativePath ?? path.relative(params.rootPath, params.absolutePath); return { - segments: relative.split(path.sep).filter(Boolean), + segments: splitTraversalSegments(relative), allowFinalSymlink: params.params.policy?.allowFinalSymlinkForUnlink === true, canonicalCursor: params.rootCanonicalPath, lexicalCursor: params.rootPath, @@ -153,6 +161,31 @@ function createLexicalTraversalState(params: { }; } +function splitTraversalSegments(value: string): string[] { + return value + .split(process.platform === "win32" ? /[\\/]+/ : /\/+/) + .filter((segment) => Boolean(segment) && segment !== "."); +} + +function rawPathRelativeToRoot(rootPath: string, candidatePath: string): string | undefined { + if (!path.isAbsolute(candidatePath)) { + return undefined; + } + const root = path.resolve(rootPath); + const candidate = process.platform === "win32" + ? candidatePath.replaceAll("/", path.sep) + : candidatePath; + if (candidate === root) { + return ""; + } + const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + const candidatePrefix = candidate.slice(0, rootWithSep.length); + const prefixMatches = process.platform === "win32" + ? candidatePrefix.toLowerCase() === rootWithSep.toLowerCase() + : candidatePrefix === rootWithSep; + return prefixMatches ? candidate.slice(rootWithSep.length) : undefined; +} + function assertLexicalCursorInsideBoundary(params: { params: ResolveRootPathParams; rootCanonicalPath: string; @@ -175,13 +208,15 @@ function applyMissingSuffixToCanonicalCursor(params: { absolutePath: string; }): void { const missingSuffix = params.state.segments.slice(params.missingFromIndex); - params.state.canonicalCursor = path.resolve(params.state.canonicalCursor, ...missingSuffix); - assertLexicalCursorInsideBoundary({ - params: params.params, - rootCanonicalPath: params.rootCanonicalPath, - candidatePath: params.state.canonicalCursor, - absolutePath: params.absolutePath, - }); + for (const segment of missingSuffix) { + advanceCanonicalCursorForSegment({ + state: params.state, + segment, + rootCanonicalPath: params.rootCanonicalPath, + params: params.params, + absolutePath: params.absolutePath, + }); + } } function advanceCanonicalCursorForSegment(params: { @@ -371,11 +406,26 @@ type LexicalTraversalStep = { isLast: boolean; }; +function applyParentTraversalStep(params: { + state: LexicalTraversalState; + rootCanonicalPath: string; + resolveParams: ResolveRootPathParams; + absolutePath: string; +}): void { + params.state.lexicalCursor = path.resolve(params.state.lexicalCursor, ".."); + advanceCanonicalCursorForSegment({ + state: params.state, + segment: "..", + rootCanonicalPath: params.rootCanonicalPath, + params: params.resolveParams, + absolutePath: params.absolutePath, + }); +} + function* iterateLexicalTraversal(state: LexicalTraversalState): Iterable { for (let idx = 0; idx < state.segments.length; idx += 1) { const segment = state.segments[idx] ?? ""; const isLast = idx === state.segments.length - 1; - state.lexicalCursor = path.join(state.lexicalCursor, segment); yield { idx, segment, isLast }; } } @@ -395,6 +445,14 @@ async function resolveRootPathLexicalAsync(params: { }; for (const { idx, segment, isLast } of iterateLexicalTraversal(state)) { + if (segment === "..") { + applyParentTraversalStep({ + ...sharedStepParams, + resolveParams: params.params, + }); + continue; + } + state.lexicalCursor = path.join(state.lexicalCursor, segment); const stat = await readLexicalStat({ ...sharedStepParams, missingFromIndex: idx, @@ -425,7 +483,7 @@ async function resolveRootPathLexicalAsync(params: { }); } - const kind = await getPathKind(params.absolutePath, state.preserveFinalSymlink); + const kind = await getPathKind(state.canonicalCursor, state.preserveFinalSymlink); return finalizeLexicalResolution({ ...params, state, @@ -443,6 +501,15 @@ function resolveRootPathLexicalSync(params: { for (let idx = 0; idx < state.segments.length; idx += 1) { const segment = state.segments[idx] ?? ""; const isLast = idx === state.segments.length - 1; + if (segment === "..") { + applyParentTraversalStep({ + state, + rootCanonicalPath: params.rootCanonicalPath, + resolveParams: params.params, + absolutePath: params.absolutePath, + }); + continue; + } state.lexicalCursor = path.join(state.lexicalCursor, segment); const maybeStat = readLexicalStat({ state, @@ -487,7 +554,7 @@ function resolveRootPathLexicalSync(params: { } } - const kind = getPathKindSync(params.absolutePath, state.preserveFinalSymlink); + const kind = getPathKindSync(state.canonicalCursor, state.preserveFinalSymlink); return finalizeLexicalResolution({ ...params, state, @@ -660,66 +727,6 @@ function buildResolvedRootPath(params: { }; } -async function resolvePathViaExistingAncestor(targetPath: string): Promise { - const normalized = path.resolve(targetPath); - let cursor = normalized; - const missingSuffix: string[] = []; - - while (!isFilesystemRoot(cursor) && !(await pathExists(cursor))) { - missingSuffix.unshift(path.basename(cursor)); - const parent = path.dirname(cursor); - if (parent === cursor) { - break; - } - cursor = parent; - } - - if (!(await pathExists(cursor))) { - return normalized; - } - - try { - const resolvedAncestor = path.resolve(await fsp.realpath(cursor)); - if (missingSuffix.length === 0) { - return resolvedAncestor; - } - return path.resolve(resolvedAncestor, ...missingSuffix); - } catch { - return normalized; - } -} - -export function resolvePathViaExistingAncestorSync(targetPath: string): string { - const normalized = path.resolve(targetPath); - let cursor = normalized; - const missingSuffix: string[] = []; - - while (!isFilesystemRoot(cursor) && !fs.existsSync(cursor)) { - missingSuffix.unshift(path.basename(cursor)); - const parent = path.dirname(cursor); - if (parent === cursor) { - break; - } - cursor = parent; - } - - if (!fs.existsSync(cursor)) { - return normalized; - } - - try { - // Keep sync behavior aligned with async (`fsp.realpath`) to avoid - // platform-specific canonical alias drift (notably on Windows). - const resolvedAncestor = path.resolve(fs.realpathSync(cursor)); - if (missingSuffix.length === 0) { - return resolvedAncestor; - } - return path.resolve(resolvedAncestor, ...missingSuffix); - } catch { - return normalized; - } -} - async function getPathKind( absolutePath: string, preserveFinalSymlink: boolean, @@ -818,22 +825,6 @@ function shortPath(value: string): string { return value; } -function isFilesystemRoot(candidate: string): boolean { - return path.parse(candidate).root === candidate; -} - -async function pathExists(targetPath: string): Promise { - try { - await fsp.lstat(targetPath); - return true; - } catch (error) { - if (isNotFoundPathError(error)) { - return false; - } - throw error; - } -} - async function resolveSymlinkHopPath(symlinkPath: string): Promise { try { return path.resolve(await fsp.realpath(symlinkPath)); diff --git a/src/root.ts b/src/root.ts index 18a1e5ec..d6d074fb 100644 --- a/src/root.ts +++ b/src/root.ts @@ -28,6 +28,7 @@ export { type WritableOpenMode, type WritableOpenResult, } from "./root-impl.js"; +export type { ContainmentGuarantee } from "./containment.js"; export type { RootWalkDataEntry, RootWalkDataEntryKind, diff --git a/src/sidecar-lock.ts b/src/sidecar-lock.ts index 1f123cf5..e6f0c284 100644 --- a/src/sidecar-lock.ts +++ b/src/sidecar-lock.ts @@ -41,10 +41,11 @@ export type { SidecarLockStaleRecovery, WithSidecarLockOptions, } from "./sidecar-lock-types.js"; +type SidecarFileHandle = Pick; type HeldLock = { refCount: number; reentrantOwner?: string; - handle: NativeFileHandle; + handle: SidecarFileHandle; lockPath: string; snapshot: SidecarLockSnapshot; acquiredAt: number; @@ -288,7 +289,7 @@ export function createSidecarLockManager(key: string) { await waitForRetry(); continue; } - let handle: NativeFileHandle | null = null; + let handle: SidecarFileHandle | null = null; try { const payload = await options.payload(); const { raw, ownershipToken } = serializeSidecarLockPayload(payload); diff --git a/test/native-integration.test.ts b/test/native-integration.test.ts index 61da426b..82496bee 100644 --- a/test/native-integration.test.ts +++ b/test/native-integration.test.ts @@ -30,18 +30,21 @@ afterEach(async () => { }); describe.runIf(native)("native filesystem primitives", () => { - it("opens beneath a directory descriptor and reports fd identity", async () => { + it("opens beneath a directory descriptor and reports containment and fd identity", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "fs-safe-native-open-")); roots.push(root); await fs.mkdir(path.join(root, "nested")); await fs.writeFile(path.join(root, "nested", "value"), "ok"); const rootFd = fsSync.openSync(root, fsSync.constants.O_RDONLY); try { - const fd = native!.openBeneath(rootFd, "nested/value", fsSync.constants.O_RDONLY); + const opened = native!.openBeneath(rootFd, "nested/value", fsSync.constants.O_RDONLY); + expect(opened.containment).toBe( + process.platform === "linux" ? "kernel-atomic" : "best-effort", + ); try { - expect(native!.fstatIdentity(fd)).toMatchObject({ isFile: true, size: 2 }); + expect(native!.fstatIdentity(opened.fd)).toMatchObject({ isFile: true, size: 2 }); } finally { - fsSync.closeSync(fd); + fsSync.closeSync(opened.fd); } expect(() => native!.openBeneath(rootFd, "../outside", fsSync.constants.O_RDONLY)).toThrow(); } finally { diff --git a/test/symlink-dotdot-escape.test.ts b/test/symlink-dotdot-escape.test.ts new file mode 100644 index 00000000..0becca01 --- /dev/null +++ b/test/symlink-dotdot-escape.test.ts @@ -0,0 +1,145 @@ +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { configureFsSafeNative, FsSafeError, root, type Root } from "../src/index.js"; +import { + __resetNativeLoaderForTest, + __setNativeLoaderForTest, +} from "../src/native.js"; +import { resolveRootPath, resolveRootPathSync } from "../src/root-path.js"; + +type NativeBinding = typeof import("../native/index.js"); + +const require = createRequire(import.meta.url); +let native: NativeBinding | undefined; +try { + native = require("../native") as NativeBinding; +} catch { + // Ordinary JavaScript-only jobs intentionally run without a platform artifact. +} + +const tempDirs: string[] = []; +const fixtureParent = process.platform === "darwin" ? "/private/tmp" : os.tmpdir(); +const escapePath = "sub/up/../outside/secret.txt"; + +afterEach(async () => { + configureFsSafeNative({ mode: "auto" }); + __resetNativeLoaderForTest(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); +}); + +async function makeStaticEscapeFixture(): Promise<{ + rootDir: string; + outsideFile: string; +}> { + const base = await mkdtemp(path.join(fixtureParent, "fs-safe-symlink-dotdot-")); + tempDirs.push(base); + const rootDir = path.join(base, "root"); + const outsideDir = path.join(base, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); + + await mkdir(path.join(rootDir, "sub"), { recursive: true }); + await mkdir(outsideDir); + await symlink("..", path.join(rootDir, "sub", "up"), "dir"); + await writeFile(outsideFile, "outside-secret"); + return { rootDir, outsideFile }; +} + +async function expectBlocked(action: () => Promise): Promise { + let value: unknown; + try { + value = await action(); + } catch (error) { + expect(error).toBeInstanceOf(FsSafeError); + return; + } + + if (typeof value === "object" && value !== null && "handle" in value) { + await (value as { handle: { close(): Promise } }).handle.close(); + } + expect.unreachable("static symlink + .. escape was accepted"); +} + +describe("static symlink + dot-dot boundary escape", () => { + const operations: Array<{ + name: string; + run(scoped: Root): Promise; + }> = [ + { name: "read", run: (scoped) => scoped.read(escapePath) }, + { name: "write", run: (scoped) => scoped.write(escapePath, "write-escaped") }, + { name: "open", run: (scoped) => scoped.open(escapePath) }, + { name: "openWritable", run: (scoped) => scoped.openWritable(escapePath) }, + ]; + + const backends = native ? (["javascript", "native"] as const) : (["javascript"] as const); + + describe.each(backends)("%s path", (backend) => { + it.runIf(process.platform !== "win32").each(operations)( + "blocks $name", + async ({ run }) => { + if (backend === "native") { + __setNativeLoaderForTest(() => native!); + configureFsSafeNative({ mode: "require" }); + } else { + __setNativeLoaderForTest(() => { + throw Object.assign(new Error("native helper disabled for regression proof"), { + code: "MODULE_NOT_FOUND", + }); + }); + configureFsSafeNative({ mode: "auto" }); + } + + const fixture = await makeStaticEscapeFixture(); + const scoped = await root(fixture.rootDir); + + await expectBlocked(() => run(scoped)); + await expect(readFile(fixture.outsideFile, "utf8")).resolves.toBe("outside-secret"); + }, + ); + }); + + it.runIf(process.platform !== "win32")( + "reports best-effort containment for JavaScript root results", + async () => { + __setNativeLoaderForTest(() => { + throw Object.assign(new Error("native helper disabled for fallback proof"), { + code: "MODULE_NOT_FOUND", + }); + }); + configureFsSafeNative({ mode: "auto" }); + const base = await mkdtemp(path.join(fixtureParent, "fs-safe-containment-result-")); + tempDirs.push(base); + await writeFile(path.join(base, "input.txt"), "input"); + const scoped = await root(base); + + const opened = await scoped.open("input.txt"); + expect(opened.containment).toBe("best-effort"); + await opened.handle.close(); + await expect(scoped.read("input.txt")).resolves.toMatchObject({ + containment: "best-effort", + }); + const writable = await scoped.openWritable("output.txt"); + expect(writable.containment).toBe("best-effort"); + await writable.handle.close(); + }, + ); + + it.runIf(process.platform !== "win32")( + "walks aliases before dot-dot in async and sync root-path resolution", + async () => { + const fixture = await makeStaticEscapeFixture(); + const absolutePath = `${fixture.rootDir}/${escapePath}`; + const params = { + rootPath: fixture.rootDir, + rootCanonicalPath: fixture.rootDir, + absolutePath, + boundaryLabel: "root", + } as const; + + await expect(resolveRootPath(params)).rejects.toThrow("outside root"); + expect(() => resolveRootPathSync(params)).toThrow("outside root"); + }, + ); +});