Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/js/node/http2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3425,6 +3425,8 @@ class ServerHttp2Stream extends Http2Stream {
}
headers = { ...headers };
assertNoConnectionHeaders(headers);
// Thrown synchronously like node, before a promised stream id is reserved.
if (session[kStrictSingleValueFields] !== false) assertSingleValueHeaders(headers);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const sensitives = headers[sensitiveHeaders];
// Note: the sensitiveHeaders symbol stays on the object — the native header walk skips
// symbol keys, and deleting it here would flip the object into dictionary mode,
Expand Down
113 changes: 87 additions & 26 deletions src/runtime/api/bun/h2_frame_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8381,6 +8381,7 @@ impl H2FrameParser {

let mut name_buffer = [0u8; 4096];
let mut encoded_headers: Vec<u8> = Vec::new();
let mut single_value_headers = [false; SINGLE_VALUE_HEADERS_LEN];

// A PUSH_PROMISE carries a REQUEST, so request pseudo-headers are valid even on the server.
// Pseudo-headers must be encoded first, so iterate twice.
Expand Down Expand Up @@ -8438,7 +8439,6 @@ impl H2FrameParser {
if js_value.is_empty_or_undefined_or_null() {
continue;
}
let value_str = js_value.to_js_string(global_object)?;
// All-digit names can't be passed to get_truthy (integer-index-like names trip
// a debug assert in getIfPropertyExistsImpl) and can never be sensitive.
let never_index = if Self::is_index_like_name(validated_name) {
Expand All @@ -8449,32 +8449,93 @@ impl H2FrameParser {
None => sensitive_arg.get_truthy(global_object, name)?.is_some(),
}
};
let value_slice = value_str.to_slice(global_object);
let value = value_slice.slice();
if !is_valid_header_value(value) {
return Err(global_object
.err(
JscErrorCode::HTTP2_INVALID_HEADER_VALUE,
format_args!(
"Invalid value for header \"{}\"",
BStr::new(validated_name)
),
let mut encode_value = |item: JSValue| -> JsResult<Option<JSValue>> {
let value_str = item.to_js_string(global_object)?;
let value_slice = value_str.to_slice(global_object);
let value = value_slice.slice();
if !is_valid_header_value(value) {
return Err(global_object
.err(
JscErrorCode::HTTP2_INVALID_HEADER_VALUE,
format_args!(
"Invalid value for header \"{}\"",
BStr::new(validated_name)
),
)
.throw());
}
bun_output::scoped_log!(
H2FrameParser,
"encode header {} {}",
BStr::new(validated_name),
BStr::new(value)
);
if this
.encode_header_into_list(
&mut encoded_headers,
validated_name,
value,
never_index,
)
.throw());
}
if this
.encode_header_into_list(
&mut encoded_headers,
validated_name,
value,
never_index,
)
.is_err()
{
// Same as the request/respond encode failures: nghttp2 fails the whole
// session, and node never surfaces this through the pushStream callback.
this.schedule_header_compression_session_error();
return Ok(JSValue::js_number(-1.0));
.is_err()
{
// Same as the request/respond encode failures: nghttp2 fails the whole
// session, and node never surfaces this through the pushStream callback.
Comment thread
robobun marked this conversation as resolved.
this.schedule_header_compression_session_error();
return Ok(Some(JSValue::js_number(-1.0)));
}
Ok(None)
};
if js_value.js_type().is_array() {
let mut value_iter = js_value.array_iterator(global_object)?;
if let Some(idx) = this.single_value_index_checked(validated_name) {
if value_iter.len > 1 || single_value_headers[idx] {
return Err(global_object
.err(
JscErrorCode::HTTP2_HEADER_SINGLE_VALUE,
format_args!(
"Header field \"{}\" must only have a single value",
BStr::new(validated_name)
),
)
.throw());
}
single_value_headers[idx] = true;
}
while let Some(item) = value_iter.next()? {
if item.is_empty_or_undefined_or_null() {
return Err(global_object
.err(
JscErrorCode::HTTP2_INVALID_HEADER_VALUE,
format_args!(
"Invalid value for header \"{}\"",
BStr::new(validated_name)
),
)
.throw());
}
if let Some(ret) = encode_value(item)? {
return Ok(ret);
}
}
} else {
if let Some(idx) = this.single_value_index_checked(validated_name) {
if single_value_headers[idx] {
return Err(global_object
.err(
JscErrorCode::HTTP2_HEADER_SINGLE_VALUE,
format_args!(
"Header field \"{}\" must only have a single value",
BStr::new(validated_name)
),
)
.throw());
}
single_value_headers[idx] = true;
}
if let Some(ret) = encode_value(js_value)? {
return Ok(ret);
}
}
}
}
Expand Down
209 changes: 209 additions & 0 deletions test/js/node/http2/node-http2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3584,6 +3584,215 @@ it("http2 pushStream failure reports only via callback, never via stream 'error'
}
});

