Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
206ad3427b Address additional PR review comments
- Add ObjectEntries primordial for safe iteration
- Use primordials (StringPrototypeToLowerCase, ObjectKeys, ObjectEntries,
  ArrayIsArray) instead of global built-ins in Http1FallbackResponse
- Fix Buffer.byteLength to include encoding parameter for accurate byte
  length calculation with non-UTF8 encodings
- Have Http2SecureServer call initializeOptions() to ensure proper defaults
- Support custom Http1ServerResponse in http1ConnectionListener while
  defaulting to Http1FallbackResponse (http.ServerResponse requires
  native handle not available when parsing raw sockets)
- Use Promise.withResolvers in tests for cleaner async code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:43:39 +00:00
Claude Bot
38a3fae22f Address PR review comments
- Use utcDate() helper instead of new Date().toUTCString()
- Return socket write result in write() method for proper flow control
- Add Connection: close header to HTTP/1.1 test requests to ensure
  graceful server shutdown (keep-alive connections would block close)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:04:00 +00:00
Claude Bot
548798c5c0 fix(http2): implement allowHTTP1 option for secure servers
Implements HTTP/1.1 fallback for `node:http2` secure servers when
`allowHTTP1: true` is passed. Previously, the server only advertised
`h2` in ALPN negotiation, causing HTTP/1.1-only clients (like curl
with --http1.1) to fail with "tlsv1 alert no application protocol".

Changes:
- Add `http/1.1` to ALPNProtocols when allowHTTP1 is true
- Implement http1ConnectionListener to handle HTTP/1.1 connections
- Add Http1FallbackResponse class for writing HTTP/1.1 responses
- Add _addHeaderLines method to IncomingMessage for HTTP parser
- Enable Http1IncomingMessage and Http1ServerResponse options

Closes #26721

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:50:23 +00:00
3 changed files with 580 additions and 17 deletions

View File

@@ -412,6 +412,42 @@ const IncomingMessagePrototype = {
set socket(value) {
this[fakeSocketSymbol] = value;
},
// Used by HTTP parser for adding headers from raw socket parsing
// This is needed for allowHTTP1 fallback in HTTP/2 servers
_addHeaderLine(field, value, dest) {
const lowercased = field.toLowerCase();
if (lowercased === "set-cookie") {
if (dest[lowercased] !== undefined) {
dest[lowercased].push(value);
} else {
dest[lowercased] = [value];
}
} else {
if (dest[lowercased] !== undefined) {
dest[lowercased] += ", " + value;
} else {
dest[lowercased] = value;
}
}
},
_addHeaderLines(headers, n) {
if (headers?.length) {
if (!this.headers) {
this.headers = Object.create(null);
}
if (!this.rawHeaders) {
this.rawHeaders = [];
}
// Add to rawHeaders
for (let i = 0; i < n; i++) {
this.rawHeaders.push(headers[i]);
}
// Parse and add to headers object
for (let i = 0; i < n; i += 2) {
this._addHeaderLine(headers[i], headers[i + 1], this.headers);
}
}
},
} satisfies typeof import("node:http").IncomingMessage.prototype;
IncomingMessage.prototype = IncomingMessagePrototype;
$setPrototypeDirect.$call(IncomingMessage, Readable);

View File

