Skip to content

Latest commit

 

History

History
386 lines (351 loc) · 102 KB

runtime.md

File metadata and controls

386 lines (351 loc) · 102 KB

Runtime: the C runtime's memory safety and its use of the OS

The security audit's record for the runtime stage (issue #363). It covers what was checked, how, and what was found, and names the test that pins each answer. Line numbers in the findings table refer to main at bc6ad66.

Scope

File What was read
runtime/runtime.c the arena (nish_arena_grow, nish_alloc_struct, marks, nish_arena_keep), every string function, the integer and Zmij (nish_f64_format) formatters and their buffers, nish_json_put_f64 (its bounds against the caller's array), nish_parse_number, Math.random, nish_alloc_array, nish_argv_init, nish_array_grow, the panics
runtime/nish.h every prototype and layout against its definition, and every contract comment against what the code does
runtime/runtime-os.c the file reads and writes, writeFileBytesSync (O_NOFOLLOW as writeFileSync, and a close that fails answers its errno rather than the full length), writeBytes (nish_write_bytes, after the emitter's range check), the shared write loop nish_put_all, renameSync and unlinkSync (a NUL in a path answers -EINVAL before the call; neither follows a link, and unlinkSync never falls back to rmdir), isDirectorySync, mkdirSync, writeFileSyncMode, createFileExclusiveSync and mkdirSyncMode (RT-24 to RT-26), readdirSync and its sort, both spawns, spawnPiped (nish_spawn_piped, nish_pipe: its pipes' descriptors, the poll loop, the output cap, the SIGPIPE mask), spawnWithFds (nish_spawn_with_fds: its limit, the scratch duplicates and their close, RT-40), getenv, realpathSync, setCpuAffinity (nish_set_cpu_affinity: the range check, the read of the current mask, the narrow-only set)
runtime/runtime-host.c Date.now, crypto.getRandomValues (short reads, EINTR, the limit), statMtimeSync, the signal pipe and its handler
runtime/runtime-net.c every address, length and control-message buffer, descriptor flags and leaks on each error path, the readiness loop's output bounds, the socket-option whitelist, netMss's reading of a kernel-sized tcp_info, and the Unix-domain calls: the sun_path copy, the abstract name, the stale-file probe and its unlink, the mode set between bind and listen, and every error path after a bind; sendFd, recvFd and peerCredentials (nish_unix_only, the SCM_RIGHTS control buffer and its count, the descriptors closed, close-on-exec per platform, the credential read per platform, RT-37 to RT-39); tcpListenFlags flag 4 (the classic BPF program attached with SO_ATTACH_REUSEPORT_CBPF, its flag checks, and the close on a failed attach)
runtime/runtime-parallel.c the partition arithmetic, the scope task list, thread-local state, thread-creation failure
runtime/runtime-wasm.c the linear-memory arena, nish_alloc_array, nish_array_grow
runtime/runtime-region.c the region table (its pages, free list, lock and published page pointers), the handle's index and generation, every check nish_region_enter, _leave and _free make before touching a slot (zero, stale, forged, another thread's, entered), the arena and chunk-size swap, the free through nish_free_arena, the 2^24-slot cap and the 2^40 generation retirement (RT-41 to RT-47); with nish_arena_grow's read of nish_region_chunk in runtime.c
runtime/runtime-term.c its signal handlers' flags, read here for the waitpid property; the rest of the file is audited in term.md

