@cocalc/openat2
v0.1.2
Published
Linux openat2-based filesystem primitives for secure sandboxing
Readme
@cocalc/openat2
Linux-only napi-rs addon that exposes openat2-anchored filesystem operations for race-safe sandbox mutation.
Why this exists
In CoCalc safe mode, we need a strong guarantee:
- filesystem operations for a project stay inside that project root
- even if the project owner (or another process) is changing paths concurrently
The subtle failure mode is a classic race:
- We validate a string path (e.g.
a/b/file.txt) and it looks safe. - Before the actual mutation syscall runs, an attacker swaps an intermediate path component (or leaf) to a symlink.
- The mutation then lands outside the sandbox.
That validate(path) -> mutate(path) pattern is fundamentally fragile under concurrency, because validation and mutation happen at different times on a mutable namespace.
openat2 changes the model from string-based trust to descriptor-based trust:
- we first open a root directory handle (
dirfd) for the sandbox root - each operation resolves relative paths under that root via
openat2 - kernel-enforced resolve rules (
RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS) prevent escaping during resolution - we then mutate via
*atsyscalls (mkdirat,renameat,unlinkat, etc.) anchored to validated dirfds
This is closer to a capability model: possession of the root dirfd defines the authority boundary, and every derived operation stays constrained to that boundary. In practice, this removes dependence on ad-hoc deny/allow path filtering as the primary safety mechanism.
Why not just use Node fs + file descriptors?
- File descriptors help for existing-file content I/O (
read/writeon an already opened inode), and we do use that pattern. - But many dangerous operations are path mutators (
mkdir,rename,unlink,rmdir,chmod,utimes, create paths) that still require pathname resolution at operation time. - In plain Node, those mutators are path-based. You can pre-check with
realpath/lstat, but that is still a user-space check followed by a later path syscall, so there is still a race window. - For create flows, there may be no target inode yet to pin with an fd. The critical security question is whether parent-chain resolution stayed inside the sandbox at the exact syscall boundary.
- Node does not currently expose a complete
openat2/*atcapability API that lets us anchor all resolution to a sandbox dirfd with kernel-enforced constraints.
So fd-only hardening in Node is necessary but not sufficient: it meaningfully improves read/write safety, but it cannot fully eliminate TOCTOU escape classes for path-mutating operations. openat2 + *at is the piece that closes that remaining gap.
Tradeoffs:
- implementation is more tedious than plain Node
fspath calls - Linux-specific (
openat2is a Linux syscall) - existing path-oriented code needs adapter layers for migration
For our situation, that tradeoff is worth it: mutators become fail-closed under symlink/path-swap races, which is exactly the remaining hardening gap in backend sandbox safe mode.
Current API
import { SandboxRoot } from '@cocalc/openat2'
const root = new SandboxRoot('/srv/project')
root.mkdir('a/b', true)
root.rename('a/b/file.txt', 'a/b/file2.txt')
root.unlink('a/b/file2.txt')
const st = root.stat('a/b')Methods implemented now:
mkdir(path, recursive?, mode?)unlink(path)rmdir(path)rename(oldPath, newPath)renameNoReplace(oldPath, newPath)link(oldPath, newPath)symlink(target, newPath)chmod(path, mode)truncate(path, len)copyFile(src, dest, mode?)copyFileNoReplace(src, dest)rm(path, recursive?, force?)utimes(path, atimeNs, mtimeNs)stat(path)openRead(path) -> fdopenWrite(path, create?, truncate?, append?, mode?) -> fd
openRead/openWrite return numeric file descriptors intended for high-frequency
I/O paths in Node. The caller owns the descriptor and must close it.
copyFileNoReplace copies into an unnamed file in the destination directory,
then publishes it atomically only if the destination does not exist. It requires
filesystem support for Linux O_TMPFILE and otherwise returns ENOTSUP.
Security model
- Absolute paths are rejected.
..traversal is rejected.- Symlink traversal is blocked by
openat2resolve flags. - Operations are anchored to a root dirfd opened once in constructor.
Build
pnpm install
pnpm buildRequirements:
- Linux kernel with
openat2support (>=5.6) - Rust toolchain
- Node 18+
Test
pnpm run test:rustPackaging notes
This repository is configured to publish Linux prebuilt binaries for:
x86_64-unknown-linux-gnuaarch64-unknown-linux-gnu
Runtime loading prefers:
- local
cocalc_openat2.linux-*.nodefiles (for CI/dev artifacts) - optional npm packages (
@cocalc/openat2-linux-x64-gnu/@cocalc/openat2-linux-arm64-gnu) - local
cocalc_openat2.nodefallback for local development
Release automation
The workflow in .github/workflows/release.yml:
- builds both linux targets
- uploads
.nodeartifacts - on
v*tags, downloads artifacts and publishes:@cocalc/openat2-linux-x64-gnu@cocalc/openat2-linux-arm64-gnu@cocalc/openat2(root package)
Publishing relies on the root prepublishOnly script:
pnpm run create-npm-dirs
napi prepublish -t npm --tagstyle npm --skip-gh-release