Skip to content

undici.request: reject a method that is not a string, not an HTTP token, or CONNECT - #44814

Open
robobun wants to merge 3 commits into
mainfrom
robobun/bed2ed12/undici-request-method-checks
Open

robobun wants to merge 3 commits into
mainfrom
robobun/bed2ed12/undici-request-method-checks

Conversation

@robobun

@robobun robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The built-in undici accepts a request() method that is not a string (true) or not a token ("GET POST"). It sends GET, or CONNECT for "CONNECT".
  • undici rejects each before it connects (InvalidArgumentError: method must be a string, invalid request method, invalid method).
  • Cause: src/js/thirdparty/undici.js:190 maps a non-string to null, and fetch() reads null as GET. No user reported this.

Fix

  • requestMethod() makes the same three checks before fetch(), with undici's class, name, code (UND_ERR_INVALID_ARG) and messages.
  • Partial fix: 3 of undici's 22 argument checks (see Notes).
  • Verified: test/js/first_party/undici/undici.test.ts, raw node:net server. 21 of 31 new tests fail on main.
  • Self-reviewed: 11 concerns raised, 11 addressed.

Background

Downsides

  • A non-string, a non-token and any case of CONNECT now reject. A falsy method with a body rejects with the shim's Error.
  • A method outside GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH pays one token scan per call (0 before). Binary: +875 bytes.
  • "connect" rejects, where undici sends connect. Here it would go out as CONNECT. "brew" still goes out as GET.
Notes

Reproduction (a raw TCP server, so that the request line is visible)

import { createServer } from "node:net";
import { request } from "undici";

