1/**
2 * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
3 *
4 * This can be used with JS designed for browsers to improve reuse of code and
5 * allow the use of existing libraries.
6 *
7 * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs.
8 *
9 * @author Dan DeFelippi <dan@driverdan.com>
10 * @contributor David Ellis <d.f.ellis@ieee.org>
11 * @contributor Guillaume Grossetie <ggrossetie@yuzutech.fr>
12 * @contributor David Jencks <djencks@apache.org>
13 * @license MIT
14 */
15const Url = require('url')
16const fs = require('fs')
17const ospath = require('path')
18
19// 100 MB
20const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100
21
22exports.XMLHttpRequest = function () {
23  'use strict'
24
25  /**
26   * Private variables
27   */
28  const self = this
29  const http = require('http')
30  const https = require('https')
31
32  // Holds http.js objects
33  let request
34  let response
35
36  // Request settings
37  let settings = {}
38
39  // Disable header blacklist.
40  // Not part of XHR specs.
41  let disableHeaderCheck = false
42
43  // Set some default headers
44  const defaultHeaders = {
45    'User-Agent': 'node-XMLHttpRequest',
46    Accept: '*/*'
47  }
48
49  let headers = {}
50  const headersCase = {}
51
52  // These headers are not user setable.
53  // The following are allowed but banned in the spec:
54  // * user-agent
55  const forbiddenRequestHeaders = [
56    'accept-charset',
57    'accept-encoding',
58    'access-control-request-headers',
59    'access-control-request-method',
60    'connection',
61    'content-length',
62    'content-transfer-encoding',
63    'cookie',
64    'cookie2',
65    'date',
66    'expect',
67    'host',
68    'keep-alive',
69    'origin',
70    'referer',
71    'te',
72    'trailer',
73    'transfer-encoding',
74    'upgrade',
75    'via'
76  ]
77
78  // These request methods are not allowed
79  const forbiddenRequestMethods = [
80    'TRACE',
81    'TRACK',
82    'CONNECT'
83  ]
84
85  // Send flag
86  let sendFlag = false
87  // Error flag, used when errors occur or abort is called
88  let errorFlag = false
89
90  // Binary response (chunk)
91  const responseBinary = []
92
93  // Event listeners
94  const listeners = {}
95
96  /**
97   * Constants
98   */
99
100  this.UNSENT = 0
101  this.OPENED = 1
102  this.HEADERS_RECEIVED = 2
103  this.LOADING = 3
104  this.DONE = 4
105
106  /**
107   * Public vars
108   */
109
110  // Current state
111  this.readyState = this.UNSENT
112
113  // default ready state change handler in case one is not set or is set late
114  this.onreadystatechange = null
115
116  // Result & response
117  this.responseText = ''
118  this.responseXML = ''
119  this.status = null
120  this.statusText = null
121
122  // Whether cross-site Access-Control requests should be made using
123  // credentials such as cookies or authorization headers
124  this.withCredentials = false
125  // "text", "arraybuffer", "blob", or "document", depending on your data needs.
126  // Note, setting xhr.responseType = '' (or omitting) will default the response to "text".
127  // Omitting, '', or "text" will return a String.
128  // Other values will return an ArrayBuffer.
129  this.responseType = ''
130
131  /**
132   * Private methods
133   */
134
135  /**
136   * Check if the specified header is allowed.
137   *
138   * @param header - {string} Header to validate
139   * @return {boolean} - False if not allowed, otherwise true
140   */
141  const isAllowedHttpHeader = function (header) {
142    return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1)
143  }
144
145  /**
146   * Check if the specified method is allowed.
147   *
148   * @param method - {string}  Request method to validate
149   * @return {boolean} - False if not allowed, otherwise true
150   */
151  const isAllowedHttpMethod = function (method) {
152    return (method && forbiddenRequestMethods.indexOf(method) === -1)
153  }
154
155  /**
156   * Public methods
157   */
158
159  /**
160   * Open the connection. Currently supports local server requests.
161   *
162   * @param method - {string} Connection method (eg GET, POST)
163   * @param url - {string} URL for the connection.
164   * @param async - {boolean} Asynchronous connection. Default is true.
165   * @param [user] - {string} Username for basic authentication (optional)
166   * @param [password] - {string} Password for basic authentication (optional)
167   */
168  this.open = function (method, url, async, user, password) {
169    this.abort()
170    errorFlag = false
171
172    // Check for valid request method
173    if (!isAllowedHttpMethod(method)) {
174      throw new Error('SecurityError: Request method not allowed')
175    }
176
177    settings = {
178      method: method,
179      url: url.toString(),
180      async: (typeof async !== 'boolean' ? true : async),
181      user: user || null,
182      password: password || null
183    }
184
185    setState(this.OPENED)
186  }
187
188  /**
189   * Disables or enables isAllowedHttpHeader() check the request. Enabled by default.
190   * This does not conform to the W3C spec.
191   *
192   * @param state - {boolean} Enable or disable header checking.
193   */
194  this.setDisableHeaderCheck = function (state) {
195    disableHeaderCheck = state
196  }
197
198  /**
199   * Sets a header for the request or appends the value if one is already set.
200   *
201   * @param header - {string} Header name
202   * @param value - {string} Header value
203   */
204  this.setRequestHeader = function (header, value) {
205    if (this.readyState !== this.OPENED) {
206      throw new Error('INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN')
207    }
208    if (!isAllowedHttpHeader(header)) {
209      console.warn('Refused to set unsafe header "' + header + '"')
210      return
211    }
212    if (sendFlag) {
213      throw new Error('INVALID_STATE_ERR: send flag is true')
214    }
215    header = headersCase[header.toLowerCase()] || header
216    headersCase[header.toLowerCase()] = header
217    headers[header] = headers[header] ? headers[header] + ', ' + value : value
218  }
219
220  /**
221   * Gets a header from the server response.
222   *
223   * @param header - {string} Name of header to get.
224   * @return {Object} - Text of the header or null if it doesn't exist.
225   */
226  this.getResponseHeader = function (header) {
227    if (typeof header === 'string' &&
228      this.readyState > this.OPENED &&
229      response &&
230      response.headers &&
231      response.headers[header.toLowerCase()] &&
232      !errorFlag
233    ) {
234      return response.headers[header.toLowerCase()]
235    }
236
237    return null
238  }
239
240  /**
241   * Gets all the response headers.
242   *
243   * @return string A string with all response headers separated by CR+LF
244   */
245  this.getAllResponseHeaders = function () {
246    if (this.readyState < this.HEADERS_RECEIVED || errorFlag) {
247      return ''
248    }
249    let result = ''
250
251    for (const i in response.headers) {
252      // Cookie headers are excluded
253      if (i !== 'set-cookie' && i !== 'set-cookie2') {
254        result += i + ': ' + response.headers[i] + '\r\n'
255      }
256    }
257    return result.substr(0, result.length - 2)
258  }
259
260  /**
261   * Gets a request header
262   *
263   * @param name - {string} Name of header to get
264   * @return {string} Returns the request header or empty string if not set
265   */
266  this.getRequestHeader = function (name) {
267    if (typeof name === 'string' && headersCase[name.toLowerCase()]) {
268      return headers[headersCase[name.toLowerCase()]]
269    }
270
271    return ''
272  }
273
274  /**
275   * Sends the request to the server.
276   *
277   * @param data - {string} Optional data to send as request body.
278   */
279  this.send = function (data) {
280    if (this.readyState !== this.OPENED) {
281      throw new Error('INVALID_STATE_ERR: connection must be opened before send() is called')
282    }
283
284    if (sendFlag) {
285      throw new Error('INVALID_STATE_ERR: send has already been called')
286    }
287    let ssl = false
288    let local = false
289    const url = new Url.URL(settings.url)
290    let host
291    // Determine the server
292    switch (url.protocol) {
293      case 'https:':
294        ssl = true
295        host = url.hostname
296        break
297      case 'http:':
298        host = url.hostname
299        break
300      case 'file:':
301        local = true
302        break
303      case undefined:
304      case null:
305      case '':
306        host = 'localhost'
307        break
308      default:
309        throw new Error('Protocol not supported.')
310    }
311
312    // Load files off the local filesystem (file://)
313    if (local) {
314      if (settings.method !== 'GET') {
315        throw new Error('XMLHttpRequest: Only GET method is supported')
316      }
317      if (settings.async) {
318        fs.readFile(url, 'utf8', function (error, data) {
319          if (error) {
320            self.handleError(error, url)
321          } else {
322            self.status = 200
323            self.responseText = data
324            setState(self.DONE)
325          }
326        })
327      } else {
328        try {
329          this.responseText = fs.readFileSync(url, 'utf8')
330          this.status = 200
331          setState(self.DONE)
332        } catch (e) {
333          this.handleError(e, url)
334        }
335      }
336
337      return
338    }
339
340    // Default to port 80. If accessing localhost on another port be sure
341    // to use http://localhost:port/path
342    const port = url.port || (ssl ? 443 : 80)
343    // Add query string if one is used
344    const uri = url.pathname + (url.search ? url.search : '')
345
346    // Set the defaults if they haven't been set
347    for (const name in defaultHeaders) {
348      if (!headersCase[name.toLowerCase()]) {
349        headers[name] = defaultHeaders[name]
350      }
351    }
352
353    // Set the Host header or the server may reject the request
354    headers.Host = host
355    // IPv6 addresses must be escaped with brackets
356    if (url.host[0] === '[') {
357      headers.Host = '[' + headers.Host + ']'
358    }
359    if (!((ssl && port === 443) || port === 80)) {
360      headers.Host += ':' + url.port
361    }
362
363    // Set Basic Auth if necessary
364    if (settings.user) {
365      if (typeof settings.password === 'undefined') {
366        settings.password = ''
367      }
368      const authBuf = Buffer.from(settings.user + ':' + settings.password)
369      headers.Authorization = 'Basic ' + authBuf.toString('base64')
370    }
371
372    // Set content length header
373    if (settings.method === 'GET' || settings.method === 'HEAD') {
374      data = null
375    } else if (data) {
376      headers['Content-Length'] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data)
377
378      if (!this.getRequestHeader('Content-Type')) {
379        headers['Content-Type'] = 'text/plain;charset=UTF-8'
380      }
381    } else if (settings.method === 'POST') {
382      // For a post with no data set Content-Length: 0.
383      // This is required by buggy servers that don't meet the specs.
384      headers['Content-Length'] = 0
385    }
386
387    const options = {
388      host: host,
389      port: port,
390      path: uri,
391      method: settings.method,
392      headers: headers,
393      agent: false,
394      withCredentials: self.withCredentials
395    }
396
397    const responseType = this.responseType || 'text'
398
399    // Reset error flag
400    errorFlag = false
401
402    // Handle async requests
403    if (settings.async) {
404      // Use the proper protocol
405      const doRequest = ssl ? https.request : http.request
406
407      // Request is being sent, set send flag
408      sendFlag = true
409
410      // As per spec, this is called here for historical reasons.
411      self.dispatchEvent('readystatechange')
412
413      // Handler for the response
414      const responseHandler = function responseHandler (resp) {
415        // Set response var to the response we got back
416        // This is so it remains accessable outside this scope
417        response = resp
418        // Check for redirect
419        // @TODO Prevent looped redirects
420        if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) {
421          // Change URL to the redirect location
422          settings.url = response.headers.location
423          const url = new Url.URL(settings.url)
424          // Set host var in case it's used later
425          host = url.hostname
426          // Options for the new request
427          const newOptions = {
428            hostname: url.hostname,
429            port: url.port,
430            path: url.path,
431            method: response.statusCode === 303 ? 'GET' : settings.method,
432            headers: headers,
433            withCredentials: self.withCredentials
434          }
435
436          // Issue the new request
437          request = doRequest(newOptions, responseHandler).on('error', errorHandler)
438          request.end()
439          // @TODO Check if an XHR event needs to be fired here
440          return
441        }
442
443        const encoding = responseType === 'text' ? 'utf8' : 'binary'
444        if (encoding === 'utf8') {
445          response.setEncoding('utf8')
446        }
447
448        setState(self.HEADERS_RECEIVED)
449        self.status = response.statusCode
450
451        if (encoding === 'utf8') {
452          response.on('data', function (chunk) {
453            // Make sure there's some data
454            if (chunk) {
455              self.responseText += chunk
456            }
457            // Don't emit state changes if the connection has been aborted.
458            if (sendFlag) {
459              setState(self.LOADING)
460            }
461          })
462
463          response.on('end', function () {
464            if (sendFlag) {
465              // Discard the end event if the connection has been aborted
466              setState(self.DONE)
467              sendFlag = false
468            }
469          })
470        } else {
471          response.on('data', function (chunk) {
472            // Make sure there's some data
473            if (chunk) {
474              responseBinary.push(chunk)
475            }
476            // Don't emit state changes if the connection has been aborted.
477            if (sendFlag) {
478              setState(self.LOADING)
479            }
480          })
481
482          response.on('end', function () {
483            // buffers are Uint8Array instances
484            self.response = Buffer.concat(responseBinary).buffer
485            if (sendFlag) {
486              // Discard the end event if the connection has been aborted
487              setState(self.DONE)
488              sendFlag = false
489            }
490          })
491        }
492
493        response.on('error', function (error) {
494          self.handleError(error, url)
495        })
496      }
497
498      // Error handler for the request
499      const errorHandler = function errorHandler (error) {
500        self.handleError(error, url)
501      }
502
503      // Create the request
504      request = doRequest(options, responseHandler).on('error', errorHandler)
505
506      // Node 0.4 and later won't accept empty data. Make sure it's needed.
507      if (data) {
508        request.write(data)
509      }
510
511      request.end()
512
513      self.dispatchEvent('loadstart')
514    } else { // Synchronous
515      const maxBuffer = process.env.UNXHR_MAX_BUFFER
516        ? parseInt(process.env.UNXHR_MAX_BUFFER)
517        : DEFAULT_MAX_BUFFER
518      const encoding = responseType === 'text' ? 'utf8' : 'binary'
519      const scriptPath = ospath.join(__dirname, 'request.js')
520      const output = require('child_process').execSync(`"${process.execPath}" "${scriptPath}" \
521--ssl="${ssl}" \
522--encoding="${encoding}" \
523--request-options=${JSON.stringify(JSON.stringify(options))}`, { stdio: ['pipe', 'pipe', 'pipe'], input: data, maxBuffer: maxBuffer })
524      const result = JSON.parse(output.toString('utf8'))
525      if (result.error) {
526        throw translateError(result.error, url)
527      } else {
528        response = result.data
529        self.status = result.data.statusCode
530        if (encoding === 'binary') {
531          self.response = Uint8Array.from(result.data.binary.data).buffer
532        } else {
533          self.responseText = result.data.text
534        }
535        setState(self.DONE)
536      }
537    }
538  }
539
540  /**
541   * Called when an error is encountered to deal with it.
542   */
543  this.handleError = function (error, url) {
544    this.status = 0
545    this.statusText = ''
546    this.responseText = ''
547    errorFlag = true
548    setState(this.DONE)
549    this.dispatchEvent('error', { error: translateError(error, url) })
550  }
551
552  /**
553   * Aborts a request.
554   */
555  this.abort = function () {
556    if (request) {
557      request.abort()
558      request = null
559    }
560
561    headers = defaultHeaders
562    this.status = 0
563    this.responseText = ''
564    this.responseXML = ''
565
566    errorFlag = true
567
568    if (this.readyState !== this.UNSENT &&
569      (this.readyState !== this.OPENED || sendFlag) &&
570      this.readyState !== this.DONE) {
571      sendFlag = false
572      setState(this.DONE)
573    }
574    this.readyState = this.UNSENT
575    this.dispatchEvent('abort')
576  }
577
578  /**
579   * Adds an event listener. Preferred method of binding to events.
580   */
581  this.addEventListener = function (event, callback) {
582    if (!(event in listeners)) {
583      listeners[event] = []
584    }
585    // Currently allows duplicate callbacks. Should it?
586    listeners[event].push(callback)
587  }
588
589  /**
590   * Remove an event callback that has already been bound.
591   * Only works on the matching funciton, cannot be a copy.
592   */
593  this.removeEventListener = function (event, callback) {
594    if (event in listeners) {
595      // Filter will return a new array with the callback removed
596      listeners[event] = listeners[event].filter(function (ev) {
597        return ev !== callback
598      })
599    }
600  }
601
602  /**
603   * Dispatch any events, including both "on" methods and events attached using addEventListener.
604   */
605  this.dispatchEvent = function (event, args) {
606    if (typeof self['on' + event] === 'function') {
607      self['on' + event](args)
608    }
609    if (event in listeners) {
610      for (let i = 0, len = listeners[event].length; i < len; i++) {
611        listeners[event][i].call(self, args)
612      }
613    }
614  }
615
616  /**
617   * Changes readyState and calls onreadystatechange.
618   *
619   * @param state - {Number} New state
620   */
621  const setState = function (state) {
622    if (state === self.LOADING || self.readyState !== state) {
623      self.readyState = state
624
625      if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) {
626        self.dispatchEvent('readystatechange')
627      }
628
629      if (self.readyState === self.DONE && !errorFlag) {
630        self.dispatchEvent('load')
631        // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie)
632        self.dispatchEvent('loadend')
633      }
634    }
635  }
636
637  const translateError = function (error, url) {
638    if (typeof error === 'object') {
639      if (error.code === 'ENOTFOUND' || error.code === 'EAI_AGAIN') {
640        // XMLHttpRequest throws a DOMException when DNS lookup fails:
641        // code: 19
642        // message: "Failed to execute 'send' on 'XMLHttpRequest': Failed to load 'http://url/'."
643        // name: "NetworkError"
644        // stack: (...)
645        return new Error(`Failed to execute 'send' on 'XMLHttpRequest': Failed to load '${url}'.`)
646      }
647      if (error instanceof Error) {
648        return error
649      }
650      return new Error(JSON.stringify(error))
651    }
652    return new Error(error)
653  }
654}
655