You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
598 lines
18 KiB
598 lines
18 KiB
var url = require("url"); |
|
var URL = url.URL; |
|
var http = require("http"); |
|
var https = require("https"); |
|
var Writable = require("stream").Writable; |
|
var assert = require("assert"); |
|
var debug = require("./debug"); |
|
|
|
// Create handlers that pass events from native requests |
|
var events = ["abort", "aborted", "connect", "error", "socket", "timeout"]; |
|
var eventHandlers = Object.create(null); |
|
events.forEach(function (event) { |
|
eventHandlers[event] = function (arg1, arg2, arg3) { |
|
this._redirectable.emit(event, arg1, arg2, arg3); |
|
}; |
|
}); |
|
|
|
// Error types with codes |
|
var RedirectionError = createErrorType( |
|
"ERR_FR_REDIRECTION_FAILURE", |
|
"Redirected request failed" |
|
); |
|
var TooManyRedirectsError = createErrorType( |
|
"ERR_FR_TOO_MANY_REDIRECTS", |
|
"Maximum number of redirects exceeded" |
|
); |
|
var MaxBodyLengthExceededError = createErrorType( |
|
"ERR_FR_MAX_BODY_LENGTH_EXCEEDED", |
|
"Request body larger than maxBodyLength limit" |
|
); |
|
var WriteAfterEndError = createErrorType( |
|
"ERR_STREAM_WRITE_AFTER_END", |
|
"write after end" |
|
); |
|
|
|
// An HTTP(S) request that can be redirected |
|
function RedirectableRequest(options, responseCallback) { |
|
// Initialize the request |
|
Writable.call(this); |
|
this._sanitizeOptions(options); |
|
this._options = options; |
|
this._ended = false; |
|
this._ending = false; |
|
this._redirectCount = 0; |
|
this._redirects = []; |
|
this._requestBodyLength = 0; |
|
this._requestBodyBuffers = []; |
|
|
|
// Attach a callback if passed |
|
if (responseCallback) { |
|
this.on("response", responseCallback); |
|
} |
|
|
|
// React to responses of native requests |
|
var self = this; |
|
this._onNativeResponse = function (response) { |
|
self._processResponse(response); |
|
}; |
|
|
|
// Perform the first request |
|
this._performRequest(); |
|
} |
|
RedirectableRequest.prototype = Object.create(Writable.prototype); |
|
|
|
RedirectableRequest.prototype.abort = function () { |
|
abortRequest(this._currentRequest); |
|
this.emit("abort"); |
|
}; |
|
|
|
// Writes buffered data to the current native request |
|
RedirectableRequest.prototype.write = function (data, encoding, callback) { |
|
// Writing is not allowed if end has been called |
|
if (this._ending) { |
|
throw new WriteAfterEndError(); |
|
} |
|
|
|
// Validate input and shift parameters if necessary |
|
if (!(typeof data === "string" || typeof data === "object" && ("length" in data))) { |
|
throw new TypeError("data should be a string, Buffer or Uint8Array"); |
|
} |
|
if (typeof encoding === "function") { |
|
callback = encoding; |
|
encoding = null; |
|
} |
|
|
|
// Ignore empty buffers, since writing them doesn't invoke the callback |
|
// https://github.com/nodejs/node/issues/22066 |
|
if (data.length === 0) { |
|
if (callback) { |
|
callback(); |
|
} |
|
return; |
|
} |
|
// Only write when we don't exceed the maximum body length |
|
if (this._requestBodyLength + data.length <= this._options.maxBodyLength) { |
|
this._requestBodyLength += data.length; |
|
this._requestBodyBuffers.push({ data: data, encoding: encoding }); |
|
this._currentRequest.write(data, encoding, callback); |
|
} |
|
// Error when we exceed the maximum body length |
|
else { |
|
this.emit("error", new MaxBodyLengthExceededError()); |
|
this.abort(); |
|
} |
|
}; |
|
|
|
// Ends the current native request |
|
RedirectableRequest.prototype.end = function (data, encoding, callback) { |
|
// Shift parameters if necessary |
|
if (typeof data === "function") { |
|
callback = data; |
|
data = encoding = null; |
|
} |
|
else if (typeof encoding === "function") { |
|
callback = encoding; |
|
encoding = null; |
|
} |
|
|
|
// Write data if needed and end |
|
if (!data) { |
|
this._ended = this._ending = true; |
|
this._currentRequest.end(null, null, callback); |
|
} |
|
else { |
|
var self = this; |
|
var currentRequest = this._currentRequest; |
|
this.write(data, encoding, function () { |
|
self._ended = true; |
|
currentRequest.end(null, null, callback); |
|
}); |
|
this._ending = true; |
|
} |
|
}; |
|
|
|
// Sets a header value on the current native request |
|
RedirectableRequest.prototype.setHeader = function (name, value) { |
|
this._options.headers[name] = value; |
|
this._currentRequest.setHeader(name, value); |
|
}; |
|
|
|
// Clears a header value on the current native request |
|
RedirectableRequest.prototype.removeHeader = function (name) { |
|
delete this._options.headers[name]; |
|
this._currentRequest.removeHeader(name); |
|
}; |
|
|
|
// Global timeout for all underlying requests |
|
RedirectableRequest.prototype.setTimeout = function (msecs, callback) { |
|
var self = this; |
|
|
|
// Destroys the socket on timeout |
|
function destroyOnTimeout(socket) { |
|
socket.setTimeout(msecs); |
|
socket.removeListener("timeout", socket.destroy); |
|
socket.addListener("timeout", socket.destroy); |
|
} |
|
|
|
// Sets up a timer to trigger a timeout event |
|
function startTimer(socket) { |
|
if (self._timeout) { |
|
clearTimeout(self._timeout); |
|
} |
|
self._timeout = setTimeout(function () { |
|
self.emit("timeout"); |
|
clearTimer(); |
|
}, msecs); |
|
destroyOnTimeout(socket); |
|
} |
|
|
|
// Stops a timeout from triggering |
|
function clearTimer() { |
|
// Clear the timeout |
|
if (self._timeout) { |
|
clearTimeout(self._timeout); |
|
self._timeout = null; |
|
} |
|
|
|
// Clean up all attached listeners |
|
self.removeListener("abort", clearTimer); |
|
self.removeListener("error", clearTimer); |
|
self.removeListener("response", clearTimer); |
|
if (callback) { |
|
self.removeListener("timeout", callback); |
|
} |
|
if (!self.socket) { |
|
self._currentRequest.removeListener("socket", startTimer); |
|
} |
|
} |
|
|
|
// Attach callback if passed |
|
if (callback) { |
|
this.on("timeout", callback); |
|
} |
|
|
|
// Start the timer if or when the socket is opened |
|
if (this.socket) { |
|
startTimer(this.socket); |
|
} |
|
else { |
|
this._currentRequest.once("socket", startTimer); |
|
} |
|
|
|
// Clean up on events |
|
this.on("socket", destroyOnTimeout); |
|
this.on("abort", clearTimer); |
|
this.on("error", clearTimer); |
|
this.on("response", clearTimer); |
|
|
|
return this; |
|
}; |
|
|
|
// Proxy all other public ClientRequest methods |
|
[ |
|
"flushHeaders", "getHeader", |
|
"setNoDelay", "setSocketKeepAlive", |
|
].forEach(function (method) { |
|
RedirectableRequest.prototype[method] = function (a, b) { |
|
return this._currentRequest[method](a, b); |
|
}; |
|
}); |
|
|
|
// Proxy all public ClientRequest properties |
|
["aborted", "connection", "socket"].forEach(function (property) { |
|
Object.defineProperty(RedirectableRequest.prototype, property, { |
|
get: function () { return this._currentRequest[property]; }, |
|
}); |
|
}); |
|
|
|
RedirectableRequest.prototype._sanitizeOptions = function (options) { |
|
// Ensure headers are always present |
|
if (!options.headers) { |
|
options.headers = {}; |
|
} |
|
|
|
// Since http.request treats host as an alias of hostname, |
|
// but the url module interprets host as hostname plus port, |
|
// eliminate the host property to avoid confusion. |
|
if (options.host) { |
|
// Use hostname if set, because it has precedence |
|
if (!options.hostname) { |
|
options.hostname = options.host; |
|
} |
|
delete options.host; |
|
} |
|
|
|
// Complete the URL object when necessary |
|
if (!options.pathname && options.path) { |
|
var searchPos = options.path.indexOf("?"); |
|
if (searchPos < 0) { |
|
options.pathname = options.path; |
|
} |
|
else { |
|
options.pathname = options.path.substring(0, searchPos); |
|
options.search = options.path.substring(searchPos); |
|
} |
|
} |
|
}; |
|
|
|
|
|
// Executes the next native request (initial or redirect) |
|
RedirectableRequest.prototype._performRequest = function () { |
|
// Load the native protocol |
|
var protocol = this._options.protocol; |
|
var nativeProtocol = this._options.nativeProtocols[protocol]; |
|
if (!nativeProtocol) { |
|
this.emit("error", new TypeError("Unsupported protocol " + protocol)); |
|
return; |
|
} |
|
|
|
// If specified, use the agent corresponding to the protocol |
|
// (HTTP and HTTPS use different types of agents) |
|
if (this._options.agents) { |
|
var scheme = protocol.slice(0, -1); |
|
this._options.agent = this._options.agents[scheme]; |
|
} |
|
|
|
// Create the native request and set up its event handlers |
|
var request = this._currentRequest = |
|
nativeProtocol.request(this._options, this._onNativeResponse); |
|
request._redirectable = this; |
|
for (var event of events) { |
|
request.on(event, eventHandlers[event]); |
|
} |
|
|
|
// RFC7230§5.3.1: When making a request directly to an origin server, […] |
|
// a client MUST send only the absolute path […] as the request-target. |
|
this._currentUrl = /^\//.test(this._options.path) ? |
|
url.format(this._options) : |
|
// When making a request to a proxy, […] |
|
// a client MUST send the target URI in absolute-form […]. |
|
this._currentUrl = this._options.path; |
|
|
|
// End a redirected request |
|
// (The first request must be ended explicitly with RedirectableRequest#end) |
|
if (this._isRedirect) { |
|
// Write the request entity and end |
|
var i = 0; |
|
var self = this; |
|
var buffers = this._requestBodyBuffers; |
|
(function writeNext(error) { |
|
// Only write if this request has not been redirected yet |
|
/* istanbul ignore else */ |
|
if (request === self._currentRequest) { |
|
// Report any write errors |
|
/* istanbul ignore if */ |
|
if (error) { |
|
self.emit("error", error); |
|
} |
|
// Write the next buffer if there are still left |
|
else if (i < buffers.length) { |
|
var buffer = buffers[i++]; |
|
/* istanbul ignore else */ |
|
if (!request.finished) { |
|
request.write(buffer.data, buffer.encoding, writeNext); |
|
} |
|
} |
|
// End the request if `end` has been called on us |
|
else if (self._ended) { |
|
request.end(); |
|
} |
|
} |
|
}()); |
|
} |
|
}; |
|
|
|
// Processes a response from the current native request |
|
RedirectableRequest.prototype._processResponse = function (response) { |
|
// Store the redirected response |
|
var statusCode = response.statusCode; |
|
if (this._options.trackRedirects) { |
|
this._redirects.push({ |
|
url: this._currentUrl, |
|
headers: response.headers, |
|
statusCode: statusCode, |
|
}); |
|
} |
|
|
|
// RFC7231§6.4: The 3xx (Redirection) class of status code indicates |
|
// that further action needs to be taken by the user agent in order to |
|
// fulfill the request. If a Location header field is provided, |
|
// the user agent MAY automatically redirect its request to the URI |
|
// referenced by the Location field value, |
|
// even if the specific status code is not understood. |
|
|
|
// If the response is not a redirect; return it as-is |
|
var location = response.headers.location; |
|
if (!location || this._options.followRedirects === false || |
|
statusCode < 300 || statusCode >= 400) { |
|
response.responseUrl = this._currentUrl; |
|
response.redirects = this._redirects; |
|
this.emit("response", response); |
|
|
|
// Clean up |
|
this._requestBodyBuffers = []; |
|
return; |
|
} |
|
|
|
// The response is a redirect, so abort the current request |
|
abortRequest(this._currentRequest); |
|
// Discard the remainder of the response to avoid waiting for data |
|
response.destroy(); |
|
|
|
// RFC7231§6.4: A client SHOULD detect and intervene |
|
// in cyclical redirections (i.e., "infinite" redirection loops). |
|
if (++this._redirectCount > this._options.maxRedirects) { |
|
this.emit("error", new TooManyRedirectsError()); |
|
return; |
|
} |
|
|
|
// Store the request headers if applicable |
|
var requestHeaders; |
|
var beforeRedirect = this._options.beforeRedirect; |
|
if (beforeRedirect) { |
|
requestHeaders = Object.assign({ |
|
// The Host header was set by nativeProtocol.request |
|
Host: response.req.getHeader("host"), |
|
}, this._options.headers); |
|
} |
|
|
|
// RFC7231§6.4: Automatic redirection needs to done with |
|
// care for methods not known to be safe, […] |
|
// RFC7231§6.4.2–3: For historical reasons, a user agent MAY change |
|
// the request method from POST to GET for the subsequent request. |
|
var method = this._options.method; |
|
if ((statusCode === 301 || statusCode === 302) && this._options.method === "POST" || |
|
// RFC7231§6.4.4: The 303 (See Other) status code indicates that |
|
// the server is redirecting the user agent to a different resource […] |
|
// A user agent can perform a retrieval request targeting that URI |
|
// (a GET or HEAD request if using HTTP) […] |
|
(statusCode === 303) && !/^(?:GET|HEAD)$/.test(this._options.method)) { |
|
this._options.method = "GET"; |
|
// Drop a possible entity and headers related to it |
|
this._requestBodyBuffers = []; |
|
removeMatchingHeaders(/^content-/i, this._options.headers); |
|
} |
|
|
|
// Drop the Host header, as the redirect might lead to a different host |
|
var currentHostHeader = removeMatchingHeaders(/^host$/i, this._options.headers); |
|
|
|
// If the redirect is relative, carry over the host of the last request |
|
var currentUrlParts = url.parse(this._currentUrl); |
|
var currentHost = currentHostHeader || currentUrlParts.host; |
|
var currentUrl = /^\w+:/.test(location) ? this._currentUrl : |
|
url.format(Object.assign(currentUrlParts, { host: currentHost })); |
|
|
|
// Determine the URL of the redirection |
|
var redirectUrl; |
|
try { |
|
redirectUrl = url.resolve(currentUrl, location); |
|
} |
|
catch (cause) { |
|
this.emit("error", new RedirectionError(cause)); |
|
return; |
|
} |
|
|
|
// Create the redirected request |
|
debug("redirecting to", redirectUrl); |
|
this._isRedirect = true; |
|
var redirectUrlParts = url.parse(redirectUrl); |
|
Object.assign(this._options, redirectUrlParts); |
|
|
|
// Drop confidential headers when redirecting to a less secure protocol |
|
// or to a different domain that is not a superdomain |
|
if (redirectUrlParts.protocol !== currentUrlParts.protocol && |
|
redirectUrlParts.protocol !== "https:" || |
|
redirectUrlParts.host !== currentHost && |
|
!isSubdomain(redirectUrlParts.host, currentHost)) { |
|
removeMatchingHeaders(/^(?:authorization|cookie)$/i, this._options.headers); |
|
} |
|
|
|
// Evaluate the beforeRedirect callback |
|
if (typeof beforeRedirect === "function") { |
|
var responseDetails = { |
|
headers: response.headers, |
|
statusCode: statusCode, |
|
}; |
|
var requestDetails = { |
|
url: currentUrl, |
|
method: method, |
|
headers: requestHeaders, |
|
}; |
|
try { |
|
beforeRedirect(this._options, responseDetails, requestDetails); |
|
} |
|
catch (err) { |
|
this.emit("error", err); |
|
return; |
|
} |
|
this._sanitizeOptions(this._options); |
|
} |
|
|
|
// Perform the redirected request |
|
try { |
|
this._performRequest(); |
|
} |
|
catch (cause) { |
|
this.emit("error", new RedirectionError(cause)); |
|
} |
|
}; |
|
|
|
// Wraps the key/value object of protocols with redirect functionality |
|
function wrap(protocols) { |
|
// Default settings |
|
var exports = { |
|
maxRedirects: 21, |
|
maxBodyLength: 10 * 1024 * 1024, |
|
}; |
|
|
|
// Wrap each protocol |
|
var nativeProtocols = {}; |
|
Object.keys(protocols).forEach(function (scheme) { |
|
var protocol = scheme + ":"; |
|
var nativeProtocol = nativeProtocols[protocol] = protocols[scheme]; |
|
var wrappedProtocol = exports[scheme] = Object.create(nativeProtocol); |
|
|
|
// Executes a request, following redirects |
|
function request(input, options, callback) { |
|
// Parse parameters |
|
if (typeof input === "string") { |
|
var urlStr = input; |
|
try { |
|
input = urlToOptions(new URL(urlStr)); |
|
} |
|
catch (err) { |
|
/* istanbul ignore next */ |
|
input = url.parse(urlStr); |
|
} |
|
} |
|
else if (URL && (input instanceof URL)) { |
|
input = urlToOptions(input); |
|
} |
|
else { |
|
callback = options; |
|
options = input; |
|
input = { protocol: protocol }; |
|
} |
|
if (typeof options === "function") { |
|
callback = options; |
|
options = null; |
|
} |
|
|
|
// Set defaults |
|
options = Object.assign({ |
|
maxRedirects: exports.maxRedirects, |
|
maxBodyLength: exports.maxBodyLength, |
|
}, input, options); |
|
options.nativeProtocols = nativeProtocols; |
|
|
|
assert.equal(options.protocol, protocol, "protocol mismatch"); |
|
debug("options", options); |
|
return new RedirectableRequest(options, callback); |
|
} |
|
|
|
// Executes a GET request, following redirects |
|
function get(input, options, callback) { |
|
var wrappedRequest = wrappedProtocol.request(input, options, callback); |
|
wrappedRequest.end(); |
|
return wrappedRequest; |
|
} |
|
|
|
// Expose the properties on the wrapped protocol |
|
Object.defineProperties(wrappedProtocol, { |
|
request: { value: request, configurable: true, enumerable: true, writable: true }, |
|
get: { value: get, configurable: true, enumerable: true, writable: true }, |
|
}); |
|
}); |
|
return exports; |
|
} |
|
|
|
/* istanbul ignore next */ |
|
function noop() { /* empty */ } |
|
|
|
// from https://github.com/nodejs/node/blob/master/lib/internal/url.js |
|
function urlToOptions(urlObject) { |
|
var options = { |
|
protocol: urlObject.protocol, |
|
hostname: urlObject.hostname.startsWith("[") ? |
|
/* istanbul ignore next */ |
|
urlObject.hostname.slice(1, -1) : |
|
urlObject.hostname, |
|
hash: urlObject.hash, |
|
search: urlObject.search, |
|
pathname: urlObject.pathname, |
|
path: urlObject.pathname + urlObject.search, |
|
href: urlObject.href, |
|
}; |
|
if (urlObject.port !== "") { |
|
options.port = Number(urlObject.port); |
|
} |
|
return options; |
|
} |
|
|
|
function removeMatchingHeaders(regex, headers) { |
|
var lastValue; |
|
for (var header in headers) { |
|
if (regex.test(header)) { |
|
lastValue = headers[header]; |
|
delete headers[header]; |
|
} |
|
} |
|
return (lastValue === null || typeof lastValue === "undefined") ? |
|
undefined : String(lastValue).trim(); |
|
} |
|
|
|
function createErrorType(code, defaultMessage) { |
|
function CustomError(cause) { |
|
Error.captureStackTrace(this, this.constructor); |
|
if (!cause) { |
|
this.message = defaultMessage; |
|
} |
|
else { |
|
this.message = defaultMessage + ": " + cause.message; |
|
this.cause = cause; |
|
} |
|
} |
|
CustomError.prototype = new Error(); |
|
CustomError.prototype.constructor = CustomError; |
|
CustomError.prototype.name = "Error [" + code + "]"; |
|
CustomError.prototype.code = code; |
|
return CustomError; |
|
} |
|
|
|
function abortRequest(request) { |
|
for (var event of events) { |
|
request.removeListener(event, eventHandlers[event]); |
|
} |
|
request.on("error", noop); |
|
request.abort(); |
|
} |
|
|
|
function isSubdomain(subdomain, domain) { |
|
const dot = subdomain.length - domain.length - 1; |
|
return dot > 0 && subdomain[dot] === "." && subdomain.endsWith(domain); |
|
} |
|
|
|
// Exports |
|
module.exports = wrap({ http: http, https: https }); |
|
module.exports.wrap = wrap;
|
|
|