Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8d3535d158 fix(http2): respect remote peer's SETTINGS_HEADER_TABLE_SIZE
When an HTTP/2 client sends SETTINGS_HEADER_TABLE_SIZE=0 to disable
the dynamic header table, Bun's HPACK encoder was ignoring this setting
and continuing to use dynamic table indices (62+). This caused errors
like "upstream sent invalid http2 table index: 67" when proxied through
nginx or other HTTP/2 clients that disable the dynamic table.

Per RFC 7540 Section 6.5.2, the encoder must respect the peer's header
table size setting. This fix:

1. Adds lshpack_wrapper_set_enc_max_capacity() in c-bindings.cpp to
   expose lshpack_enc_set_max_capacity() from the lshpack library
2. Exposes setEncMaxCapacity() in lshpack.zig
3. Updates h2_frame_parser.zig to call setEncMaxCapacity() when
   processing remote SETTINGS frames

Fixes #19152

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:19:51 +00:00
4 changed files with 250 additions and 0 deletions

View File

@@ -2493,6 +2493,11 @@ pub const H2FrameParser = struct {
}
this.readBuffer.reset();
this.remoteSettings = remoteSettings;
// Update HPACK encoder max capacity per RFC 7540 Section 6.5.2:
// The encoder must respect the peer's SETTINGS_HEADER_TABLE_SIZE
if (this.hpack) |hpack| {
hpack.setEncMaxCapacity(remoteSettings.headerTableSize);
}
log("remoteSettings.initialWindowSize: {} {} {}", .{ remoteSettings.initialWindowSize, this.remoteUsedWindowSize, this.remoteWindowSize });
if (remoteSettings.initialWindowSize >= this.remoteWindowSize) {
var it = this.streams.valueIterator();

View File

@@ -53,6 +53,11 @@ pub const HPACK = extern struct {
pub fn deinit(self: *HPACK) void {
lshpack_wrapper_deinit(self);
}
/// Update the encoder's max capacity (used when remote SETTINGS_HEADER_TABLE_SIZE changes)
pub fn setEncMaxCapacity(self: *HPACK, max_capacity: u32) void {
lshpack_wrapper_set_enc_max_capacity(self, max_capacity);
}
};
const lshpack_wrapper_alloc = ?*const fn (size: usize) callconv(.c) ?*anyopaque;
@@ -61,6 +66,7 @@ extern fn lshpack_wrapper_init(alloc: lshpack_wrapper_alloc, free: lshpack_wrapp
extern fn lshpack_wrapper_deinit(self: *HPACK) void;
extern fn lshpack_wrapper_decode(self: *HPACK, src: [*]const u8, src_len: usize, output: *lshpack_header) usize;
extern fn lshpack_wrapper_encode(self: *HPACK, name: [*]const u8, name_len: usize, value: [*]const u8, value_len: usize, never_index: c_int, buffer: [*]u8, buffer_len: usize, buffer_offset: usize) usize;
extern fn lshpack_wrapper_set_enc_max_capacity(self: *HPACK, max_capacity: c_uint) void;
const bun = @import("bun");
const mimalloc = bun.mimalloc;

View File

@@ -337,6 +337,11 @@ void lshpack_wrapper_deinit(lshpack_wrapper* self)
lshpack_enc_cleanup(&self->enc);
self->free(self);
}
void lshpack_wrapper_set_enc_max_capacity(lshpack_wrapper* self, unsigned max_capacity)
{
lshpack_enc_set_max_capacity(&self->enc, max_capacity);
}
}
#if OS(LINUX)

View File