const lines = [];
const server = createServer(socket => {
  socket.on("data", data => {
    lines.push(String(data).split("\r\n")[0]);
    socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
  });
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
for (const method of [true, 123, "GET POST", "CONNECT", null, "Put", "brew", "connect"]) {
  lines.length = 0;
  const outcome = await request(url, { method }).then(
    async res => (await res.body.text(), "ok"),
    err => `${err.name}: ${err.message}`,
  );
  console.log(JSON.stringify(method).padEnd(10), outcome.padEnd(50), JSON.stringify(lines));
}
server.close();
method undici 6.21.3 and 7.16.0 on Node 26.3 main this PR
true, 123 InvalidArgumentError: method must be a string sends GET as undici
"GET POST" InvalidArgumentError: invalid request method sends GET as undici
"CONNECT" InvalidArgumentError: invalid method sends CONNECT as undici
null sends GET sends GET sends GET
"Put" sends Put sends PUT sends PUT
"brew" sends brew sends GET sends GET
"connect" sends connect sends CONNECT InvalidArgumentError: invalid method

One non-token does not go out as GET on main: "po\u017ft" (U+017F) goes out as POST, because toUpperCase() maps that character to S. undici rejects it, and so does this PR.

What the change is

  • requestMethod() replaces line 190. GET, HEAD, POST, PUT, DELETE, OPTIONS and PATCH return at once. A falsy method is "GET". Then the type check and the token check run on the method as written. Then the method is put in upper case, as before, now through a String.prototype.toUpperCase that is captured when the module loads. Then CONNECT is rejected.
  • InvalidArgumentError gets the constructor that it has in undici 6.21.3 and 7.16.0 (lib/core/errors.js): name, default message, code.
  • The references in the code are to undici v6.21.3. The 48 exports of the built-in module are the 48 exports of undici 6.21.3, and its options (maxRedirections, throwOnError) are those of undici 6. The three rejections are the same in 6.21.3 and 7.16.0.
  • undici 7.16.0 makes 22 argument checks in the RequestHandler and Request constructors before it connects (6.21.3 makes 20). This PR ports the 3 for the method.

A wider probe

69 rows of request(url, { method }), with and without a body, against undici 7.16.0 on Node 26.3: 16 rows match on main, 35 match with this PR. The rows that change are the rejections. No row that resolved with a valid method resolves differently.

Still different from undici after this PR

  • Spelling. undici sends every token as written. The built-in module sends the upper-case spelling: "Put" as PUT, "get" as GET. Without the upper-casing, fetch() on main sends GET for "Put", "Delete" and "PatCh", so this PR keeps it. The tests pin "Put", "Delete" and "patch" for that reason. These rows pass on main.
  • A token outside the method table of fetch() ("brew", "LIST", "TRACK") still goes out as GET. That is fetch and Request silently turn an unrecognised HTTP method into GET #42497, and fetch, Request, server.fetch: stop sending GET for a method outside the method table #44718 is the open PR for it.
  • "connect" and "Connect" now reject with invalid method. undici rejects only the exact string CONNECT and sends the other spellings as written, so this is stricter than undici. The built-in module puts the method in upper case, so on main these spellings go out as CONNECT / HTTP/1.1. After a 200 answer to that request, main sends the next requests to the same origin on the same socket, and a server treats that socket as a tunnel. The tests name the two rows as a difference from undici.
  • A body with no method, or with a falsy method. undici sends PUT (index.js: opts.method || (opts.body ? 'PUT' : 'GET')). The built-in module rejects with Body not allowed for GET or HEAD requests. This PR changes only the rejection for null, "", 0, false and NaN: a falsy method is now "GET" for the module's own guard, so the guard rejects before fetch() does.
  • GET or HEAD with a body. undici sends it. The built-in module rejects (an existing test asserts that).
  • A body that is a plain object. request(url, { method: "POST", body: { a: 1 } }) sends [object Object]. undici rejects with InvalidArgumentError.
  • The other argument checks of request() keep their class: the url check is a TypeError, and the maxRedirections and signal checks are a plain Error. undici throws InvalidArgumentError for each.
  • The other 21 error classes of the module still have no name and no code. fix(node): preserve undici timeout error metadata #42716 and undici: implement dispatch(), close() and destroy() on the shim dispatchers #39250 each give some of them a constructor.
  • Check order. The method checks run where line 190 was, before the maxRedirections check. So { method: true, maxRedirections: -1 } now rejects with the method error, as in undici 7.16.0. undici 6.21.3 and main answer with the maxRedirections error. { method: null, body: "x", maxRedirections: -1 } now rejects with the body error. Main answered with the maxRedirections error. No test pins these orders.

Measurements

  • Calls per 3,000 requests, counted with String.prototype.toUpperCase and RegExp.prototype.exec replaced before undici loads:

    method toUpperCase calls, main this PR RegExp execs, main this PR
    none, "GET", "POST" 3,000 0 0 0
    "PROPFIND", "get" 3,000 3,000 0 3,000
  • The token scan is checkIsHttpToken of internal/validators, which the node:http client calls for every request. node:events already loads that module, so the new require loads nothing.

  • Size: the release bundle of the built-in modules (InternalModuleRegistryConstants.bin, the .bun_builtins section) is 2,631,735 bytes on main and 2,632,610 bytes with this PR. Measured with src/codegen/bundle-modules.ts --debug=OFF on both trees. No release binary was linked for this number.

Other open PRs on these files

History


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 21 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/undici/undici.test.ts
bun test v1.4.3 (367d939d9)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [146.26ms]
(pass) undici > request > should error when body has already been consumed [44.13ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [493.29ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [198.14ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [200.95ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [108.01ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [115.86ms]
(pass) undici > request > body stream when a body method took the response > re
... (truncated)

release without fix: 21 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [20.78ms]
(pass) undici > request > should error when body has already been consumed [2.70ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [33.60ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [20.82ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [6.19ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [9.45ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [8.98ms]
(pass) undici > request > body stream when a body method took the response > resume() leaves a body method that is still reading alone [12.81ms]
(pass) undici > request > body stream when a body method took the response > destroy() leaves a body method t
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/undici/undici.test.ts
bun test v1.4.3 (367d939d9)

test/js/first_party/undici/undici.test.ts:
(pass) undici > request > should make a GET request when passed a URL string [241.26ms]
(pass) undici > request > should error when body has already been consumed [64.38ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after arrayBuffer() consumed it [617.24ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after blob() consumed it [126.38ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after formData() consumed it [145.59ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after json() consumed it [133.95ms]
(pass) undici > request > body stream when a body method took the response > ends without an error after text() consumed it [106.39ms]
(pass) undici > request > body stream when a body method took the response > re
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2486ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/599] cxx obj/vendor/boringssl/crypto/x509/x_crl.cc.o
[2/599] cxx obj/vendor/boringssl/crypto/x509/x_exten.cc.o
[3/599] cxx obj/vendor/boringssl/crypto/x509/x_all.cc.o
[4/599] cxx obj/vendor/boringssl/crypto/x509/x_name.cc.o
[5/599] cxx obj/vendor/boringssl/crypto/x509/x_attrib.cc.o
[6/599] cxx obj/vendor/boringssl/crypto/x509/x_req.cc.o
[7/599] cxx obj/vendor/boringssl/crypto/x509/x_sig.cc.o
[8/599] cxx obj/vendor/boringssl/crypto/x509/x_spki.cc.o
[9/599] cxx obj/vendor/boringssl/crypto/x509/x_x509a.cc.o
[10/599] cxx obj/vendor/boringssl/gen/crypto/err_data.cc.o
[11/599] cxx obj/vendor/boringssl/crypto/x509/x_x509.cc.o
[12/599] cxx obj/vendor/boringssl/crypto/xwing/xwing.cc.o
[13/599] cxx obj/vendor/boringssl/ssl/d1_lib.cc.o
[14/599] cxx obj/vendor/boringssl/ssl/d1_pkt.cc.o
[15/599] cxx obj/vendor/boringssl/ssl/bio_ssl.cc.o
[16/599] cxx obj/vendor/boringssl/ssl/d1_both.cc.o
[17/599] cxx obj/vendor/boringssl/ssl/dtls_record.cc.o
[18/599] cxx obj/vendor/boringssl/ssl/dtls_method.cc.o
[19/599] cxx obj/ven
... (truncated)
diff hotspot
src/js/thirdparty/undici.js               |  36 ++++++++-
 test/js/first_party/undici/undici.test.ts | 123 +++++++++++++++++++++++++++++-
 2 files changed, 156 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/js/thirdparty/undici.js                    6      6     17
test/js/first_party/undici/undici.test.ts      4      4     14

…en, or CONNECT

request() sent GET for a method that is not a string, and for most
strings that are not an HTTP token. It sent CONNECT too. undici rejects
each of them before it connects, with InvalidArgumentError:

- not a string: "method must be a string"
- not an HTTP token: "invalid request method"
- exactly "CONNECT": "invalid method"

request() now makes the same checks before it calls fetch().
InvalidArgumentError gets the name, the code UND_ERR_INVALID_ARG and the
default message that it has in undici.

A falsy method is still the default. It is now "GET" for the guard of
request() too, so a falsy method with a body rejects there and no longer
in fetch(). A valid method still goes out in upper case, because fetch()
sends GET for a spelling outside its method table. GET, HEAD, POST, PUT,
DELETE, OPTIONS and PATCH skip the checks and the case map.
@github-actions github-actions Bot added the claude label Oct 8, 2026
@robobun

robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the released bun 1.4.3-canary.1+367d939d9 (linux x64), whose src/js/thirdparty/undici.js is the one on main, with a raw TCP server, so that the request line is visible:

import { createServer } from "node:net";
import { request } from "undici";

const lines = [];
const server = createServer(socket => {
  socket.on("data", data => {
    lines.push(String(data).split("\r\n")[0]);
    socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
  });
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
for (const method of [true, 123, "GET POST", "CONNECT", null, "Put", "brew", "connect"]) {
  lines.length = 0;
  const outcome = await request(url, { method }).then(
    async res => (await res.body.text(), "ok"),
    err => `${err.name}: ${err.message}`,
  );
  console.log(JSON.stringify(method).padEnd(10), outcome.padEnd(50), JSON.stringify(lines));
}
server.close();

main:

true       ok                                                 ["GET / HTTP/1.1"]
123        ok                                                 ["GET / HTTP/1.1"]
"GET POST" ok                                                 ["GET / HTTP/1.1"]
"CONNECT"  ok                                                 ["CONNECT / HTTP/1.1"]
null       ok                                                 ["GET / HTTP/1.1"]
"Put"      ok                                                 ["PUT / HTTP/1.1"]
"brew"     ok                                                 ["GET / HTTP/1.1"]
"connect"  ok                                                 ["CONNECT / HTTP/1.1"]

This branch (debug build):

true       InvalidArgumentError: method must be a string      []
123        InvalidArgumentError: method must be a string      []
"GET POST" InvalidArgumentError: invalid request method       []
"CONNECT"  InvalidArgumentError: invalid method               []
null       ok                                                 ["GET / HTTP/1.1"]
"Put"      ok                                                 ["PUT / HTTP/1.1"]
"brew"     ok                                                 ["GET / HTTP/1.1"]
"connect"  InvalidArgumentError: invalid method               []

The first four rows now match undici 6.21.3 and 7.16.0 on Node 26.3. "Put" and "brew" are not changed by this PR. "connect" now rejects, where undici sends connect as written (see Notes in the description).

test/js/first_party/undici/undici.test.ts: 21 of the 31 new tests fail on main, and all pass on this branch.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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: 691b8889-de76-4709-bd40-d00aed9ad31e
📥 Commits

Reviewing files that changed from the base of the PR and between b10d662 and d27605a.

📒 Files selected for processing (2)
  • src/js/thirdparty/undici.js
  • test/js/first_party/undici/undici.test.ts

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


Walkthrough

Undici now validates request methods before connecting. Falsy methods default to GET, accepted methods are uppercased, and invalid methods, including CONNECT in any casing, are rejected. InvalidArgumentError now sets its name, default message, and code. Tests cover request lines and error properties.

Changes

Request method validation

Layer / File(s) Summary
Method validation and errors
src/js/thirdparty/undici.js
The request path validates method values and normalizes accepted methods before sending. InvalidArgumentError now sets its name, default message, and code.
Request method behavior tests
test/js/first_party/undici/undici.test.ts
Tests verify rejected methods send no request line, accepted methods are uppercased, falsy methods default to GET, and InvalidArgumentError has the expected properties.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to d2760

This change adds method validation to undici.request, so invalid methods and CONNECT are rejected before any request is sent. No actionable merge-blocking risk remains.

🚥 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 invalid HTTP methods, including CONNECT, in undici.request.
Description check ✅ Passed The description is complete and directly related to the change. It explains the problem, fix, scope, known differences, and verification results, although it uses custom headings instead of the templa…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/thirdparty/undici.js Outdated
Comment thread src/js/thirdparty/undici.js
…ptured primordial

request() puts the method in upper case, so "connect" and "Connect"
went out as CONNECT, the one method that undici never sends from
request(). After a 200 answer to that request, the next requests to the
same origin go out on the same socket, which a server treats as a
tunnel. The CONNECT check now runs on the upper-case method. undici
rejects only the exact string and sends another case as written, and
the tests name that difference.

The case map now calls a String.prototype.toUpperCase that is captured
when the module loads, so a replaced prototype method does not change
the method that goes out.
Comment thread src/js/thirdparty/undici.js Outdated
The reference for the type check and the token check sits on the lines
that it covers, and the CONNECT comment names its own reference.

@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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

This branch has not been deployed

No deployments
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.

1 participant