Skip to content

child_process: reject a tls.TLSSocket in stdio like node - #43722

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/737776d0/reject-tls-socket-stdio
Sep 23, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
robobun/737776d0/reject-tls-socket-stdio

Conversation

@robobun

@robobun robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • spawn() and spawnSync() accept a tls.TLSSocket in stdio and give the child the raw TCP descriptor under the TLS session. The child reads TLS records (17 03 03 ...). A child that writes puts plaintext into the TLS stream. Node throws ERR_INVALID_ARG_VALUE.
  • The cause is streamFdOf (src/js/node/child_process.ts:1752). It returns _handle.fd for any stream. Node accepts a stream only when its handle is a pipe, TCP, TTY or UDP wrap (child_process.js#L1063-L1083).

Fix

  • streamFdOf throws ERR_INVALID_ARG_VALUE for a tls.TLSSocket before it reads _handle.fd. It reuses the check that subprocess.send() has for a TLS socket (src/js/builtins/Ipc.ts:31).
  • The message names only the class: Received '[TLSSocket]'. The error builder inspects its value in full, and an inspected TLSSocket reaches its key and passphrase.
  • Correct because a TLS socket is the only Bun stream whose _handle.fd does not carry the stream's own bytes. A net.Socket is still accepted.
  • Verified: test/js/node/child_process/child_process.test.ts (a socket as a stdio entry, the TLS test fails without the fix). Also test/js/node/child_process/ and 18 vendored test-child-process-* scripts.

Background

  • A stream in stdio shares its descriptor with the child: both processes hold the same kernel socket.
  • A tls.TLSSocket is a net.Socket whose bytes pass through a TLS session in the parent. Its kernel socket carries only TLS records.
  • In Bun, socket._handle is the native socket object. Native TCPSocket and TLSSocket expose fd.
  • Only tls.TLSSocket and its subclasses have the Symbol.for("::buntls::") method. net.ts and Ipc.ts detect a TLS socket with it.
Notes

Repro. A TLS server hands its accepted socket to a child as stdin. The peer sends 20,000 bytes of 0x41. The child counts what it reads (python3, so the reader does not depend on Bun).

const tls = require("tls"), fs = require("fs"), os = require("os"), { spawn, execFileSync } = require("child_process");
const d = fs.mkdtempSync(os.tmpdir() + "/c-"); execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", d + "/k", "-out", d + "/c", "-days", "2", "-subj", "/CN=localhost"], { stdio: "ignore" });
const srv = tls.createServer({ key: fs.readFileSync(d + "/k"), cert: fs.readFileSync(d + "/c") }, (s) => { s.on("error", () => {});
  let c; try { c = spawn("python3", ["-c", "import os,time\nn=0;first=b''\nwhile True:\n  try: b=os.read(0,65536)\n  except BlockingIOError: time.sleep(0.01); continue\n  if not b: break\n  first=first or b[:5]; n+=len(b)\nprint('child read',n,'bytes; first 5:',first.hex())"], { stdio: [s, "pipe", "inherit"] }); }
  catch (e) { console.log("spawn() throws", e.code); s.destroy(); return srv.close(); }
  let out = ""; c.stdout.on("data", (x) => (out += x)); c.on("close", () => { console.log(`${out.trim()}; parent socket.bytesRead=${s.bytesRead}`); s.destroy(); srv.close(); }); });
srv.listen(0, "127.0.0.1", () => { const cl = tls.connect({ port: srv.address().port, host: "127.0.0.1", rejectUnauthorized: false }, () => { let i = 0; const t = setInterval(() => { cl.write(Buffer.alloc(1000, 65)); if (++i === 20) { clearInterval(t); cl.end(); } }, 5); }); cl.on("error", () => {}); });
build result (3 runs)
node v26.3.0 spawn() throws ERR_INVALID_ARG_VALUE
bun 1.4.3-canary.1 (367d939) child read 1022 bytes; first 5: 17030303f9; parent socket.bytesRead=5000, then child read 0 bytes, then child read 1046 bytes; first 5: 17030303f9
this branch (debug build) spawn() throws ERR_INVALID_ARG_VALUE

Compared with node v26.3.0, entry by entry (Linux x64, spawn("true", [], { stdio: [entry, "ignore", "ignore"] })):

stdio entry node main this branch
TLSSocket that a tls.Server accepted, at index 0, 1, 2 or 3, spawn and spawnSync ERR_INVALID_ARG_VALUE accepted ERR_INVALID_ARG_VALUE
TLSSocket from tls.connect() ERR_INVALID_ARG_VALUE accepted ERR_INVALID_ARG_VALUE
TLSSocket from tls.connect({ socket }) ERR_INVALID_ARG_VALUE accepted ERR_INVALID_ARG_VALUE
the net.Socket under that wrap accepted accepted accepted
session.socket of a secure HTTP/2 session ERR_INVALID_ARG_VALUE accepted ERR_INVALID_ARG_VALUE
new tls.TLSSocket(), a destroyed TLSSocket, a subclass instance ERR_INVALID_ARG_VALUE plain Error ("without an underlying file descriptor") ERR_INVALID_ARG_VALUE
TLSSocket with an own numeric fd property accepted as that fd accepted accepted
net.Socket accepted accepted accepted

Node takes a numeric fd property before it looks at handles, so the new check sits after the own fd check.

Why only TLS. Node has an allowlist of handle wraps. Bun has no wrap classes: a stream reaches the child through _handle.fd, and the only handles with an fd are the native TCPSocket (TCP and unix sockets, the raw transport) and the native TLSSocket. fs, tty and process.std* streams carry their own fd. The HTTP/2 session.socket proxy forwards the ::buntls:: lookup to its TLSSocket, so the same check covers it.

Error message. $ERR_INVALID_ARG_VALUE(name, value) inspects value into the message with no depth or length limit. The first commit passed the socket. With a PEM string key and a passphrase, the message was 81,943 characters for an accepted socket and 16,431 for a connected one, and it contained the key body and the passphrase. Node's message is 173 characters (node cuts the inspected value at 128) and contains neither. The second commit keeps the socket away from the builder. inspect(item, { depth: -1 }) is the shallow form that node:events uses for the MaxListeners warning. It reads the constructor name and no property value: '[TLSSocket]', '[Sub]' for a subclass, '[ServerHttp2Session]' for the HTTP/2 session.socket proxy. The quotes are there because the builder quotes a string value. The test pins the exact message. #38402 (open) adds node's 128 character cut to the builder for every caller. This PR does not depend on it.

Windows. Checked on canary 1.4.3-canary.1+9cba9036a: any socket in stdio fails with EBADF: bad file descriptor, uv_spawn (node: spawn ENOTSUP). The new check runs before that, so the TLS test is not platform specific. The net.Socket control is POSIX only.

Related open PRs on the same mapping: #35226 (error codes for entries with no fd), #39220 (entries that carry an fd), #43707 (stop the parent's reads after a hand-off). None of them rejects a TLS socket. A trial merge with #43707 conflicts in one line, the node:net import of child_process.test.ts (keep #43707's line, it is a superset of this one). #35226 and #39220 conflict in streamFdOf, so whichever merges later needs a small rebase.

Local runs of test/js/node/child_process/ (debug build). Three tests fail, none through this diff (a fourth, "should allow us to set env", takes 4.6 s to 5.1 s on my debug build and crosses the 5 s timeout in some runs):

  • child_process.test.ts, "should allow us to spawn in the default shell": also fails on the release canary in my container.
  • child_process.test.ts, "extra stdio pipes are not double-closed on GC": 5 s timeout. Its script prints OK after 6.2 s on a debug build (test(child_process): keep the extra stdio GC test under the timeout on debug builds #39687).
  • child-process-exec.test.ts, "stderr > exceeding maxBuffer should throw": fails the same way on a debug build of main without this change, passes on the release canary.

child_process_ipc_handle.test.ts hit the 5 s timeout in 2 tests on one run and passed 14 of 14 on the next. Of the 18 vendored scripts, test-child-process-emfile.js fails only on a debug build: it loads built-in modules from disk and cannot under EMFILE. It passes on the release canary.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/child_process/child_process.test.ts

spawn() and spawnSync() took the descriptor of any stream that has
`_handle.fd`. For a tls.TLSSocket that is the raw TCP descriptor under the
TLS session: a child that reads it gets TLS records, and what a child
writes to it goes into the TLS stream as plaintext.

Node accepts only pipe, TCP, TTY and UDP handles here and throws
ERR_INVALID_ARG_VALUE for a TLSSocket. Do the same, with the check that
subprocess.send() already uses to refuse a TLS socket as an IPC handle.
@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests are pushed. This PR is #43722.

How I reproduced it (Linux x64, bun 1.4.3-canary.1 367d939, node v26.3.0 for comparison):

  1. A tls.createServer listener calls spawn("python3", ["-c", countStdin], { stdio: [socket, "pipe", "inherit"] }) with the TLSSocket it accepted. The child counts the bytes on its stdin and prints the first five.
  2. A tls.connect client sends 20,000 bytes of 0x41 in 20 pieces, then ends.
  3. Bun does not throw. In 2 of 3 runs the child reads about 1,000 bytes that start with 17 03 03 (a TLS application data record header). In the other run the child reads 0 bytes. Node throws ERR_INVALID_ARG_VALUE from spawn(). With this branch Bun throws the same error, 3 of 3 runs.

The new test spawn() > stdio > a socket as a stdio entry > rejects a tls.TLSSocket in test/js/node/child_process/child_process.test.ts fails on main ("Received function did not throw") and passes on this branch. The control next to it (accepts a net.Socket) passes on both.

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 3b7e6f5a-7567-41b1-8700-32b4e02c6457

📥 Commits

Reviewing files that changed from the base of the PR and between a56c98b and 1b70102.

📒 Files selected for processing (2)
  • src/js/node/child_process.ts
  • test/js/node/child_process/child_process.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The child-process stdio descriptor helper now rejects Bun TLS sockets with ERR_INVALID_ARG_VALUE. Tests cover rejection in spawn and spawnSync, and acceptance of plain net.Socket stdio on non-Windows platforms.

Changes

Socket stdio handling

Layer / File(s) Summary
Validate socket stdio handling
src/js/node/child_process.ts, test/js/node/child_process/child_process.test.ts
streamFdOf rejects TLS sockets before descriptor extraction. Tests create socket pairs, verify TLS rejection for spawn and spawnSync, and verify successful child exits with plain network sockets on non-Windows platforms.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: rejecting tls.TLSSocket values in child_process stdio, matching Node.js behavior.
Description check ✅ Passed The description explains the problem, implementation, behavioral scope, error-message handling, tests, platform considerations, and known unrelated failures. It provides the required verification info…

Comment @coderabbitai help to get the list of available commands.

Comment thread src/js/node/child_process.ts Resolved
@robobun

robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:04 PM PT - Sep 21st, 2026

✅ @robobun, your commit 1b70102523b2b31e0e92dfa759501095aefc3541 passed in Build #119443! 🎉


🧪   To try this PR locally:

bunx bun-pr 43722

That installs a local version of the PR into your bun-43722 executable, so you can run:

bun-43722 --bun

ERR_INVALID_ARG_VALUE inspects the value it receives into the message.
For a tls.TLSSocket the inspected object reaches the key and the
passphrase of its server or of its connect options, so the message
carried them. Pass a depth -1 inspect of the socket, which is the class
name in brackets, and pin the exact message in the test.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The follow-up commit addresses the secret-exposure concern from the earlier review, and I found no bugs in the current diff; a human look is still worthwhile because the error message now deliberately departs from Node's text, which is a compat call a maintainer should make.

What was reviewed:

  • Confirmed inspect(item, { depth: -1 }) hits the depth cutoff in src/js/internal/util/inspect.js:1467 and yields [TLSSocket], and that net.Socket/TLSSocket define no custom inspect hook that could bypass it.
  • The pre-rendered string is quoted by the shared ERR_INVALID_ARG_VALUE builder, so the message reads Received '[TLSSocket]' rather than Node's unquoted Received TLSSocket {... (cut at 128 chars); this is intentional and the test pins it, but it is a visible deviation.
  • Checked the check's placement after the own-fd lookup and before _handle.fd, matching Node's ordering; both spawn and spawnSync reach it through nodeToBun.
  • The toThrow(expect.objectContaining(...)) shape is used elsewhere in the suite; no debug build was available here, so I did not execute the new tests locally.
Extended reasoning...

The change adds a four-line guard in streamFdOf in src/js/node/child_process.ts that throws ERR_INVALID_ARG_VALUE when a stdio entry is a tls.TLSSocket (detected by the ::buntls:: marker), plus a new describe block in test/js/node/child_process/child_process.test.ts covering both socket ends in stdio slots 0 and 1 for spawn and spawnSync, and a net.Socket control. It touches TLS-adjacent data exposure only in that the error message previously could inspect the socket graph; the second commit limits it to the class name. Deferring rather than approving because the message format intentionally differs from Node's and that trade-off is a maintainer decision, and because I could not run the tests locally to confirm they pass on this branch.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the message text, for the maintainer who decides it:

  • Node's text after Received is its own util.inspect output, cut at 128 characters (<ref *3> TLSSocket {\n _tlsOptions: {\n secureContext: ...). Bun's inspector prints a different object, so that part cannot match node byte for byte. The parts that can match do: the TypeError class, the ERR_INVALID_ARG_VALUE code, and The argument 'stdio' is invalid. Received .
  • The current text is Received '[TLSSocket]'. The quotes come from the shared builder, which quotes a string value.
  • The alternative is node's shape: pass the socket and let the builder cut the inspected text at 128 characters. That cut is node errors: cut the ERR_INVALID_ARG_VALUE received value at 128 chars like node #38402. With it, the first 128 characters of Bun's rendering are TLSSocket { [Symbol(ksecureContext)]: SecureContext { context: ..., which holds no key material today. That stays true only while no secret-bearing property comes earlier in the socket's property order.

I kept the class name because it does not depend on that order and does not need #38402. If you prefer node's shape, I will switch this line back to the socket once #38402 is merged.

@Jarred-Sumner
Jarred-Sumner merged commit 3d390b7 into main Sep 23, 2026
5 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/737776d0/reject-tls-socket-stdio branch September 23, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants