Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions src/js/node/http2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ const bunHTTP2Native = Symbol.for("::bunhttp2native::");
const bunHTTP2Socket = Symbol.for("::bunhttp2socket::");
const bunHTTP2OriginSet = Symbol("::bunhttp2originset::");
const bunHTTP2StreamFinal = Symbol.for("::bunHTTP2StreamFinal::");
const bunHTTP2WaitForTrailers = Symbol("::bunhttp2waitfortrailers::");

const bunHTTP2StreamStatus = Symbol.for("::bunhttp2StreamStatus::");

Expand Down Expand Up @@ -2050,7 +2051,17 @@ class Http2Stream extends Duplex {
sensitiveNames[sensitives[i]] = true;
}
}
session[bunHTTP2Native]?.sendTrailers(this.#id, headers, sensitiveNames);
// RFC 7540 §8.1: a trailer HEADERS frame must carry a valid header block.
// If the user asked to send an empty trailer object (which the compat
// Http2ServerResponse does unconditionally from onStreamTrailersReady),
// emit an empty DATA frame with END_STREAM instead — this matches how
// Node terminates the stream and avoids a zero-length HEADERS frame that
// strict peers (nghttp2/curl) reject as a callback failure.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (ObjectKeys(headers).length === 0) {
session[bunHTTP2Native]?.noTrailers(this.#id);
} else {
session[bunHTTP2Native]?.sendTrailers(this.#id, headers, sensitiveNames);
}
this.#sentTrailers = headers;
}

Expand Down Expand Up @@ -2198,6 +2209,27 @@ class Http2Stream extends Duplex {
const native = session[bunHTTP2Native];
if (native) {
this[bunHTTP2StreamStatus] |= StreamState.FinalCalled;
// When waitForTrailers is active, writing an empty DATA frame with
// close=true emits a bare empty DATA frame (flags=0) to the wire
// before the trailer/noTrailers path runs, which then emits ANOTHER
// empty DATA (with END_STREAM). Two consecutive empty DATA frames
// confuse strict peers (nghttp2 callback failure). Skip the empty
// writeStream and drive the wantTrailers path directly — the
// eventual `sendTrailers({})` → `noTrailers` call terminates the
// stream with a single empty DATA END_STREAM frame, matching Node.
if (this[bunHTTP2WaitForTrailers]) {
this[bunHTTP2WaitForTrailers] = false;
if ((this[bunHTTP2StreamStatus] & StreamState.WantTrailer) === 0) {
this[bunHTTP2StreamStatus] |= StreamState.WantTrailer;
if (this.listenerCount("wantTrailers") === 0) {
native.noTrailers(this.#id);
} else {
this.emit("wantTrailers");
}
}
callback();
return;
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.
native.writeStream(this.#id, "", "ascii", true, callback);
return;
}
Expand Down Expand Up @@ -2643,6 +2675,9 @@ class ServerHttp2Stream extends Http2Stream {
session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames);
} else {
session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames, options);
if (options.waitForTrailers) {
this[bunHTTP2WaitForTrailers] = true;
}
Comment thread
robobun marked this conversation as resolved.
Outdated
}
this.headersSent = true;
this[bunHTTP2Headers] = headers;
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -3069,7 +3104,11 @@ class ServerHttp2Session extends Http2Session {
this.#parser = new H2FrameParser({
native: nativeSocket,
context: this,
settings: { ...options, ...options?.settings },
// RFC 9113 §7.2.2: a server MUST NOT send SETTINGS_ENABLE_PUSH with a
// value other than 0 — any non-zero value is treated by a client as a
// PROTOCOL_ERROR (nghttp2 reports this as callback failure). Default
// to `enablePush: false` for the server, overridable by user settings.
settings: { enablePush: false, ...options, ...options?.settings },
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
type: 0, // server type
handlers: ServerHttp2Session.#Handlers,
});
Expand Down
173 changes: 173 additions & 0 deletions test/regression/issue/29073.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// https://github.com/oven-sh/bun/issues/29073
//
// `node:http2.createServer` fails for h2c (cleartext HTTP/2).
//
// Two separate bugs caused strict HTTP/2 peers (curl's nghttp2, Node's
// http2 client) to reject Bun's server with "callback failure":
//
// 1. The server's initial SETTINGS frame advertised `ENABLE_PUSH=1`.
// RFC 9113 §7.2.2 says any value other than 0 for ENABLE_PUSH sent
// by a server MUST be treated by the client as a PROTOCOL_ERROR.
//
// 2. `res.end("ok")` wrote an extra empty DATA frame followed by an
// empty trailer HEADERS frame. The compat `Http2ServerResponse`
// layer sets `waitForTrailers: true` and then unconditionally calls
// `sendTrailers({})` — Bun was emitting a zero-length trailer block
// instead of the single empty DATA with END_STREAM that Node sends.
import { expect, test } from "bun:test";
import { once } from "node:events";
import http2 from "node:http2";
import net from "node:net";

function parseFrames(buf: Buffer) {
const frames: { length: number; type: number; flags: number; streamId: number; payload: Buffer }[] = [];
let offset = 0;
while (offset + 9 <= buf.length) {
const length = buf.readUIntBE(offset, 3);
const type = buf[offset + 3];
const flags = buf[offset + 4];
const streamId = buf.readUInt32BE(offset + 5) & 0x7fffffff;
if (offset + 9 + length > buf.length) break;
frames.push({
length,
type,
flags,
streamId,
payload: buf.slice(offset + 9, offset + 9 + length),
});
offset += 9 + length;
}
return frames;
}

// Send a minimal HTTP/2 request over a raw TCP socket (prior-knowledge h2c)
// so we can inspect the exact bytes the server writes. This is what curl
// does with --http2-prior-knowledge.
async function rawH2cRequest(port: number) {
const preface = Buffer.from("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
// SETTINGS frame (empty payload)
const settings = Buffer.from([0, 0, 0, 0x04, 0, 0, 0, 0, 0]);

// HEADERS frame: stream 1, END_HEADERS|END_STREAM.
// HPACK: :method GET (0x82), :scheme http (0x86), :path / (0x84),
// :authority localhost:PORT (literal name-indexed 0x41 + length + value).
const authority = `localhost:${port}`;
const authLiteral = Buffer.alloc(2 + authority.length);
authLiteral[0] = 0x41;
authLiteral[1] = authority.length;
authLiteral.write(authority, 2);
const hpack = Buffer.concat([Buffer.from([0x82, 0x86, 0x84]), authLiteral]);
const headersFrame = Buffer.alloc(9 + hpack.length);
headersFrame.writeUIntBE(hpack.length, 0, 3);
headersFrame[3] = 0x01; // HEADERS
headersFrame[4] = 0x04 | 0x01; // END_HEADERS | END_STREAM
headersFrame.writeUInt32BE(1, 5); // stream id 1
hpack.copy(headersFrame, 9);

const sock = net.connect({ port, host: "127.0.0.1" });
await once(sock, "connect");
sock.write(preface);
sock.write(settings);
sock.write(headersFrame);

const chunks: Buffer[] = [];
sock.on("data", chunk => chunks.push(chunk));
// Wait for the server to close the connection (after END_STREAM trailer /
// final DATA frame).
const timer = setTimeout(() => sock.destroy(), 3000);
await once(sock, "close");
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
clearTimeout(timer);
return parseFrames(Buffer.concat(chunks));
}

test("http2.createServer serves h2c response with well-formed frames (#29073)", async () => {
const server = http2.createServer((req, res) => {
res.setHeader("X-Foo", "bar");
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("ok");
});
await once(server.listen(0), "listening");
try {
const port = (server.address() as net.AddressInfo).port;
const frames = await rawH2cRequest(port);

// A) The server's initial SETTINGS frame must not advertise
// ENABLE_PUSH != 0 — that's a connection error per RFC 9113 §7.2.2.
const serverSettings = frames.find(f => f.type === 4 && (f.flags & 0x1) === 0);
expect(serverSettings).toBeDefined();
// Walk settings entries (6 bytes each: 2-byte id, 4-byte value) and
// assert there is no ENABLE_PUSH setting with a nonzero value.
const settingsPayload = serverSettings!.payload;
for (let i = 0; i + 6 <= settingsPayload.length; i += 6) {
const id = settingsPayload.readUInt16BE(i);
const value = settingsPayload.readUInt32BE(i + 2);
if (id === 0x02) {
// SETTINGS_ENABLE_PUSH
expect(value).toBe(0);
}
}

// B) The response HEADERS frame should be present on stream 1.
const responseHeaders = frames.find(f => f.type === 1 && f.streamId === 1);
expect(responseHeaders).toBeDefined();

// C) Exactly one DATA frame should carry the body payload; the stream
// must be terminated by a DATA frame with END_STREAM, NOT by a
// trailer HEADERS frame with an empty header block. Empty trailer
// blocks are rejected by strict peers (nghttp2 callback failure).
const stream1Data = frames.filter(f => f.type === 0 && f.streamId === 1);
expect(stream1Data.length).toBeGreaterThanOrEqual(1);
const bodyBytes = Buffer.concat(stream1Data.map(f => f.payload));
expect(bodyBytes.toString("utf8")).toBe("ok");
// The last DATA frame on stream 1 must carry END_STREAM.
const lastData = stream1Data[stream1Data.length - 1];
expect(lastData.flags & 0x1).toBe(0x1);

// There must be no trailer HEADERS frame emitted by the server on
// stream 1 after the response headers (would be a second HEADERS
// frame on the same stream).
const stream1Headers = frames.filter(f => f.type === 1 && f.streamId === 1);
expect(stream1Headers).toHaveLength(1);
} finally {
Comment thread
robobun marked this conversation as resolved.
server.close();
}
});

// Exercise the exact client→server path from the issue (curl prior
// knowledge succeeds, so Node's http2 client should too against Bun).
test("http2.connect client can read h2c response from http2.createServer (#29073)", async () => {
const server = http2.createServer((req, res) => {
res.setHeader("X-Foo", "bar");
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("ok");
});
await once(server.listen(0), "listening");
try {
const port = (server.address() as net.AddressInfo).port;
const client = http2.connect(`http://127.0.0.1:${port}`);
try {
const req = client.request({ ":path": "/" });
req.setEncoding("utf8");

const { promise, resolve, reject } = Promise.withResolvers<{ status: number; body: string }>();
let body = "";
let status = 0;
req.on("response", headers => {
status = headers[":status"] as number;
});
req.on("data", chunk => {
body += chunk;
});
req.on("end", () => resolve({ status, body }));
req.on("error", reject);
req.end();

const result = await promise;
expect(result).toEqual({ status: 200, body: "ok" });
} finally {
client.close();
}
} finally {
server.close();
}
});
Loading