diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index 2f952a86120e..56d00dd5cf1e 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -441,6 +441,12 @@ var require_wasi = __commonJS({ const nsInt = BigInt(ns); return Number(nsInt / BigInt(1e6)); }; + // Mirrors uvwasi_serdes_check_bounds: the start must be inside memory even when len is 0. + var checkBounds = (ptr, len, byteLength) => { + if (!(ptr >>> 0 === ptr && ptr < byteLength && len <= byteLength - ptr)) { + throw new types_1.WASIError(constants_1.WASI_EOVERFLOW); + } + }; var wrap = f => (...args) => { @@ -451,12 +457,16 @@ var require_wasi = __commonJS({ while (e.prev != null) { e = e.prev; } - if (e?.code && typeof e?.code === "string") { - return constants_1.ERROR_MAP[e.code] || constants_1.WASI_EINVAL; - } if (e instanceof types_1.WASIError) { return e.errno; } + // Out-of-bounds guest pointer: a bare RangeError from DataView/TypedArray, ERR_BUFFER_OUT_OF_BOUNDS from Buffer.from. + if (e instanceof RangeError && (e.code == null || e.code === "ERR_BUFFER_OUT_OF_BOUNDS")) { + return constants_1.WASI_EOVERFLOW; + } + if (e?.code && typeof e?.code === "string") { + return constants_1.ERROR_MAP[e.code] || constants_1.WASI_EINVAL; + } throw e; } }; @@ -640,32 +650,25 @@ var require_wasi = __commonJS({ path: v, }); } - const getiovs = (iovs, iovsLen) => { + // outPtr is the caller's nread/nwritten pointer, checked here so nothing is read or written first. + const getiovs = (iovs, iovsLen, outPtr) => { this.refreshMemory(); const { view, memory } = this; const { buffer } = memory; const { byteLength } = buffer; - if (iovsLen === 1) { - const ptr = iovs; - const buf = view.getUint32(ptr, true); - let bufLen = view.getUint32(ptr + 4, true); + if (iovsLen >>> 0 !== iovsLen) { + throw new types_1.WASIError(constants_1.WASI_EOVERFLOW); + } + checkBounds(iovs, iovsLen * 8, byteLength); + checkBounds(outPtr, 4, byteLength); - if (bufLen > byteLength - buf) { - console.log({ - buf, - bufLen, - total_memory: byteLength, - }); - bufLen = Math.min(bufLen, Math.max(0, byteLength - buf)); - } - try { - return [new Uint8Array(buffer, buf, bufLen)]; - } catch (err) { - console.warn("WASI.getiovs -- invalid buffer", err); - throw new types_1.WASIError(constants_1.WASI_EINVAL); - } + if (iovsLen === 1) { + const buf = view.getUint32(iovs, true); + const bufLen = view.getUint32(iovs + 4, true); + checkBounds(buf, bufLen, byteLength); + return [new Uint8Array(buffer, buf, bufLen)]; } // Avoid referencing Array because materializing the Array constructor can show up in profiling @@ -674,22 +677,9 @@ var require_wasi = __commonJS({ for (let i = 0, ptr = iovs; i < iovsLen; i++, ptr += 8) { const buf = view.getUint32(ptr, true); - let bufLen = view.getUint32(ptr + 4, true); - - if (bufLen > byteLength - buf) { - console.log({ - buf, - bufLen, - total_memory: byteLength, - }); - bufLen = Math.min(bufLen, Math.max(0, byteLength - buf)); - } - try { - buffers[i] = new Uint8Array(buffer, buf, bufLen); - } catch (err) { - console.warn("WASI.getiovs -- invalid buffer", err); - throw new types_1.WASIError(constants_1.WASI_EINVAL); - } + const bufLen = view.getUint32(ptr + 4, true); + checkBounds(buf, bufLen, byteLength); + buffers[i] = new Uint8Array(buffer, buf, bufLen); } return buffers; }; @@ -965,7 +955,7 @@ var require_wasi = __commonJS({ fd_pwrite: wrap((fd, iovs, iovsLen, offset, nwritten) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_WRITE | constants_1.WASI_RIGHT_FD_SEEK); let written = 0; - getiovs(iovs, iovsLen).forEach(iov => { + getiovs(iovs, iovsLen, nwritten).forEach(iov => { let w = 0; while (w < iov.byteLength) { w += fs.writeSync(stats.real, iov, w, iov.byteLength - w, Number(offset) + written + w); @@ -980,7 +970,7 @@ var require_wasi = __commonJS({ const IS_STDOUT = fd == constants_1.WASI_STDOUT_FILENO; const IS_STDERR = fd == constants_1.WASI_STDERR_FILENO; let written = 0; - getiovs(iovs, iovsLen).forEach(iov => { + getiovs(iovs, iovsLen, nwritten).forEach(iov => { if (iov.byteLength == 0) return; if (IS_STDOUT && this.sendStdout != null) { this.sendStdout(iov); @@ -1010,7 +1000,7 @@ var require_wasi = __commonJS({ fd_pread: wrap((fd, iovs, iovsLen, offset, nread) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_READ | constants_1.WASI_RIGHT_FD_SEEK); let read = 0; - outer: for (const iov of getiovs(iovs, iovsLen)) { + outer: for (const iov of getiovs(iovs, iovsLen, nread)) { let r = 0; while (r < iov.byteLength) { const length = iov.byteLength - r; @@ -1030,7 +1020,7 @@ var require_wasi = __commonJS({ const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_READ); const IS_STDIN = fd == constants_1.WASI_STDIN_FILENO; let read = 0; - outer: for (const iov of getiovs(iovs, iovsLen)) { + outer: for (const iov of getiovs(iovs, iovsLen, nread)) { let r = 0; while (r < iov.byteLength) { let length = iov.byteLength - r; @@ -1162,6 +1152,7 @@ var require_wasi = __commonJS({ fd_seek: wrap((fd, offset, whence, newOffsetPtr) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_SEEK); this.refreshMemory(); + checkBounds(newOffsetPtr, 8, this.memory.buffer.byteLength); switch (whence) { case constants_1.WASI_WHENCE_CUR: stats.offset = (stats.offset ? stats.offset : BigInt(0)) + BigInt(offset); @@ -1183,6 +1174,7 @@ var require_wasi = __commonJS({ fd_tell: wrap((fd, offsetPtr) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_TELL); this.refreshMemory(); + checkBounds(offsetPtr, 8, this.memory.buffer.byteLength); if (!stats.offset) { stats.offset = BigInt(0); } @@ -1342,6 +1334,7 @@ var require_wasi = __commonJS({ neededInheriting |= constants_1.WASI_RIGHT_FD_SEEK; } this.refreshMemory(); + checkBounds(fdPtr, 4, this.memory.buffer.byteLength); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); if (p == "dev/tty") { this.view.setUint32(fdPtr, constants_1.WASI_STDIN_FILENO, true); diff --git a/test/js/bun/wasm/wasi.test.js b/test/js/bun/wasm/wasi.test.js index bfb1099630bc..24f1d20c251f 100644 --- a/test/js/bun/wasm/wasi.test.js +++ b/test/js/bun/wasm/wasi.test.js @@ -110,6 +110,318 @@ it("path_open reports the host errno to the guest when the open fails", () => { expect(wasi.FD_MAP.has(4)).toBe(false); }); +const WASI_ESUCCESS = 0; +const WASI_EOVERFLOW = 61; + +it("fd_write/fd_read return EOVERFLOW for out-of-bounds iovecs instead of throwing or writing to the host", async () => { + // Runs in a subprocess so the assertion can also cover what reaches the host's + // stdout/stderr: previously some of these shapes printed a debug object, some + // wrote the (truncated) guest bytes to fd 2, and the rest threw a RangeError + // out of the hostcall. Node.js returns WASI_EOVERFLOW (61) for every one of them. + const src = ` + const { WASI } = require("node:wasi"); + const wasi = new WASI({ version: "preview1" }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const END = wasi.memory.buffer.byteLength; // 65536 + const view = new DataView(wasi.memory.buffer); + const iovec = (index, buf, len) => { view.setUint32(index * 8, buf, true); view.setUint32(index * 8 + 4, len, true); }; + const probe = (name, fn, ...args) => { + let result; + try { result = wasi.wasiImport[fn](...args); } catch (e) { result = "threw " + e.constructor.name; } + console.log(name, result); + }; + // Every iovec below that is in bounds points at this marker, so any bytes + // written to the host by a call that reported an error show up in stderr. + const marker = Buffer.from(wasi.memory.buffer).write("LEAKED\\n", 1024); + const NWRITTEN = 256; + + probe("iovec-array-oob", "fd_write", 2, END - 2, 1, NWRITTEN); + probe("iovec-array-past-end", "fd_write", 2, END, 0, NWRITTEN); + probe("iovec-array-too-long", "fd_write", 2, 0, 0x10000000, NWRITTEN); + // A pointer or length >= 2**31 reaches the host from a wasm guest as a + // negative i32. Nothing that large is addressable, so it is EOVERFLOW too. + probe("iovec-array-negative-ptr", "fd_write", 2, -8, 1, NWRITTEN); + probe("iovec-array-negative-len", "fd_write", 2, 0, -1, NWRITTEN); + + iovec(0, END + 1000, 10); + probe("buf-oob", "fd_write", 2, 0, 1, NWRITTEN); + iovec(0, END, 0); + probe("empty-buf-past-end", "fd_write", 2, 0, 1, NWRITTEN); + iovec(0, END - 6, 4096); + probe("len-overrun", "fd_write", 2, 0, 1, NWRITTEN); + iovec(0, 1024, marker); + iovec(1, END - 6, 4096); + probe("len-overrun-second-iovec", "fd_write", 2, 0, 2, NWRITTEN); + + iovec(0, 1024, marker); + probe("nwritten-oob", "fd_write", 2, 0, 1, END + 1000); + probe("nwritten-partially-oob", "fd_write", 2, 0, 1, END - 3); + probe("nwritten-negative", "fd_write", 2, 0, 1, -4); + + // fd_read shares the decoder. Only shapes that are rejected before any read + // is attempted are used here so the probe never waits on the host's stdin. + probe("fd_read-iovec-array-oob", "fd_read", 0, END - 2, 1, NWRITTEN); + iovec(0, 1024, 0); + probe("fd_read-nread-oob", "fd_read", 0, 0, 1, END + 1000); + + // Hostcalls that write a struct through a guest pointer get the same errno + // instead of throwing a RangeError into the host. + probe("fd_fdstat_get-buf-oob", "fd_fdstat_get", 0, END + 1000); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const cases = [ + "iovec-array-oob", + "iovec-array-past-end", + "iovec-array-too-long", + "iovec-array-negative-ptr", + "iovec-array-negative-len", + "buf-oob", + "empty-buf-past-end", + "len-overrun", + "len-overrun-second-iovec", + "nwritten-oob", + "nwritten-partially-oob", + "nwritten-negative", + "fd_read-iovec-array-oob", + "fd_read-nread-oob", + "fd_fdstat_get-buf-oob", + ]; + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: cases.map(name => `${name} ${WASI_EOVERFLOW}\n`).join(""), + stderr: "", + exitCode: 0, + }); +}); + +it("fd_write accepts iovecs, iovec arrays and output pointers that end exactly at the end of memory", () => { + const chunks = []; + const wasi = new WASI({ sendStdout: bytes => chunks.push(Buffer.from(bytes).toString()) }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const END = wasi.memory.buffer.byteLength; + const memory = Buffer.from(wasi.memory.buffer); + const view = new DataView(wasi.memory.buffer); + const iovec = (ptr, buf, len) => { + view.setUint32(ptr, buf, true); + view.setUint32(ptr + 4, len, true); + }; + const nwritten = ptr => view.getUint32(ptr, true); + const WASI_STDOUT = 1; + memory.write("mid", 1024); + memory.write("end", END - 3); + + const results = []; + iovec(0, END - 3, 3); + results.push(["buf ends at end of memory", wasi.wasiImport.fd_write(WASI_STDOUT, 0, 1, 256), nwritten(256)]); + iovec(END - 8, 1024, 3); + results.push([ + "iovec array ends at end of memory", + wasi.wasiImport.fd_write(WASI_STDOUT, END - 8, 1, 256), + nwritten(256), + ]); + iovec(0, 1024, 3); + results.push([ + "nwritten ends at end of memory", + wasi.wasiImport.fd_write(WASI_STDOUT, 0, 1, END - 4), + nwritten(END - 4), + ]); + results.push([ + "empty iovec array at last byte", + wasi.wasiImport.fd_write(WASI_STDOUT, END - 1, 0, 256), + nwritten(256), + ]); + iovec(0, END - 1, 0); + results.push(["empty buf at last byte", wasi.wasiImport.fd_write(WASI_STDOUT, 0, 1, 256), nwritten(256)]); + + expect(results).toEqual([ + ["buf ends at end of memory", WASI_ESUCCESS, 3], + ["iovec array ends at end of memory", WASI_ESUCCESS, 3], + ["nwritten ends at end of memory", WASI_ESUCCESS, 3], + ["empty iovec array at last byte", WASI_ESUCCESS, 0], + ["empty buf at last byte", WASI_ESUCCESS, 0], + ]); + expect(chunks).toEqual(["end", "mid", "mid"]); +}); + +it("fd_read returns EOVERFLOW for an out-of-bounds nread pointer before consuming any input", () => { + let stdinReads = 0; + const wasi = new WASI({ + getStdin: () => { + stdinReads++; + return Buffer.from("input"); + }, + }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const END = wasi.memory.buffer.byteLength; + const memory = Buffer.from(wasi.memory.buffer); + const view = new DataView(wasi.memory.buffer); + const WASI_STDIN = 0; + const iovsPtr = 0; + const bufPtr = 1024; + const nreadPtr = 256; + view.setUint32(iovsPtr, bufPtr, true); + view.setUint32(iovsPtr + 4, 5, true); + const destination = () => memory.toString("latin1", bufPtr, bufPtr + 5); + + expect(wasi.wasiImport.fd_read(WASI_STDIN, iovsPtr, 1, END + 1000)).toBe(WASI_EOVERFLOW); + expect({ stdinReads, destination: destination() }).toEqual({ + stdinReads: 0, + destination: Buffer.alloc(5, 0).toString("latin1"), + }); + + expect(wasi.wasiImport.fd_read(WASI_STDIN, iovsPtr, 1, nreadPtr)).toBe(WASI_ESUCCESS); + expect({ stdinReads, destination: destination(), nread: view.getUint32(nreadPtr, true) }).toEqual({ + stdinReads: 1, + destination: "input", + nread: 5, + }); +}); + +it("fd_pwrite/fd_pread return EOVERFLOW before touching the file or guest memory", () => { + using dir = tempDir("wasi-pwrite-oob", { + "data.txt": "original", + }); + const file = path.join(String(dir), "data.txt"); + const wasi = new WASI({ preopens: { "/": String(dir) } }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const END = wasi.memory.buffer.byteLength; + const memory = Buffer.from(wasi.memory.buffer); + const view = new DataView(wasi.memory.buffer); + + const WASI_RIGHT_FD_READ = BigInt(2); + const WASI_RIGHT_FD_SEEK = BigInt(4); + const WASI_RIGHT_FD_WRITE = BigInt(64); + const preopenFd = 3; + const pathPtr = 1024; + const fdPtr = 2048; + const iovsPtr = 4096; + const dataPtr = 8192; + const readPtr = 16384; + const outPtr = 32768; + const offset = BigInt(0); + const iovec = (buf, len) => { + view.setUint32(iovsPtr, buf, true); + view.setUint32(iovsPtr + 4, len, true); + }; + + const pathLen = memory.write("data.txt", pathPtr); + expect( + wasi.wasiImport.path_open( + preopenFd, + 0, + pathPtr, + pathLen, + 0, + WASI_RIGHT_FD_READ | WASI_RIGHT_FD_SEEK | WASI_RIGHT_FD_WRITE, + BigInt(0), + 0, + fdPtr, + ), + ).toBe(WASI_ESUCCESS); + const fd = view.getUint32(fdPtr, true); + const dataLen = memory.write("CLOBBERED", dataPtr); + + iovec(dataPtr, dataLen); + expect(wasi.wasiImport.fd_pwrite(fd, iovsPtr, 1, offset, END + 1000)).toBe(WASI_EOVERFLOW); + expect(wasi.wasiImport.fd_pwrite(fd, END + 1000, 1, offset, outPtr)).toBe(WASI_EOVERFLOW); + iovec(dataPtr, END); + expect(wasi.wasiImport.fd_pwrite(fd, iovsPtr, 1, offset, outPtr)).toBe(WASI_EOVERFLOW); + expect(fs.readFileSync(file, "utf8")).toBe("original"); + + iovec(dataPtr, dataLen); + expect(wasi.wasiImport.fd_pwrite(fd, iovsPtr, 1, offset, outPtr)).toBe(WASI_ESUCCESS); + expect(view.getUint32(outPtr, true)).toBe(dataLen); + expect(fs.readFileSync(file, "utf8")).toBe("CLOBBERED"); + + const destination = () => memory.toString("latin1", readPtr, readPtr + dataLen); + const untouched = Buffer.alloc(dataLen, 0).toString("latin1"); + iovec(readPtr, dataLen); + expect(wasi.wasiImport.fd_pread(fd, iovsPtr, 1, offset, END + 1000)).toBe(WASI_EOVERFLOW); + expect(wasi.wasiImport.fd_pread(fd, END + 1000, 1, offset, outPtr)).toBe(WASI_EOVERFLOW); + iovec(readPtr, END); + expect(wasi.wasiImport.fd_pread(fd, iovsPtr, 1, offset, outPtr)).toBe(WASI_EOVERFLOW); + expect(destination()).toBe(untouched); + + iovec(readPtr, dataLen); + expect(wasi.wasiImport.fd_pread(fd, iovsPtr, 1, offset, outPtr)).toBe(WASI_ESUCCESS); + expect(destination()).toBe("CLOBBERED"); + expect(wasi.wasiImport.fd_close(fd)).toBe(WASI_ESUCCESS); +}); + +it("path_open/fd_seek/fd_tell return EOVERFLOW for an out-of-bounds output pointer before opening or seeking", () => { + using dir = tempDir("wasi-open-seek-oob", { + "exists.txt": "0123456789", + }); + const wasi = new WASI({ preopens: { "/": String(dir) } }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const END = wasi.memory.buffer.byteLength; + const memory = Buffer.from(wasi.memory.buffer); + const view = new DataView(wasi.memory.buffer); + + const WASI_O_CREAT = 1 << 0; + const WASI_WHENCE_SET = 0; + const WASI_RIGHT_FD_READ = BigInt(2); + const WASI_RIGHT_FD_SEEK = BigInt(4); + const WASI_RIGHT_FD_TELL = BigInt(32); + const rights = WASI_RIGHT_FD_READ | WASI_RIGHT_FD_SEEK | WASI_RIGHT_FD_TELL; + const preopenFd = 3; + const pathPtr = 1024; + const fdPtr = 2048; + const offsetPtr = 4096; + const open = (name, oflags, outPtr) => { + const len = memory.write(name, pathPtr); + return wasi.wasiImport.path_open(preopenFd, 0, pathPtr, len, oflags, rights, BigInt(0), 0, outPtr); + }; + const tell = fd => { + expect(wasi.wasiImport.fd_tell(fd, offsetPtr)).toBe(WASI_ESUCCESS); + return view.getBigUint64(offsetPtr, true); + }; + + const fdsBefore = [...wasi.FD_MAP.keys()]; + expect(open("exists.txt", 0, END + 1000)).toBe(WASI_EOVERFLOW); + expect(open("exists.txt", 0, END - 3)).toBe(WASI_EOVERFLOW); + expect(open("created.txt", WASI_O_CREAT, END + 1000)).toBe(WASI_EOVERFLOW); + expect([...wasi.FD_MAP.keys()]).toEqual(fdsBefore); + expect(fs.existsSync(path.join(String(dir), "created.txt"))).toBe(false); + + expect(open("exists.txt", 0, fdPtr)).toBe(WASI_ESUCCESS); + const fd = view.getUint32(fdPtr, true); + // A descriptor that has not been positioned yet reads through the host's file + // position; a rejected fd_tell must not switch it to an explicit offset. + expect(wasi.wasiImport.fd_tell(fd, END + 1000)).toBe(WASI_EOVERFLOW); + expect(wasi.wasiImport.fd_tell(fd, END - 7)).toBe(WASI_EOVERFLOW); + expect(wasi.FD_MAP.get(fd).offset).toBeUndefined(); + expect(wasi.wasiImport.fd_seek(fd, BigInt(5), WASI_WHENCE_SET, END + 1000)).toBe(WASI_EOVERFLOW); + expect(wasi.wasiImport.fd_seek(fd, BigInt(5), WASI_WHENCE_SET, END - 7)).toBe(WASI_EOVERFLOW); + expect(tell(fd)).toBe(BigInt(0)); + expect(wasi.wasiImport.fd_seek(fd, BigInt(5), WASI_WHENCE_SET, END - 8)).toBe(WASI_ESUCCESS); + expect(view.getBigUint64(END - 8, true)).toBe(BigInt(5)); + expect(tell(fd)).toBe(BigInt(5)); + expect(wasi.wasiImport.fd_close(fd)).toBe(WASI_ESUCCESS); +}); + +it("path_* hostcalls return EOVERFLOW when the path itself lies outside of memory", () => { + using dir = tempDir("wasi-path-oob", { + "data.txt": "x", + }); + const wasi = new WASI({ preopens: { "/": String(dir) } }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const END = wasi.memory.buffer.byteLength; + const preopenFd = 3; + const statBufPtr = 4096; + + expect(wasi.wasiImport.path_filestat_get(preopenFd, 0, END + 1000, 8, statBufPtr)).toBe(WASI_EOVERFLOW); + expect(wasi.wasiImport.path_filestat_get(preopenFd, 0, END - 4, 8, statBufPtr)).toBe(WASI_EOVERFLOW); + expect(wasi.wasiImport.path_create_directory(preopenFd, END + 1000, 8)).toBe(WASI_EOVERFLOW); + expect(fs.readdirSync(String(dir))).toEqual(["data.txt"]); +}); + it("path_* syscalls cannot escape the preopened directory", () => { using dir = tempDir("wasi-sandbox", { "secret.txt": "outside",