@@ -0,0 +1,234 @@
/**
* Test for GitHub Issue #19152: Bun HTTP/2 server ignores SETTINGS_HEADER_TABLE_SIZE=0
*
* When an HTTP/2 client sends SETTINGS_HEADER_TABLE_SIZE=0 to disable the dynamic
* header table, Bun's HPACK encoder must not use dynamic table indices (62+).
* Per RFC 7540 Section 6.5.2, the encoder must respect the peer's header table size.
*
* This test verifies that when Bun acts as a server and receives SETTINGS_HEADER_TABLE_SIZE=0,
* it correctly updates its HPACK encoder to not use the dynamic table.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir, tls } from "harness";
import http2 from "node:http2";
import path from "node:path";
describe("HTTP/2 SETTINGS_HEADER_TABLE_SIZE=0", () => {
test("server respects client's SETTINGS_HEADER_TABLE_SIZE=0", async () => {
// Create a fixture server script that will be run with Bun
using dir = tempDir("http2-settings", {
"server.ts": `
import http2 from "node:http2";
const tlsCert = JSON.parse(process.argv[2]);
const server = http2.createSecureServer({
...tlsCert,
rejectUnauthorized: false,
});
server.on("stream", (stream, headers) => {
// Return a response with multiple custom headers
// Without the fix, these will be encoded with dynamic table indices
stream.respond({
"content-type": "text/plain",
":status": 200,
"x-custom-header-1": "value1",
"x-custom-header-2": "value2",
"x-custom-header-3": "value3",
"x-another-header": "another-value",
"x-test-header": "test-value",
});
stream.end("Hello");
});
server.on("error", (err) => {
console.error("Server error:", err);
process.exit(1);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
console.log(JSON.stringify({ port: addr.port }));
});
`,
});
// Start the server using Bun (or the current runtime under test)
const serverProcess = Bun.spawn([bunExe(), path.join(String(dir), "server.ts"), JSON.stringify(tls)], {
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
// Read the port from stdout
const reader = serverProcess.stdout.getReader();
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
const { port } = JSON.parse(text);
try {
// Connect to the server with headerTableSize: 0
// This tells the server to NOT use dynamic table indices in responses
const { promise, resolve, reject } = Promise.withResolvers<{
headers: http2.IncomingHttpHeaders;
data: string;
}>();
const client = http2.connect(`https://127.0.0.1:${port}`, {
rejectUnauthorized: false,
settings: {
headerTableSize: 0, // Disable dynamic table - server must respect this
enablePush: false,
},
});
client.on("error", reject);
const req = client.request({ ":path": "/" });
let responseHeaders: http2.IncomingHttpHeaders = {};
req.on("response", headers => {
responseHeaders = headers;
});
let data = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
data += chunk;
});
req.on("end", () => {
client.close();
resolve({ headers: responseHeaders, data });
});
req.on("error", reject);
req.end();
// If the bug exists, this will fail because the client can't decode
// headers that use dynamic table indices (since headerTableSize: 0)
const result = await promise;
// Verify we got the response correctly
expect(result.headers[":status"]).toBe(200);
expect(result.headers["content-type"]).toBe("text/plain");
expect(result.headers["x-custom-header-1"]).toBe("value1");
expect(result.headers["x-custom-header-2"]).toBe("value2");
expect(result.headers["x-custom-header-3"]).toBe("value3");
expect(result.headers["x-another-header"]).toBe("another-value");
expect(result.headers["x-test-header"]).toBe("test-value");
expect(result.data).toBe("Hello");
} finally {
serverProcess.kill();
}
});
// Additional test: Make multiple requests to verify dynamic table isn't used
test("server respects SETTINGS_HEADER_TABLE_SIZE=0 across multiple requests", async () => {
// Create a fixture server script that will be run with Bun
using dir = tempDir("http2-settings-multi", {
"server.ts": `
import http2 from "node:http2";
const tlsCert = JSON.parse(process.argv[2]);
let requestCount = 0;
const server = http2.createSecureServer({
...tlsCert,
rejectUnauthorized: false,
});
server.on("stream", (stream, headers) => {
requestCount++;
// Return headers that would normally be added to dynamic table
stream.respond({
"content-type": "application/json",
":status": 200,
"x-request-id": \`request-\${requestCount}\`,
"x-custom-value": "same-value-each-time",
});
stream.end(JSON.stringify({ count: requestCount }));
});
server.on("error", (err) => {
console.error("Server error:", err);
process.exit(1);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
console.log(JSON.stringify({ port: addr.port }));
});
`,
});
// Start the server using Bun
const serverProcess = Bun.spawn([bunExe(), path.join(String(dir), "server.ts"), JSON.stringify(tls)], {
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
// Read the port from stdout
const reader = serverProcess.stdout.getReader();
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
const { port } = JSON.parse(text);
try {
const client = http2.connect(`https://127.0.0.1:${port}`, {
rejectUnauthorized: false,
settings: {
headerTableSize: 0, // Disable dynamic table
enablePush: false,
},
});
// Make multiple requests - if dynamic table was being used incorrectly,
// later requests would fail because they'd reference indices that don't exist
for (let i = 1; i <= 3; i++) {
const { promise, resolve, reject } = Promise.withResolvers<{
headers: http2.IncomingHttpHeaders;
data: string;
}>();
const req = client.request({ ":path": "/" });
let responseHeaders: http2.IncomingHttpHeaders = {};
req.on("response", headers => {
responseHeaders = headers;
});
let data = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
data += chunk;
});
req.on("end", () => {
resolve({ headers: responseHeaders, data });
});
req.on("error", reject);
req.end();
const result = await promise;
expect(result.headers[":status"]).toBe(200);
expect(result.headers["x-request-id"]).toBe(`request-${i}`);
expect(result.headers["x-custom-value"]).toBe("same-value-each-time");
const parsed = JSON.parse(result.data);
expect(parsed.count).toBe(i);
}
client.close();
} finally {
serverProcess.kill();
}
});
});