Compare commits

...

4 Commits

Author SHA1 Message Date
Claude
3d1d317d74 fix: add await to rejects assertions and correct 301/302 redirect for custom methods
- Add missing `await` on `.rejects.toThrow()` calls so assertions are
  actually checked by the test runner
- Remove `custom_method_len > 0` from 301/302 redirect condition: per
  RFC 9110, only POST should convert to GET on 301/302; custom methods
  must be preserved. The 303 branch correctly converts all non-GET/HEAD.

https://claude.ai/code/session_01DxWwVfmGtbbGpv1GcXua4i
2026-02-21 06:26:39 +00:00
Claude
10b7d2227f fix: add assert in setCustomMethod and preserve body on 307/308 for custom methods
- Add assert guard in setCustomMethod() to catch oversized method
  strings instead of silently truncating
- Check custom_method_len > 0 in 307/308 redirect path so custom
  methods correctly preserve the request body on redirect

https://claude.ai/code/session_01DxWwVfmGtbbGpv1GcXua4i
2026-02-21 05:49:33 +00:00
Claude
93fbacf600 fix: clear custom_method_len on HTTP redirect and use module-scope import
- Reset custom_method_len to 0 when converting to GET on 301/302/303
  redirects so methodName() correctly returns "GET" instead of the
  custom method string
- Update redirect condition to account for custom methods (where the
  enum fallback is .GET but custom_method_len > 0)
- Move require("net") to module-scope import per test conventions

https://claude.ai/code/session_01DxWwVfmGtbbGpv1GcXua4i
2026-02-21 04:54:40 +00:00
Claude Bot
843c97be51 fix: don't silently convert unknown HTTP methods to GET in fetch()
Bun's fetch() was silently converting unknown/custom HTTP method strings
(like "BUN", "CHICKEN") to "GET" because Method.fromJS() returns null for
methods not in the known enum, and the code defaulted to .GET via `orelse`.

This caused Bun.serve() routes with method-specific handlers (e.g.
`{ GET: handler }`) to incorrectly handle requests sent with unknown
methods as if they were GET requests, returning 200 instead of 404/501.

Changes:
- fetch() now validates and passes through custom HTTP method strings
  per RFC 9110, using a fixed buffer in the HTTP client pipeline
- uWS HttpRouter now also registers ANY (*) handlers on the actual "*"
  method node, so unknown methods fall through to the catch-all handler
  instead of causing a connection drop

Fixes #18406

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:26:31 +00:00
6 changed files with 272 additions and 22 deletions

View File