// Serves a single request with `onStream` (which must push synchronously and then respond) and
// resolves with the PUSH_PROMISE header blocks the client received, in wire order: `fields` is the
// flat [name, value, ...] list as decoded from the wire, `headers` the object form, both without
// pseudo-headers. Every PUSH_PROMISE precedes the parent's response on the wire, so once the parent
// response has ended every push has been observed.
async function pushedHeaderBlocks(onStream, serverOptions) {
// Every failure on either side, from listen() onwards, rejects this one promise.
const { promise, resolve, reject } = Promise.withResolvers();
const server = http2.createServer(serverOptions);
server.on("error", reject);
server.on("sessionError", reject);
server.on("stream", onStream);
let client;
try {
await Promise.race([promise, new Promise(listening => server.listen(0, "127.0.0.1", listening))]);
client = http2.connect(`http://127.0.0.1:${server.address().port}`);
client.on("error", reject);
const blocks = [];
client.on("stream", (pushed, headers, _flags, rawHeaders) => {
pushed.on("error", reject);
pushed.resume();
const fields = [];
for (let i = 0; i < rawHeaders.length; i += 2) {
if (!rawHeaders[i].startsWith(":")) fields.push(rawHeaders[i], rawHeaders[i + 1]);
}
blocks.push({
id: pushed.id,
path: headers[":path"],
sensitive: headers[http2.sensitiveHeaders],
headers: Object.fromEntries(Object.entries(headers).filter(([name]) => !name.startsWith(":"))),
fields,
});
});
const req = client.request({ ":path": "/" });
req.on("error", reject);
req.on("end", resolve);
req.resume();
await promise;
return blocks;
} finally {
client?.close();
server.close();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

it("http2 pushStream sends an array-valued header as one field per element", async () => {
const blocks = await pushedHeaderBlocks(stream => {
stream.pushStream(
{
":path": ["/pushed"],
"set-cookie": ["c1=1", "c2=2"],
"x-multi": ["a", "b"],
"x-number": [1, 2],
"x-one": ["only"],
"x-none": [],
"content-type": [],
"x-plain": "p",
},
(err, push) => {
if (err) {
stream.destroy(err);
return;
}
push.respond({ ":status": 200 });
push.end();
},
);
stream.respond({ ":status": 200 });
stream.end();
});

expect(blocks.length).toBe(1);
const [block] = blocks;
expect(block.path).toBe("/pushed");
expect(block.fields).toEqual([
...["set-cookie", "c1=1", "set-cookie", "c2=2"],
...["x-multi", "a", "x-multi", "b"],
...["x-number", "1", "x-number", "2"],
...["x-one", "only"],
...["x-plain", "p"],
]);
// The object form a receiver builds from those fields: set-cookie stays a list, other repeated
// fields are joined with ", ", and an empty array (single-value field or not) sends nothing.
expect(block.headers).toEqual({
"set-cookie": ["c1=1", "c2=2"],
"x-multi": "a, b",
"x-number": "1, 2",
"x-one": "only",
"x-plain": "p",
});
});

it("http2 pushStream never-indexes every element of a sensitive array-valued header", async () => {
const blocks = await pushedHeaderBlocks(stream => {
stream.pushStream(
{ ":path": "/pushed", "x-secret": ["s1", "s2"], [http2.sensitiveHeaders]: ["x-secret"] },
(err, push) => {
if (err) {
stream.destroy(err);
return;
}
push.respond({ ":status": 200 });
push.end();
},
);
stream.respond({ ":status": 200 });
stream.end();
});

// The receiver lists a name once per field that arrived with the never-index flag.
expect(blocks.map(({ headers, sensitive }) => ({ secret: headers["x-secret"], sensitive }))).toEqual([
{ secret: "s1, s2", sensitive: ["x-secret", "x-secret"] },
]);
});

it("http2 pushStream throws ERR_HTTP2_HEADER_SINGLE_VALUE synchronously without reserving a stream", async () => {
const attempts = [
{ ":path": "/pushed", "content-type": ["text/plain", "text/html"] },
{ ":path": ["/a", "/b"] },
{ ":path": "/pushed", "content-type": "text/plain", "Content-Type": "text/html" },
];
const thrown = [];
let rejectedCallbackCalls = 0;
const blocks = await pushedHeaderBlocks(stream => {
for (const headers of attempts) {
try {
stream.pushStream(headers, () => rejectedCallbackCalls++);
thrown.push(null);
} catch (err) {
thrown.push({ name: err.constructor.name, code: err.code, message: err.message });
}
}
stream.pushStream({ ":path": "/after" }, (err, push) => {
if (err) {
stream.destroy(err);
return;
}
push.respond({ ":status": 200 });
push.end();
});
stream.respond({ ":status": 200 });
stream.end();
});

const singleValue = name => ({
name: "TypeError",
code: "ERR_HTTP2_HEADER_SINGLE_VALUE",
message: `Header field "${name}" must only have a single value`,
});
expect(thrown).toEqual([singleValue("content-type"), singleValue(":path"), singleValue("content-type")]);
expect(rejectedCallbackCalls).toBe(0);
// The rejected attempts reserved nothing: the push that followed them got the first even id.
expect(blocks.map(({ id, path }) => ({ id, path }))).toEqual([{ id: 2, path: "/after" }]);
});

it("http2 pushStream sends each element of a single-value header when strictSingleValueFields is off", async () => {
const blocks = await pushedHeaderBlocks(
stream => {
stream.pushStream({ ":path": "/pushed", "content-type": ["text/plain", "text/html"] }, (err, push) => {
if (err) {
stream.destroy(err);
return;
}
push.respond({ ":status": 200 });
push.end();
});
stream.respond({ ":status": 200 });
stream.end();
},
{ strictSingleValueFields: false },
);

expect(blocks.map(({ path, fields }) => ({ path, fields }))).toEqual([
{ path: "/pushed", fields: ["content-type", "text/plain", "content-type", "text/html"] },
]);
});

it("http2 pushStream reports an unsendable array element through the callback", async () => {
const results = [];
const blocks = await pushedHeaderBlocks(stream => {
const values = [
["ok", "a\nb"],
["ok", null],
];
let pending = values.length;
for (const value of values) {
stream.pushStream({ ":path": "/pushed", "x-custom": value }, (err, push) => {
results.push(err ? { name: err.constructor.name, code: err.code, message: err.message } : "pushed");
if (push) {
push.respond({ ":status": 200 });
push.end();
}
if (--pending === 0) {
stream.respond({ ":status": 200 });
stream.end();
}
});
}
});

const invalidValue = {
name: "TypeError",
code: "ERR_HTTP2_INVALID_HEADER_VALUE",
message: 'Invalid value for header "x-custom"',
};
expect(results).toEqual([invalidValue, invalidValue]);
expect(blocks).toEqual([]);
});

it("http2 option range error messages use the options. prefix", () => {
for (const opt of ["maxSessionInvalidFrames", "maxSessionRejectedStreams", "unknownProtocolTimeout"]) {
let error;
Expand Down