diff --git a/src/ant.c b/src/ant.c index 5db23e0..070ba84 100644 --- a/src/ant.c +++ b/src/ant.c @@ -8323,9 +8323,17 @@ static ant_value_t builtin_object_defineProperty(ant_t *js, ant_value_t *args, i } if (has_existing_prop) { - bool existing_nonconfig = has_existing_meta - ? !existing_meta.configurable - : ((existing_off > 0 && is_nonconfig_prop(js, existing_off)) || (obj_ptr && obj_ptr->frozen)); + bool existing_accessor = has_existing_meta && (existing_meta.has_getter || existing_meta.has_setter); + bool new_accessor = has_get || has_set; + bool new_data = has_value || has_writable; + bool existing_nonconfig = + (has_existing_meta && !existing_meta.configurable) || + (existing_off > 0 && is_nonconfig_prop(js, existing_off)) || + (obj_ptr && (obj_ptr->sealed || obj_ptr->frozen)); + bool existing_readonly = + (has_existing_meta && !existing_accessor && !existing_meta.writable) || + (existing_off > 0 && is_const_prop(js, existing_off)) || + (obj_ptr && obj_ptr->frozen); if (existing_nonconfig) { if (has_configurable && configurable) return js_mkerr(js, "Cannot redefine property %.*s: cannot change configurable from false to true", @@ -8341,6 +8349,39 @@ static ant_value_t builtin_object_defineProperty(ant_t *js, ant_value_t *args, i "Cannot redefine property %.*s: cannot change writable from false to true", (int)prop_len, prop_str ); + if (has_existing_meta && existing_accessor && new_data) + return js_mkerr(js, + "Cannot redefine property %.*s: cannot convert accessor property to data property", + (int)prop_len, prop_str + ); + if (has_existing_meta && !existing_accessor && new_accessor) + return js_mkerr(js, + "Cannot redefine property %.*s: cannot convert data property to accessor property", + (int)prop_len, prop_str + ); + if (has_existing_meta && existing_accessor && has_get) { + ant_value_t existing_getter = existing_meta.has_getter ? existing_meta.getter : js_mkundef(); + if (getter_val != existing_getter) + return js_mkerr(js, + "Cannot redefine property %.*s: cannot replace getter of a non-configurable property", + (int)prop_len, prop_str + ); + } + if (has_existing_meta && existing_accessor && has_set) { + ant_value_t existing_setter = existing_meta.has_setter ? existing_meta.setter : js_mkundef(); + if (setter_val != existing_setter) + return js_mkerr(js, + "Cannot redefine property %.*s: cannot replace setter of a non-configurable property", + (int)prop_len, prop_str + ); + } + if (existing_readonly && has_writable && writable) + return js_mkerr(js, + "Cannot redefine property %.*s: cannot change writable from false to true", + (int)prop_len, prop_str + ); + if (existing_readonly && has_value) + return js_mkerr(js, "Cannot assign to read-only property '%.*s'", (int)prop_len, prop_str); } } diff --git a/src/builtins/node/http.mjs b/src/builtins/node/http.mjs index a02dcd1..46cd4f3 100644 --- a/src/builtins/node/http.mjs +++ b/src/builtins/node/http.mjs @@ -158,6 +158,7 @@ function hostIncludesExplicitPort(host) { const value = String(host); const bracketEnd = value.lastIndexOf(']'); const colonIndex = value.lastIndexOf(':'); + if (!value.startsWith('[') && value.indexOf(':') !== colonIndex) return false; return colonIndex > bracketEnd; } @@ -251,6 +252,102 @@ function getFetchBody(chunks) { return Buffer.concat(chunks); } +function bodyLengthFromHeaders(headers) { + const value = headers && headers['content-length']; + if (value === undefined || value === null) return null; + const length = Number(value); + return Number.isFinite(length) && length >= 0 ? Math.trunc(length) : null; +} + +function createSocketBodyReader(socket, initialBody, expectedLength, onDone) { + const queue = []; + let pending = null; + let received = 0; + let ended = false; + let failure = null; + + const cleanup = () => { + socket.removeListener('data', onData); + socket.removeListener('end', onEnd); + socket.removeListener('close', onEnd); + socket.removeListener('error', onError); + }; + + const finish = () => { + if (ended) return; + ended = true; + cleanup(); + if (typeof onDone === 'function') onDone(); + settlePending(); + }; + + const fail = error => { + if (ended) return; + failure = error; + ended = true; + cleanup(); + if (typeof onDone === 'function') onDone(); + settlePending(); + }; + + const push = chunk => { + if (ended) return; + let bodyChunk = bufferFrom(chunk); + if (bodyChunk.length === 0) return; + + if (expectedLength !== null) { + const remaining = expectedLength - received; + if (remaining <= 0) return finish(); + if (bodyChunk.length > remaining) bodyChunk = bodyChunk.subarray(0, remaining); + } + + received += bodyChunk.length; + queue.push(bodyChunk); + if (expectedLength !== null && received >= expectedLength) finish(); + else settlePending(); + }; + + function settlePending() { + if (!pending) return; + const { resolve, reject } = pending; + pending = null; + + if (queue.length > 0) { + resolve({ done: false, value: queue.shift() }); + return; + } + if (failure) reject(failure); + else if (ended) resolve({ done: true, value: undefined }); + else pending = { resolve, reject }; + } + + const onData = chunk => push(chunk); + const onEnd = () => finish(); + const onError = error => fail(error); + + socket.on('data', onData); + socket.on('end', onEnd); + socket.on('close', onEnd); + socket.on('error', onError); + if (expectedLength === 0) finish(); + else push(initialBody); + + return { + read() { + if (queue.length > 0) return Promise.resolve({ done: false, value: queue.shift() }); + if (failure) return Promise.reject(failure); + if (ended) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => { + pending = { resolve, reject }; + }); + }, + cancel(reason) { + fail(reason instanceof Error ? reason : new Error('socket body reader cancelled')); + return Promise.resolve(); + } + }; +} + function hasUpgradeHeader(headers) { const value = headers && headers.upgrade; return value !== undefined && value !== null; @@ -261,12 +358,15 @@ function formatHostHeader(options) { const port = options.port; const protocol = options.protocol || 'http:'; const defaultPort = defaultPortForProtocol(protocol); + const hostText = String(host); + const hasExplicitPort = hostIncludesExplicitPort(hostText); + const isUnbracketedIpv6Literal = hostText.includes(':') && !hostText.startsWith('[') && !hasExplicitPort; - if (hostIncludesExplicitPort(host) || port === undefined || port === null || port === '' || Number(port) === defaultPort) { - return String(host); + if (hasExplicitPort || port === undefined || port === null || port === '' || Number(port) === defaultPort) { + return hostText; } - return `${host}:${port}`; + return isUnbracketedIpv6Literal ? `[${hostText}]:${port}` : `${hostText}:${port}`; } function buildRawRequestHeaders(options, headers) { @@ -546,11 +646,7 @@ export class ClientRequest extends OutgoingMessage { ALPNProtocols: this.ALPNProtocols }; const createConnection = - typeof this.createConnection === 'function' - ? this.createConnection - : this.protocol === 'https:' - ? tls.connect - : net.connect; + typeof this.createConnection === 'function' ? this.createConnection : this.protocol === 'https:' ? tls.connect : net.connect; let socket; try { @@ -588,27 +684,37 @@ export class ClientRequest extends OutgoingMessage { socket.removeListener('secureConnect', onConnect); }; - const fail = (error) => { + const fail = error => { this._clearTimeoutTimer(); if (this.destroyed) return; this.destroyed = true; cleanup(); + this._upgradeBuffer = Buffer.alloc(0); + closeSocket(socket); this.emit('error', error); this._emitClose(); }; - const onError = (error) => fail(error); + const onError = error => fail(error); const onEnd = () => fail(new Error('socket hang up')); const onClose = () => { if (!this.destroyed) fail(new Error('socket hang up')); }; - const onData = (chunk) => { + const onData = chunk => { if (this.destroyed) return; this._upgradeBuffer = appendSocketChunk(this._upgradeBuffer, chunk); const headerEnd = this._upgradeBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1 && this._upgradeBuffer.length > maxHeaderSize) { + fail(new Error('HTTP response header exceeded maxHeaderSize')); + return; + } if (headerEnd === -1) return; + if (headerEnd + 4 > maxHeaderSize) { + fail(new Error('HTTP response header exceeded maxHeaderSize')); + return; + } const headText = this._upgradeBuffer.subarray(0, headerEnd).toString('latin1'); const rest = this._upgradeBuffer.subarray(headerEnd + 4); @@ -627,18 +733,20 @@ export class ClientRequest extends OutgoingMessage { if (response.statusCode === 101) { this.emit('upgrade', response, socket, rest); } else { + const bodyLength = bodyLengthFromHeaders(response.headers); const incoming = new FetchIncomingMessage({ status: response.statusCode, statusText: response.statusMessage, headers: new Map(Object.entries(response.headers)), - body: null, + body: { + getReader: () => createSocketBodyReader(socket, rest, bodyLength, () => closeSocket(socket)) + }, url: this._requestUrl }); incoming.rawHeaders = response.rawHeaders; incoming.httpVersion = response.httpVersion; incoming.httpVersionMajor = response.httpVersionMajor; incoming.httpVersionMinor = response.httpVersionMinor; - socket.destroy(); this.emit('response', incoming); incoming._pumpBody(); } diff --git a/tests/test_node_http_host_header_ipv6.cjs b/tests/test_node_http_host_header_ipv6.cjs new file mode 100644 index 0000000..7b6e314 --- /dev/null +++ b/tests/test_node_http_host_header_ipv6.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert'); +const http = require('node:http'); +const net = require('node:net'); + +let observedHostHeader; + +const server = net.createServer((socket) => { + socket.once('data', (chunk) => { + const request = chunk.toString('latin1'); + const match = /^Host: ([^\r\n]*)/im.exec(request); + observedHostHeader = match ? match[1] : undefined; + + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Connection: Upgrade\r\n' + + 'Upgrade: test\r\n' + + '\r\n' + ); + }); +}); + +server.listen(0, '::1', () => { + const { port } = server.address(); + const req = http.request({ + host: '::1', + port, + path: '/', + headers: { + Connection: 'Upgrade', + Upgrade: 'test', + }, + }); + + req.on('upgrade', (_res, socket) => { + assert.strictEqual(observedHostHeader, `[::1]:${port}`); + socket.destroy(); + server.close(() => { + console.log('node-http:ipv6-host-header:ok'); + }); + }); + + req.on('error', (error) => { + server.close(() => { + throw error; + }); + }); + + req.end(); +}); diff --git a/tests/test_node_http_upgrade_max_header_size.cjs b/tests/test_node_http_upgrade_max_header_size.cjs new file mode 100644 index 0000000..cc8c3db --- /dev/null +++ b/tests/test_node_http_upgrade_max_header_size.cjs @@ -0,0 +1,60 @@ +const assert = require('node:assert'); +const http = require('node:http'); +const net = require('node:net'); + +let serverSocketClosed = false; +let sawClientError = false; + +function finish() { + if (!sawClientError || !serverSocketClosed) return; + clearTimeout(timeout); + server.close(() => { + console.log('node-http:upgrade-max-header-size:ok'); + }); +} + +const server = net.createServer((socket) => { + socket.on('close', () => { + serverSocketClosed = true; + finish(); + }); + + socket.once('data', () => { + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + `X-Fill: ${'a'.repeat(http.maxHeaderSize)}\r\n` + + '\r\n' + ); + }); +}); + +const timeout = setTimeout(() => { + server.close(() => { + throw new Error('timed out waiting for oversized upgrade header failure'); + }); +}, 2000); + +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + const req = http.request({ + host: '127.0.0.1', + port, + path: '/', + headers: { + Connection: 'Upgrade', + Upgrade: 'test', + }, + }); + + req.on('upgrade', () => { + throw new Error('unexpected upgrade for oversized response header'); + }); + + req.on('error', (error) => { + assert.match(error.message, /maxHeaderSize|header/i); + sawClientError = true; + finish(); + }); + + req.end(); +}); diff --git a/tests/test_node_http_upgrade_non101_body.cjs b/tests/test_node_http_upgrade_non101_body.cjs new file mode 100644 index 0000000..e4839ab --- /dev/null +++ b/tests/test_node_http_upgrade_non101_body.cjs @@ -0,0 +1,63 @@ +const assert = require('node:assert'); +const http = require('node:http'); +const net = require('node:net'); + +const server = net.createServer((socket) => { + socket.once('data', () => { + socket.write( + 'HTTP/1.1 400 Bad Request\r\n' + + 'Content-Length: 10\r\n' + + '\r\n' + + 'hello' + ); + setTimeout(() => { + socket.write('world'); + }, 0); + }); +}); + +const timeout = setTimeout(() => { + server.close(() => { + throw new Error('timed out waiting for non-101 upgrade response body'); + }); +}, 2000); + +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + const chunks = []; + const req = http.request({ + host: '127.0.0.1', + port, + path: '/', + headers: { + Connection: 'Upgrade', + Upgrade: 'test', + }, + }); + + req.on('upgrade', () => { + throw new Error('unexpected upgrade for non-101 response'); + }); + + req.on('response', (incoming) => { + assert.strictEqual(incoming.statusCode, 400); + incoming.on('data', chunk => chunks.push(Buffer.from(chunk))); + incoming.on('end', () => { + assert.strictEqual(Buffer.concat(chunks).toString('utf8'), 'helloworld'); + if (incoming.socket && typeof incoming.socket.destroy === 'function') incoming.socket.destroy(); + clearTimeout(timeout); + server.close(() => { + console.log('node-http:upgrade-non-101-body:ok'); + }); + }); + incoming.resume(); + }); + + req.on('error', (error) => { + server.close(() => { + throw error; + }); + }); + + req.end(); +}); diff --git a/tests/test_object_define_property_generic_descriptor.cjs b/tests/test_object_define_property_generic_descriptor.cjs index cb4e83f..005d5b1 100644 --- a/tests/test_object_define_property_generic_descriptor.cjs +++ b/tests/test_object_define_property_generic_descriptor.cjs @@ -55,4 +55,34 @@ function assertThrowsTypeError(fn) { assertThrowsTypeError(() => Object.defineProperty(locked, 'x', { configurable: true })); assertThrowsTypeError(() => Object.defineProperty(locked, lockedSym, { configurable: true })); +const accessorLocked = {}; +function originalGetter() { return 1; } +function replacementGetter() { return 2; } +function originalSetter(value) { this.value = value; } +function replacementSetter(value) { this.value = value + 1; } + +Object.defineProperty(accessorLocked, 'x', { + get: originalGetter, + set: originalSetter, + configurable: false +}); + +Object.defineProperty(accessorLocked, 'x', { get: originalGetter }); +Object.defineProperty(accessorLocked, 'x', { set: originalSetter }); +assertThrowsTypeError(() => Object.defineProperty(accessorLocked, 'x', { get: replacementGetter })); +assertThrowsTypeError(() => Object.defineProperty(accessorLocked, 'x', { set: replacementSetter })); +assertThrowsTypeError(() => Object.defineProperty(accessorLocked, 'x', { value: 1 })); + +const dataLocked = {}; +Object.defineProperty(dataLocked, 'x', { value: 1, configurable: false }); +assertThrowsTypeError(() => Object.defineProperty(dataLocked, 'x', { get: originalGetter })); + +const accessorLockedSym = Symbol('locked-accessor'); +Object.defineProperty(locked, accessorLockedSym, { + get: originalGetter, + configurable: false +}); +assertThrowsTypeError(() => Object.defineProperty(locked, accessorLockedSym, { get: replacementGetter })); +assertThrowsTypeError(() => Object.defineProperty(locked, accessorLockedSym, { value: 1 })); + console.log('object-define-property-generic-descriptor:ok');