Read beside them, without owning them: src/runtime.ts (the declarations and the inline allocator), the range checks src/emit-builtins.ts puts in front of the network calls and writeBytes, src/interop-wasm.ts (the loader's nish_alloc_array), runtime/shim.mjs (the Node twin) and docs/security/codegen.md, whose K1-6, CG-3 and CG-9 were routed here.

Threat model

The attacker controls the input of a well-typed program: every byte it reads from a file, a socket or the environment, every name in a directory it lists, how large a file is, what sits at a path it writes, and the strings it builds from all of these. The attacker does not write the program and does not pick its flags. The programmer is honest but writes ordinary code: a path assembled from a request, a suffix check before an open, a listing of an upload directory. A C host that calls nish.h directly is trusted to pass the sizes it means.

A finding is any of these, reachable through a runtime entry point:

  • an out-of-bounds access, or a block shorter than its header claims;
  • a length the compiler's bounds-check elimination trusts and should not;
  • a file opened, written or created other than the one the program named;
  • memory or descriptors handed to the program uninitialised or leaked to a child;
  • a cost in time or memory that grows faster than the attacker's input.

Method

  • Every size computation, read for overflow and checked against its caller. Each nish_alloc_struct and malloc in the C and wasm runtimes was traced back to where its size comes from. A size that could wrap was driven to wrap from a C harness. For the wasm runtime that harness was a module built with scripts/build.sh --profile wasm and run under Node.
  • Every length the runtime hands back, checked against 2^31 − 1. Arrays and strings made from outside data (files, listings, concatenation, the host entry) were made at and past the limit, read with a sparse file where the input is a file, and their length read back under --number-mode i32. Exactly 2^31 − 1 is checked to still succeed for a file read, a concatenation and the host entry, so a limit one too low fails too. Those three checks run in a child whose arena points at 4 GiB of address space reserved with no access: a private, PROT_NONE mapping of a sparse file, with one read-write page for the headers. The host entry's elements are never touched. The concatenation's source starts in the inaccessible pages, and so does the file read's destination. The child faults on the first byte copied, and its handler checks that the arena handed out the whole block for that length. Nothing commits more than a page, so each check costs about a millisecond and no memory, and a run that passes is deterministic. Each was run against a build with the one > turned into >= and seen to fail.
  • Every path, name and argument, given an embedded NUL. Each one was passed name\0suffix from a compiled program and directly, and checked for whether the call answered for name.
  • Every file the runtime creates, given a symbolic link at its name. That covers both writers and both spawnSyncTo streams. The creation modes were read back under umask 022.
  • Every buffer and descriptor in runtime-net.c, read against the kernel's contract. That means recvmsg's msg_namelen and msg_controllen, CMSG_NXTHDR's bounds, accept4's flags, the 64-entry cap in the readiness loop, and the i32 range of every byte count, which the compiled range check (emitNetCall) and Linux's MAX_RW_COUNT bound together.
  • Every loop over attacker-sized input, timed. readdirSync was given 3,000 and 100,000 names with its compares counted, by interposing strcmp.
  • The thread and signal code, read for races. Each function was checked for which state is thread-local, which is shared and how that is ordered. The signal handler was checked for async-signal safety and errno. nish_parallel_range was built with -fsanitize=signed-integer-overflow -fsanitize-trap=....
  • Every fix shown failing first. Each regression test was run against the runtime at bc6ad66 and seen to fail, then against this change and seen to pass. The .text* budget of every unit was measured with the suite's own check.

Findings

Id Severity Where (bc6ad66) Description Disposition
RT-1 High runtime/runtime-os.c:90, :95 (nish_read_file_or_null), :112 (nish_read_file_bytes), :120 (nish_read_file) The runtime half of CG-3. A file of 2^31 bytes or more was read whole into a string or a u8[] whose length, under --number-mode i32, reads back negative. Bounds-check elimination trusts a length to be non-negative, so const n = xs.length; const j = n - 1; xs[j] on a 2^31 + 16 byte file read xs[-2147483633] unchecked: SIGSEGV in the reproducer, an out-of-bounds read in general. The attacker only has to make the file large, which a sparse file does at no cost Fixed. nish_read_file_or_null refuses a file longer than 2^31 − 1 bytes before it allocates anything. That reaches all three readers through the failure path each already has for an unreadable file: readFileSyncOrNull and readFileBytesSync answer null, and readFileSync exits 1 with nish: cannot read. The runtime cannot tell which number mode compiled its caller, so the limit holds in both, as nish_array_grow's does. It is also Node's own limit (ERR_FS_FILE_TOO_LARGE), so the two runtimes now agree. A file of exactly 2^31 − 1 bytes is still read. Tests: tests/link/rt_sec_read_cap, RT-1 in tests/runtime-test.c (both sides of the limit)
RT-2 High runtime/runtime.c:199 (nish_str_concat) a + b and every template literal concatenate through this function, with no limit on the result. Two strings read from files, each under the RT-1 limit, gave a string longer than 2^31 − 1 bytes, with RT-1's negative length and the same unchecked reads Fixed. A result past 2^31 − 1 bytes fails as an allocation does (nish: out of memory, exit 1), checked before anything is copied. nish_oom became one cold function rather than a macro, so the seven call sites stopped paying for the message's address, and that saving keeps runtime.c under its ceiling. A result of exactly 2^31 − 1 bytes is still made. Test: RT-2 in tests/runtime-test.c (both sides of the limit)
RT-3 Medium runtime/runtime-os.c:87, :127, :152, :159, :184, :194–:195, :243, :293, :324; runtime/runtime-host.c:101 Every path, environment name and spawn argument went to the kernel as s->data, which the kernel reads to its first NUL, so a string holding a NUL named something else. "README.md\0.txt" passes endsWith(".txt") and reads README.md. A program that checks a suffix, a prefix or a pattern on a name it was given and then opens, writes, lists or spawns with it acted on a file it never checked. Node refuses all of these (ERR_INVALID_ARG_VALUE), so the native runtime also answered differently from shim.mjs Fixed. nish_cpath, one static inline in nish.h that every path, name and spawn argument goes through, hands the kernel "" for a string holding a NUL. "" names nothing, so each call answers as it does for a missing path: null, false, cannot read/cannot write and exit 1, or a spawn that answers -1. A spawn argument holding a NUL answers -1, because its C string is not its own bytes. statMtimeSync answers NaN, and the RT-9 primitives answer -1 or false. Tests: tests/link/rt_sec_path_nul, RT-3 in tests/runtime-test.c
RT-4 Medium runtime/runtime-os.c:127 (nish_put_file), :194–:195 (spawnSyncTo's file actions) CLI-6 (docs/security/cli.md). writeFileSync, appendFileSync and spawnSyncTo's streams opened with O_CREAT and no O_NOFOLLOW. nish file.ts writes file.ll beside its source, so a symbolic link planted at that name in a checkout truncated any file the user could write. Any program writing to a name in a directory someone else can write had the same flaw Fixed. All four opens pass O_NOFOLLOW. A link as the last component is refused: cannot write and exit 1 from the writers, -1 from spawnSyncTo. A link among the directories on the way is still followed. Every caller in src/, std/ and tests/ writes a path it made itself or /dev/null, and none writes through a link. The compiler picks this up from the release that ships this runtime, because build/nish-test is linked with the seed's own runtime. One visible change: on Linux /dev/stdout is a link to /proc/self/fd/1, so writeFileSync("/dev/stdout", …) now fails, and console.log or write is the way to stdout. Test: RT-4 in tests/runtime-test.c
RT-5 Medium runtime/runtime-os.c:255–:266 (nish_readdir) readdirSync sorted its listing with an insertion sort. Whoever fills a directory chooses its length: 100,000 names took 2,507,163,583 compares and 12.5 s, and the cost grows with the square of the count Fixed. An in-place heapsort: 3,019,323 compares and 0.071 s for the same 100,000 names, at most 2 n (log2 n + 1), with no allocation. Under -ffunction-sections the function grew 94 bytes. nish_getenv and nish_realpath now build their answers with nish_str_new, and spawnSyncTo's two streams share one loop; between them that pays for the sort and RT-3 inside the unit's ceiling. Test: RT-5 in tests/runtime-test.c, which counts compares and checks the edges of the heap (0 to 3 names)
RT-6 Low runtime/runtime-net.c:235 (nish_net_put), :380–:402 (nish_udp_recv_from) recvmsg reports no sender on a connected or Unix-domain socket (msg_namelen 0) and leaves the address buffer as it was: uninitialised stack. udpRecvFrom then copied 18 of those stack bytes into the program's from. The program chooses the descriptor, so this is a leak of this process's own stack to itself Fixed. nish_net_put takes the length the kernel reported. It writes an address only for a full AF_INET or AF_INET6 one, and eighteen zeros otherwise; tcpAccept passes its length too. Test: RT-6 in tests/runtime-test.c (a Unix datagram pair, over a dirtied stack)
RT-7 Low runtime/runtime-wasm.c:228–:236 (nish_arena_grow), :241 (nish_alloc_struct), :258 (nish_alloc_array), :265 (nish_array_grow); runtime/runtime.c:1110 (nish_alloc_array) CG-9 and its neighbours. In the wasm runtime, off + size could wrap 2^64 into a small number the fast path took for "fits", moving the offset backwards so the next block landed on the first. A page count of 2^32 or more was truncated to the 32-bit memory.grow argument: a request of 2^48 bytes grew nothing and claimed 2^48 bytes of capacity. nish_alloc_array(8, 2^61) wrapped len * elem_size to 0 behind a header claiming 2^61 elements, in both runtimes. nish_array_grow in the wasm runtime had no K1-6 limit. Compiled code does not reach these: its own allocator's zero-fill traps first, and the loader cannot pass a JavaScript length that large. Each is reached by a C caller Fixed. The wasm grow traps on a wrapped or 4 GiB-overflowing need. Its fast path compares size <= cap - off and traps on a size that rounding would wrap. Both runtimes' nish_alloc_array refuse a length past 2^31 − 1 (native: nish: out of memory; wasm: a trap), and the wasm nish_array_grow now keeps runtime.c's limit. Exactly 2^31 − 1 elements is still handed back by both. Tests: tests/link/rt_sec_wasm_arena (five probes, each in a fresh instance under Node, one of them at exactly 2^31 − 1), RT-7 in tests/runtime-test.c (both sides of the limit)
RT-8 Low runtime/runtime-parallel.c:112 (nish_parallel_range) (len + grain - 1) / grain is signed overflow, which is undefined behaviour, once len is within grain of 2^63. At -O0 it ran the whole range on one thread; clang 18 at -O2 happens to fold it into the right answer Fixed. len / grain + (len % grain != 0). With -fsanitize=signed-integer-overflow -fsanitize-trap=signed-integer-overflow the old code traps and the new one divides the range. Test: RT-8 in tests/runtime-test.c (threads build), which checks that a range of INT64_MAX is divided
RT-9 Low (new) runtime/runtime-host.c CLI-7 and CLI-9 asked for primitives to check who owns a path and whether a program runs: lstat's owner and mode, and access(X_OK) Added, then used by #486 (CLI-7, CLI-9, now fixed): nish_lstat_owner_mode, nish_euid, nish_is_executable, declared in nish.h, 133 bytes of runtime-host.c between them. src/ may call a runtime function only once the last release declares it (the rolling freeze), so CLI-7 and CLI-9 waited for the release after this one. Test: RT-9 in tests/runtime-test.c
RT-10 Low runtime/runtime-host.c:143 (nish_signal_fd) Two threads whose first signalFd() calls overlap each make a pipe. One pipe's write end goes to the handler, and the other thread's read end never hears a signal; the loser's pipe leaks too Fixed (#387). Both ends live in one 64-bit word, published by a single compare-and-swap. The caller that loses closes its own pipe and answers the winner's read end, and every first caller installs the handler before it returns. The overlap cannot be timed from a program, so the test makes it happen: its own pipe2 runs a whole other first call in the middle of one. Before the fix the two calls answered different descriptors and the losing pipe stayed open. Test: RT-10 in tests/runtime-test.c (Linux, the default build)
RT-11 Low runtime/runtime.c:253 (nish_write) console.log, write and the panic message make one write(2) and ignore a short count. Output to a non-blocking stdout, or to a pipe when a signal arrives mid-write, is cut short silently Fixed (#387). nish_write writes until every byte is out or a write fails. A failure still ends the output, EAGAIN on a non-blocking stdout included, because spinning on it would burn a core. EINTR needs a handler installed without SA_RESTART, and the runtime installs none, so an interrupted write restarts in the kernel or comes back short. The newline is one byte and is never written in part. nish_die writes its message with fputs to the unbuffered stderr, whose libc loop does the same. Fitting the loop under runtime.c's ceiling took 12 bytes from nish_str_concat and nish_die. Revised (#611, anchors at 015e092e): nish_write now retries EINTR as well (runtime/runtime.c:321), the trailing newline included, because a C host may install a handler without SA_RESTART. panic's and expect's messages go through it, and so do nish_die's (runtime/runtime.c:115), the arithmetic and out-of-memory panics, which used fputs: glibc's loop finishes a short write but gives up on EINTR, and the message was lost. The index and slice panics, the file and entropy failures (dprintf) and runtime-parallel.c's out of memory (one write) still do not retry EINTR. 62 bytes, recorded in tests/runtime-budgets.js. Test: RT-11 in tests/runtime-test.c, whose own write takes three bytes a call: before the fix console.log("hello, world") sent hel and the newline. Three more RT-11 checks fail one write with EINTR: the message's first, the newline's, and an arithmetic panic's in a child, which also counts that the message went through the runtime's loop. Before the revision the first sent nothing, the second lost the newline, and the third printed nothing
RT-12 Low runtime/runtime-os.c:87, :127 The descriptors the file reads and writes open are not O_CLOEXEC. A child another thread spawns while one is open inherits it Fixed (#387). Both opens pass O_CLOEXEC. opendir already is close-on-exec, and spawnSyncTo's files are opened by the child. Test: RT-12 in tests/runtime-test.c, whose own pread and write read the flags of the descriptor they are handed
RT-13 Low runtime/runtime-os.c:98 The read loop stops at the first pread that fails, EINTR included, and answers the bytes so far as the whole file. A local disk does not interrupt a read; NFS mounted intr and FUSE can Fixed (#387). The read loop retries a pread that fails with EINTR, and so does writeFileSync's write loop, which used to say cannot write and exit. Any other failure still ends the read, and the bytes so far are the answer, as before. runtime-os.c pays for this with two simplifications: getenv and realpathSync share one copy into the arena, and a spawn always builds its list of file actions, empty or not. Test: RT-13 in tests/runtime-test.c, whose own pread and write fail once with EINTR: before the fix the read answered "" and the write exited 1
RT-14 Medium (new, anchors at 680d9e1) src/emit-strings.ts:509 (emitForeignString; the check from :520), :485 (foreignStringIsNulFree); src/escape.ts:718 (the borrow rule); src/declarations.ts:165 WP27 S3.1 lets a string cross into a declare function as const char *, the first pointer this compiler allocated that C sees. Two hazards come with it. A C function that keeps the address (putenv) or writes through it (strtok) reads a released arena or corrupts a value the program still holds, and nothing about a C body can be checked. A string holding a NUL is read by C to that NUL, so "secret\0.txt" passes a .txt check in the program and names secret in C: RT-3's bug, on calls the runtime does not make Decided and fixed. The borrow is a rule, not a proof (docs/wp27-ffi.md §8b): C reads the bytes for the length of the call. The escape analysis treats the argument as a read, the arena scope is released after the call returns, and a call with a string argument is never tail. A function that keeps or writes its argument is declared to take a CPtr, as LANGUAGE.md's "Calling C" says with strdup/putenv. The NUL is a panic, ffi-nul, before the call: one nish_str_index_of for "\0", lowered in the IR, because runtime.c has no room under its ceiling for nish_ffi_cstr. A literal with no NUL in it is proved and not checked. nish_cpath's "" is not used, because a foreign function has no answer for an empty name that the program can rely on. Tests: tests/cases/ffi_string_param, ffi_string_param_arena (a C sidecar copies what it read, after the scope that built it would have released), panics_ffi_nul, reject_ffi_string_array_param; "ffi_string: puts("hello") has no ffi-nul site and no NUL check in its IR", "ffi_string: a constructor's string argument to C is an ffi-nul site, and new is a call site via it", "ffi_string: a string handed to C inside a using block, and one read through this stored out of a constructor, are each an ffi-nul site" in tests/ffi-string.js
RT-15 Medium (new, anchors at 3e02a2a) src/emit-strings.ts:605 (emitForeignStringResult), :575 (foreignCopy, the @nish.ffi.str helper); src/escape.ts:490 (the local allocation site); src/emit.ts:655 (the tail-call refusal); src/declarations.ts:195. The loop and arena-block clause anchors at 5bee0324: src/attributes.ts:3559 (the foreign facts), src/escape.ts:1629 (PassWalk.isOld reads them), :1228 (reclaimsReturnedString) WP27 S3.2 lets a declare function return string, the first pointer C answers that the program reads as its own value. Two hazards come with it. C's const char * points into storage C keeps (strerror's table, getenv's environment, setlocale's static), which a later call may overwrite or move, so a string that kept the pointer would change under the program or read freed bytes. And C may answer NULL, which a string cannot hold: a read of its length would dereference address 0 Decided and fixed. The bytes are copied into the arena at the call (docs/wp27-ffi.md §8d): one scan for the NUL and one nish_str_new, lowered in the IR because runtime.c has no room under its ceiling for nish_ffi_str. C's pointer is never held past the copy, and the copy is an allocation of the calling function, placed, kept and released by the escape analysis like any other: the foreign function's facts say it allocates and returns its allocation, so a loop pass that keeps one keeps it past the pass, and a using a = arena() block that would release one held outside it is refused (until 5bee0324 the facts said neither, and a loop released the copy it kept). A call whose result is copied is never tail. NULL is tested before the copy: `string
RT-16 Medium (new, anchors at b982d4f) src/emit-strings.ts:602 (emitCStringCopy, the @nish.ffi.str copy readCString shares with RT-15); src/builtins.ts:744 (checkReadCString); src/escape.ts:139 (the copy is a fresh allocation); src/expressions.ts:2510 (refuseForeignNullable) WP27 S3.3 adds readCString(p: CPtr): string to nish:unsafe, the first read of bytes behind a pointer C answered and the program holds. Nothing proves the pointer is live, that its bytes end in a NUL, or that it is not NULL: a `CPtr nullresult must be narrowed first, but adeclare functionwhose result is declared plainCPtris trusted, gets noffi-nulltest, and a declaration that lies handsNULLto the copy, which reads address 0. The same stage lets aCPtr
RT-17 Low (new, anchors at 87fa0ce, rewritten there for TCP_NODELAY) runtime/runtime-net.c:249 (nish_tcp_listen_flags), :214 (the SO_REUSEPORT in nish_net_bound), :217 (its TCP_NODELAY on the listener), :291 (nish_tcp_accept, which reads it back and sets it on the accepted socket) tcpListenFlags (#478) sets SO_REUSEPORT on a TCP listener, so a second socket can bind a port a first one holds. Two hazards come with it. Another process that binds the same port takes a share of the connections meant for this one: on Linux it must be of the same effective uid and have asked for the option too, but on a system that checks less, or for a server that runs as a shared account, that is a process the program did not start reading its clients. And a flags word with a bit nobody defined would be passed on as whatever the kernel makes of it Decided. The option is set only when the program asks for it by name, flag 1, on that socket. tcpListen is unchanged and never sets it, so a plain listener still refuses a second bind (-98), and a flagged listener cannot take a port a plain one holds, nor a plain one a flagged one's. Who may share is the kernel's rule, not the runtime's: on Linux the same effective uid, and the program that asks for the flag accepts that any process of its user may share the port, as LANGUAGE.md's row says. Flag 2 (#760) sets TCP_NODELAY, which changes when bytes leave and nothing about who may connect or what they read: the runtime sets it on the listener, and tcpAccept reads it back with getsockopt and sets it on each accepted socket itself rather than trusting the kernel to copy it, which Linux does and other systems need not. A failed set on the accepted socket closes it and answers the error, so no connection is handed out without the option its listener asked for, and the descriptor does not leak. Any bit but 1 and 2, a negative word included, answers -22 before a socket is made, so a later flag cannot change the meaning of an old program's call. The socket is close-on-exec and non-blocking as every listener is. Tests: test_tcp_reuseport in tests/runtime-test.c (the option on a flagged socket and not on a plain one, a second flagged bind, -98 both ways, -22 for 4 and -1), test_tcp_nodelay in tests/runtime-test.c (a listener given TCP_NODELAY only after the handshake, which Linux does not copy, still hands out a connection that has it, so the set in nish_tcp_accept is what this checks), tests/cases/net_tcp_reuseport, tests/link/net_tcp_reuseport_spread, tests/cases/reject_net_reuseport_wasm; for flag 2, ${name}: native output matches .out for net_tcp_nodelay (getsockopt reads TCP_NODELAY 1 on a socket accepted from a flag-2 or flag-3 listener and 0 from a flag-0 or flag-1 one, and -22 for 4, 6, 8 and -1), link/${name}: exits with ${wantCode} and stdout matches expected.out for net_tcp_nodelay_shared (two flag-3 listeners on one port carry a round trip), and ${name}: rejected with ${needles...} for reject_net_nodelay_wasm. Amended for spawnWithFds (RT-40): a listener can now also reach a worker as the same socket, handed on as descriptor 3 + i, rather than as a second bind. That share takes no flag and no uid rule: the child holds this process's open file, so it shares its status flags, and only the child the program names gets it, since every other child still has it closed at exec. The listener stays close-on-exec here, so a plain spawn started meanwhile does not get it. Pinned by spawn_fds_child's attempt 6, which hands on one end of a Unix stream pair and reads 3:-~ (the shared O_NONBLOCK), and by the pass child's own spawn, which sees no descriptor from 3 up. That a child accepts from the listener's own queue and sees its TCP_NODELAY follows from the shared open file and is not pinned: no test hands a TCP listener to a child
RT-18 Medium (new, anchors at 9ca082d) src/emit-strings.ts:571 (emitForeignBuffers; the ffi.length block from :592); src/attributes.ts:5475 (foreignBufferCountProven), :2904 (the ffi-length site); src/emit.ts:898 (the i8* parameter); src/escape.ts:762 (the borrow), :1634 (no pointer C holds past a call); src/parallel.ts:462 (the task refusal) WP40 B1.2 lets a u8[] or readonly u8[] cross into a declare function as the array's data pointer, with a count C trusts. Three hazards come with it. A count larger than the array's length lets C read or write past the data block: a heap overflow the program cannot see. A count computed before a later argument pops or pushes the same array is stale by the time C runs. And C writes through a u8[] it is passed, so a task that reaches C with an array its parent is reading races it Decided and fixed. Every buffer argument is compared with its own header before C runs: icmp ule i64 n, len, unsigned because the count is a u64, branching to an ffi-length panic (exit 1) that names the function, the count and the buffer. The length and the data pointer are loaded after the last argument is evaluated, so a resize by a later argument is seen. The one count proved and not checked is toU64(b.length) of the buffer itself, where b is a const local or a parameter and no later argument may resize it; --deny-panics refuses any other and names that form. The pointer is lent for the call, as a string's bytes are (RT-14), and a u8[] C writes holds bytes, not pointers. A spawned task whose body reaches a foreign call with a buffer is refused by the existing "writes memory its caller can see" rule. On wasm32 the pointer is 32 bits and the count stays i64, a gap docs/LANGUAGE.md records. Tests: tests/cases/panics_ffi_length, panics_ffi_length_memcpy, panics_ffi_length_wrapped, panics_ffi_length_unchecked, panics_ffi_length_shrink, panics_ffi_length_inline_pop, ffi_buffer_late_load, ffi_buffer_count_proved, ffi_buffer_read, ffi_buffer_export_written, ffi_buffer_call_sites, reject_ffi_buffer_call_spawn, reject_ffi_buffer_scope_race, reject_ffi_buffer_scope_field_race, reject_ffi_buffer_scope_element_race; "ffi_buffer: toU64(b.length) of the buffer itself is a proved ffi-length site", "ffi_buffer: a proved count compiles under --deny-panics", "ffi_buffer: --deny-panics refuses an unproved count handed to C in a constructor", "ffi_buffer: a constructor's count to C is an ffi-length site, and new is a call site via it", "ffi_buffer: a count handed to C inside a two-declarator using block, and one for a buffer read through this stored out of a constructor, are each an ffi-length site", "ffi_buffer: --deny ffi refuses a call that passes a buffer", "ffi_buffer: --deny ffi refuses a buffer call reached from a constructor by new", "ffi_buffer: a task that reaches C with a buffer the parent can see is refused, through ${how}", "ffi_buffer: an exported u8[] parameter C writes is nish_array *, and one C only reads is const nish_array *", "ffi_buffer: an exported u8[] parameter C writes loses readonly, and one C only reads keeps it", "ffi_buffer: the wasm glue copies back a u8[] parameter C writes, and not one C only reads", "ffi_buffer: a --target wasm32 build passes llvm-as, and llc reads the data pointer as an i32.load at offset 16 and the count check as i64.le_u", "ffi_buffer: the freestanding --profile wasm link of host IR resolves write against a stub with B1's signature, and hands it the bytes the native run writes" in tests/ffi-buffer.js, which tests/run.js runs
RT-19 Low (new, anchors at 6ab976b) runtime/runtime-os.c:325 (nish_kill), :342 (nish_wait_pid) kill(pid, sig) (WP34 N12) signals any process this user owns, not only a child the program spawned: kill(2) checks the uid and nothing else, so a program handed a pid it did not start can stop it. Two wider reaches of kill(2) itself would have come with a raw pass-through: a pid of 0 or below addresses a process group or every process of the user, and a signal number means different signals on different hosts Accepted, narrowed. The reach is the one kill(1) has, and a supervisor that adopts a child it did not start (a pid file) needs it; the label process.spawn says a program has it, and a capability policy can deny it. What is left out is refused before kill(2) is called: a pid of 0 or below, and every signal but 0, 2, 9 and 15, are -22, and waitPid refuses a process-group pid and any flag bit but WNOHANG the same way. Tests: tests/cases/os_kill, os_wait; test_process in tests/runtime-test.c. Amended for spawnWithFds (RT-40): its answer is a pid of this process's own child, as spawn's is, so kill and waitPid take it under the same narrowing and it widens neither; its label is process.spawn as well (tests/cases/caps_process_spawn_fds). A child started with descriptors is reaped the same way: spawn_fds_child's run waits on each with waitPid(pid, 0) and reads its status
RT-20 Medium (new, anchors at 943d8231) src/emit-parallel.ts:789 (openScope, the handle kind), :851 (closeHandle), :876 (closeScope), :905 (emitScopeJoins and its later test), :922 (closeBlockScopes); src/emit.ts:1127 (each declarator opened before the next initialiser, Q12(a)); src/attributes.ts:2863 (noteHandleClose, the ffi offer at the using); src/handles.ts:139 (openedHandle) WP40 H1.2 lets a using own a C resource: a handle class's CPtr, closed by the C function its dispose names. Three hazards come with it. An exit that skips the close leaks the resource (a descriptor, a FILE * and its buffer); an exit that runs it twice frees it twice, which is heap corruption in C; and a close that runs before the last read, or while a later declarator's initialiser is still running, hands C a freed pointer or leaks the earlier handle Decided and fixed. The object goes onto the scope stack beside task scopes, arenas and locks, and every exit of its block runs the close once, inline: fall-through, return (the value computed first, so a call returned from inside the block is never tail), break, continue, an orReturn() and a scope's join. Closes run in reverse order of opening. Each declarator opens as soon as its initialiser returns, so an orReturn() in a later one closes the earlier handles. The object cannot be named after the close and its dispose cannot be called (H1.1's placement rules, docs/wp40-ffi-buffers-handles.md §4c, §4d), and a callee lent it cannot keep it (H1.3), so no code follows the close. What stays a C contract, as LANGUAGE.md's rule says: the constructor's C call returns a pointer the object owns, and the class's methods neither close nor keep it through another C function. A panic or process.exit does not close: the process ends, the kernel takes descriptors back, and a panic's exit(1) flushes stdio while a failed check's or out-of-memory's _exit(1) does not. Tests: tests/cases/ffi_handle_file (a driver that counts each open and close through an fclose wrapper and prints a second close or a leaked file), ffi_handle_exits, ffi_handle_arena, caps_ffi_handle, reject_ffi_handle_deny, reject_ffi_handle_use_after_close, reject_ffi_handle_use_after_close_loop, reject_ffi_handle_use_after_close_do_while, reject_ffi_handle_use_after_close_for_update, reject_ffi_handle_use_after_close_switch, reject_ffi_handle_use_after_close_switch_clause, reject_ffi_handle_use_after_close_else, reject_ffi_handle_use_after_close_ctor, reject_ffi_handle_use_after_close_keep, reject_ffi_handle_use_after_close_keep_module, reject_ffi_handle_use_after_close_keep_module_expr, reject_ffi_handle_use_after_close_keep_field, reject_ffi_handle_use_after_close_keep_this, reject_ffi_handle_use_after_close_keep_map, reject_ffi_handle_use_after_close_keep_channel, reject_ffi_handle_ctor_this_lend, reject_ffi_handle_ctor_this_receiver, reject_ffi_handle_ctor_this_store, reject_ffi_handle_ctor_this_alias, tests/link/ffi_handle_mutex (a lock and a handle in one using, both orders); "ffi_handle: a --target wasm32 build passes llvm-as, and llc calls counted_fclose with the handle's pointer as an i32", "ffi_handle: the --profile wasi link of ffi_handle_file and its driver (sysroot ${wasiSysroot}) closes each file once and prints the native expected .out under Node's WASI" in tests/ffi-handle.js, which tests/run.js runs
RT-21 Low (new, anchors at 5d80b08) runtime/runtime.c:1239 (nish_argv_init) Found while building the runtime under UBSan for the fuzzing stage (#888). nish_argv_init computed a + 1 from malloc's answer before it tested that answer for null. Arithmetic on a null pointer is undefined behaviour, so a compiler may infer a is not null and drop the nish_oom() test after it, leaving the stores below it to write through null when malloc fails. clang 18 at -O2 keeps the test (two nish_oom references in the function's IR, before and after the fix), so no build is known to be affected Fixed. The null test comes first. No regression test: the path needs malloc to fail for the program's first allocation, and the compiled code is the same before and after. The runtime is now built under -fsanitize=address,undefined by tests/run.js's runtime_sanitize section, which tests/fuzz/README.md describes. Tests: runtime_sanitize: the runtime unit test (${label}) runs clean under ASan and UBSan
RT-22 Low (new) runtime/runtime-net.c:327 (nish_tcp_connect), :334 (its TCP_NODELAY) tcpConnect left Nagle's algorithm on (#942), so a client that writes a small request behind another one waits on a delayed ACK; libpq and Go's net package turn it off on every client socket, and a program wanting the same had to declare setsockopt itself, an untyped call into libc with a length the program spells (bench/postgres/main.ts did). It changes when bytes leave, not who may connect or what anyone reads Fixed. The runtime sets TCP_NODELAY on the socket before connect, so the option holds from the first byte whether or not the connection finishes at once. A refused set closes the socket and answers the error, so no descriptor leaks and no socket is handed out without the option. The signature is unchanged, and the bench's setsockopt declaration is gone. Test: ${name}: native output matches .out for net_tcp_nodelay (its connecting export opens a client socket to a flag-0 listener, and getsockopt reads TCP_NODELAY 1 on it; 0 before the fix)
RT-23 Low (new) runtime/runtime-net.c:396 (nish_net_set_option), :105 (ENOTSOCK in nish_net_err) netSetOption (part of #982) hands a program setsockopt on a descriptor it names. Passed through raw, a level, an option number and a value would let a program set any option the kernel has, among them ones that change who may bind or read (SO_REUSEPORT, SO_BINDTODEVICE) or that take a pointer-sized struct the runtime would then read as a bare int, and an option number means different things on Linux and Darwin Decided. The program names an option by the runtime's own id, 1 to 5, and the runtime maps it to a fixed level and name: TCP_NODELAY, TCP_QUICKACK (Linux only, -95 on Darwin, where it does not exist), SO_KEEPALIVE, SO_SNDBUF and SO_RCVBUF. Every one takes an int, which is what is passed, by the runtime's own sizeof, so no length is the program's to spell. Any other id, 0 and negatives included, is -22 before any call, so there is no passthrough and a later id cannot change an old program's meaning. None of the five changes who may connect or what anyone reads: the two TCP_ options change when bytes leave or are acknowledged, keep-alive when an idle peer is probed, and the buffers how much the kernel holds, within its own rmem_max / wmem_max caps for an unprivileged process. The descriptor is the program's, as for every nish:net call; one that is closed answers -9 and one that is not a socket -88, Darwin's ENOTSOCK translated to Linux's number as the other codes are. Tests: ${name}: native output matches .out for net_set_option (getsockopt reads each option back after a set on and off, both buffers at least what was asked, Linux's doubled send buffer, -22 for ids 0, 6 and -1, -9 on a closed listener and -88 on a pipe), ${name}: rejected with ${needles...} for reject_net_set_option_wasm
RT-24 Medium (new, anchors at fe02a75b) runtime/runtime-os.c:264 (nish_put_file_mode, the fchmod at :274), :284 (nish_write_file_mode), :292 (nish_create_file_exclusive); runtime/node.mjs (putFileMode) A program writing a secret (#992's credential store) had only writeFileSync, which creates at 0644 under the umask and never changes the mode of a file already there: a key written over a 0644 file stays world-readable, and the chmod a program could add afterwards leaves a window in which another user can open the file and read the secret. The open's own mode argument does not close it either: the umask can only narrow it, so a 0o640 asked for may come out 0o600, and a file that exists keeps its old mode Fixed by the design of writeFileSyncMode and createFileExclusiveSync. Each opens with the mode and then fchmods the descriptor to exactly mode before nish_put_all writes the first byte, so the file is never readable under a wider mode while it holds the data, the umask cannot change the answer, and a 0644 file is narrowed before its contents become the secret. A mode outside 0..0o777 is -EINVAL before anything is opened: the setuid, setgid and sticky bits are refused rather than set on a secret, where fchmod would apply them and open would drop them. A winner of createFileExclusiveSync whose write or close then fails unlinks the path before it answers (the unlink after the close in nish_put_file_mode, and fs.unlinkSync in putFileMode), so a failed create leaves no half-written lock for every later caller to find taken. Node's twin opens with the mode and fchmodSyncs before its write loop. What fchmod cannot reach: a descriptor another process opened while an existing file was wider is not revoked and reads what is written after, so the guide and LANGUAGE.md replace a secret by writing a temporary at 0600 beside it and renameSyncing it over, a new inode no earlier descriptor names. The write is in place, after O_TRUNC: a file the process may write but does not own is emptied before fchmod answers -EPERM, and a crash leaves it empty or partial, which the same temporary-and-rename route also covers. Under WASI the three answer -ENOSYS; no test runs that path. Tests: fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js read each mode back by stat under umask 022 and 077 (a new file at 0600, a 0644 file rewritten at 0600 with the new contents, a 0644 asked for under umask 077 kept 0644, and an exclusive create whose write fails under ulimit -f 0 answering -27 EFBIG with nothing left at the path); tests/cases/io_write_file_mode (setuid 0o4600 is -22), io_create_exclusive (setgid 0o2600 is -22 and creates nothing)
RT-25 Medium (new, anchors at fe02a75b) runtime/runtime-os.c:264 (O_NOFOLLOW in nish_put_file_mode's open) A secret written to a path in a directory another user can write is the classic link attack: plant a symbolic link at the name and the write goes to the file it names, or fchmod narrows or widens a file the attacker chose. O_EXCL alone refuses a link that exists, but a truncating write does not Fixed. The open passes O_NOFOLLOW (with O_CLOEXEC), so a symbolic link as the last component is -ELOOP and neither the link nor its target is opened, written or fchmoded; O_EXCL answers -EEXIST for a link, dangling or not, before it would be followed. A linked directory on the way still resolves, as RT-4's writes do. Under Node the flag set carries O_NOFOLLOW the same way. Tests: fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js (a link at the path: -40 ELOOP and the target's contents and mode untouched; createFileExclusiveSync at a dangling link: -17, and nothing created where it points)
RT-26 Low (new, anchors at fe02a75b) runtime/runtime-os.c:300 (nish_mkdir_mode) mkdirSyncMode cannot set an exact mode the way the file pair does: mkdir(2) applies the umask, and a chmod after it is by path, so it can be raced onto a name swapped in its place. A directory that already exists keeps its mode, which may be wider than asked Decided, by contract. The umask applies and is documented in docs/LANGUAGE.md: it only ever narrows, so a 0o700 directory is 0o700 under any umask that leaves the owner's bits, which is the mode a secret's directory wants; no chmod follows. An existing name answers -17 EEXIST and its mode is left as it was, never changed by path, so a caller that needs the mode checks it with lstatOwnerModeSync. Tests: fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js (0700 under umask 022, then -17 with a 0755 directory left at 0755); tests/cases/io_mkdir_mode (the sticky 0o1700 is -22)
RT-27 Medium (new, anchors at e8c5b66c) runtime/runtime-os.c:478 (nish_spawn_piped's poll loop), :483 (the stdin write) spawnPiped both writes a child's stdin and reads its stdout and stderr. Done in turn, write everything then read, it waits forever on any child that prints more than a pipe holds (64 KiB on Linux) before it has read all its input: cat on a large input, or any filter, fills its stdout, stops reading, and the parent stops writing. Whoever chooses the input or the child's output size hangs the program Fixed before landing. The three parent ends are non-blocking and one poll loop writes stdin and drains both outputs as each is ready, so neither side waits on the other. Node's spawnSync (the Node host's spawnPiped) does the same. A child that hands its pipes to a grandchild which keeps them open makes the call wait for that grandchild, as spawnSync does. Test: ${name}: native output matches .out for io_spawn_piped, its big: 0 1572864 true line (1.5 MiB through cat both ways; written in turn, it hangs)
RT-28 Medium (new, anchors at e8c5b66c) runtime/runtime-os.c:498–:505 (the cap), :519 (the kill) The child chooses how much it prints, and every byte is appended to an arena array. A child that never stops (yes, or one an attacker controls) grows the array until the arena fails and the program exits out of memory Fixed before landing. Each stream stops at NISH_PIPED_CAP, 16 MiB, the cap the Node host passes spawnSync as maxBuffer. The loop reads at most one byte past the cap, which tells a stream that ends at exactly 16 MiB from one that goes on; that byte is dropped, the child is killed with SIGKILL and reaped, and the call answers -2 with the array holding the first 16 MiB. The Node host answers -2 for ENOBUFS. Tests: ${name}: native output matches .out for io_spawn_piped, its flood: -2 16777216 line (yes on stdout), stderr flood: -2 16777216 line (yes >&2) and at cap: 0 16777216 line (exactly 16 MiB is kept whole, status 0)
RT-29 Low (new, anchors at e8c5b66c) runtime/runtime-os.c:465, :518 (pthread_sigmask), :516 (sigwait) A child that exits, or closes its stdin, before reading all of it makes the next write raise SIGPIPE, whose default action ends this process: a program whose child rejected its input died silently. The first fix ignored SIGPIPE for the call, and the review found that wrong twice: the disposition is process-wide, so another thread's write to a closed pipe stopped ending the process for that window, and posix_spawn keeps an ignored signal ignored across exec, so the child itself, and every child another thread started meanwhile, ran with SIGPIPE ignored and never died of a reader that had gone (sh -c 'kill -PIPE $$; echo alive' printed alive and exited 0) Fixed before landing. SIGPIPE is blocked on the calling thread only, after the child has started, so the child begins with the mask and dispositions this program had. A write's SIGPIPE is directed at the thread that wrote, so it stays pending; it is taken off with sigwait if it was not pending before the call, and the thread's mask is put back before the answer. A SIGPIPE another process sends this one with kill inside that window is taken too. Tests: ${name}: native output matches .out for io_spawn_piped, its unread: 0 line (true given 1.5 MiB, and the program goes on) and child sigpipe: 141 line (0 before)
RT-30 Medium (new, anchors at e8c5b66c) runtime/runtime-os.c:422–:438 (nish_pipe), :458 (the child's ends closed), :531 (every end closed on every path); :329 (nish_spawn_start's dup2 actions) Three pipes per call, one carrying the stdin a secret travels on. A pipe end without close-on-exec is inherited by every child the program starts later, or that another thread starts meanwhile: a later child holding the write end of an earlier child's stdin keeps that child from ever seeing end of file, so the earlier call hangs, and a later child holding a read end can read what the program sent another. An end that lands on descriptor 0, 1 or 2 (a program that closed its own stdin) would be dup2ed onto itself, which leaves close-on-exec set, and the child would start without that stream Fixed before landing. Linux creates each pipe with pipe2(O_CLOEXEC), and an end on 0 to 2 is moved above them with F_DUPFD_CLOEXEC. posix_spawn's dup2 clears close-on-exec on the child's three copies only. The parent closes the child's ends as soon as the spawn returns, and every end it still holds on each exit, a failed spawn and a failed pipe included. Open on Darwin: it has no pipe2, so an end is close-on-exec only once F_DUPFD_CLOEXEC has moved it, and a child another thread starts in that window inherits it; the runtime spawns from one thread, so this needs a program that spawns from two. Test: ${name}: native output matches .out for io_spawn_piped, its inherited: [] line (a child started after every other call holds no descriptor from 3 to 9: []; with plain pipe the run hangs at its first call, cat never seeing end of file)
RT-31 Low (new, anchors at e8c5b66c) runtime/runtime-os.c:483; src/builtins.ts (spawnPiped's checker) A program that hands a secret to a child (a password to a keychain tool, a key to openssl) had spawn's argv, the environment or a file to put it in. Argv is world-readable in /proc/<pid>/cmdline and ps on Linux, the environment is readable by the same user in /proc/<pid>/environ and is inherited by everything the child starts, and a file outlives the call Decided. spawnPiped takes stdin as a u8[] the runtime writes from the caller's array as it stands, so the bytes reach the child through a pipe only, and no copy is made: the kernel's pipe buffer and the child are the only other holders. The array is the caller's to wipe after the call. Test: ${name}: native output matches .out for io_spawn_piped, its secret visible: false argv printed: true line (the secret is fed to a shell that prints its own argument vector and its environment; the secret appears in neither, and the marker argument shows the vector was printed)
RT-32 Low (new, anchors at cbf2db04) runtime/runtime-net.c:437 (nish_net_mss) netMss (part of #1094) reads a socket's send MSS with getsockopt on a descriptor the program names, and a program sizes its writes by the answer. Read carelessly, the kernel's reply could be trusted past what it wrote: TCP_INFO's struct tcp_info grows from kernel to kernel, and an older kernel than the headers copies a shorter struct, so a field past its end would be read from the runtime's own stack. An unconnected socket also answers a plausible number, the default 536, to both options, which a program would take for a path's real MSS; and the reply is an unsigned field the program receives as an i32 Decided. The struct is zeroed before the call and its tcpi_snd_mss is read only when the length the kernel reports reaches the end of that field; TCP_MAXSEG is read only when the kernel wrote exactly an int. A getpeername that fails comes first, so a socket with no peer (fresh, listening, a UDP one never connected) is -1 rather than 536. Only an answer in 1 to INT32_MAX is returned; zero, a failure of either option, a closed descriptor and a non-socket are all the same plain -1, never an errno, so nothing reaches the program beyond "this many bytes" or "none". Nothing is written: both calls only read, and the descriptor is the program's, as for every nish:net call. A wasm build has no socket and compiles the call to the constant -1 after evaluating the descriptor, so no import reaches a host. Tests: ${name}: native output matches .out for net_mss (a positive MSS on both ends of a loopback connection; -1 for a listener, a bound UDP socket, a closed descriptor, a descriptor never opened, an unconnected TCP socket and a pipe, and on Linux that the unconnected socket's TCP_MAXSEG and tcpi_snd_mss are both 536, the answer the getpeername check keeps from the program), net_mss: a wasm32 build compiles netMss to -1, with no call into runtime-net.c and the --profile wasi build that prints -1. Not exercised by a test: on Linux, the TCP_MAXSEG fallback after TCP_INFO and the guard on a TCP_INFO reply too short to reach tcpi_snd_mss, since a connected socket on a current kernel takes neither; the fallback is Darwin's only path and runs there in CI's runtime-net (macos-latest) job
RT-33 Medium (new, anchors at 27b1a90d) runtime/runtime-net.c:706 (nish_unix_addr) A Unix path is copied into sun_path, 108 bytes on Linux and 104 on Darwin. A path that does not fit, with its terminating NUL for a pathname, would overrun the struct, or, cut short, name a different file than the one asked for; a NUL inside names a shorter path, and a bare @ the empty abstract name Fixed before landing: a path too long is -36 before any socket exists, an empty path, a NUL inside or a bare @ is -22, and a leading @ on Darwin, which has no abstract namespace, is -95. Pinned by tests/cases/net_unix_calls (107 and 108 bytes, "", "@", "@a\x00b"), net_unix_paths (a 108-byte path is -36 and leaves neither it nor the 107-byte path a cut-short bind would make) and net_unix_fds (the same with no descriptor left). The Darwin -95 for @ is not pinned: CI does not run the Unix calls on a mac
RT-34 Low (new, anchors at 27b1a90d) runtime/runtime-net.c:735 (nish_unix_stale) unixListen flag 1 clears a socket file nothing listens on: it lstats the path (anything but a socket is -17), probes it with a connect, and unlinks it only on ECONNREFUSED. Between the probe and the unlink another process may bind the same path, and its file is the one unlinked; on Darwin a listener whose backlog is full also refuses, so its file could be taken for stale Recorded open for the window: it is two system calls, and only a process that can write the socket's directory can bind there, so the trust boundary is the directory, as for any socket file. Put a listener in a directory only its owner can write ($XDG_RUNTIME_DIR). Without flag 1 an existing path is always -98. Fixed before landing for Darwin: there the probe cannot tell a full backlog from a dead file, so flag 1 meeting a socket file is -95 and unlinks nothing. Pinned on Linux by tests/cases/net_unix_paths (a stale file cleared, a live one -98, a regular file -17 and still holding its 4 bytes) and net_unix_fds (the stale rebind with no descriptor left); the Darwin -95 is not pinned, since CI does not run the Unix calls on a mac
RT-35 Medium (new, anchors at 27b1a90d) runtime/runtime-net.c:780 (the chmod in nish_unix_bound) A connect to a pathname socket needs write permission on its file, and bind creates it with the umask's mode, often 0755 or wider for a group. Set after the listen, the mode would admit anyone the umask allowed for that window Fixed before landing: unixListen gives the file 0600 and unixListenMode the mode asked for (above 0777 is -22), set between the bind and the listen, when every connect is still refused. The chmod names the path, so a process that can write the directory could swap the file in between; the trust boundary is the directory, as for RT-34. Pinned by tests/cases/net_unix_paths: it runs under umask 0, so the bind makes the file 0777, reads back 0600 and a unixListenMode mode with lstat, and its driver's chmod stands in for libc's and connects to the path first, counting a connect that succeeds; the count must be 0, and moving the chmod after the listen makes it 4. The swap through the directory is not pinned
RT-36 Low (new, anchors at 27b1a90d) runtime/runtime-net.c:757 (nish_unix_bound), :810 (nish_unix_connect), :842 (nish_unix_socket_pair) A call that fails after its socket exists could leak the descriptor, and one that fails after the bind could leave a socket file that blocks the next listener with -98 Fixed before landing: every failure after socket closes it, every failure after a pathname bind also unlinks the file, unixConnect keeps the socket only on success or EINPROGRESS, and unixSocketPair checks its kind and array before any socket exists. Pinned by tests/cases/net_unix_fds: the descriptor count, read with fcntl so it holds without /proc, is unchanged around every refusal of nish_unix_addr, nish_unix_bound, nish_unix_socket_pair and unixAccept, the probe included, and around a listen-connect-accept cycle, a pair and a stale rebind; its driver's chmod fails for one path, so the post-bind branch runs, and afterwards none of the paths the attempts named is on disk. Dropping that branch's close and unlink makes the count +1 and leaves the file
RT-37 Low (new, anchors at 17d8979f) runtime/runtime-net.c:981 (nish_recv_fd), :924 (NISH_RECV_FDS), :914 (nish_unix_only) Whoever is at the other end of a Unix socket chooses how many descriptors a message carries. A receiver that kept every one would hold descriptors it never asked for, up to its limit, and one that gave the control buffer too little room would read MSG_CTRUNC's truncated last header for more descriptors than arrived. And SCM_RIGHTS and SO_PEERCRED on a socket of another family do not refuse alike: Linux answers -22 for the control message, and SO_PEERCRED on a TCP socket succeeds with no peer behind it Fixed before landing. The buffer has room for 8; the first descriptor is kept in fd[0] and every other one is closed before the answer, and the kernel drops what does not fit. Each header's count is the smaller of what it claims and what the buffer holds after it, so a truncated header never reads past the buffer, and a truncated message still answers its bytes and its first descriptor. All three calls ask getsockname first and refuse a descriptor that is not a socket (-88) or a socket not AF_UNIX (-95), so no call reaches the kernel with a socket it means nothing on. Pinned by tests/cases/net_unix_scm: the driver sends three descriptors in one message and then twelve, and both lines read read 1, kept 1, descriptors +1 (without the close, +3 and +8); attempts 23 and 24 send and drain with the count unchanged, and attempts 2, 3, 9, 10, 18 and 19 are -88 and -95 for a file and a TCP socket with the count unchanged (without the check, the unconnected TCP peerCredentials answers -107, its missing peer rather than its family)
RT-38 Low (new, anchors at 17d8979f) runtime/runtime-net.c:929 (MSG_CMSG_CLOEXEC), :1015 (the Darwin fcntl) A received descriptor must be close-on-exec, or every child the program starts inherits a file another process chose. Linux marks it in the recvmsg with MSG_CMSG_CLOEXEC; Darwin has no such flag, so there it is marked by fcntl after recvmsg returns, and a child another thread spawns in that window inherits it Open (Darwin). On Linux there is no window. On Darwin the window is the time between the two calls; it matters only to a program that spawns from one thread while another receives, and it is the same window as RT-30's pipes. A Darwin fcntl that fails closes the descriptor and answers the error, so none is handed out without close-on-exec. Pinned on Linux by tests/cases/net_unix_scm's carry lines, cloexec 1 for a file and both kinds of socket (without the flag, cloexec 0), and by spawn_fds_child's pass child, whose own spawn sees no descriptor from 3 up, the received one included. Not compiled or run on Darwin, the failing fcntl path included: macOS CI is disabled
RT-39 Low (new, anchors at 17d8979f) runtime/runtime-net.c:1042 (nish_peer_credentials) peerCredentials tells a server which process, user and group it is talking to, and a server that grants by uid trusts the answer. Credentials read at call time could be raced by a peer that changes its uid, or hands its end to another process, after connecting; and Darwin has no one call for all three Decided. Both platforms answer what the kernel recorded when the connection was made: SO_PEERCRED on Linux, getpeereid on Darwin, each fixed at connect (or socketpair), so a peer that later drops or gains privilege, or passes the socket on, reads as it was. That is the property a check wants, and it is also its limit: the answer names who connected, not who holds the end now. On Darwin the pid is LOCAL_PEERPID where the SDK defines it, else -1, and a caller must not take -1 for a pid. A socket with no peer, a listener or one never connected, is -107 on both platforms: Linux asks getpeername first, since SO_PEERCRED on a listener answers its own credentials, and Darwin's getpeereid fails so itself; a peer that has since closed keeps its connect-time answer. A uid or gid of 2^31 or above wraps into the i32. Pinned by tests/cases/net_unix_scm's pair, connect and accept lines, which compare all three fields with the driver's own, and by spawn_fds_child's far end: peer line, where a child names its parent, and by net_unix_scm's listen: refused 1 (without the getpeername, Linux answers 0). The uid and gid pins cannot tell the two fields apart where uid equals gid, as for root and in many containers, so a swap of out[1] and out[2] passes there. The connect-time capture is the kernel's and no test changes a uid to show it; Darwin's three answers are not yet seen on a mac
RT-40 Low (new, anchors at 17d8979f) runtime/runtime-os.c:485 (nish_spawn_with_fds), :466 (NISH_SPAWN_FDS), :369 (nish_spawn_start) spawnWithFds(argv, fds) hands a child chosen descriptors at 3 + i. Done as one dup2 per entry it goes wrong two ways: a source already on its target (fds[0] == 3) is a dup2 onto itself, which leaves close-on-exec set, so the child loses it; and a source that is an earlier entry's target (fds = [5, 3, 4]) is overwritten before it is read, so the child gets the wrong file. The rest of the program's descriptors must stay closed at exec, and an unbounded list would be a stack array of the caller's size Fixed before landing. Each source is first duplicated close-on-exec to a scratch descriptor at or above 3 + n, and the child's dup2s read only those, so every move is between two different descriptors and no target is a source. More than 64 entries, or a negative one, is -22 before anything is opened; a source that is not open is the duplicate's -9. The scratch descriptors are closed on every path, and nothing else this process holds changes. Pinned by tests/cases/spawn_fds_child: attempt 1 ([5, 3, 4] reads 3:c 4:a 5:b; moved in place, a letter repeats on every libc), attempt 0 ([3, 4, 5], each source on its own target, reads 3:a 4:b 5:c; moved in place, the three are missing on a libc whose dup2 action onto itself keeps close-on-exec, while glibc's clears it), attempt 2 (one source twice), attempt 4 (64 sources, 3:a to 66:a), attempt 5 (65, -22), attempts 7 and 11 (a negative one, -22), attempt 8 (one not open, -9), each with descriptors +0, and each child lists exactly its targets and nothing else
RT-41 High (new, anchors at 924e0ee1) runtime/runtime-region.c:142 (nish_region_slot_of), :195 (nish_region_enter), :205 (_leave), :214 (_free) WP46 stage 1 hands a program a region as a number, Region<T>, which it can copy, store, keep after freeing, or (through an array's zero fill) never have been given. A handle used after free, or one never made, that reached a slot would enter a freed arena or free one twice: use after free and a double free of every chunk the region held, in C Fixed in the design. A handle is the slot's index (low 24 bits) and its generation (40 above); generations start at 1, so 0 is never a handle. Every call takes the slot through nish_region_slot_of, which panics (exit 1) on 0, on an index whose page was never made, on a free slot, on a generation that is not the slot's, and on a slot another thread made; free moves the generation on before the slot is reused. The panic runs before any field of the slot is written. Tests: "runtime-region.c: zero-enter / zero-free / zero-leave / stale-enter / double-free / forged / thread panics with ..." in tests/run.js's region section, in both builds (thread in the threads build)
RT-42 Medium (new, anchors at 924e0ee1) runtime/runtime-region.c:197, :207, :216 Entering swaps the slot's arena with the thread's, and the slot holds the displaced arena until leave. Entering a region already entered would swap the displaced arena into itself and lose the thread's; freeing an entered region would free the thread's own arena, which the program's live values sit in; and a leave of a region that is not the innermost would restore arenas out of order, so later allocations land in a freed region Fixed. A slot carries entered; enter of an entered slot and free of one each panic. leave must name the handle entered last on this thread (nish_region_current, a thread-local chain through each slot's outer) and panics otherwise, including for a region never entered. Tests: "reenter", "reenter-nested", "free-entered", "leave-outer", "leave-unentered" in the region section
RT-43 Low (new, anchors at 924e0ee1) runtime/runtime-region.c:166 (nish_region_new), :226 A program that makes regions forever could exhaust the 24-bit index, and a slot reused 2^40 times would wrap its generation back onto a handle a program may still hold Fixed. new panics with "more than 16777216 live regions" past the last index rather than wrapping. A slot whose next generation would reach 2^40 is retired, never put back on the free list, so no handle names a slot twice; at one free of the same slot a microsecond it takes 2^40 µs, about 12.7 days, to retire (at one a nanosecond, about 18 minutes), and a retired slot costs its 80 bytes. Recorded open: no test reaches either limit (2^24 regions, or 2^40 frees of one slot), which is a test cost, not a gap in the code; both are read
RT-44 Low (new, anchors at 924e0ee1) runtime/runtime.c:191 (nish_arena_grow) A region whose chunks start at the thread arena's 64 KiB would make a server holding 100,000 small sessions reserve 6.5 GB and touch about 450 MB; a peer that opens sessions is then a memory-amplification lever Fixed. A region's first chunk is its first allocation's size and later chunks double (or take an allocation's size when that is more), through the thread-local nish_region_chunk the swap carries; a thread's own arena keeps its 64 KiB floor (0 there). The table's pages are allocated a thousand slots at a time and kept. Tests: the positive run of the region section checks the chunk sizes, the thread arena's floor afterwards, and that 100,000 regions made, filled and freed ten times leave the resident set within 4 MiB of the first round and under 128 MiB at the peak (measured 38,620 KiB flat on linux-x64)
RT-45 Medium (new, anchors at 924e0ee1) runtime/runtime.c:338 (nish_arena_release), :333 (nish_arena_mark); runtime/runtime-region.c:195 (the swap in nish_region_enter) Arena.mark() and Arena.release(mark) read and rewind whichever arena is current, and entering a region swaps a different arena in. A mark of 0, taken in a region that has allocated nothing, released after leave, resets the thread's own arena and frees every chunk the program's live values sit in: use after free. A mark taken outside a region and released inside names no chunk of the region, so the release is a no-op and frees nothing Fixed (WP46 stage 2). Arena.release and Arena.reset are gone from the language (NL2244), and a region's function may not reach Arena.mark or Arena.used, itself or through any callee: the checker refuses the region, regionWith, enter or enterWith call that hands it over (NL2617, docs/LANGUAGE.md → "Regions: nish:region"), so no mark is taken on one side of a region boundary and released on the other. Pinned: tests/cases/reject_region_arena_mark takes Arena.mark() inside a region's f and its .err pins the refusal, and reject_region_arena_used_callee reaches Arena.used() through a callee of enter's function. regionWith and enterWith are argued, not pinned: all four calls hand their function to the same check, which reads the function's readsArenaState fact (closed over its callees), so the call that hands it over does not change the verdict
RT-46 Low (new, anchors at 924e0ee1) runtime/runtime-region.c:168 (the lock in nish_region_new), :176 and :181 (nish_region_die under it) Under -DNISH_THREADS, new panics for a full table or a failed page allocation while it holds the table's spin lock, so another thread that calls new or free while the panic is on its way out spins until the process exits Fixed (WP46 stage 2). new lets go of the lock before each of its two panics, so nothing that runs while the process exits waits on it, and it counts a never-used slot only once that slot's page is allocated, so the atexit handler's own new after a failed page reuses the index instead of reading a page that is not there. That is reachable, not hypothetical: the panic exits through exit(1), so atexit handlers run, and one that freed a region spun forever. Pinned: the oom-unlocks mode of the region section (threads build, Linux) caps the address space so new panics out of memory and an atexit handler then frees a region; it exits 1 with the panic, where the unfixed runtime spun until the test's alarm killed it
RT-47 Low (new, anchors at 924e0ee1) runtime/runtime.c:194 (nish_region_chunk = cap * 2) A region's next chunk is twice the last with no ceiling, so a region that keeps allocating asks for 1 GiB, then 2 GiB, then 4 GiB chunks: at most half the region's reservation is unused, but a single large request can fail, ending the program with out of memory, while smaller chunks would still have fit Fixed (WP46 stage 2). The doubling stops at 1 MiB (NISH_REGION_CHUNK_MAX in runtime.c): a small region still starts at its first allocation's size and stays small (RT-44), a region that keeps allocating takes 1 MiB chunks, and one allocation larger than that still gets a chunk of its own size. Pinned: the positive run of the region section makes a 3 MiB allocation, then checks the next chunk is 1 MiB (the unfixed runtime asked for 6 MiB) and that four more stay there
CG-3 (rest) High src/emit-arrays.ts (join) join builds its string inline in the IR, not in the runtime, so it can still exceed 2^31 − 1 bytes Fixed (#427): a join past 2^31 − 1 bytes fails as a concatenation does (codegen.md, CG-3), and is counted there. With RT-1 and RT-2 fixed, every other runtime source of a string or array is bounded. A listing is bounded by nish_array_grow, a name by the file system (255 bytes), getenv and argv by MAX_ARG_STRLEN (128 KiB), and realpathSync by PATH_MAX. /dev/stdin as a pipe is unreadable here, since lseek fails on a pipe

Properties verified

Property How it holds Pinned by
No string or array the runtime makes is longer than 2^31 − 1, and one of exactly 2^31 − 1 is still made RT-1, RT-2 and RT-7 for reads, concatenation and the host entry, K1-6 for push, the bounds above for the rest tests/link/rt_sec_read_cap; RT-1, RT-2, RT-7 in tests/runtime-test.c; tests/link/cg_sec_push_limit
A path, name or argument holding a NUL names nothing nish_cpath in nish.h, the one copy of the test, which the spawn argument loop also uses tests/link/rt_sec_path_nul; RT-3 in tests/runtime-test.c
No file is written or truncated through a symbolic link at its name; a linked directory on the way still works O_NOFOLLOW on all four opens RT-4 in tests/runtime-test.c
Files are created 0644 and directories 0777, both under the umask (Node creates 0666; this is tighter) the modes in runtime-os.c "modes" in tests/runtime-test.c
A file writeFileSyncMode or createFileExclusiveSync writes holds exactly the mode asked for, whatever the umask or its old mode, before it holds any of the data; neither writes through a symbolic link at its name RT-24, RT-25: fchmod before the first write, O_NOFOLLOW fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js
Of any number of processes racing createFileExclusiveSync for one path, exactly one creates it O_CREAT|O_EXCL fs_mode: of eight children racing createFileExclusiveSync for one path exactly one wins, the other seven answer -17, and the lock is the winner's at 0600 in tests/run.js
readdirSync is sorted by bytes in O(n log n) compares the heapsort RT-5 in tests/runtime-test.c; tests/cases/io_readdir
crypto.getRandomValues fills every byte from the kernel's CSPRNG, loops on short reads and EINTR, never falls back, and refuses more than 65,536 bytes nish_random_fill "entropy" in tests/runtime-test.c; tests/cases/os_random, os_random_limit
Math.random is not used for key material the standard library never calls it, and std/crypto/x509.ts tells callers to draw keys and serials from crypto.getRandomValues nothing automated; checked with grep -rn Math.random std/
The number formatters fit their buffers at the widest value 21 bytes for an i64 (20 digits and a sign), 32 for a double (NISH_F64_MAX, 25 used at most: -0.0000012345678901234567) tests/runtime-test.c (INT64_MIN, five doubles of 21 to 25 characters, and a #-filled buffer that no byte past the 25th is written to); tests/f64-format-oracle.c checks the length and the sentinel past it for every value it prints
nish_json_put_f64 writes only inside buf[at, len), and all of a number or none of it the spelling is formatted into a NISH_F64_MAX stack buffer first, then copied only when at >= 0 and at <= len - n, compared as int64_t so neither a negative len nor INT32_MAX wraps; otherwise -1 and no byte moves. The emitter passes the array's own data pointer and its length truncated to i32, the same bound the Nish walk reads tests/runtime-test.c (in range, exact fit, one byte short, a negative at, at == len, a negative len, a null buffer of length 0, INT32_MAX, a sentinel-filled buffer untouched on refusal, -0, and the 25-byte spelling at 30 and refused at 29); json_f64_seam in tests/run.js
nish_parse_number stops at the string's own end every string is NUL-terminated after its len bytes (literals, nish_str_new, join, argv, the reads), and the embedded-NUL case is pinned tests/runtime-test.c ("5\0")
No network call writes outside the caller's array each checks its u8[]'s length before the call (-22); the byte ranges are checked in the IR as i32s, so a count fits the i32 answer; pollWait writes at most ready.length / 2 pairs test_net, test_udp, test_poll in tests/runtime-test.c; tests/cases/net_tcp_bounds, net_udp_bounds
A TCP listener shares its port only when it asked to with tcpListenFlags flag 1, flag 2's TCP_NODELAY is set by the runtime on each accepted socket, every tcpConnect socket holds TCP_NODELAY, and an undefined flag is refused RT-17, RT-22 test_tcp_reuseport and test_tcp_nodelay in tests/runtime-test.c, tests/cases/net_tcp_nodelay
netSetOption sets only its five whitelisted options, each an int the runtime sizes, and refuses any other id RT-23 tests/cases/net_set_option
netMss reads only what the kernel wrote, answers a connected TCP socket's MSS and -1 for anything else, and is a constant -1 on wasm RT-32 tests/cases/net_mss, tests/run.js (net_mss: a wasm32 build compiles netMss to -1, with no call into runtime-net.c)
Every socket, accepted connection, Unix socket pair, loop and signal pipe is close-on-exec and non-blocking from creation on Linux SOCK_CLOEXEC, accept4, EPOLL_CLOEXEC, pipe2 test_net, test_udp, test_poll in tests/runtime-test.c; tests/cases/net_unix_fds (reads FD_CLOEXEC and O_NONBLOCK on a unixListen, unixConnect and unixAccept descriptor and both ends of a stream and a seqpacket unixSocketPair)
A Unix-domain call never writes past sun_path, leaves no descriptor or socket file behind when it fails, and a pathname listener's file holds its mode before anyone can connect RT-33 to RT-36 tests/cases/net_unix_calls, net_unix_paths, net_unix_fds
A received descriptor is the only one kept and is close-on-exec (Linux: from the receive), a refused or other-family socket changes no descriptor, and a peer's credentials are its own at connect time RT-37 to RT-39 tests/cases/net_unix_scm, net_unix_scm_bounds, spawn_fds_child
A child given descriptors holds exactly fds[i] at 3 + i, past self-dups and collisions, and this process's descriptors are as they were RT-40 tests/cases/spawn_fds_child
setCpuAffinity only narrows: a CPU below 0, at or past CPU_SETSIZE, or outside the mask the process already holds answers -22 and changes nothing, so no value from a config file or argument moves a process out of the CPUs taskset or a cgroup gave it nish_set_cpu_affinity checks the range before it builds a set and the current mask before it sets one pinned: tests/link/net_prefork_pin (-1 and 1 << 20 answer -22 and leave availableParallelism() as it was; each pinned worker's Cpus_allowed_list is its index alone). The refusal of a CPU outside a narrower inherited mask is argued: no test runs under taskset
Off Linux setCpuAffinity answers -95 and the Node shim answers -95; a wasm32 or WASI build refuses it at compile time, and a scope() task may not call it the Darwin and shim branches; refuseOnWasm; the builtin's write effect pinned: tests/cases/reject_os_affinity_wasm, reject_os_affinity_task; the Darwin answer is argued (CI does not run the link tests on a mac), and net_prefork_pin takes the -95 branch there
tcpListenFlags flag 4 attaches only the two-instruction program that answers the receiving CPU, only with flag 1, and a failed attach closes the socket; flag 4 without 1 or any bit above 7 answers -22 before a socket is made, and off Linux flag 4 answers -95 the flag checks and the attach in nish_tcp_listen_flags pinned: tests/link/net_prefork_pin (flags 4 and 8 answer -22; four rounds of a client pinned to each CPU are answered by the worker pinned there); net_tcp_nodelay (4, 6 and 8 answer -22)
A CPU index past the reuseport group's size, as on a CPU with no worker, falls back to the kernel's hash and is still served, never dropped the kernel's rule for a SO_ATTACH_REUSEPORT_CBPF answer out of range pinned: tests/link/net_prefork_pin, where a machine with more CPUs than workers sends one more client from the first CPU past the group and checks a worker answers it; argued on a machine with no such CPU
std/net/prefork.ts refuses pin with more workers than CPUs, and a listen port, backlog or flags word out of range, by panicking before any socket or worker exists preforkInvalid and the pin check in preforkSupervise pinned: tests/link/net_prefork_pin_too_many (one worker more than the CPUs), net_prefork_bad_listen (port 70000); the backlog and flags checks are argued, by reading preforkInvalid
pin maps worker k to CPU number k, not to the k-th CPU of the inherited mask, and steering answers the raw CPU number. Recorded open as a limit, not a hazard: under a mask that does not start at 0 (taskset -c 4-7), each pin outside it answers -22 and that worker runs unpinned, saying so on stderr, and a connection received past the group falls back to the kernel's hash, so nothing escapes the mask and nothing is dropped; the server only loses its locality. The count the "more workers than CPUs" panic compares against is availableParallelism(), which a cgroup quota or NISH_NUM_THREADS lowers below the mask's size preforkWith's pin and the two-instruction program, which can only answer the CPU number argued: no test runs under taskset; the guide's pinning section and the TechEmpower README say to run a pinned server on CPUs that start at 0
A held listener reaches only the worker of its index, as descriptor 3, and stays close-on-exec in the supervisor spawnWithFds (RT-40) argued, from RT-40; net_prefork_pin checks each worker answers on its own listener's port
Every file descriptor the runtime opens is close-on-exec O_CLOEXEC on the reads and writes (RT-12); glibc's opendir sets it itself RT-12 in tests/runtime-test.c
No output, file write or file read is cut short by a short count or an interrupted call the loops of RT-11 and RT-13 RT-11 and RT-13 in tests/runtime-test.c
A handle a using opens is closed exactly once on every exit of its block that is not a panic or process.exit RT-20 tests/cases/ffi_handle_file, ffi_handle_exits; tests/link/ffi_handle_mutex
A region handle reaches its slot only while the region is live, on the thread that made it, and an entered region is neither entered again nor freed; regions leave in the order they were entered RT-41, RT-42 the region section of tests/run.js (tests/runtime/region-test.c), default and -DNISH_THREADS builds
Freeing regions gives their memory back: 100,000 made and freed ten times hold the resident set flat RT-44 the positive run of the region section
A region's chunks double from its first allocation's size and stop at 1 MiB; no region asks for a chunk larger than 1 MiB or its largest single allocation RT-47 the positive run of the region section
A panic in nish_region_new holds no lock while the process exits RT-46 oom-unlocks in the region section (threads build, Linux)
No Arena mark is taken or moved inside a region's function, so no mark crosses a region boundary RT-45 (NL2617) tests/cases/reject_region_arena_mark, tests/cases/reject_region_arena_used_callee
Overlapping first signalFd() calls answer one descriptor, and it hears the signal RT-10's compare-and-swap RT-10 in tests/runtime-test.c
The signal handler is async-signal-safe and keeps errno it makes one non-blocking write tests/cases/os_signal; the threads block of tests/runtime-test.c
No wasm allocation can claim memory the module does not have RT-7 tests/link/rt_sec_wasm_arena
A parallel range is divided without overflow and every chunk runs, however many threads could be made RT-8; the chunks a failed pthread_create left run on the caller RT-8 and test_parallel_* in tests/runtime-test.c
kill reaches one process, never a group or every process, and sends only 0, SIGINT, SIGKILL or SIGTERM RT-19 tests/cases/os_kill; test_process in tests/runtime-test.c
waitpid is not interrupted into leaving a child behind every handler the runtime installs is SA_RESTART: the signal pipe's for SIGINT and SIGTERM in runtime-host.c, and nish:term's in runtime-term.c (nish_term_catch) by reading; nothing automated
spawnPiped cannot deadlock against its child, holds at most 16 MiB of each stream, leaves no pipe end in another child, and starts the child with SIGPIPE as the program had it RT-27 to RT-30 tests/cases/io_spawn_piped
Every runtime unit stays inside its .text* ceiling measured after #387: runtime.c 3,583 / 3,584 (3,708 / 3,840 threaded), runtime-os.c 1,530 / 1,536, runtime-host.c 764 / 768, runtime-net.c 2,087 / 2,304, runtime-parallel.c 286 / 320 (905 / 1,024 threaded); after WP34 N12 (RT-19), runtime-os.c 2,383 / 2,560 and runtime-parallel.c threaded 2,522 / 2,560; after netSetOption (RT-23), runtime-net.c 2,588 / 2,816; after the mode-taking writes (RT-24 to RT-26), runtime-os.c 3,174 / 3,328; after spawnPiped (RT-27 to RT-31), runtime-os.c 4,450 / 4,608; after netMss (RT-32), runtime-net.c 2,797 / 2,816; after the Unix-domain calls (RT-33 to RT-36), runtime-net.c 3,841 / 4,096; after the descriptor calls (RT-37 to RT-40), runtime-net.c 4,511 / 4,608 and runtime-os.c 4,726 / 4,864 the budget checks in tests/run.js

Binary hardening (RT-HARDEN)

Every native binary the toolchain links carries the platform's exploit mitigations: what nish --link and nish run build, and the released nish, which scripts/bootstrap.sh links through the same scripts/build.sh. The anchors are by name, not line: the hardening block in scripts/build.sh (after the --cpu line), HARDENING_FLAGS and HARDENING_ATTRS in src/target.ts, and their use in src/ir.ts and src/emit.ts. It closes no finding; it raises the cost of exploiting a memory-safety bug that the findings above did not find.

Protection Flag What it closes Where
Position independence clang's default PIE, checked a fixed load address for return-oriented code ELF and Mach-O
Full RELRO -Wl,-z,relro,-z,now overwriting a GOT entry after start-up: the table is read-only once relocated ELF
Non-executable stack -Wl,-z,noexecstack code injected onto the stack, even when one object lacks the .note.GNU-stack note ELF (Mach-O's stack is non-executable unless MH_ALLOW_STACK_EXECUTION, which is checked)
Stack-clash probes -fstack-clash-protection; in the IR, "probe-stack"="inline-asm" on every definition and the probe-stack module flag a frame larger than the guard page stepping over it into another mapping ELF
Control-flow protection -fcf-protection=full on x86-64, -mbranch-protection=standard on arm64; in the IR, the cf-protection-*, branch-target-enforcement and sign-return-address module flags an indirect jump or call to anything but a function entry (IBT, BTI), and a return to an overwritten address (shadow stack, PAC), where the CPU and kernel enforce it ELF and Mach-O
Stack protector -fstack-protector-strong a linear overflow of a C runtime frame's buffer reaching its return address the C runtime only
_FORTIFY_SOURCE=3 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3, not in debug a memcpy, memset or snprintf in the C runtime past an object whose size the compiler knows the C runtime only

The IR flags are what make the program's own code agree: on a .ll input clang takes no stack-protector or code-generation flag from its command line, and the linker marks a binary with IBT, SHSTK or BTI only when every object asks for it. Nish code needs no stack protector or FORTIFY: an array access is bounds-checked, and the compiler emits no memcpy it has not sized.

Cost. Measured with bench/run.mjs's programs before and after, each linked by scripts/build.sh from the same IR less the hardening, on x86-64 Linux with clang 18, 11 interleaved rounds (21 where the first pass was unclear), the median of each, the first round dropped. Size grows by 328 to 1,136 bytes per binary under speed, 328 to 944 under size, and about 6.6 KB (5%) under debug. Time, under speed: 12 of 14 programs moved within the noise band (a copy of the same binary moved up to ±1.5%, 4% on lexer); cursor is 6.1% and aes-gcm 3.6% slower, and a build without only -fcf-protection brings both back inside the band, so CET is the cost there (endbr64 at each entry, .plt.sec, and the layout they shift). A build without the stack protector and FORTIFY measured the same as the full set. fib's −9.9% (speed) and +16.4% (size), and getbyte's +9.5% (size), are placement: their hot loops are the same instructions at a new address. Instructions retired (bench/run.mjs --instructions, cachegrind, speed): strbuild runs 53,754,318 against 53,229,282 for the same build without -fstack-protector-strong (+0.99%), the guard on the runtime's string builders. The same guard on str_from_digits (a 21-byte digit buffer, run once per integer formatted) adds about 166,000 instructions to the map benchmarks that format each key: map-wordcount +0.11%, map-presize +0.24%. Those three baselines in bench/instructions.json move with this change. The landing pads also end the speed link's LTO partitions on x86-64 and arm64 (scripts/build.sh): a partition boundary turns the internal functions called across it into external ones, each opening with endbr64, and fib ran 492,835,930 instructions with two to eight partitions against 462,974,845 with one (+6.5%; Permute +3.9%, Towers +3.2%). One partition costs link time instead: 11.5 s against 8.3 s for tests/link/net_relay_f64 on four cores, compile included.

What it does not cover.

  • build/nish-test, the stage-1 compiler scripts/bootstrap.sh links with the seed's own scripts/build.sh, is hardened only once a release carries this change. The programs it links, and stage 2, are hardened now.
  • The wasm and wasi profiles: linear memory has no stack to protect in this way, and the module flags are not emitted for a wasm triple.
  • Mach-O gets no stack-clash probes (clang ignores the flag there) and no RELRO flag: dyld applies the binary's fixups itself, so what ELF's -z relro -z now asks for is dyld's to give. The check reads MH_PIE and the stack flag alone.
  • The x86 feature note is an AND over every object linked, the C library's start files included. A distribution whose crt1.o lacks it gives a binary without the note, and so without IBT and the shadow stack, whatever Nish compiled. scripts/check-hardening.mjs reports that; on AArch64 it falls back to finding bti and paciasp in the code.
  • Enforcement is the CPU's and the kernel's. CET runs only where the kernel enables user shadow stacks for the binary; the marking costs nothing where it does not.

Tests: "hardening: a --profile debug link of hello is PIE, full RELRO, a non-executable stack and control-flow protected", the same for --profile speed, --profile size and "a nish run binary" (templated as `hardening: ${what} of hello is PIE, full RELRO, a non-executable stack and control-flow protected`), and "hardening: a link with every protection off is reported for PIE, RELRO, binding, the stack, CET and the import", in tests/run.js; CI's bootstrap job runs scripts/check-hardening.mjs on build/nish, and its test job reads the four positive checks from the log.

Static analysis

.github/workflows/codeql.yml runs CodeQL's security-and-quality suite over runtime/*.c on every change to runtime/, and weekly so a new query pack is picked up without one. Each translation unit is compiled by itself, the way scripts/build.sh compiles it beside a program; runtime-wasm.c is compiled for wasm32, freestanding. Its actions are pinned to commits. The first run, on 804120e with CodeQL 2.23.3, raised four alerts and no defect:

Rule Where (804120e) Triage
cpp/overflow-buffer (9.3) runtime.c:194, nish_free_arena False positive. memset(&nish_arena, 0, sizeof nish_arena) clears the object by its own size. struct nish_arena is defined twice, in runtime.c (64-bit pointers) and runtime-wasm.c (32-bit), and the one database holds both; the alert pairs this unit's 32-byte sizeof with the other unit's layout. The two never link together.
cpp/path-injection (7.5) runtime-os.c:396, nish_cgroup_cpus Not a defect. The path is the 0:: line of /proc/self/cgroup, which the kernel writes, under /sys/fs/cgroup. Whoever can rename this process's cgroup already controls the process. The copy is bounded (len > sizeof path - 24 returns early), the file is opened read-only, and only a leading number is parsed from it.
cpp/implicit-function-declaration ×2 runtime-wasm.c:55, :61 False positive. __builtin_wasm_memory_size and __builtin_wasm_memory_grow are clang builtins for wasm32, which CodeQL's extractor does not know.

A new alert is triaged the same way: fixed with a before/after test and given an RT id, or added here with the reason it is not a defect.

Design: a debug arena that traps a stale reference

Not built. Recorded so the work can be picked up as one change; it touches scripts/build.sh, which the binary-hardening work also changes.

The hazard. nish_arena_release and nish_reset_arena (runtime/runtime.c) free every chunk newer than the mark with free, and rewind off inside the chunk the mark is in. A value bumped after the mark and still referenced after the release (the checker's escape rules are meant to make that impossible, and nish_arena_keep is the one sanctioned way out) reads memory that the next allocation reuses: freed chunks go back to malloc, the rewound part of the kept chunk to the next bump. Nothing traps; the program reads whatever was written there next. That is the use-after-free a compiler bug in the escape analysis would turn into, and today only review and the arena tests stand against it.

The mode. A --debug-arena flag (on nish build, nish run and --link) links a runtime compiled with -DNISH_ARENA_DEBUG, and changes nothing in the IR. Under it:

  • nish_arena_grow maps each chunk with mmap, page-aligned, its header on a page of its own, instead of malloc.
  • A release or reset never returns memory for reuse. The whole pages past the mark, in the mark's chunk and in every newer chunk, are mprotected to PROT_NONE and kept mapped; the partial page holding the mark is filled with a poison byte (0xDB) from the mark to its end. cap is cut to the mark, so the next allocation grows a fresh chunk rather than bumping into the poisoned page.
  • A stale read of a whole freed page traps with SIGSEGV at the read; a read of the poisoned tail reads 0xDB bytes, which the arena tests can assert.
  • The runtime's SIGSEGV path names the arena when the fault address lies in a protected chunk, so a failure reads as a stale arena reference, not a crash.

Memory only grows under the mode, which is the cost of never reusing an address; it is for tests and fuzzing, never for a release build.

What it needs. The flag in src/compile.ts and its --help line; a debug-arena runtime variant in scripts/build.sh and in the runtime cache's key, so a cached normal runtime is never linked under it; a docs/LANGUAGE.md rule and a guide page for the flag; the arena tests (tests/cases/arena_* and the runtime's arena checks in tests/run.js) run once more under it; and a negative test, a C harness that keeps a pointer across nish_arena_release and must die with SIGSEGV. The fuzzers would then run under it.

Doc corrections for the security-policy stage

  • docs/LANGUAGE.md, the file builtins:
    • readFileSync, readFileSyncOrNull and readFileBytesSync treat a file longer than 2^31 − 1 bytes as unreadable, in both number modes. That matches Node.
    • writeFileSync, appendFileSync and spawnSyncTo's two paths refuse a symbolic link as the last component, where Node follows it.
    • Every path, name and argument holding a NUL is refused: null, false, -1 or cannot …, as for a missing path. Node also refuses these, by throwing.
  • docs/LANGUAGE.md, strings: a concatenation or template whose result would pass 2^31 − 1 bytes fails as an allocation does (nish: out of memory, exit 1), in both number modes. join does not yet (CG-3). Since #427, join does too, and docs/LANGUAGE.md says so.
  • runtime/shim.mjs (the Node twin): done in #387. writeFileSync, appendFileSync and spawnImpl's stream open with O_NOFOLLOW, so the two runtimes agree on RT-4, and runtime/nish.d.ts's comments on those builtins say so. No check in tests/run.js drives the shim through a link yet.
  • docs/ARCHITECTURE.md (the runtime budget table) and docs/wp7-runtime.md §"Runtime additions and budget": the "Today" figures are now those in the last row of the properties table. No ceiling moved.
  • docs/ARCHITECTURE.md and docs/wp7-runtime.md: nish_oom is a function, and runtime-host.c also holds the RT-9 primitives.
  • docs/ARCHITECTURE.md (the Runtime row, "The header is the public C ABI for both"): the header also defines one static inline, nish_cpath, which every path the runtime hands the OS goes through. It is written with __builtin_strlen, so the header still includes no libc header and still compiles for wasm32.
  • docs/security/codegen.md: CG-9 is fixed (RT-7), and the runtime sources of CG-3 are closed (RT-1, RT-2); only join remains. Since #427 join is bounded too, so none remains.
  • docs/security/cli.md: CLI-6 is fixed in the runtime (RT-4) and reaches the compiler with the next release. CLI-7 and CLI-9 have their primitives (RT-9).
  • std/README.md (crypto): the K1-6 caveat from crypto-k1.md now reads: no array or string the runtime makes passes 2^31 − 1, and a string built by join still can (CG-3). Since #427 no string built by join can either, and the caveat says nothing passes 2^31 − 1.
  • src/runtime.ts (CG-8): done in #387. The declarations of the file calls, nish_str_concat and nish_alloc_array say their willreturn is the approximation CG-8 records. Dropping it is #382's. Since done by #427: none of them is willreturn now (CG-8).