From 5db8a0de4e54452a1df8ace3ec9f318a5529fe62 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Sat, 3 May 2025 13:27:15 +0900 Subject: [PATCH 01/13] [draft] syncPolicy, MIME support, and more --- lib/XMLHttpRequest.js | 638 +++++++++++++++++++----------------- tests/server.js | 39 ++- tests/test-constants.js | 6 + tests/test-data-uri.js | 19 +- tests/test-headers.js | 12 +- tests/test-keepalive.js | 2 +- tests/test-max-redirects.js | 2 +- tests/test-mimetype.js | 157 +++++++++ tests/test-redirect-301.js | 2 +- tests/test-redirect-302.js | 2 +- tests/test-redirect-303.js | 2 +- tests/test-redirect-307.js | 2 +- tests/test-redirect-308.js | 2 +- tests/test-sync-flag.js | 107 ++++++ tests/test-sync-response.js | 19 +- 15 files changed, 667 insertions(+), 344 deletions(-) create mode 100644 tests/test-mimetype.js create mode 100644 tests/test-sync-flag.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 8bbc904..f45b04c 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -12,7 +12,31 @@ */ var fs = require('fs'); +var os = require('os'); +var path = require('path'); var spawn = require('child_process').spawn; +/** + * Constants + */ + +var stateConstants = { + UNSENT: 0, + OPENED: 1, + HEADERS_RECEIVED: 2, + LOADING: 3, + DONE: 4 +}; + +var assignStateConstants = function (object) { + for (let stateKey in stateConstants) Object.defineProperty(object, stateKey, { + enumerable: true, + writable: false, + configurable: false, + value: stateConstants[stateKey] + }); +} + +assignStateConstants(XMLHttpRequest); /** * Module exports. @@ -36,6 +60,10 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; function XMLHttpRequest(opts) { "use strict"; + if (!new.target) { + throw new TypeError("Failed to construct 'XMLHttpRequest': Please use the 'new' operator, this object constructor cannot be called as a function."); + } + // defines a list of default options to prevent parameters pollution var default_options = { pfx: undefined, @@ -49,11 +77,16 @@ function XMLHttpRequest(opts) { agent: undefined, allowFileSystemResources: true, maxRedirects: 20, // Chrome standard + syncPolicy: "warn", origin: undefined }; opts = Object.assign(default_options, opts); + if (opts.syncPolicy !== "warn" && opts.syncPolicy !== "disabled" && opts.syncPolicy !== "enabled") { + opts.syncPolicy = "warn"; + } + var sslOptions = { pfx: opts.pfx, key: opts.key, @@ -84,9 +117,7 @@ function XMLHttpRequest(opts) { // Request settings var settings = {}; - // Disable header blacklist. - // Not part of XHR specs. - var disableHeaderCheck = false; + assignStateConstants(this); // Set some default headers var defaultHeaders = { @@ -135,25 +166,24 @@ function XMLHttpRequest(opts) { var errorFlag = false; var abortedFlag = false; + // Custom encoding (if user called via xhr.overrideMimeType) + var customEncoding = ""; + // Event listeners var listeners = {}; - /** - * Constants - */ - - this.UNSENT = 0; - this.OPENED = 1; - this.HEADERS_RECEIVED = 2; - this.LOADING = 3; - this.DONE = 4; + // private ready state (not exposed so user cannot modify) + var readyState = this.UNSENT; /** * Public vars */ - // Current state - this.readyState = this.UNSENT; + Object.defineProperty(this, "readyState", { + get: function () { return readyState; }, + configurable: true, + enumerable: true + }); // default ready state change handler in case one is not set or is set late this.onreadystatechange = null; @@ -186,7 +216,7 @@ function XMLHttpRequest(opts) { * @return boolean False if not allowed, otherwise true */ var isAllowedHttpHeader = function(header) { - return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); + return header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1; }; /** @@ -226,25 +256,6 @@ function XMLHttpRequest(opts) { return result; }; - /** - * When xhr.responseType === 'arraybuffer', xhr.response must have type ArrayBuffer according - * to section 3.6.9 of https://xhr.spec.whatwg.org/#the-response-attribute . - * However, buf = Buffer.from(str) often has byteOffset > 0, so buf.buffer is larger than the - * usable region in buf. This means that a new copy of buf would need to be created to get the - * correct arrayBuffer. Instead, do it by hand to create the right sized ArrayBuffer in the - * first place. - * - * @param {string} str - * @returns {Buffer} - */ - var stringToBuffer = function(str) { - const ab = new ArrayBuffer(str.length) - const buf = Buffer.from(ab); - for (let k = 0; k < str.length; k++) - buf[k] = Number(str.charCodeAt(k)); - return buf; - } - /** * Given a Buffer buf, check whether buf.buffer.byteLength > buf.length and if so, * create a new ArrayBuffer whose byteLength is buf.length, containing the bytes. @@ -264,10 +275,112 @@ function XMLHttpRequest(opts) { return ab; } + /** + * Given the user-input (or Content-Type header value) of MIME type, + * Parse given string to retrieve mimeType and its encoding (defaults to utf8 if not exists) + * @param {string} contentType + */ + var parseContentType = function (contentType) { + const regex = /([a-zA-Z0-9!#$%&'*+.^_`|~-]+\/[a-zA-Z0-9!#$%&'*+.^_`|~-]+)(?:; charset=([a-zA-Z0-9-]+))?/; + + const matches = contentType.toLowerCase().match(regex); + + if (matches) { + const mimeType = matches[1]; + const charset = matches[2] || 'utf-8'; + + return { mimeType, charset }; + } else { + return { mimeType: "", charset: "utf-8" } + } + } + + /** + * Called when an error is encountered to deal with it. + * @param status {number} HTTP status code to use rather than the default (0) for XHR errors. + */ + var handleError = function(error, status) { + self.status = status || 0; + self.statusText = error.message || ""; + self.responseText = ""; + self.responseXML = ""; + self.responseURL = ""; + self.response = Buffer.alloc(0); + errorFlag = true; + setState(self.DONE); + if (!settings.async) throw error; + }; + + /** + * Construct the correct form of response, given default content type + * + * The input is the response parameter which is a Buffer. + * When self.responseType is "", "text", + * the input is further refined to be: new TextDecoder(encoding).decode(response), + * encoding is defined either by `Content-Type` header or set through `xhr.overrideMimetype()`. + * When self.responseType is "json", + * the input is further refined to be: JSON.parse(response.toString('utf8')). + * When self.responseType is "arraybuffer", "blob", + * the input is further refined to be: checkAndShrinkBuffer(response). + * + * @param {Buffer} response + */ + var createResponse = function(response, customContentType) { + self.responseText = ''; + self.responseXML = ''; + switch (self.responseType) { + case "": + case "text": + try { + // Use TextDecoder for more supported charset encodings + self.responseText = new TextDecoder(customEncoding || parseContentType(String(customContentType)).charset).decode(response); + } + catch (e) { + // fall back to utf8 ONLY if custom encoding is present + if (customEncoding) self.responseText = response.toString('utf8'); + else self.responseText = ""; + } + self.response = self.responseText; + break; + case 'json': + self.response = JSON.parse(response.toString('utf8')); + break; + default: + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + self.response = checkAndShrinkBuffer(response); + if (self.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + self.response = new Blob([self.response]); + } + break; + } + } + /** * Public methods */ + /** + * Acts as if the Content-Type header value for a response is mime. (It does not change the header.) + * Throws an error if state is LOADING or DONE. + * + * @param {string} mimeType - The MIME type to override with (e.g., "text/plain; charset=UTF-8"). + */ + this.overrideMimeType = function(mimeType) { + if (arguments.length === 0) { + throw new TypeError("Failed to execute 'overrideMimeType' on 'XMLHttpRequest': 1 argument required, but only 0 present."); + } + + // check if state is LOADING or DONE + if (readyState === this.LOADING || readyState === this.DONE) { + throw new Error("INVALID_STATE_ERR: MimeType cannot be overridden when the state is LOADING or DONE."); + } + + // parse mimeType from given string and set custom charset + customEncoding = parseContentType(String(mimeType)).charset; + } + /** * Open the connection. Currently supports local server requests. * @@ -278,10 +391,15 @@ function XMLHttpRequest(opts) { * @param string password Password for basic authentication (optional) */ this.open = function(method, url, async, user, password) { - this.abort(); + abort(); errorFlag = false; abortedFlag = false; + // check for sync + if (opts.syncPolicy === "warn") { + console.warn("[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + } + // Check for valid request method if (!isAllowedHttpMethod(method)) { throw new Error("SecurityError: Request method not allowed"); @@ -306,16 +424,6 @@ function XMLHttpRequest(opts) { setState(this.OPENED); }; - /** - * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. - * This does not conform to the W3C spec. - * - * @param boolean state Enable or disable header checking. - */ - this.setDisableHeaderCheck = function(state) { - disableHeaderCheck = state; - }; - /** * Sets a header for the request. * @@ -324,7 +432,7 @@ function XMLHttpRequest(opts) { * @return boolean Header added */ this.setRequestHeader = function(header, value) { - if (this.readyState != this.OPENED) { + if (readyState != this.OPENED) { throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); } if (!isAllowedHttpHeader(header)) { @@ -347,7 +455,7 @@ function XMLHttpRequest(opts) { this.getResponseHeader = function(header) { // in case of local request, headers are not present if (typeof header === "string" - && this.readyState > this.OPENED + && readyState > this.OPENED && response.headers[header.toLowerCase()] && !errorFlag && response @@ -366,7 +474,7 @@ function XMLHttpRequest(opts) { */ this.getAllResponseHeaders = function() { // in case of local request, headers are not present - if (this.readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { + if (readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { return ""; } var result = ""; @@ -380,21 +488,6 @@ function XMLHttpRequest(opts) { return result.slice(0, -2); }; - /** - * Gets a request header - * - * @param string name Name of header to get - * @return string Returns the request header or empty string if not set - */ - this.getRequestHeader = function(name) { - // @TODO Make this case insensitive - if (typeof name === "string" && headers[name]) { - return headers[name]; - } - - return ""; - }; - /** * Convert from Data URI to Buffer * @param {URL} url URI to parse @@ -410,10 +503,21 @@ function XMLHttpRequest(opts) { if (parts.length < 2) throw "Invalid URL"; + var dataHeaders = parts[0].split(";"); + + var base64 = false, charset; + // check if header part has base64 (from 2nd header onwards) - var base64 = parts[0].split(";").some(function (dataHeader, index) { - return index > 0 && dataHeader.toLowerCase() === "base64"; - }); + // also get charset encoding of data URI (from FIRST found only) + for (var i = 1; i < dataHeaders.length; ++i) { + if (base64 && charset) break; + var header = dataHeaders[i]; + + if (!base64) base64 = header.toLowerCase() === "base64"; + if (!charset && header.startsWith("charset=")) { + charset = header.slice(8).toLowerCase(); + } + } var responseData, inputData = decodeURIComponent(parts[1]); @@ -426,10 +530,16 @@ function XMLHttpRequest(opts) { inputData = inputData.slice(0, inputData.length - padding.length); responseData = Buffer.from(inputData, "base64"); if (responseData.toString("base64").replace(/=+$/, "") !== inputData) throw "malformed base64 encoding"; - return responseData; + return { + data: responseData, + charset: charset || "utf-8" + } } else { - return Buffer.from(inputData); + return { + data: Buffer.from(inputData), + charset: charset || "utf-8" + } } } @@ -439,7 +549,7 @@ function XMLHttpRequest(opts) { * @param string data Optional data to send as request body. */ this.send = function(data) { - if (this.readyState != this.OPENED) { + if (readyState != this.OPENED) { throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); } @@ -447,6 +557,10 @@ function XMLHttpRequest(opts) { throw new Error("INVALID_STATE_ERR: send has already been called"); } + if (opts.syncPolicy === "disabled") { + throw new Error("Synchronous requests are disabled for this instance."); + } + var isSsl = false, isLocal = false, isDataUri = false; var url; try { @@ -460,7 +574,7 @@ function XMLHttpRequest(opts) { } catch (e) { // URL parsing throws TypeError, here we only want to take its message - self.handleError(new Error(e.message)); + handleError(new Error(e.message)); return; } var host; @@ -495,17 +609,18 @@ function XMLHttpRequest(opts) { if (isDataUri) try { self.status = 200; self.responseURL = settings.url; - self.createFileOrSyncResponse(bufferFromDataUri(url)); + var uriData = bufferFromDataUri(url); + createResponse(uriData.data, "text/plain; charset=" + uriData.charset); setState(self.DONE); return; } catch (e) { - self.handleError(new Error("Invalid data URI")); + handleError(new Error("Invalid data URI")); return; } if (!opts.allowFileSystemResources) { - self.handleError(new Error("Not allowed to load local resource: " + url.href)); + handleError(new Error("Not allowed to load local resource: " + url.href)); return; } @@ -516,12 +631,12 @@ function XMLHttpRequest(opts) { if (settings.async) { fs.readFile(unescape(url.pathname), function(error, data) { if (error) { - self.handleError(error, error.errno || -1); + handleError(error, error.errno || -1); } else { self.status = 200; self.responseURL = settings.url; // Use self.responseType to create the correct self.responseType, self.response. - self.createFileOrSyncResponse(data); + createResponse(data, ""); setState(self.DONE); } }); @@ -531,7 +646,7 @@ function XMLHttpRequest(opts) { const syncData = fs.readFileSync(unescape(url.pathname)); // Use self.responseType to create the correct self.responseType, self.response. this.responseURL = settings.url; - this.createFileOrSyncResponse(syncData); + createResponse(syncData, ""); setState(self.DONE); } catch(e) { this.handleError(e, e.errno || -1); @@ -598,7 +713,7 @@ function XMLHttpRequest(opts) { sendFlag = true; // As per spec, this is called here for historical reasons. - self.dispatchEvent("readystatechange"); + dispatchEvent("readystatechange"); // Handler for the response var responseHandler = function(resp) { @@ -614,7 +729,7 @@ function XMLHttpRequest(opts) { // end the response resp.destroy(); if (redirectCount > maxRedirects) { - self.handleError(new Error("Too many redirects")); + handleError(new Error("Too many redirects")); return; } // Change URL to the redirect location @@ -626,7 +741,7 @@ function XMLHttpRequest(opts) { settings.url = url.href; } catch (e) { - self.handleError(new Error("Unsafe redirect")); + handleError(new Error("Unsafe redirect")); return; } // change request options again to match with new redirect protocol @@ -665,30 +780,12 @@ function XMLHttpRequest(opts) { setState(self.HEADERS_RECEIVED); - // When responseType is 'text' or '', self.responseText will be utf8 decoded text. - // When responseType is 'json', self.responseText initially will be utf8 decoded text, - // which is then JSON parsed into self.response. - // When responseType is 'arraybuffer', self.response is an ArrayBuffer. - // When responseType is 'blob', self.response is a Blob. - // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute - const isUtf8 = self.responseType === "" || self.responseType === "text" || self.responseType === "json"; - if (isUtf8 && response.setEncoding) { - response.setEncoding("utf8"); - } - self.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data if (chunk) { - if (isUtf8) { - // When responseType is 'text', '', 'json', - // then each chunk is already utf8 decoded. - self.responseText += chunk; - } else { - // Otherwise collect the chunk buffers. - buffers.push(chunk); - } + buffers.push(chunk); } // Don't emit state changes if the connection has been aborted. if (sendFlag) { @@ -698,11 +795,11 @@ function XMLHttpRequest(opts) { response.on('end', function() { if (sendFlag) { - // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks + // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks // there can be a timing issue (the callback is called and a new call is made before the flag is reset). sendFlag = false; // Create the correct response for responseType. - self.createResponse(buffers); + createResponse(Buffer.concat(buffers), response.headers['content-type'] || ""); self.statusText = this.statusMessage; self.responseURL = settings.url; // Discard the 'end' event if the connection has been aborted @@ -711,7 +808,7 @@ function XMLHttpRequest(opts) { }.bind(response)); response.on('error', function(error) { - self.handleError(error); + handleError(error); }.bind(response)); } @@ -721,7 +818,7 @@ function XMLHttpRequest(opts) { // don't fail the xhr request, attempt again. if (request.reusedSocket && error.code === 'ECONNRESET') return doRequest(options, responseHandler).on('error', errorHandler); - self.handleError(error); + handleError(error); } var createRequest = function (opt) { @@ -747,120 +844,128 @@ function XMLHttpRequest(opts) { // Create the request createRequest(options); - self.dispatchEvent("loadstart"); + dispatchEvent("loadstart"); } else { // Synchronous - // Create a temporary file for communication with the other Node process - var contentFile = ".node-xmlhttprequest-content-" + process.pid; - var syncFile = ".node-xmlhttprequest-sync-" + process.pid; - fs.writeFileSync(syncFile, "", "utf8"); - // The async request the other Node process executes - var execString = "'use strict';" - + "var http = require('http'), https = require('https'), fs = require('fs');" - + "function concat(bufferArray) {" - + " let length = 0, offset = 0;" - + " for (let k = 0; k < bufferArray.length; k++)" - + " length += bufferArray[k].length;" - + " const result = Buffer.alloc(length);" - + " for (let k = 0; k < bufferArray.length; k++) {" - + " for (let i = 0; i < bufferArray[k].length; i++) {" - + " result[offset+i] = bufferArray[k][i]" - + " }" - + " offset += bufferArray[k].length;" - + " }" - + " return result;" - + "};" - + "var doRequest = http" + (isSsl ? "s" : "") + ".request;" - + "var isSsl = " + !!isSsl + ";" - + "var options = " + JSON.stringify(options) + ";" - + "var sslOptions = " + JSON.stringify(sslOptions) + ";" - + "var responseData = Buffer.alloc(0);" - + "var buffers = [];" - + "var url = new URL(" + JSON.stringify(settings.url) + ");" - + "var maxRedirects = " + maxRedirects + ", redirects_count = 0;" - + "var makeRequest = function () {" - + " var opt = Object.assign({}, options);" - + " if (isSsl) Object.assign(opt, sslOptions);" - + " var req = doRequest(opt, function(response) {" - + " if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307 || response.statusCode === 308) {" - + " response.destroy();" - + " ++redirects_count;" - + " if (redirects_count > maxRedirects) {" - + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Too many redirects', 'utf8');" - + " fs.unlinkSync('" + syncFile + "');" - + " return;" - + " }" - + " try {" - + " url = new URL(response.headers.location, url);" - + " if (url.protocol !== 'https:' && url.protocol !== 'http:') throw 'bad protocol';" - + " }" - + " catch (e) {" - + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Unsafe redirect', 'utf8');" - + " fs.unlinkSync('" + syncFile + "');" - + " return;" - + " };" - + " isSsl = url.protocol === 'https:';" - + " doRequest = isSsl ? https.request : http.request;" - + " var port = url.port;" - + " options = {" - + " hostname: url.hostname," - + " port: port," - + " path: url.pathname + (url.search || '')," - + " method: response.statusCode === 303 ? 'GET' : options.method," - + " headers: options.headers" - + " };" - + " options.headers['Host'] = url.host;" - + " if (!((isSsl && port === 443) || port === 80)) options.headers['Host'] += ':' + port;" - + " makeRequest();" - + " return;" - + " }" - + " response.on('data', function(chunk) {" - + " buffers.push(chunk);" - + " });" - + " response.on('end', function() {" - + " responseData = concat(buffers);" - + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {url: url.href, statusCode: response.statusCode, statusText: response.statusMessage, headers: response.headers, data: responseData.toString('utf8')}}), 'utf8');" - + " fs.unlinkSync('" + syncFile + "');" - + " });" - + " response.on('error', function(error) {" - + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + " fs.unlinkSync('" + syncFile + "');" - + " });" - + " }).on('error', function(error) {" - + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + " fs.unlinkSync('" + syncFile + "');" - + " });" - + " " + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") - + " req.end();" - + "};" - + "makeRequest();" - // Start the other Node Process, executing this string - var syncProc = spawn(process.argv[0], ["-e", execString]); - while(fs.existsSync(syncFile)) { - // Wait while the sync file is empty + try { + // Create a temporary file for communication with the other Node process + var tmpDir = os.tmpdir(); + var contentFile = path.join(tmpDir, ".node-xmlhttprequest-content-" + process.pid); + var syncFile = path.join(tmpDir, ".node-xmlhttprequest-sync-" + process.pid); + fs.writeFileSync(syncFile, "", "utf8"); + // The async request the other Node process executes + var execString = "'use strict';" + + "var http = require('http'), https = require('https'), fs = require('fs');" + + "function concat(bufferArray) {" + + " let length = 0, offset = 0;" + + " for (let k = 0; k < bufferArray.length; k++)" + + " length += bufferArray[k].length;" + + " const result = Buffer.alloc(length);" + + " for (let k = 0; k < bufferArray.length; k++) {" + + " for (let i = 0; i < bufferArray[k].length; i++) {" + + " result[offset+i] = bufferArray[k][i]" + + " }" + + " offset += bufferArray[k].length;" + + " }" + + " return result;" + + "};" + + "var doRequest = http" + (isSsl ? "s" : "") + ".request;" + + "var isSsl = " + !!isSsl + ";" + + "var options = " + JSON.stringify(options) + ";" + + "var sslOptions = " + JSON.stringify(sslOptions) + ";" + + "var responseData = Buffer.alloc(0);" + + "var buffers = [];" + + "var url = new URL(" + JSON.stringify(settings.url) + ");" + + "var maxRedirects = " + maxRedirects + ", redirects_count = 0;" + + "var makeRequest = function () {" + + " var opt = Object.assign({}, options);" + + " if (isSsl) Object.assign(opt, sslOptions);" + + " var req = doRequest(opt, function(response) {" + + " if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307 || response.statusCode === 308) {" + + " response.destroy();" + + " ++redirects_count;" + + " if (redirects_count > maxRedirects) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Too many redirects', 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " return;" + + " }" + + " try {" + + " url = new URL(response.headers.location, url);" + + " if (url.protocol !== 'https:' && url.protocol !== 'http:') throw 'bad protocol';" + + " }" + + " catch (e) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Unsafe redirect', 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " return;" + + " };" + + " isSsl = url.protocol === 'https:';" + + " doRequest = isSsl ? https.request : http.request;" + + " var port = url.port;" + + " options = {" + + " hostname: url.hostname," + + " port: port," + + " path: url.pathname + (url.search || '')," + + " method: response.statusCode === 303 ? 'GET' : options.method," + + " headers: options.headers" + + " };" + + " options.headers['Host'] = url.host;" + + " if (!((isSsl && port === 443) || port === 80)) options.headers['Host'] += ':' + port;" + + " makeRequest();" + + " return;" + + " }" + + " response.on('data', function(chunk) {" + + " buffers.push(chunk);" + + " });" + + " response.on('end', function() {" + + " responseData = concat(buffers);" + + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: { url: url.href, statusCode: response.statusCode, statusText: response.statusMessage, headers: response.headers }}), 'utf8');" + + " fs.writeFileSync('" + contentFile + ".bin', responseData);" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " response.on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " }).on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " " + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") + + " req.end();" + + "};" + + "makeRequest();" + // Start the other Node Process, executing this string + var syncProc = spawn(process.argv[0], ["-e", execString]); + while(fs.existsSync(syncFile)) { + // Wait while the sync file is empty + } + self.responseText = fs.readFileSync(contentFile, 'utf8'); + // Kill the child process once the file has data + syncProc.stdin.end(); + // Remove the temporary file + fs.unlinkSync(contentFile); + } + catch (e) { + handleError(new Error("Sync operation aborted: read/write permissions required in operating system temporary directory.")); } - self.responseText = fs.readFileSync(contentFile, 'utf8'); - // Kill the child process once the file has data - syncProc.stdin.end(); - // Remove the temporary file - fs.unlinkSync(contentFile); if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it if (self.responseText.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { - self.handleError(new Error(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); + handleError(new Error(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); } else { var errorObj = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); - self.handleError(errorObj, 503); + handleError(errorObj, 503); } - } else { + } else try { // If the file returned okay, parse its data and move to the DONE state const resp = JSON.parse(self.responseText); self.status = resp.data.statusCode; self.statusText = resp.data.statusText; self.responseURL = resp.data.url; - self.response = stringToBuffer(resp.data.data); + self.response = fs.readFileSync(contentFile + ".bin"); + fs.unlinkSync(contentFile + ".bin"); // Use self.responseType to create the correct self.responseType, self.response, self.responseXML. - self.createFileOrSyncResponse(self.response); + createResponse(self.response, resp.data.headers["content-type"] || ""); // Set up response correctly. response = { statusCode: self.status, @@ -868,49 +973,41 @@ function XMLHttpRequest(opts) { }; setState(self.DONE); } + catch (e) { + handleError(new Error("Sync operation aborted: read/write permissions required in operating system temporary directory.")); + } } }; - /** - * Called when an error is encountered to deal with it. - * @param status {number} HTTP status code to use rather than the default (0) for XHR errors. - */ - this.handleError = function(error, status) { - this.status = status || 0; - this.statusText = error.message || ""; - this.responseText = ""; - this.responseXML = ""; - this.responseURL = ""; - this.response = Buffer.alloc(0); - errorFlag = true; - setState(this.DONE); - if (!settings.async) throw error; - }; - /** * Aborts a request. */ - this.abort = function() { + var abort = function() { if (request) { request.abort(); request = null; } headers = Object.assign({}, defaultHeaders); - this.responseText = ""; - this.responseXML = ""; - this.response = Buffer.alloc(0); + self.responseText = ""; + self.responseXML = ""; + self.response = Buffer.alloc(0); errorFlag = abortedFlag = true - if (this.readyState !== this.UNSENT - && (this.readyState !== this.OPENED || sendFlag) - && this.readyState !== this.DONE) { + if (readyState !== self.UNSENT + && (readyState !== self.OPENED || sendFlag) + && readyState !== self.DONE) { sendFlag = false; - setState(this.DONE); + setState(self.DONE); } - this.readyState = this.UNSENT; + readyState = self.UNSENT; }; + /** + * Aborts a request. + */ + this.abort = abort; + /** * Adds an event listener. Preferred method of binding to events. */ @@ -938,17 +1035,17 @@ function XMLHttpRequest(opts) { /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = function (event) { + var dispatchEvent = function (event) { let argument = { type: event }; if (typeof self["on" + event] === "function") { - if (this.readyState === this.DONE && settings.async) + if (readyState === self.DONE && settings.async) setTimeout(function() { self["on" + event](argument) }, 0) else self["on" + event](argument) } if (event in listeners) { for (let i = 0, len = listeners[event].length; i < len; i++) { - if (this.readyState === this.DONE) + if (readyState === self.DONE) setTimeout(function() { listeners[event][i].call(self, argument) }, 0) else listeners[event][i].call(self, argument) @@ -957,74 +1054,9 @@ function XMLHttpRequest(opts) { }; /** - * Construct the correct form of response, given responseType when in non-file based, asynchronous mode. - * - * When self.responseType is "", "text", "json", self.responseText is a utf8 string. - * When self.responseType is "arraybuffer", "blob", the response is in the buffers parameter, - * an Array of Buffers. Then concat(buffers) is Uint8Array, from which checkAndShrinkBuffer - * extracts the correct sized ArrayBuffer. - * - * @param {Array} buffers - */ - this.createResponse = function(buffers) { - self.responseXML = ''; - switch (self.responseType) { - case "": - case "text": - self.response = self.responseText; - break; - case 'json': - self.response = JSON.parse(self.responseText); - self.responseText = ''; - break; - default: - self.responseText = ''; - const totalResponse = concat(buffers); - // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. - // Get the correct sized ArrayBuffer. - self.response = checkAndShrinkBuffer(totalResponse); - if (self.responseType === 'blob' && typeof Blob === 'function') { - // Construct the Blob object that contains response. - self.response = new Blob([self.response]); - } - break; - } - } - - /** - * Construct the correct form of response, given responseType when in synchronous mode or file based. - * - * The input is the response parameter which is a Buffer. - * When self.responseType is "", "text", "json", - * the input is further refined to be: response.toString('utf8'). - * When self.responseType is "arraybuffer", "blob", - * the input is further refined to be: checkAndShrinkBuffer(response). - * - * @param {Buffer} response + * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.createFileOrSyncResponse = function(response) { - self.responseText = ''; - self.responseXML = ''; - switch (self.responseType) { - case "": - case "text": - self.responseText = response.toString('utf8'); - self.response = self.responseText; - break; - case 'json': - self.response = JSON.parse(response.toString('utf8')); - break; - default: - // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. - // Get the correct sized ArrayBuffer. - self.response = checkAndShrinkBuffer(response); - if (self.responseType === 'blob' && typeof Blob === 'function') { - // Construct the Blob object that contains response. - self.response = new Blob([self.response]); - } - break; - } - } + this.dispatchEvent = dispatchEvent; /** * Changes readyState and calls onreadystatechange. @@ -1032,16 +1064,16 @@ function XMLHttpRequest(opts) { * @param int state New state */ var setState = function(state) { - if ((self.readyState === state) || (self.readyState === self.UNSENT && abortedFlag)) + if ((readyState === state) || (readyState === self.UNSENT && abortedFlag)) return - self.readyState = state; + readyState = state; - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); + if (settings.async || readyState < self.OPENED || readyState === self.DONE) { + dispatchEvent("readystatechange"); } - if (self.readyState === self.DONE) { + if (readyState === self.DONE) { let fire if (abortedFlag) @@ -1051,10 +1083,10 @@ function XMLHttpRequest(opts) { else fire = "load" - self.dispatchEvent(fire) + dispatchEvent(fire) // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); + dispatchEvent("loadend"); } }; }; diff --git a/tests/server.js b/tests/server.js index e76752b..28fe380 100644 --- a/tests/server.js +++ b/tests/server.js @@ -1,6 +1,26 @@ 'use strict'; var http = require("http"); +var bufferBody = Buffer.from([ + 0x48, // H + 0xE9, // é + 0x6C, // l + 0x6C, // l + 0x6F, // o + 0x20, // + 0x77, // w + 0xF8, // ø + 0x72, // r + 0x6C, // l + 0x64, // d + 0x20, // + 0x6E, // n + 0x61, // a + 0xEF, // ï + 0x76, // v + 0x65 // e +]); + var server = http.createServer(function (req, res) { switch (req.url) { case "/": { @@ -32,10 +52,23 @@ var server = http.createServer(function (req, res) { return; case "/binary2": const ta = new Float32Array([1, 5, 6, 7]); - const buf = Buffer.from(ta.buffer); - const str = buf.toString('binary'); + const buf = Buffer.from(ta); res.writeHead(200, {"Content-Type": "application/octet-stream"}) - res.end(str); + res.end(buf); + return; + case "/latin1": + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=ISO-8859-1', + 'Content-Length': bufferBody.length + }); + res.end(bufferBody); + return; + case "/latin1-invalid": + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=lorem_ipsum', + 'Content-Length': bufferBody.length + }); + res.end(bufferBody); return; default: if (req.url.startsWith('/redirectingResource/')) { diff --git a/tests/test-constants.js b/tests/test-constants.js index 57e1780..0cd7ec4 100644 --- a/tests/test-constants.js +++ b/tests/test-constants.js @@ -3,6 +3,12 @@ var assert = require("assert") , xhr = new XMLHttpRequest(); // Test constant values +assert.equal(0, XMLHttpRequest.UNSENT); +assert.equal(1, XMLHttpRequest.OPENED); +assert.equal(2, XMLHttpRequest.HEADERS_RECEIVED); +assert.equal(3, XMLHttpRequest.LOADING); +assert.equal(4, XMLHttpRequest.DONE); + assert.equal(0, xhr.UNSENT); assert.equal(1, xhr.OPENED); assert.equal(2, xhr.HEADERS_RECEIVED); diff --git a/tests/test-data-uri.js b/tests/test-data-uri.js index c94dd23..e7c3314 100644 --- a/tests/test-data-uri.js +++ b/tests/test-data-uri.js @@ -1,8 +1,5 @@ var assert = require("assert") - , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest - , xhr; - -xhr = new XMLHttpRequest(); + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; // define test data var tests = [ @@ -63,7 +60,7 @@ var tests_passed = 0; var runAsyncTest = function (test) { console.log(" ASYNC"); - xhr = new XMLHttpRequest; + var xhr = new XMLHttpRequest; xhr.open("get", test.data); xhr.onreadystatechange = function () { if (this.readyState === 4) { @@ -75,7 +72,7 @@ var runAsyncTest = function (test) { assert.equal(xhr.status, 200); assert.equal(xhr.responseText, test.output); } - console.log(" --> SUCESS"); + console.log(" --> SUCCESS"); ++tests_passed; } } @@ -85,7 +82,7 @@ var runAsyncTest = function (test) { var runSyncTest = function (test) { console.log(" SYNC"); - xhr = new XMLHttpRequest; + var xhr = new XMLHttpRequest; xhr.open("get", test.data, false); try { xhr.send(); @@ -108,7 +105,13 @@ var startTest = function () { let test = tests[i]; if (!test) { - console.log("Done:", tests_passed === tests.length * 2 ? "PASS" : "FAILED"); + console.log(tests_passed, "/", tests.length * 2, "tests passed"); + if (tests_passed === tests.length * 2) + console.log("Done: PASS"); + else { + console.error("Done: FAILED"); + throw ""; + } return; } diff --git a/tests/test-headers.js b/tests/test-headers.js index e22e5f9..569c602 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -10,7 +10,7 @@ var server = http.createServer(function (req, res) { // Test non-conforming allowed header assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); // Test header set with blacklist disabled - assert.equal("http://github.com", req.headers["referer"]); + // assert.equal("http://github.com", req.headers["referer"]); // Test case insensitive header was set assert.equal("text/plain", req.headers["content-type"]); @@ -65,14 +65,14 @@ try { // Case insensitive header xhr.setRequestHeader("content-type", 'text/plain'); // Test getRequestHeader - assert.equal("Foobar", xhr.getRequestHeader("X-Test")); + // assert.equal("Foobar", xhr.getRequestHeader("X-Test")); // Test invalid header - assert.equal("", xhr.getRequestHeader("Content-Length")); + // assert.equal("", xhr.getRequestHeader("Content-Length")); // Test allowing all headers - xhr.setDisableHeaderCheck(true); - xhr.setRequestHeader("Referer", "http://github.com"); - assert.equal("http://github.com", xhr.getRequestHeader("Referer")); + // xhr.setDisableHeaderCheck(true); + // xhr.setRequestHeader("Referer", "http://github.com"); + // assert.equal("http://github.com", xhr.getRequestHeader("Referer")); xhr.send(body); } catch(e) { diff --git a/tests/test-keepalive.js b/tests/test-keepalive.js index a5b09e7..f7d22bd 100644 --- a/tests/test-keepalive.js +++ b/tests/test-keepalive.js @@ -34,4 +34,4 @@ var interval = setInterval(function sendRequest() { } } xhr.send(); -}, 200); \ No newline at end of file +}, 200); diff --git a/tests/test-max-redirects.js b/tests/test-max-redirects.js index 9938528..1afd3ec 100644 --- a/tests/test-max-redirects.js +++ b/tests/test-max-redirects.js @@ -11,7 +11,7 @@ var runTest = function () { xhr.open("GET", "http://localhost:8888/redirectingResource/10", false); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("safe redirects count: done"); } diff --git a/tests/test-mimetype.js b/tests/test-mimetype.js new file mode 100644 index 0000000..5cb8aaf --- /dev/null +++ b/tests/test-mimetype.js @@ -0,0 +1,157 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn + , serverProcess; + +const body = Buffer.from([ + 0x48, // H + 0xE9, // é + 0x6C, // l + 0x6C, // l + 0x6F, // o + 0x20, // + 0x77, // w + 0xF8, // ø + 0x72, // r + 0x6C, // l + 0x64, // d + 0x20, // + 0x6E, // n + 0x61, // a + 0xEF, // ï + 0x76, // v + 0x65 // e +]); + +var base64Str = function (charset) { + return "data:text/plain;base64;charset=" + charset + "," + body.toString('base64'); +} +var plainStr = new TextDecoder("iso-8859-1").decode(body); +var plainStrUTF8 = new TextDecoder("utf-8").decode(body); + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + serverProcess.kill('SIGINT'); + throw e; + } finally { + + } +}, 100); + +var tests = [ + { + name: "XHR with default latin-1 encoding", + endpoint: "http://localhost:8888/latin1", + expected: plainStr + }, + { + name: "XHR with overrideMimeType charset=latin-1", + endpoint: "http://localhost:8888/latin1-invalid", + override: "text/plain; charset=ISO-8859-1", + expected: plainStr + }, + { + name: "XHR with wrong charset (utf-8 expected, actual is latin-1)", + endpoint: "http://localhost:8888/latin1-invalid", + expected: '' + }, + { + name: "XHR with wrong overriden charset (utf-8 expected, actual is latin-1)", + endpoint: "http://localhost:8888/latin1-invalid", + override: "text/plain; charset=lorem_ipsum", + expected: plainStrUTF8 + }, + { + name: "XHR on data URI with Latin-1 and charset specified", + endpoint: base64Str("ISO-8859-1"), + expected: plainStr + }, + { + name: "XHR on data URI with overrideMimeType to Latin-1", + endpoint: base64Str("UTF-8"), + override: "text/plain; charset=ISO-8859-1", + expected: plainStr + }, + { + name: "XHR on data URI with wrong default charset (utf-8 vs latin-1)", + endpoint: base64Str("lorem_ipsum"), + expected: '' + }, + { + name: "XHR with wrong overriden charset and Data URI (utf-8 expected, actual is latin-1)", + endpoint: base64Str("iso-8859-1"), + override: "text/plain; charset=lorem_ipsum", + expected: plainStrUTF8 + } +]; + +var tests_passed = 0; + +var total_tests = tests.length * 2; + +var runSyncTest = function (i) { + var test = tests[i]; + var index = i + 1; + try { + var xhr = new XMLHttpRequest(); + console.log("Test " + index + ": [SYNC] " + test.name); + xhr.open("GET", test.endpoint, false); + if (test.override) xhr.overrideMimeType(test.override); + xhr.send(); + assert.equal(xhr.responseText, test.expected); + console.log("Test " + index + ": PASSED"); + ++tests_passed; + } catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + } +} + +var runAsyncTest = function (i) { + if (i >= tests.length) { + serverProcess.kill('SIGINT'); + if (tests_passed === total_tests) return console.log("ALL PASSED"); + else { + console.error("FAILED: Only " + tests_passed + " / " + total_tests + " tests passed"); + throw ""; + }; + } + var test = tests[i]; + var index = i + tests.length + 1; + try { + var xhr = new XMLHttpRequest(); + console.log("Test " + index + ": [ASYNC] " + test.name); + xhr.open("GET", test.endpoint); + if (test.override) xhr.overrideMimeType(test.override); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) try { + assert.equal(xhr.responseText, test.expected); + console.log("Test " + index + ": PASSED"); + ++tests_passed; + runAsyncTest(i + 1); + } + catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + runAsyncTest(i + 1); + } + } + xhr.send(); + } catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + runAsyncTest(i + 1); + } +} + +var runTest = function () { + for (var i = 0; i < tests.length; i++) { + runSyncTest(i); + } + + runAsyncTest(0); +} diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js index 91ec4cf..c116533 100644 --- a/tests/test-redirect-301.js +++ b/tests/test-redirect-301.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index 802e948..ef32808 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index 4d51962..717aa42 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index 6e8cb9f..04ff971 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.js @@ -28,7 +28,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-308.js b/tests/test-redirect-308.js index d517f68..6de9f7b 100644 --- a/tests/test-redirect-308.js +++ b/tests/test-redirect-308.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-sync-flag.js b/tests/test-sync-flag.js new file mode 100644 index 0000000..8f9af5c --- /dev/null +++ b/tests/test-sync-flag.js @@ -0,0 +1,107 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require("child_process").spawn + , serverProcess + , process = require("process"); + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + throw e; + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); + +/** + * stage = 0 // idle + * stage = 1 // expect warning to check + * stage = 2 // available but does not expect warning + */ +var stage = 0; +// warning catch +let oldWarn = console.warn; +console.warn = function (warning) { + if (stage > 0) { + if (stage === 1) { + assert.equal(warning, "[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + console.log("Correct warning caught."); + } + else if (stage === 2) { + throw "Does not expect warning, caught " + JSON.stringify(warning); + } + } + + return oldWarn.call(this, warning); +} + +var runTest = function () { + // xhr with no syncPolicy (default = warn) + try { + console.log("Testing 1: XHR with no syncPolicy (default = warn)"); + var xhr = new XMLHttpRequest(); + stage = 1; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 1: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = warn + try { + console.log("Testing 2: XHR with syncPolicy = warn"); + var xhr = new XMLHttpRequest({ syncPolicy: "warn" }); + stage = 1; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 2: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = enabled + try { + console.log("Testing 3: XHR with syncPolicy = enabled"); + var xhr = new XMLHttpRequest({ syncPolicy: "enabled" }); + stage = 2; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 3: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = disabled + var errored = false; + try { + console.log("Testing 4: XHR with syncPolicy = disabled"); + var xhr = new XMLHttpRequest({ syncPolicy: "disabled" }); + stage = 2; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + } catch(e) { + errored = true; + assert.equal(e.message, "Synchronous requests are disabled for this instance."); + console.log("Correct error message.") + console.log("Test 4: PASSED"); + } + + if (!errored) throw "Test 4 expects an error."; +} diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index 734fe01..a9ae473 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -30,21 +30,6 @@ setTimeout(function () { } }, 100); -/** - * Assumes hexStr is the in-memory representation of a Float32Array. - * Relies on the fact that the char codes in hexStr are all <= 0xFF. - * Returns Float32Array corresponding to hexStr. - * - * @param {string} hexStr - * @returns {Float32Array} - */ -function stringToFloat32Array (hexStr) { - const u8 = new Uint8Array(hexStr.length); - for (let k = 0; k < hexStr.length; k++) - u8[k] = Number(hexStr.charCodeAt(k)); - return new Float32Array(u8.buffer); -} - /** * Check to see if 2 array-like objects have the same elements. * @param {{ length: number }} ar1 @@ -106,8 +91,8 @@ function runTest() { xhr.onreadystatechange = function () { if (xhr.readyState === 4) { // xhr.response is an ArrayBuffer - var binaryStr = Buffer.from(xhr.response).toString('binary'); - var f32 = stringToFloat32Array(binaryStr); + var binary = Buffer.from(xhr.response); + var f32 = new Float32Array(binary); log('/binary2', f32); var answer = new Float32Array([1, 5, 6, 7]); assert.equal(isEqual(f32, answer), true); From ff74860a208b518b828b9a61fa0d7b2579f29be3 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 22:06:07 +0900 Subject: [PATCH 02/13] make disableHeaderCheck an option and add deprecation for xhr.getRequestHeader --- lib/XMLHttpRequest.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index f45b04c..1a780d9 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -78,6 +78,7 @@ function XMLHttpRequest(opts) { allowFileSystemResources: true, maxRedirects: 20, // Chrome standard syncPolicy: "warn", + disableHeaderCheck: false, origin: undefined }; @@ -216,7 +217,7 @@ function XMLHttpRequest(opts) { * @return boolean False if not allowed, otherwise true */ var isAllowedHttpHeader = function(header) { - return header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1; + return opts.disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); }; /** @@ -446,6 +447,23 @@ function XMLHttpRequest(opts) { return true; }; + /** + * Gets a request header + * + * @deprecated + * @param string name Name of header to get + * @return string Returns the request header or empty string if not set + */ + this.getRequestHeader = function(name) { + // @TODO Make this case insensitive + console.warn("`xhr.getRequestHeader()` is deprecated and will be removed in a future release. It’s non-standard and not part of the XHR spec."); + if (typeof name === "string" && headers[name]) { + return headers[name]; + } + + return ""; + }; + /** * Gets a header from the server response. * From 46d1166515d53167a3cf60c5d9fe307be26ea593 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 22:28:45 +0900 Subject: [PATCH 03/13] change fs error msg --- lib/XMLHttpRequest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 1a780d9..13ee9d1 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -963,7 +963,7 @@ function XMLHttpRequest(opts) { fs.unlinkSync(contentFile); } catch (e) { - handleError(new Error("Sync operation aborted: read/write permissions required in operating system temporary directory.")); + handleError(new Error("Synchronous operation aborted: Failed to perfoem read/write operations in operating system temporary directory.")); } if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it @@ -992,7 +992,7 @@ function XMLHttpRequest(opts) { setState(self.DONE); } catch (e) { - handleError(new Error("Sync operation aborted: read/write permissions required in operating system temporary directory.")); + handleError(new Error("Synchronous operation aborted: Failed to perfoem read/write operations in operating system temporary directory.")); } } }; From 6022e6815096317743d645fe8ab9c14afd5c0420 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 22:30:28 +0900 Subject: [PATCH 04/13] change to shorter message --- lib/XMLHttpRequest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 13ee9d1..e31e344 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -963,7 +963,7 @@ function XMLHttpRequest(opts) { fs.unlinkSync(contentFile); } catch (e) { - handleError(new Error("Synchronous operation aborted: Failed to perfoem read/write operations in operating system temporary directory.")); + handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); } if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it @@ -992,7 +992,7 @@ function XMLHttpRequest(opts) { setState(self.DONE); } catch (e) { - handleError(new Error("Synchronous operation aborted: Failed to perfoem read/write operations in operating system temporary directory.")); + handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); } } }; From de4e835d985b2def651ea5a5a3959d8fd39f1621 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 23:04:24 +0900 Subject: [PATCH 05/13] check which version does not support iso-8859-1 --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 555b1b7..c0056f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,7 @@ on: branches: [ master ] jobs: integration-tests: + if: '!canceled()' runs-on: ubuntu-latest strategy: matrix: @@ -16,4 +17,4 @@ jobs: uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - - run: npm test \ No newline at end of file + - run: npm test From 8fdaa2d629082b175fe8f2e2f848897000879d5e Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 23:07:47 +0900 Subject: [PATCH 06/13] add dispatch --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0056f4..8cec321 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: branches: [ master ] pull_request: branches: [ master ] + workflow_dispatch: jobs: integration-tests: if: '!canceled()' From e40140fada9afe2d79735a260adc58667cd4355b Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 23:09:11 +0900 Subject: [PATCH 07/13] fix typo --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cec321..f41eddb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: jobs: integration-tests: - if: '!canceled()' + if: '!cancelled()' runs-on: ubuntu-latest strategy: matrix: From b3775e57b4907f6d2c59e770b16913f524a4cb66 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Thu, 8 May 2025 23:12:22 +0900 Subject: [PATCH 08/13] matrix not fail fast --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f41eddb..224849a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: jobs: integration-tests: - if: '!cancelled()' runs-on: ubuntu-latest strategy: + fail-fast: false matrix: node-version: [12.x, 20.x, 22.x, 23.x] steps: From cfa22bfe9bfecc56ea56f10900d49a585fa11dc3 Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Fri, 9 May 2025 20:35:27 +0900 Subject: [PATCH 09/13] Add barebone support for extensive text decoding and xml parsing --- README.md | 101 ++++++++++++++++++++++++++++++++++++------ lib/XMLHttpRequest.js | 81 ++++++++++++++++----------------- 2 files changed, 126 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index c6c7912..0b2f42a 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,94 @@ XHR object. Note: use the lowercase string "xmlhttprequest-ssl" in your require(). On case-sensitive systems (eg Linux) using uppercase letters won't work. -### Non-standard features ### - -Non-standard options for this module are passed through the `XMLHttpRequest` constructor. The following options control `https:` SSL requests: `ca`, `cert`, `ciphers`, `key`, `passphrase`, `pfx`, and `rejectUnauthorized`. You can find their functionality in the [Node.js docs](https://nodejs.org/api/https.html#httpsrequestoptions-callback). - -Additionally, the `agent` option allows you to specify a [Node.js Agent](https://nodejs.org/api/https.html#class-httpsagent) instance, allowing connection reuse. - -To prevent a process from not exiting naturally because a request socket from this module is still open, you can set `autoUnref` to a truthy value. - -This module allows control over the maximum number of redirects that are followed. You can set the `maxRedirects` option to do this. The default number is 20. - -Using the `allowFileSystemResources` option allows you to control access to the local filesystem through the `file:` protocol. - -The `origin` option allows you to set a base URL for the request. The resulting request URL will be constructed as follows `new URL(url, origin)`. +## Non-standard features ## +### Additional options ### + +Non-standard options for this module are passed through the `XMLHttpRequest` constructor. Here is the list of all options: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDefault valueDescription
caundefinedControl https: requests, you can find their functionality in the Nodejs Documentation
cert
ciphers
key
passhphrase
pfx
rejectUnauthorizedtrue
agentundefinedAllows to specify a Nodejs Agent instance, allowing connection reuse
autoUnreffalseSet to any truthy value to prevent a process from not exiting naturally because a request socket from this module is still open
maxRedirects20Allows control over the maximum number of redirects that are followed
allowFileSystemResourcestrueAllows user to control access to the local filesystem through the file: protocol
originundefinedAllows user to set a base URL for the request. The resulting request URL will be constructed as follows new URL(url, origin)
syncPolicy"warn"Control feature behavior of the synchronous feature:
  • "disabled": Disable the feature completely, throws error after calling .send() if in synchronous mode
  • "warn": Enable the feature, but show a warning when calling .open() with synchronous mode
  • "enabled": Enable the feature without showing any additional warnings or errors
