Skip to content
Closed
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
39 changes: 32 additions & 7 deletions lib/_http_outgoing.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const {
ArrayIsArray,
MathFloor,
ObjectDefineProperty,
ObjectHasOwn,
ObjectKeys,
ObjectSetPrototypeOf,
ObjectValues,
Expand Down Expand Up @@ -534,10 +533,14 @@ function _storeHeader(firstLine, headers) {
}
}
} else {
for (const key in headers) {
if (ObjectHasOwn(headers, key)) {
processHeader(this, state, key, headers[key], true, lenient);
}
// ObjectKeys() yields the same own enumerable keys, in the same order,
// as for...in filtered by ObjectHasOwn(). Objects with many headers are
// in dictionary mode, where for...in re-checks every key with extra
// hash lookups.
const keys = ObjectKeys(headers);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
processHeader(this, state, key, headers[key], true, lenient);
}
}
}
Expand Down Expand Up @@ -713,8 +716,30 @@ function lowerOutgoingHeaderName(field) {
}

function matchHeader(self, state, field, value) {
if (field.length < 4 || field.length > 17)
return;
// Header names are validated tokens (ASCII), so a name can only match one
// of the fields below if its length and case-folded first letter do. This
// skips the toLowerCase() allocation for most other headers.
switch (field.length) {
case 4: // Date
case 6: // Expect
case 7: // Trailer
case 10: // Connection, Keep-Alive
case 14: // Content-Length
case 17: // Transfer-Encoding
break;
default:
return;
}
switch (field.charCodeAt(0) | 0x20) {
case 0x63: // C
case 0x64: // D
case 0x65: // E
case 0x6b: // K
case 0x74: // T
break;
default:
return;
}
field = lowerOutgoingHeaderName(field);
switch (field) {
case 'connection':
Expand Down
222 changes: 222 additions & 0 deletions test/parallel/test-http-write-head-object-headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const http = require('http');

// Verify how writeHead() renders a plain headers object, and that the fields
// that drive message framing are recognized in any casing while other names
// are emitted verbatim.

function createResponse({ sendDate = false, keepAliveTimeout = 0 } = {}) {
const res = new http.ServerResponse({
method: 'GET',
httpVersionMajor: 1,
httpVersionMinor: 1,
});
res.sendDate = sendDate;
res._keepAliveTimeout = keepAliveTimeout;
return res;
}

function render(headers, options) {
const res = createResponse(options);
res.writeHead(200, headers);
return res;
}

function headerLines(res) {
const lines = res._header.split('\r\n');
assert.strictEqual(lines.shift(), 'HTTP/1.1 200 OK');
// The header block ends with an empty line.
assert.deepStrictEqual(lines.splice(-2), ['', '']);
return lines;
}

// Only own enumerable string keys are emitted, in Object.keys() order.
{
const headers = { __proto__: { inherited: 'no' } };
headers['x-b'] = '1';
headers['x-a'] = '2';
headers[42] = '3';
Object.defineProperty(headers, 'hidden', { value: 'no', enumerable: false });
headers[Symbol('symbol')] = 'no';

assert.deepStrictEqual(headerLines(render(headers)), [
'42: 3',
'x-b: 1',
'x-a: 2',
'Connection: keep-alive',
'Transfer-Encoding: chunked',
]);
}

// Null-prototype objects work, and accessors are read exactly once.
{
let reads = 0;
const headers = { __proto__: null };
headers['x-a'] = '1';
Object.defineProperty(headers, 'x-getter', {
enumerable: true,
get() {
reads++;
return 'v';
},
});

assert.deepStrictEqual(headerLines(render(headers)), [
'x-a: 1',
'x-getter: v',
'Connection: keep-alive',
'Transfer-Encoding: chunked',
]);
assert.strictEqual(reads, 1);
}

// An object with many headers keeps their order, and a framing header among
// them is still honored.
{
const headers = {};
const expected = [];
for (let i = 0; i < 600; i++) {
if (i === 300) {
headers['Content-Length'] = '0';
expected.push('Content-Length: 0');
}
headers[`x-h${i}`] = `v${i}`;
expected.push(`x-h${i}: v${i}`);
}

const res = render(headers);
assert.deepStrictEqual(headerLines(res),
[...expected, 'Connection: keep-alive']);
assert.strictEqual(res.chunkedEncoding, false);
}

// Framing fields are recognized in any casing.
{
const res = render({ 'CONTENT-LENGTH': '5' });
assert.deepStrictEqual(headerLines(res),
['CONTENT-LENGTH: 5', 'Connection: keep-alive']);
assert.strictEqual(res.chunkedEncoding, false);
}
{
const res = render({ 'tRaNsFeR-eNcOdInG': 'chunked' });
assert.deepStrictEqual(headerLines(res),
['tRaNsFeR-eNcOdInG: chunked', 'Connection: keep-alive']);
assert.strictEqual(res.chunkedEncoding, true);
}
{
const res = render({ 'cOnNeCtIoN': 'close' });
assert.deepStrictEqual(headerLines(res),
['cOnNeCtIoN: close', 'Transfer-Encoding: chunked']);
assert.strictEqual(res._last, true);
}
{
// A user supplied Date suppresses the automatic one.
const res = render({ 'DATE': 'x' }, { sendDate: true });
assert.deepStrictEqual(headerLines(res), [
'DATE: x',
'Connection: keep-alive',
'Transfer-Encoding: chunked',
]);
}
{
// A user supplied Keep-Alive suppresses the automatic one.
const res = render({ 'kEeP-aLiVe': 'timeout=1' }, { keepAliveTimeout: 5000 });
assert.deepStrictEqual(headerLines(res), [
'kEeP-aLiVe: timeout=1',
'Connection: keep-alive',
'Transfer-Encoding: chunked',
]);
}
{
// Trailers require chunked encoding.
assert.throws(() => render({ 'TRAILER': 'x', 'Content-Length': '1' }), {
code: 'ERR_HTTP_TRAILER_INVALID',
});
}
{
// Expect flushes the header right away. There is no socket here, so it is
// queued on the response.
assert.strictEqual(render({ 'eXpEcT': '100-continue' }).outputData.length, 1);
}

// Names that share a framing field's length and first letter, or only its
// length, are emitted verbatim and do not affect framing.
{
const res = render({
'Content-Lengtx': '5',
'Xontent-Length': '5',
'Connectiom': 'close',
'Transfer-Encodinx': 'identity',
'Dat3': 'x',
'Expecx': '100-continue',
'Trailes': 'x',
'Keep-Alivx': 'timeout=1',
}, { sendDate: true, keepAliveTimeout: 5000 });

const lines = headerLines(res);
assert.deepStrictEqual(lines.slice(0, 8), [
'Content-Lengtx: 5',
'Xontent-Length: 5',
'Connectiom: close',
'Transfer-Encodinx: identity',
'Dat3: x',
'Expecx: 100-continue',
'Trailes: x',
'Keep-Alivx: timeout=1',
]);
assert.match(lines[8], /^Date: .+ GMT$/);
assert.deepStrictEqual(lines.slice(9), [
'Connection: keep-alive',
'Keep-Alive: timeout=5',
'Transfer-Encoding: chunked',
]);
assert.strictEqual(res.chunkedEncoding, true);
assert.strictEqual(res._last, false);
assert.strictEqual(res.outputData.length, 0);
}

// The same rules apply to headers set through setHeader().
{
const res = createResponse();
res.setHeader('CONTENT-LENGTH', '0');
res.setHeader('Content-Lengtx', '1');
res.writeHead(200);
assert.deepStrictEqual(headerLines(res), [
'CONTENT-LENGTH: 0',
'Content-Lengtx: 1',
'Connection: keep-alive',
]);
assert.strictEqual(res.chunkedEncoding, false);
}

// End to end: a response with many headers and an upper-case Content-Length.
{
const headers = {};
for (let i = 0; i < 600; i++) {
headers[`x-h${i}`] = `v${i}`;
}
headers['CONTENT-LENGTH'] = '5';

const server = http.createServer(common.mustCall((req, res) => {
res.writeHead(200, headers);
res.end('hello');
}));

server.listen(0, common.mustCall(() => {
http.get({ port: server.address().port }, common.mustCall((res) => {
assert.strictEqual(res.headers['x-h0'], 'v0');
assert.strictEqual(res.headers['x-h599'], 'v599');
assert.strictEqual(res.headers['content-length'], '5');
assert.strictEqual(res.headers['transfer-encoding'], undefined);
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => body += chunk);
res.on('end', common.mustCall(() => {
assert.strictEqual(body, 'hello');
server.close();
}));
}));
}));
}
Loading