@@ -86,6 +86,48 @@ static constexpr auto supportedHttpMethods = makeArray<std::string_view>(
"UNSUBSCRIBE"
);
// Same as supportedHttpMethods but also includes the "*" (ANY) method token,
// so that handlers registered with "*" also catch unknown/custom HTTP methods
// that don't match any known method node in the router.
static constexpr auto supportedHttpMethodsAndAny = makeArray<std::string_view>(
"ACL",
"BIND",
"CHECKOUT",
"CONNECT",
"COPY",
"DELETE",
"GET",
"HEAD",
"LINK",
"LOCK",
"M-SEARCH",
"MERGE",
"MKACTIVITY",
"MKCALENDAR",
"MKCOL",
"MOVE",
"NOTIFY",
"OPTIONS",
"PATCH",
"POST",
"PROPFIND",
"PROPPATCH",
"PURGE",
"PUT",
"QUERY",
"REBIND",
"REPORT",
"SEARCH",
"SOURCE",
"SUBSCRIBE",
"TRACE",
"UNBIND",
"UNLINK",
"UNLOCK",
"UNSUBSCRIBE",
"*"
);
} // namespace detail
template<bool> struct HttpResponse;
@@ -586,7 +628,9 @@ public:
std::string_view method_sv_buffer;
// When it's NOT node:http, allow the uWS default precedence ordering.
if (method == "*" && !httpContextData->flags.useStrictMethodValidation) {
methods = detail::supportedHttpMethods;
// Register for all known methods AND the "*" (ANY) node so that
// unknown/custom methods also get routed to this handler.
methods = detail::supportedHttpMethodsAndAny;
} else {
method_buffer = std::string(method);
method_sv_buffer = std::string_view(method_buffer);

View File

@@ -177,6 +177,41 @@ pub fn nodeHttpClient(
return fetchImpl(true, ctx, callframe);
}
/// Validates that a string is a valid HTTP method token per RFC 9110.
/// A token is 1*tchar, where tchar is: "!" / "#" / "$" / "%" / "&" / "'" /
/// "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
fn isValidHTTPToken(str: []const u8) bool {
if (str.len == 0) return false;
for (str) |c| {
if (!isTchar(c)) return false;
}
return true;
}
fn isTchar(c: u8) bool {
return switch (c) {
'!' => true,
'#' => true,
'$' => true,
'%' => true,
'&' => true,
'\'' => true,
'*' => true,
'+' => true,
'-' => true,
'.' => true,
'^' => true,
'_' => true,
'`' => true,
'|' => true,
'~' => true,
'0'...'9' => true,
'A'...'Z' => true,
'a'...'z' => true,
else => false,
};
}
/// Shared implementation of fetch
fn fetchImpl(
comptime allow_get_body: bool,
@@ -201,6 +236,7 @@ fn fetchImpl(
var headers: ?Headers = null;
var method = Method.GET;
var custom_method: []const u8 = "";
var args = jsc.CallFrame.ArgumentsSlice.init(vm, arguments.slice());
@@ -278,6 +314,11 @@ fn fetchImpl(
conf.deinit();
bun.default_allocator.destroy(conf);
}
if (custom_method.len > 0) {
allocator.free(custom_method);
custom_method = "";
}
}
const options_object: ?JSValue = brk: {
@@ -375,25 +416,64 @@ fn fetchImpl(
// **Start with the harmless ones.**
// "method"
method = extract_method: {
if (options_object) |options| {
if (try options.getTruthyComptime(globalThis, "method")) |method_| {
break :extract_method try Method.fromJS(globalThis, method_);
extract_method: {
const method_js: ?jsc.JSValue = brk: {
if (options_object) |options| {
if (try options.getTruthyComptime(globalThis, "method")) |method_| {
break :brk method_;
}
}
if (request) |req| {
method = req.method;
break :extract_method;
}
if (request_init_object) |req| {
if (try req.getTruthyComptime(globalThis, "method")) |method_| {
break :brk method_;
}
}
break :brk null;
};
if (method_js) |method_value| {
if (try Method.fromJS(globalThis, method_value)) |m| {
method = m;
} else {
// Unknown method — extract raw string and validate as HTTP token
const str = try bun.String.fromJS(method_value, globalThis);
defer str.deref();
var utf8_slice = str.toUTF8(allocator);
defer utf8_slice.deinit();
const method_str = utf8_slice.slice();
if (method_str.len == 0 or !isValidHTTPToken(method_str)) {
is_error = true;
const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() method must be a valid HTTP token", .{});
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(
globalThis,
err,
);
}
if (method_str.len > 24) {
is_error = true;
const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() method is too long", .{});
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(
globalThis,
err,
);
}
// Store the custom method string. Use GET as the enum fallback
// since the custom_method string takes precedence in the HTTP client.
custom_method = bun.handleOom(allocator.dupe(u8, method_str));
method = .GET;
}
}
if (request) |req| {
break :extract_method req.method;
}
if (request_init_object) |req| {
if (try req.getTruthyComptime(globalThis, "method")) |method_| {
break :extract_method try Method.fromJS(globalThis, method_);
}
}
break :extract_method null;
} orelse .GET;
}
// "decompress: boolean"
disable_decompression = extract_disable_decompression: {
@@ -1077,7 +1157,7 @@ fn fetchImpl(
}
}
if (!allow_get_body and !method.hasRequestBody() and body.hasBody() and !upgraded_connection) {
if (!allow_get_body and custom_method.len == 0 and !method.hasRequestBody() and body.hasBody() and !upgraded_connection) {
const err = globalThis.toTypeError(.INVALID_ARG_VALUE, fetch_error_unexpected_body, .{});
is_error = true;
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err);
@@ -1407,6 +1487,7 @@ fn fetchImpl(
.upgraded_connection = upgraded_connection,
.check_server_identity = if (check_server_identity.isEmptyOrUndefinedOrNull()) .empty else .create(check_server_identity, globalThis),
.unix_socket_path = unix_socket_path,
.custom_method = custom_method,
},
// Pass the Strong value instead of creating a new one, or else we
// will leak it

View File

@@ -1080,6 +1080,7 @@ pub const FetchTasklet = struct {
.reject_unauthorized = fetch_options.reject_unauthorized,
.verbose = fetch_options.verbose,
.tls_props = fetch_options.ssl_config,
.custom_method = fetch_options.custom_method,
},
);
// enable streaming the write side
@@ -1261,6 +1262,7 @@ pub const FetchTasklet = struct {
unix_socket_path: ZigString.Slice,
ssl_config: ?*SSLConfig = null,
upgraded_connection: bool = false,
custom_method: []const u8 = "",
};
pub fn queue(

View File

@@ -465,6 +465,11 @@ pub const Flags = packed struct(u16) {
// TODO: reduce the size of this struct
// Many of these fields can be moved to a packed struct and use less space
method: Method,
/// When non-zero length, this is an arbitrary HTTP method string that is not in the
/// standard Method enum (e.g. a custom method like "BUN" or "CHICKEN").
/// When set, buildRequest() uses this instead of @tagName(method).
custom_method_len: u8 = 0,
custom_method_buf: [24]u8 = undefined,
header_entries: Headers.Entry.List,
header_buf: string,
url: URL,
@@ -600,6 +605,20 @@ pub fn headerStr(this: *const HTTPClient, ptr: api.StringPointer) string {
pub const HeaderBuilder = @import("./http/HeaderBuilder.zig");
pub fn methodName(this: *const HTTPClient) []const u8 {
if (this.custom_method_len > 0) {
return this.custom_method_buf[0..this.custom_method_len];
}
return @tagName(this.method);
}
pub fn setCustomMethod(this: *HTTPClient, method_str: []const u8) void {
assert(method_str.len <= this.custom_method_buf.len);
const len: u8 = @intCast(@min(method_str.len, this.custom_method_buf.len));
@memcpy(this.custom_method_buf[0..len], method_str[0..len]);
this.custom_method_len = len;
}
pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
var header_count: usize = 0;
var header_entries = this.header_entries.slice();
@@ -753,7 +772,7 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
}
return picohttp.Request{
.method = @tagName(this.method),
.method = this.methodName(),
.path = this.url.pathname,
.minor_version = 1,
.headers = request_headers_buf[0..header_count],
@@ -2496,10 +2515,11 @@ pub fn handleResponseMetadata(
// - internalResponses status is 303 and requests method is not `GET` or `HEAD`
// then:
if (((status_code == 301 or status_code == 302) and this.method == .POST) or
(status_code == 303 and this.method != .GET and this.method != .HEAD))
(status_code == 303 and (this.method != .GET or this.custom_method_len > 0) and this.method != .HEAD))
{
// - Set requests method to `GET` and requests body to null.
this.method = .GET;
this.custom_method_len = 0;
// https://github.com/oven-sh/bun/issues/6053
if (this.header_entries.len > 0) {
@@ -2582,7 +2602,7 @@ pub fn handleResponseMetadata(
}
}
this.state.flags.is_redirect_pending = true;
if (this.method.hasRequestBody()) {
if (this.method.hasRequestBody() or this.custom_method_len > 0) {
this.state.flags.resend_request_body_on_redirect = true;
}
},

View File

@@ -103,8 +103,11 @@ pub const Options = struct {
disable_decompression: ?bool = null,
reject_unauthorized: ?bool = null,
tls_props: ?*SSLConfig = null,
custom_method: []const u8 = "",
};
pub const max_custom_method_len = 24;
const Preconnect = struct {
async_http: AsyncHTTP,
response_buffer: MutableString,
@@ -189,6 +192,9 @@ pub fn init(
.proxy_headers = options.proxy_headers,
.redirect_type = redirect_type,
};
if (options.custom_method.len > 0) {
this.client.setCustomMethod(options.custom_method);
}
if (options.unix_socket_path) |val| {
assert(this.client.unix_socket_path.length() == 0);
this.client.unix_socket_path = val;

View File

@@ -0,0 +1,97 @@
import { expect, test } from "bun:test";
import { createServer } from "net";
// https://github.com/oven-sh/bun/issues/18406
// Unknown random HTTP methods should not be silently routed to GET handlers
test("fetch() with unknown method sends correct method on wire", async () => {
// Use a raw TCP server to verify what fetch actually sends
const receivedMethods: string[] = [];
const server = createServer((socket: any) => {
socket.on("data", (data: Buffer) => {
const line = data.toString().split("\r\n")[0];
const method = line.split(" ")[0];
receivedMethods.push(method);
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
socket.end();
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
await fetch(`http://localhost:${port}/test`, { method: "CHICKEN" });
await fetch(`http://localhost:${port}/test`, { method: "BUN" });
await fetch(`http://localhost:${port}/test`, { method: "GET" });
await fetch(`http://localhost:${port}/test`, { method: "POST" });
await fetch(`http://localhost:${port}/test`, { method: "PATCH" });
expect(receivedMethods).toEqual(["CHICKEN", "BUN", "GET", "POST", "PATCH"]);
} finally {
server.close();
}
});
test("Bun.serve() with routes does not route unknown methods to GET handler", async () => {
await using server = Bun.serve({
port: 0,
routes: {
"/test": {
GET: () => new Response("get handler"),
},
},
});
// GET should work as normal
const getRes = await fetch(new URL("/test", server.url), { method: "GET" });
expect(getRes.status).toBe(200);
expect(await getRes.text()).toBe("get handler");
// Unknown methods should NOT be routed to the GET handler
const bunRes = await fetch(new URL("/test", server.url), { method: "BUN" });
expect(bunRes.status).not.toBe(200);
// Known but unregistered methods should return 404
const postRes = await fetch(new URL("/test", server.url), { method: "POST" });
expect(postRes.status).toBe(404);
});
test("Bun.serve() with fetch handler receives requests with unknown methods", async () => {
await using server = Bun.serve({
port: 0,
routes: {
"/test": {
GET: () => new Response("get handler"),
},
},
fetch(req) {
return new Response("fetch handler", { status: 418 });
},
});
// GET should still route to the specific handler
const getRes = await fetch(new URL("/test", server.url), { method: "GET" });
expect(getRes.status).toBe(200);
expect(await getRes.text()).toBe("get handler");
// Unknown method should fall through to fetch handler, not GET route
const bunRes = await fetch(new URL("/test", server.url), { method: "BUN" });
expect(bunRes.status).toBe(418);
expect(await bunRes.text()).toBe("fetch handler");
// POST should also go to fetch handler since no POST route defined
const postRes = await fetch(new URL("/test", server.url), { method: "POST" });
expect(postRes.status).toBe(418);
expect(await postRes.text()).toBe("fetch handler");
});
test("fetch() rejects invalid HTTP method tokens", async () => {
// Methods with spaces should be rejected
await expect(fetch("http://localhost:1/test", { method: "INVALID METHOD" })).rejects.toThrow();
// Empty method should be rejected
await expect(fetch("http://localhost:1/test", { method: "" })).rejects.toThrow();
// Methods with special characters should be rejected
await expect(fetch("http://localhost:1/test", { method: "GET\r\n" })).rejects.toThrow();
});