disableHeaderCheckfalseDisable the check against forbidden headers to be added to a XHR request
xmlParsernoneSpecify a parser (non-async) to parse document from text when xhr.responseType is either "document" or in text format. If the parser is invalid or omitted, xhr.responseXML will be null
textEncoderTextDecoder or buf.toString(enc) depending on Node versionSpecify a text decoder, accepting a buffer buf and encoding enc to decode to desired encoding.
Note that TextDecoder at version 12 does not support a wide range of encodings than later node version does
+ +### Additional methods ### +`XMLHttpRequest` object created using this library exposes `xhr.getRequestHeader(header_name)` method to retrieve any header content by name in the request headers list. This feature is deprecated and will be removed in future releases. # Original README # diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index e31e344..4d38dcf 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -79,6 +79,13 @@ function XMLHttpRequest(opts) { maxRedirects: 20, // Chrome standard syncPolicy: "warn", disableHeaderCheck: false, + xmlParser: function (text) { + return null; + }, + textDecoder: function (buf, enc) { + if ("function" === typeof TextDecoder) return new TextDecoder(enc).decode(buf); + return buf.toString(enc); + }, origin: undefined }; @@ -88,6 +95,13 @@ function XMLHttpRequest(opts) { opts.syncPolicy = "warn"; } + for (var i of ["xmlParser", "textDecoder"]) { + if (opts[i] != null && typeof opts[i] !== "function") { + //@TODO: find a reliable way to check if function is async + opts[i] = default_options[i]; + } + } + var sslOptions = { pfx: opts.pfx, key: opts.key, @@ -230,33 +244,6 @@ function XMLHttpRequest(opts) { return (method && forbiddenRequestMethods.indexOf(method) === -1); }; - /** - * When xhr.responseType === 'arraybuffer', xhr.response must have type ArrayBuffer according - * to section 3.6.9 of https://xhr.spec.whatwg.org/#the-response-attribute . - * However, bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer is larger - * than the useable region in bufTotal. This means that a new copy of bufTotal would need to be - * created to get the correct ArrayBuffer. Instead, do the concat by hand to create the right - * sized ArrayBuffer in the first place. - * - * The return type is Uint8Array, - * because often Buffer will have Buffer.length < Buffer.buffer.byteLength. - * - * @param {Array} bufferArray - * @returns {Uint8Array} - */ - var concat = function(bufferArray) { - let length = 0, offset = 0; - for (let k = 0; k < bufferArray.length; k++) - length += bufferArray[k].length; - const result = new Uint8Array(length); - for (let k = 0; k < bufferArray.length; k++) - { - result.set(bufferArray[k], offset); - offset += bufferArray[k].length; - } - return result; - }; - /** * Given a Buffer buf, check whether buf.buffer.byteLength > buf.length and if so, * create a new ArrayBuffer whose byteLength is buf.length, containing the bytes. @@ -323,30 +310,20 @@ function XMLHttpRequest(opts) { * the input is further refined to be: JSON.parse(response.toString('utf8')). * When self.responseType is "arraybuffer", "blob", * the input is further refined to be: checkAndShrinkBuffer(response). + * A special case is when self.responseType is "document", + * the decoded text will be passed to a parser function to create a DOM, or returns `null` * * @param {Buffer} response */ var createResponse = function(response, customContentType) { - self.responseText = ''; - self.responseXML = ''; + self.responseText = null; + self.responseXML = null; switch (self.responseType) { - case "": - case "text": - try { - // Use TextDecoder for more supported charset encodings - self.responseText = new TextDecoder(customEncoding || parseContentType(String(customContentType)).charset).decode(response); - } - catch (e) { - // fall back to utf8 ONLY if custom encoding is present - if (customEncoding) self.responseText = response.toString('utf8'); - else self.responseText = ""; - } - self.response = self.responseText; - break; case 'json': self.response = JSON.parse(response.toString('utf8')); break; - default: + case 'blob': + case 'arraybuffer': // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. // Get the correct sized ArrayBuffer. self.response = checkAndShrinkBuffer(response); @@ -355,6 +332,24 @@ function XMLHttpRequest(opts) { self.response = new Blob([self.response]); } break; + default: + try { + self.responseText = opts.textDecoder.call(opts, response, customEncoding || parseContentType(String(customContentType)).charset); + } + catch (e) { + // fall back to utf8 ONLY if custom encoding is present + if (customEncoding) self.responseText = response.toString('utf8'); + else self.responseText = ""; + } + self.response = self.responseText; + try { self.responseXML = opts.xmlParser.call(opts, self.responseText); } + catch (e) { self.responseXML = null; } + } + + // Special handling of self.responseType === 'document' + if (self.responseType === 'document') { + self.response = self.responseXML; + self.responseText = null; } } From 741e701b78d28bedf7e924a46fdc57b5fa931a1e Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Fri, 9 May 2025 20:50:15 +0900 Subject: [PATCH 10/13] Fix docs typo --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0b2f42a..0b5babb 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,12 @@ Non-standard options for this module are passed through the `XMLHttpRequest` con allowFileSystemResources true - Allows user to control access to the local filesystem through the file: protocol + Allows control access to the local filesystem through the file: protocol origin undefined - Allows user to set a base URL for the request. The resulting request URL will be constructed as follows new URL(url, origin) + Set a base URL for the requests called using this instance. The resulting request URL will be constructed as follows: new URL(url, origin) syncPolicy @@ -92,9 +92,9 @@ Non-standard options for this module are passed through the `XMLHttpRequest` con Specify a parser (non-async) to parse document from text when xhr.responseType is either "document" or in text format. If the parser is invalid or omitted, xhr.responseXML will be null - textEncoder + textDecoder TextDecoder or buf.toString(enc) depending on Node version - Specify a text decoder, accepting a buffer buf and encoding enc to decode to desired encoding.
Note that TextDecoder at version 12 does not support a wide range of encodings than later node version does + Specify a text decoder (non-async), accepting a buffer buf and encoding enc to decode to desired encoding.
Note that TextDecoder at version 12 does not support a wide range of encodings than later node version does From aa6f488e311b57725193c11a62fa6aa0fd1b3b0d Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Fri, 9 May 2025 20:59:42 +0900 Subject: [PATCH 11/13] Fix test of mime type --- lib/XMLHttpRequest.js | 2 +- tests/test-mimetype.js | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 4d38dcf..7d11da4 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -96,7 +96,7 @@ function XMLHttpRequest(opts) { } for (var i of ["xmlParser", "textDecoder"]) { - if (opts[i] != null && typeof opts[i] !== "function") { + if (typeof opts[i] !== "function") { //@TODO: find a reliable way to check if function is async opts[i] = default_options[i]; } diff --git a/tests/test-mimetype.js b/tests/test-mimetype.js index 5cb8aaf..4a108a5 100644 --- a/tests/test-mimetype.js +++ b/tests/test-mimetype.js @@ -26,8 +26,19 @@ const body = Buffer.from([ var base64Str = function (charset) { return "data:text/plain;base64;charset=" + charset + "," + body.toString('base64'); } -var plainStr = new TextDecoder("iso-8859-1").decode(body); -var plainStrUTF8 = new TextDecoder("utf-8").decode(body); + +// specify custom decoder to work on older node versions +var decodeTextFromBuffer = function (buf, enc) { + if (enc == "iso-8859-1") enc = "latin1"; + return new TextDecoder(enc).decode(buf); +} + +var plainStr = decodeTextFromBuffer(body, "iso-8859-1"); +var plainStrUTF8 = decodeTextFromBuffer(body, "utf-8"); + +var createXHRInstance = function (opts) { + return new XMLHttpRequest(Object.assign({ textDecoder: decodeTextFromBuffer }, opts)); +} // spawn a server serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); @@ -100,7 +111,7 @@ var runSyncTest = function (i) { var test = tests[i]; var index = i + 1; try { - var xhr = new XMLHttpRequest(); + var xhr = createXHRInstance(); console.log("Test " + index + ": [SYNC] " + test.name); xhr.open("GET", test.endpoint, false); if (test.override) xhr.overrideMimeType(test.override); @@ -125,7 +136,7 @@ var runAsyncTest = function (i) { var test = tests[i]; var index = i + tests.length + 1; try { - var xhr = new XMLHttpRequest(); + var xhr = createXHRInstance(); console.log("Test " + index + ": [ASYNC] " + test.name); xhr.open("GET", test.endpoint); if (test.override) xhr.overrideMimeType(test.override); From 745cf18bf54222e43e9986f9cd84007b554c1e0f Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Fri, 9 May 2025 21:36:19 +0900 Subject: [PATCH 12/13] Fix test of mime type (failsafe) --- tests/test-mimetype.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-mimetype.js b/tests/test-mimetype.js index 4a108a5..dc91276 100644 --- a/tests/test-mimetype.js +++ b/tests/test-mimetype.js @@ -29,7 +29,7 @@ var base64Str = function (charset) { // specify custom decoder to work on older node versions var decodeTextFromBuffer = function (buf, enc) { - if (enc == "iso-8859-1") enc = "latin1"; + if (enc == "iso-8859-1") return buf.toString("latin1"); return new TextDecoder(enc).decode(buf); } From 8799cc31b38be037be91975f1fedd34fe1b5344c Mon Sep 17 00:00:00 2001 From: bhpsngum Date: Tue, 13 May 2025 22:55:53 +0900 Subject: [PATCH 13/13] bump minimum node version to v13, fix syncPolicy for non-async and add basic anti-pollution measure --- .github/workflows/test.yml | 2 +- lib/XMLHttpRequest.js | 206 +++++++++++++++++++++---------------- package.json | 2 +- tests/test-headers.js | 53 ++++++---- tests/test-mimetype.js | 2 +- tests/test-pollution.js | 61 +++++++++++ 6 files changed, 217 insertions(+), 109 deletions(-) create mode 100644 tests/test-pollution.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 224849a..541b904 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [12.x, 20.x, 22.x, 23.x] + node-version: [13.x, 20.x, 22.x, 23.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 7d11da4..f37edf0 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -64,6 +64,36 @@ function XMLHttpRequest(opts) { throw new TypeError("Failed to construct 'XMLHttpRequest': Please use the 'new' operator, this object constructor cannot be called as a function."); } + var dataMap = Object.create(null); + + /** + * Safely assign any key with value to an object, preventing prototype pollution + * @param {any} obj Object to assign + * @param {any} key key name + * @param {any} value value to assign + * @param {boolean} assignable whether user can change this value (this defaults to `true` when value is a function) + */ + var assignProp = function (obj, key, value, assignable) { + if ("function" === typeof value) Object.defineProperty(obj, key, { + value: value, + writable: true, + enumerable: true, + configurable: true + }); + else if (assignable) Object.defineProperty(obj, key, { + get: function () { return dataMap[key]; }, + set: function (value) { dataMap[key] = value; }, + enumerable: true, + configurable: true + }); + else Object.defineProperty(obj, key, { + get: function () { return dataMap[key]; }, + set: undefined, + enumerable: true, + configurable: true + }); + } + // defines a list of default options to prevent parameters pollution var default_options = { pfx: undefined, @@ -83,13 +113,16 @@ function XMLHttpRequest(opts) { return null; }, textDecoder: function (buf, enc) { - if ("function" === typeof TextDecoder) return new TextDecoder(enc).decode(buf); + if ("function" === typeof TextDecoder) try { + return new TextDecoder(enc).decode(buf); + } + catch (e) {} return buf.toString(enc); }, origin: undefined }; - opts = Object.assign(default_options, opts); + opts = Object.assign(Object.create(null), default_options, opts); if (opts.syncPolicy !== "warn" && opts.syncPolicy !== "disabled" && opts.syncPolicy !== "enabled") { opts.syncPolicy = "warn"; @@ -130,7 +163,7 @@ function XMLHttpRequest(opts) { var response; // Request settings - var settings = {}; + var settings = Object.create(null); assignStateConstants(this); @@ -140,7 +173,7 @@ function XMLHttpRequest(opts) { "Accept": "*/*" }; - var headers = Object.assign({}, defaultHeaders); + var headers = Object.assign(Object.create(null), defaultHeaders); // These headers are not user setable. // The following are allowed but banned in the spec: @@ -185,7 +218,7 @@ function XMLHttpRequest(opts) { var customEncoding = ""; // Event listeners - var listeners = {}; + var listeners = Object.create(null); // private ready state (not exposed so user cannot modify) var readyState = this.UNSENT; @@ -201,15 +234,15 @@ function XMLHttpRequest(opts) { }); // default ready state change handler in case one is not set or is set late - this.onreadystatechange = null; + assignProp(this, 'onreadystatechange', null, true); // Result & response - this.responseText = ""; - this.responseXML = ""; - this.responseURL = ""; - this.response = Buffer.alloc(0); - this.status = null; - this.statusText = null; + assignProp(this, 'responseText', ""); + assignProp(this, "responseXML", ""); + assignProp(this, "responseURL", ""); + assignProp(this, "response", Buffer.alloc(0)); + assignProp(this, "status", null); + assignProp(this, "statusText", null); // xhr.responseType is supported: // When responseType is 'text' or '', self.responseText will be utf8 decoded text. @@ -218,7 +251,7 @@ function XMLHttpRequest(opts) { // When responseType is 'arraybuffer', self.response is an ArrayBuffer. // When responseType is 'blob', self.response is a Blob. // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute - this.responseType = ""; /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' */ + assignProp(this, "responseType", "", true); /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' */ /** * Private methods @@ -288,12 +321,12 @@ function XMLHttpRequest(opts) { * @param status {number} HTTP status code to use rather than the default (0) for XHR errors. */ var handleError = function(error, status) { - self.status = status || 0; - self.statusText = error.message || ""; - self.responseText = ""; - self.responseXML = ""; - self.responseURL = ""; - self.response = Buffer.alloc(0); + dataMap.status = status || 0; + dataMap.statusText = error.message || ""; + dataMap.responseText = ""; + dataMap.responseXML = ""; + dataMap.responseURL = ""; + dataMap.response = Buffer.alloc(0); errorFlag = true; setState(self.DONE); if (!settings.async) throw error; @@ -316,40 +349,40 @@ function XMLHttpRequest(opts) { * @param {Buffer} response */ var createResponse = function(response, customContentType) { - self.responseText = null; - self.responseXML = null; + dataMap.responseText = null; + dataMap.responseXML = null; switch (self.responseType) { case 'json': - self.response = JSON.parse(response.toString('utf8')); + dataMap.response = JSON.parse(response.toString('utf8')); break; case 'blob': case 'arraybuffer': // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. // Get the correct sized ArrayBuffer. - self.response = checkAndShrinkBuffer(response); - if (self.responseType === 'blob' && typeof Blob === 'function') { + dataMap.response = checkAndShrinkBuffer(response); + if (dataMap.responseType === 'blob' && typeof Blob === 'function') { // Construct the Blob object that contains response. - self.response = new Blob([self.response]); + dataMap.response = new Blob([self.response]); } break; default: try { - self.responseText = opts.textDecoder.call(opts, response, customEncoding || parseContentType(String(customContentType)).charset); + dataMap.responseText = opts.textDecoder.call(opts, response, customEncoding || parseContentType(String(customContentType)).charset); } catch (e) { // fall back to utf8 ONLY if custom encoding is present - if (customEncoding) self.responseText = response.toString('utf8'); - else self.responseText = ""; + if (customEncoding) dataMap.responseText = response.toString('utf8'); + else dataMap.responseText = ""; } - self.response = self.responseText; - try { self.responseXML = opts.xmlParser.call(opts, self.responseText); } - catch (e) { self.responseXML = null; } + dataMap.response = self.responseText; + try { dataMap.responseXML = opts.xmlParser.call(opts, self.responseText); } + catch (e) { dataMap.responseXML = null; } } // Special handling of self.responseType === 'document' - if (self.responseType === 'document') { - self.response = self.responseXML; - self.responseText = null; + if (dataMap.responseType === 'document') { + dataMap.response = self.responseXML; + dataMap.responseText = null; } } @@ -363,7 +396,7 @@ function XMLHttpRequest(opts) { * * @param {string} mimeType - The MIME type to override with (e.g., "text/plain; charset=UTF-8"). */ - this.overrideMimeType = function(mimeType) { + assignProp(this, 'overrideMimeType', function(mimeType) { if (arguments.length === 0) { throw new TypeError("Failed to execute 'overrideMimeType' on 'XMLHttpRequest': 1 argument required, but only 0 present."); } @@ -375,7 +408,7 @@ function XMLHttpRequest(opts) { // parse mimeType from given string and set custom charset customEncoding = parseContentType(String(mimeType)).charset; - } + }); /** * Open the connection. Currently supports local server requests. @@ -386,16 +419,11 @@ function XMLHttpRequest(opts) { * @param string user Username for basic authentication (optional) * @param string password Password for basic authentication (optional) */ - this.open = function(method, url, async, user, password) { + assignProp(this, 'open', function(method, url, async, user, password) { abort(); errorFlag = false; abortedFlag = false; - // check for sync - if (opts.syncPolicy === "warn") { - console.warn("[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); - } - // Check for valid request method if (!isAllowedHttpMethod(method)) { throw new Error("SecurityError: Request method not allowed"); @@ -409,6 +437,11 @@ function XMLHttpRequest(opts) { "password": password || null }; + // check for sync + if (opts.syncPolicy === "warn" && !settings.async) { + console.warn("[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + } + // parse origin try { settings.origin = new URL(opts.origin); @@ -418,7 +451,7 @@ function XMLHttpRequest(opts) { } setState(this.OPENED); - }; + }); /** * Sets a header for the request. @@ -427,7 +460,7 @@ function XMLHttpRequest(opts) { * @param string value Header value * @return boolean Header added */ - this.setRequestHeader = function(header, value) { + assignProp(this, 'setRequestHeader', function(header, value) { if (readyState != this.OPENED) { throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); } @@ -440,7 +473,7 @@ function XMLHttpRequest(opts) { } headers[header] = value; return true; - }; + }); /** * Gets a request header @@ -449,7 +482,7 @@ function XMLHttpRequest(opts) { * @param string name Name of header to get * @return string Returns the request header or empty string if not set */ - this.getRequestHeader = function(name) { + assignProp(this, 'getRequestHeader', function(name) { // @TODO Make this case insensitive console.warn("`xhr.getRequestHeader()` is deprecated and will be removed in a future release. It’s non-standard and not part of the XHR spec."); if (typeof name === "string" && headers[name]) { @@ -457,7 +490,7 @@ function XMLHttpRequest(opts) { } return ""; - }; + }); /** * Gets a header from the server response. @@ -465,7 +498,7 @@ function XMLHttpRequest(opts) { * @param string header Name of header to get. * @return string Text of the header or null if it doesn't exist. */ - this.getResponseHeader = function(header) { + assignProp(this, 'getResponseHeader', function(header) { // in case of local request, headers are not present if (typeof header === "string" && readyState > this.OPENED @@ -478,14 +511,14 @@ function XMLHttpRequest(opts) { } return null; - }; + }); /** * Gets all the response headers. * * @return string A string with all response headers separated by CR+LF */ - this.getAllResponseHeaders = function() { + assignProp(this, 'getAllResponseHeaders', function() { // in case of local request, headers are not present if (readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { return ""; @@ -499,7 +532,7 @@ function XMLHttpRequest(opts) { } } return result.slice(0, -2); - }; + }); /** * Convert from Data URI to Buffer @@ -561,7 +594,7 @@ function XMLHttpRequest(opts) { * * @param string data Optional data to send as request body. */ - this.send = function(data) { + assignProp(this, 'send', function(data) { if (readyState != this.OPENED) { throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); } @@ -570,7 +603,7 @@ function XMLHttpRequest(opts) { throw new Error("INVALID_STATE_ERR: send has already been called"); } - if (opts.syncPolicy === "disabled") { + if (opts.syncPolicy === "disabled" && !settings.async) { throw new Error("Synchronous requests are disabled for this instance."); } @@ -620,8 +653,8 @@ function XMLHttpRequest(opts) { // or data from Data URI (data:) if (isLocal) { if (isDataUri) try { - self.status = 200; - self.responseURL = settings.url; + dataMap.status = 200; + dataMap.responseURL = settings.url; var uriData = bufferFromDataUri(url); createResponse(uriData.data, "text/plain; charset=" + uriData.charset); setState(self.DONE); @@ -646,8 +679,8 @@ function XMLHttpRequest(opts) { if (error) { handleError(error, error.errno || -1); } else { - self.status = 200; - self.responseURL = settings.url; + dataMap.status = 200; + dataMap.responseURL = settings.url; // Use self.responseType to create the correct self.responseType, self.response. createResponse(data, ""); setState(self.DONE); @@ -655,14 +688,14 @@ function XMLHttpRequest(opts) { }); } else { try { - this.status = 200; + dataMap.status = 200; const syncData = fs.readFileSync(unescape(url.pathname)); // Use self.responseType to create the correct self.responseType, self.response. - this.responseURL = settings.url; + dataMap.responseURL = settings.url; createResponse(syncData, ""); setState(self.DONE); } catch(e) { - this.handleError(e, e.errno || -1); + handleError(e, e.errno || -1); } } @@ -793,7 +826,7 @@ function XMLHttpRequest(opts) { setState(self.HEADERS_RECEIVED); - self.status = response.statusCode; + dataMap.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data @@ -813,8 +846,8 @@ function XMLHttpRequest(opts) { sendFlag = false; // Create the correct response for responseType. createResponse(Buffer.concat(buffers), response.headers['content-type'] || ""); - self.statusText = this.statusMessage; - self.responseURL = settings.url; + dataMap.statusText = this.statusMessage; + dataMap.responseURL = settings.url; // Discard the 'end' event if the connection has been aborted setState(self.DONE); } @@ -835,7 +868,7 @@ function XMLHttpRequest(opts) { } var createRequest = function (opt) { - opt = Object.assign({}, opt); + opt = Object.assign(Object.create(null), opt); if (isSsl) Object.assign(opt, sslOptions); request = doRequest(opt, responseHandler).on('error', errorHandler); @@ -862,6 +895,7 @@ function XMLHttpRequest(opts) { try { // Create a temporary file for communication with the other Node process var tmpDir = os.tmpdir(); + var syncResponse; var contentFile = path.join(tmpDir, ".node-xmlhttprequest-content-" + process.pid); var syncFile = path.join(tmpDir, ".node-xmlhttprequest-sync-" + process.pid); fs.writeFileSync(syncFile, "", "utf8"); @@ -890,7 +924,7 @@ function XMLHttpRequest(opts) { + "var url = new URL(" + JSON.stringify(settings.url) + ");" + "var maxRedirects = " + maxRedirects + ", redirects_count = 0;" + "var makeRequest = function () {" - + " var opt = Object.assign({}, options);" + + " var opt = Object.assign(Object.create(null), options);" + " if (isSsl) Object.assign(opt, sslOptions);" + " var req = doRequest(opt, function(response) {" + " if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307 || response.statusCode === 308) {" @@ -951,7 +985,7 @@ function XMLHttpRequest(opts) { while(fs.existsSync(syncFile)) { // Wait while the sync file is empty } - self.responseText = fs.readFileSync(contentFile, 'utf8'); + syncResponse = fs.readFileSync(contentFile, 'utf8'); // Kill the child process once the file has data syncProc.stdin.end(); // Remove the temporary file @@ -960,22 +994,22 @@ function XMLHttpRequest(opts) { catch (e) { handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); } - if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { + if (syncResponse.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it - if (self.responseText.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { - handleError(new Error(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); + if (syncResponse.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { + handleError(new Error(syncResponse.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); } else { - var errorObj = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); + var errorObj = JSON.parse(syncResponse.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); handleError(errorObj, 503); } } else try { // If the file returned okay, parse its data and move to the DONE state - const resp = JSON.parse(self.responseText); - self.status = resp.data.statusCode; - self.statusText = resp.data.statusText; - self.responseURL = resp.data.url; - self.response = fs.readFileSync(contentFile + ".bin"); + const resp = JSON.parse(syncResponse); + dataMap.status = resp.data.statusCode; + dataMap.statusText = resp.data.statusText; + dataMap.responseURL = resp.data.url; + dataMap.response = fs.readFileSync(contentFile + ".bin"); fs.unlinkSync(contentFile + ".bin"); // Use self.responseType to create the correct self.responseType, self.response, self.responseXML. createResponse(self.response, resp.data.headers["content-type"] || ""); @@ -990,7 +1024,7 @@ function XMLHttpRequest(opts) { handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); } } - }; + }); /** * Aborts a request. @@ -1001,10 +1035,10 @@ function XMLHttpRequest(opts) { request = null; } - headers = Object.assign({}, defaultHeaders); - self.responseText = ""; - self.responseXML = ""; - self.response = Buffer.alloc(0); + headers = Object.assign(Object.create(null), defaultHeaders); + dataMap.responseText = ""; + dataMap.responseXML = ""; + dataMap.response = Buffer.alloc(0); errorFlag = abortedFlag = true if (readyState !== self.UNSENT @@ -1019,31 +1053,31 @@ function XMLHttpRequest(opts) { /** * Aborts a request. */ - this.abort = abort; + assignProp(this, 'abort', abort); /** * Adds an event listener. Preferred method of binding to events. */ - this.addEventListener = function(event, callback) { + assignProp(this, 'addEventListener', function(event, callback) { if (!(event in listeners)) { listeners[event] = []; } // Currently allows duplicate callbacks. Should it? listeners[event].push(callback); - }; + }); /** * Remove an event callback that has already been bound. * Only works on the matching funciton, cannot be a copy. */ - this.removeEventListener = function(event, callback) { + assignProp(this, 'removeEventListener', function(event, callback) { if (event in listeners) { // Filter will return a new array with the callback removed listeners[event] = listeners[event].filter(function(ev) { return ev !== callback; }); } - }; + }); /** * Dispatch any events, including both "on" methods and events attached using addEventListener. @@ -1069,7 +1103,7 @@ function XMLHttpRequest(opts) { /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = dispatchEvent; + assignProp(this, 'dispatchEvent', dispatchEvent); /** * Changes readyState and calls onreadystatechange. diff --git a/package.json b/package.json index 8db9dfb..926be7f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "bugs": "http://github.com/mjwwit/node-XMLHttpRequest/issues", "engines": { - "node": ">=12.0.0" + "node": ">=13.0.0" }, "scripts": { "test": "cd ./tests && node run-test.js" diff --git a/tests/test-headers.js b/tests/test-headers.js index 569c602..c957103 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -5,14 +5,23 @@ var assert = require("assert") // Test server var server = http.createServer(function (req, res) { - // Test setRequestHeader - assert.equal("Foobar", req.headers["x-test"]); - // Test non-conforming allowed header - assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); - // Test header set with blacklist disabled - // assert.equal("http://github.com", req.headers["referer"]); - // Test case insensitive header was set - assert.equal("text/plain", req.headers["content-type"]); + switch (req.url) { + case "/allow": + // Test disabling header check + assert.equal("http://github.com", req.headers["referer"]); + console.log("No header check: PASSED"); + break; + default: + // Test setRequestHeader + assert.equal("Foobar", req.headers["x-test"]); + // Test non-conforming allowed header + assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); + // Test case insensitive header was set + assert.equal("text/plain", req.headers["content-type"]); + // Test forbidden header + assert.equal(null, req.headers["referer"]); + console.log("Strict header check: PASSED"); + } var body = "Hello World"; res.writeHead(200, { @@ -48,7 +57,7 @@ xhr.onreadystatechange = function() { assert.equal("", this.getAllResponseHeaders()); assert.equal(null, this.getResponseHeader("Connection")); - console.log("done"); + console.log("Response headers check: PASSED"); } }; @@ -58,24 +67,28 @@ try { var body = "Hello World"; // Valid header xhr.setRequestHeader("X-Test", "Foobar"); - // Invalid header + // Invalid header Content-Length xhr.setRequestHeader("Content-Length", Buffer.byteLength(body)); + // Invalid header Referer + xhr.setRequestHeader("Referer", "http://github.com"); // Allowed header outside of specs xhr.setRequestHeader("user-agent", "node-XMLHttpRequest-test"); // Case insensitive header xhr.setRequestHeader("content-type", 'text/plain'); - // Test getRequestHeader - // assert.equal("Foobar", xhr.getRequestHeader("X-Test")); - // Test invalid header - // assert.equal("", xhr.getRequestHeader("Content-Length")); - - // Test allowing all headers - // xhr.setDisableHeaderCheck(true); - // xhr.setRequestHeader("Referer", "http://github.com"); - // assert.equal("http://github.com", xhr.getRequestHeader("Referer")); - xhr.send(body); } catch(e) { console.error("ERROR: Exception raised", e); throw e; } + +try { + // Test allowing all headers + xhr = new XMLHttpRequest({ disableHeaderCheck: true }); + xhr.open("POST", "http://localhost:8000/allow"); + xhr.setRequestHeader("Referer", "http://github.com"); + xhr.send(); +} +catch (e) { + console.error("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-mimetype.js b/tests/test-mimetype.js index dc91276..5e65d21 100644 --- a/tests/test-mimetype.js +++ b/tests/test-mimetype.js @@ -37,7 +37,7 @@ var plainStr = decodeTextFromBuffer(body, "iso-8859-1"); var plainStrUTF8 = decodeTextFromBuffer(body, "utf-8"); var createXHRInstance = function (opts) { - return new XMLHttpRequest(Object.assign({ textDecoder: decodeTextFromBuffer }, opts)); + return new XMLHttpRequest(Object.assign({ textDecoder: null }, opts)); } // spawn a server diff --git a/tests/test-pollution.js b/tests/test-pollution.js new file mode 100644 index 0000000..9fbc516 --- /dev/null +++ b/tests/test-pollution.js @@ -0,0 +1,61 @@ +// Main purpose of this test is to ensure that XHR options and exposed methods cannot be prototype-polluted + +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest, + spawn = require("child_process").spawn, + assert = require("assert"), + xhr, + objectProto = Object.getPrototypeOf({}); + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var polluteFunc = function (buf, enc) { + return "Polluted!"; +} + +var runTest = function () { + // most naive pollution + objectProto.textDecoder = polluteFunc; + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Naive pollution: PASSED"); + + delete objectProto.textDecoder; + + // pollute with getter/setter + Object.defineProperty(objectProto, 'textDecoder', { + get: function () { return polluteFunc; }, + set: function (value) {} + }); + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Getter/Setter pollution: PASSED"); + + // pollute xhr properties + Object.defineProperty(objectProto, 'responseText', { + get: function () { return "Polluted!"; }, + set: function (value) {} + }); + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Pollute xhr.responseText: PASSED"); +} + +try { + runTest(); +} +catch (e) { + throw e; +} +finally { + serverProcess.kill('SIGINT'); +}