@@ -29,6 +29,8 @@
const { isTypedArray } = require("node:util/types");
const { hideFromStack, throwNotImplemented } = require("internal/shared");
const { STATUS_CODES } = require("internal/http");
const http = require("node:http");
const { parsers, freeParser, kIncomingMessage, HTTPParser } = require("node:_http_common");
const tls = require("node:tls");
const net = require("node:net");
const fs = require("node:fs");
@@ -59,6 +61,7 @@ const RegExpPrototypeExec = RegExp.prototype.exec;
const ObjectAssign = Object.assign;
const ArrayIsArray = Array.isArray;
const ObjectKeys = Object.keys;
const ObjectEntries = Object.entries;
const FunctionPrototypeBind = Function.prototype.bind;
const StringPrototypeTrim = String.prototype.trim;
const ArrayPrototypePush = Array.prototype.push;
@@ -3711,13 +3714,278 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) {
}
}
// Simple HTTP/1.1 ServerResponse for allowHTTP1 fallback
// This writes directly to the socket since we don't have a native Bun handle
class Http1FallbackResponse extends EventEmitter {
socket;
statusCode = 200;
statusMessage = "OK";
headersSent = false;
finished = false;
_headers = Object.create(null);
req;
sendDate = true;
_hasBody = true;
constructor(req, socket) {
super();
this.req = req;
this.socket = socket;
if (req.method === "HEAD") this._hasBody = false;
}
setHeader(name, value) {
this._headers[StringPrototypeToLowerCase.$call(name)] = value;
return this;
}
getHeader(name) {
return this._headers[StringPrototypeToLowerCase.$call(name)];
}
removeHeader(name) {
delete this._headers[StringPrototypeToLowerCase.$call(name)];
return this;
}
hasHeader(name) {
return StringPrototypeToLowerCase.$call(name) in this._headers;
}
getHeaderNames() {
return ObjectKeys(this._headers);
}
writeHead(statusCode, statusMessage?, headers?) {
if (this.headersSent) return this;
if (typeof statusMessage === "object") {
headers = statusMessage;
statusMessage = undefined;
}
this.statusCode = statusCode;
if (statusMessage) this.statusMessage = statusMessage;
else this.statusMessage = STATUS_CODES[statusCode] || "Unknown";
if (headers) {
for (const key of ObjectKeys(headers)) {
this.setHeader(key, headers[key]);
}
}
return this;
}
_flushHeaders() {
if (this.headersSent) return;
this.headersSent = true;
let head = `HTTP/1.1 ${this.statusCode} ${this.statusMessage}\r\n`;
if (this.sendDate && !this.hasHeader("date")) {
head += `Date: ${utcDate()}\r\n`;
}
for (const [name, value] of ObjectEntries(this._headers)) {
if (ArrayIsArray(value)) {
for (const v of value) {
head += `${name}: ${v}\r\n`;
}
} else {
head += `${name}: ${value}\r\n`;
}
}
head += "\r\n";
this.socket.write(head);
}
write(chunk, encoding?, callback?) {
if (typeof encoding === "function") {
callback = encoding;
encoding = undefined;
}
if (!this.headersSent) {
if (!this.hasHeader("transfer-encoding") && !this.hasHeader("content-length")) {
this.setHeader("transfer-encoding", "chunked");
}
this._flushHeaders();
}
if (this._hasBody && chunk) {
if (this.getHeader("transfer-encoding") === "chunked") {
const len = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length;
this.socket.write(len.toString(16) + "\r\n");
this.socket.write(chunk, encoding);
return this.socket.write("\r\n", undefined, callback);
} else {
return this.socket.write(chunk, encoding, callback);
}
} else if (callback) {
callback();
}
return true;
}
end(data?, encoding?, callback?) {
if (typeof data === "function") {
callback = data;
data = undefined;
} else if (typeof encoding === "function") {
callback = encoding;
encoding = undefined;
}
if (this.finished) {
if (callback) process.nextTick(callback);
return this;
}
if (!this.headersSent) {
if (data && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) {
const len = typeof data === "string" ? Buffer.byteLength(data, encoding) : (data?.length ?? 0);
this.setHeader("content-length", String(len));
} else if (!data && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) {
this.setHeader("content-length", "0");
}
this._flushHeaders();
}
if (this._hasBody && data) {
if (this.getHeader("transfer-encoding") === "chunked") {
const len = typeof data === "string" ? Buffer.byteLength(data, encoding) : data.length;
this.socket.write(len.toString(16) + "\r\n");
this.socket.write(data, encoding);
this.socket.write("\r\n0\r\n\r\n");
} else {
this.socket.write(data, encoding);
}
} else if (this.getHeader("transfer-encoding") === "chunked") {
this.socket.write("0\r\n\r\n");
}
this.finished = true;
this.emit("finish");
if (callback) process.nextTick(callback);
return this;
}
writeContinue() {
this.socket.write("HTTP/1.1 100 Continue\r\n\r\n");
}
destroy(err?) {
this.socket.destroy(err);
return this;
}
assignSocket(socket) {
this.socket = socket;
}
}
// HTTP/1.1 connection listener for allowHTTP1 fallback
// This sets up an HTTP parser to handle HTTP/1.1 requests on the TLS socket
function http1ConnectionListener(socket: Socket, options) {
const server = this;
// Ensure socket has server property set
socket.server = server;
// Get configurable classes from options (set by initializeOptions)
// Note: We default to Http1FallbackResponse because http.ServerResponse requires
// a native handle that isn't available when parsing raw sockets. Only use a custom
// ServerResponse if explicitly provided by the user.
const ServerResponse =
options.Http1ServerResponse && options.Http1ServerResponse !== http.ServerResponse
? options.Http1ServerResponse
: Http1FallbackResponse;
// Get the HTTP parser from the pool
const parser = parsers.alloc();
parser.socket = socket;
socket.parser = parser;
// Initialize parser for REQUEST mode
parser.initialize(HTTPParser.REQUEST, socket);
// When the parser completes parsing headers, create a response and emit the request event
parser.onIncoming = function onIncoming(req, shouldKeepAlive) {
// Create a response using the configured ServerResponse class
// For Http1FallbackResponse, pass (req, socket). For custom classes, just pass (req).
const res = ServerResponse === Http1FallbackResponse ? new ServerResponse(req, socket) : new ServerResponse(req);
// Emit the request event on the server
if (req.headers.expect !== undefined && req.httpVersionMajor === 1 && req.httpVersionMinor === 1) {
if (req.headers.expect === "100-continue") {
if (server.listenerCount("checkContinue") > 0) {
server.emit("checkContinue", req, res);
} else {
res.writeContinue();
server.emit("request", req, res);
}
} else {
if (server.listenerCount("checkExpectation") > 0) {
server.emit("checkExpectation", req, res);
} else {
res.writeHead(417);
res.end();
}
}
} else {
server.emit("request", req, res);
}
return 0;
};
// Set up socket event handlers
function onSocketData(data) {
const ret = parser.execute(data);
if (ret instanceof Error) {
socket.destroy(ret);
}
}
function onSocketEnd() {
const ret = parser.finish();
if (ret instanceof Error) {
socket.destroy(ret);
}
}
function onSocketClose() {
// Clean up parser
freeParser(parser, null, socket);
socket.parser = null;
}
function onSocketError(err) {
if (!server.emit("clientError", err, socket)) {
socket.destroy(err);
}
}
socket.on("data", onSocketData);
socket.on("end", onSocketEnd);
socket.on("close", onSocketClose);
socket.on("error", onSocketError);
// Emit connection event
server.emit("connection", socket);
}
function connectionListener(socket: Socket) {
const options = this[bunSocketServerOptions] || {};
if (socket.alpnProtocol === false || socket.alpnProtocol === "http/1.1") {
// TODO: Fallback to HTTP/1.1
// if (options.allowHTTP1 === true) {
// }
// Fallback to HTTP/1.1
if (options.allowHTTP1 === true) {
return http1ConnectionListener.$call(this, socket, options);
}
// Let event handler deal with the socket
if (!this.emit("unknownProtocol", socket)) {
@@ -3788,8 +4056,8 @@ function initializeOptions(options) {
else options.unknownProtocolTimeout = 10000;
// Used only with allowHTTP1
// options.Http1IncomingMessage ||= http.IncomingMessage;
// options.Http1ServerResponse ||= http.ServerResponse;
options.Http1IncomingMessage ||= http.IncomingMessage;
options.Http1ServerResponse ||= http.ServerResponse;
options.Http2ServerRequest ||= Http2ServerRequest;
options.Http2ServerResponse ||= Http2ServerResponse;
@@ -3888,10 +4156,10 @@ class Http2SecureServer extends tls.Server {
timeout = 0;
[kSessions] = new SafeSet();
constructor(options, onRequestHandler) {
//TODO: add 'http/1.1' on ALPNProtocols list after allowHTTP1 support
if (typeof options !== "undefined") {
if (options && typeof options === "object") {
options = { ...options, ALPNProtocols: ["h2"] };
const ALPNProtocols = options.allowHTTP1 === true ? ["h2", "http/1.1"] : ["h2"];
options = { ...options, ALPNProtocols };
} else {
throw $ERR_INVALID_ARG_TYPE("options", "object", options);
}
@@ -3899,16 +4167,9 @@ class Http2SecureServer extends tls.Server {
options = { ALPNProtocols: ["h2"] };
}
const settings = options.settings;
if (typeof settings !== "undefined") {
validateObject(settings, "options.settings");
}
if (options.maxSessionInvalidFrames !== undefined)
validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames");
// Initialize options with defaults (including Http1IncomingMessage/ServerResponse)
options = initializeOptions(options);
if (options.maxSessionRejectedStreams !== undefined) {
validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams");
}
super(options, connectionListener);
this[kSessions] = new SafeSet();
this.setMaxListeners(0);

View File

@@ -0,0 +1,266 @@
/**
* Regression test for issue #26721
*
* HTTP/1.1 fallback is broken for `node:http2` secure server when
* `allowHTTP1: true` is passed. The server only advertises `h2` in ALPN
* negotiation, causing HTTP/1.1-only clients to fail.
*
* @see https://github.com/oven-sh/bun/issues/26721
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import http2 from "node:http2";
import https from "node:https";
import { join } from "node:path";
// TLS certificates for testing
const fixturesDir = join(import.meta.dirname, "..", "fixtures");
const tlsOptions = {
cert: readFileSync(join(fixturesDir, "cert.pem")),
key: readFileSync(join(fixturesDir, "cert.key")),
};
interface TestContext {
server: http2.Http2SecureServer;
serverPort: number;
serverUrl: string;
}
describe("HTTP/2 allowHTTP1 option", () => {
let ctx: TestContext;
beforeAll(async () => {
const server = http2.createSecureServer({
...tlsOptions,
allowHTTP1: true,
});
// Handle HTTP/2 streams
server.on("stream", (stream, headers) => {
stream.respond({
":status": 200,
"content-type": "text/plain",
"x-protocol": "h2",
});
stream.end("ok h2\n");
});
// Handle HTTP/1.1 requests (via allowHTTP1 fallback)
// Note: HTTP/2 compatibility also emits 'request' events, but those requests
// will have already been handled by the 'stream' handler. We check if headers
// have been sent to avoid double-responding.
server.on("request", (req, res) => {
// Skip if this is from HTTP/2 compat (headers already sent by stream handler)
if (res.headersSent) return;
res.writeHead(200, {
"content-type": "text/plain",
"x-protocol": "http1",
});
res.end("ok http1\n");
});
const { promise: listenPromise, resolve: listenResolve, reject: listenReject } = Promise.withResolvers<number>();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
listenReject(new Error("Failed to get server address"));
return;
}
listenResolve(address.port);
});
server.once("error", listenReject);
const serverPort = await listenPromise;
ctx = {
server,
serverPort,
serverUrl: `https://127.0.0.1:${serverPort}`,
};
});
afterAll(async () => {
if (ctx?.server) {
// Close all active connections first to ensure server.close() completes
if (typeof ctx.server.closeAllConnections === "function") {
ctx.server.closeAllConnections();
}
const { promise, resolve } = Promise.withResolvers<void>();
ctx.server.close(() => resolve());
await promise;
}
});
test("HTTP/2 client can connect and make request", async () => {
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
const response = await new Promise<{ status: number; body: string; protocol: string }>((resolve, reject) => {
const req = client.request({ ":path": "/" });
let body = "";
let protocol = "";
req.on("response", headers => {
protocol = headers["x-protocol"] as string;
});
req.on("data", chunk => {
body += chunk;
});
req.on("end", () => {
resolve({ status: 200, body, protocol });
});
req.on("error", reject);
req.end();
});
expect(response.body).toBe("ok h2\n");
expect(response.protocol).toBe("h2");
const { promise: closePromise, resolve: closeResolve } = Promise.withResolvers<void>();
client.close(closeResolve);
await closePromise;
});
test("HTTP/1.1 client can connect when allowHTTP1 is true (issue #26721)", async () => {
// This test verifies that HTTP/1.1 clients can connect to an HTTP/2 server
// with allowHTTP1: true. Before the fix, this would fail with:
// "tlsv1 alert no application protocol" because the server only
// advertised "h2" in ALPN, not "http/1.1".
const response = await new Promise<{ statusCode: number; body: string; protocol: string }>((resolve, reject) => {
const req = https.request(
{
hostname: "127.0.0.1",
port: ctx.serverPort,
path: "/",
method: "GET",
rejectUnauthorized: false,
headers: {
Connection: "close", // Ensure connection is closed after request
},
// Force HTTP/1.1 by not specifying ALPNProtocols or by using https module
},
res => {
let body = "";
res.on("data", chunk => {
body += chunk;
});
res.on("end", () => {
resolve({
statusCode: res.statusCode!,
body,
protocol: res.headers["x-protocol"] as string,
});
});
},
);
req.on("error", reject);
req.end();
});
expect(response.statusCode).toBe(200);
expect(response.body).toBe("ok http1\n");
expect(response.protocol).toBe("http1");
});
test("HTTP/1.1 POST request works with allowHTTP1", async () => {
const postData = JSON.stringify({ message: "hello" });
// Use the shared server from ctx
const response = await new Promise<{ statusCode: number; body: string }>((resolve, reject) => {
const req = https.request(
{
hostname: "127.0.0.1",
port: ctx.serverPort,
path: "/post",
method: "POST",
rejectUnauthorized: false,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(postData),
Connection: "close", // Ensure connection is closed after request
},
},
res => {
let body = "";
res.on("data", chunk => {
body += chunk;
});
res.on("end", () => {
resolve({ statusCode: res.statusCode!, body });
});
},
);
req.on("error", reject);
req.write(postData);
req.end();
});
expect(response.statusCode).toBe(200);
expect(response.body).toBe("ok http1\n");
});
});
describe("HTTP/2 without allowHTTP1", () => {
test("HTTP/1.1 client gets rejected when allowHTTP1 is false", async () => {
const server = http2.createSecureServer({
...tlsOptions,
allowHTTP1: false,
});
server.on("stream", (stream, _headers) => {
stream.respond({ ":status": 200 });
stream.end("ok");
});
const { promise: listenPromise, resolve: listenResolve, reject: listenReject } = Promise.withResolvers<number>();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
listenReject(new Error("Failed to get server address"));
return;
}
listenResolve(address.port);
});
server.once("error", listenReject);
const port = await listenPromise;
try {
await new Promise<void>((resolve, reject) => {
const req = https.request(
{
hostname: "127.0.0.1",
port,
path: "/",
method: "GET",
rejectUnauthorized: false,
},
() => {
reject(new Error("Expected connection to fail"));
},
);
req.on("error", err => {
// We expect an ALPN negotiation error or similar
// Note: Bun's https client may report different error messages
expect(err.message).toMatch(/no application protocol|ECONNRESET|ECONNREFUSED|socket hang up/i);
resolve();
});
req.end();
});
} finally {
// Force close all connections and the server
// Use a short timeout to ensure this doesn't hang the test
await Promise.race([
new Promise<void>(resolve => server.close(() => resolve())),
new Promise<void>(resolve => setTimeout(resolve, 500)),
]);
}
});
});