diff --git a/index.js b/index.js index 057c6b1..3232a72 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,29 @@ var Writable = require("stream").Writable; var assert = require("assert"); var debug = require("./debug"); +// Whether to use the native URL object or the legacy url module +var useNativeURL = false; +try { + assert(new URL()); +} +catch (error) { + useNativeURL = error.code === "ERR_INVALID_URL"; +} + +// URL fields to preserve in copy operations +var preservedUrlFields = [ + "auth", + "host", + "hostname", + "href", + "path", + "pathname", + "port", + "protocol", + "query", + "search", +]; + // Create handlers that pass events from native requests var events = ["abort", "aborted", "connect", "error", "socket", "timeout"]; var eventHandlers = Object.create(null); @@ -15,19 +38,20 @@ events.forEach(function (event) { }; }); +// Error types with codes var InvalidUrlError = createErrorType( "ERR_INVALID_URL", "Invalid URL", TypeError ); -// 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" + "Maximum number of redirects exceeded", + RedirectionError ); var MaxBodyLengthExceededError = createErrorType( "ERR_FR_MAX_BODY_LENGTH_EXCEEDED", @@ -62,7 +86,13 @@ function RedirectableRequest(options, responseCallback) { // React to responses of native requests var self = this; this._onNativeResponse = function (response) { - self._processResponse(response); + try { + self._processResponse(response); + } + catch (cause) { + self.emit("error", cause instanceof RedirectionError ? + cause : new RedirectionError({ cause: cause })); + } }; // Perform the first request @@ -280,8 +310,7 @@ RedirectableRequest.prototype._performRequest = function () { var protocol = this._options.protocol; var nativeProtocol = this._options.nativeProtocols[protocol]; if (!nativeProtocol) { - this.emit("error", new TypeError("Unsupported protocol " + protocol)); - return; + throw new TypeError("Unsupported protocol " + protocol); } // If specified, use the agent corresponding to the protocol @@ -380,8 +409,7 @@ RedirectableRequest.prototype._processResponse = function (response) { // 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; + throw new TooManyRedirectsError(); } // Store the request headers if applicable @@ -415,33 +443,23 @@ RedirectableRequest.prototype._processResponse = function (response) { 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 currentUrlParts = parseUrl(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: cause })); - return; - } - // Create the redirected request - debug("redirecting to", redirectUrl); + var redirectUrl = resolveUrl(location, currentUrl); + debug("redirecting to", redirectUrl.href); this._isRedirect = true; - var redirectUrlParts = url.parse(redirectUrl); - Object.assign(this._options, redirectUrlParts); + spreadUrlObject(redirectUrl, this._options); // 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)) { + if (redirectUrl.protocol !== currentUrlParts.protocol && + redirectUrl.protocol !== "https:" || + redirectUrl.host !== currentHost && + !isSubdomain(redirectUrl.host, currentHost)) { removeMatchingHeaders(/^(?:authorization|cookie)$/i, this._options.headers); } @@ -456,23 +474,12 @@ RedirectableRequest.prototype._processResponse = function (response) { method: method, headers: requestHeaders, }; - try { - beforeRedirect(this._options, responseDetails, requestDetails); - } - catch (err) { - this.emit("error", err); - return; - } + beforeRedirect(this._options, responseDetails, requestDetails); this._sanitizeOptions(this._options); } // Perform the redirected request - try { - this._performRequest(); - } - catch (cause) { - this.emit("error", new RedirectionError({ cause: cause })); - } + this._performRequest(); }; // Wraps the key/value object of protocols with redirect functionality @@ -492,27 +499,16 @@ function wrap(protocols) { // Executes a request, following redirects function request(input, options, callback) { - // Parse parameters - if (isString(input)) { - var parsed; - try { - parsed = urlToOptions(new URL(input)); - } - catch (err) { - /* istanbul ignore next */ - parsed = url.parse(input); - } - if (!isString(parsed.protocol)) { - throw new InvalidUrlError({ input }); - } - input = parsed; + // Parse parameters, ensuring that input is an object + if (isURL(input)) { + input = spreadUrlObject(input); } - else if (URL && (input instanceof URL)) { - input = urlToOptions(input); + else if (isString(input)) { + input = spreadUrlObject(parseUrl(input)); } else { callback = options; - options = input; + options = validateUrl(input); input = { protocol: protocol }; } if (isFunction(options)) { @@ -551,27 +547,57 @@ function wrap(protocols) { 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); +function parseUrl(input) { + var parsed; + /* istanbul ignore else */ + if (useNativeURL) { + parsed = new URL(input); + } + else { + // Ensure the URL is valid and absolute + parsed = validateUrl(url.parse(input)); + if (!isString(parsed.protocol)) { + throw new InvalidUrlError({ input }); + } + } + return parsed; +} + +function resolveUrl(relative, base) { + /* istanbul ignore next */ + return useNativeURL ? new URL(relative, base) : parseUrl(url.resolve(base, relative)); +} + +function validateUrl(input) { + if (/^\[/.test(input.hostname) && !/^\[[:0-9a-f]+\]$/i.test(input.hostname)) { + throw new InvalidUrlError({ input: input.href || input }); } - return options; + if (/^\[/.test(input.host) && !/^\[[:0-9a-f]+\](:\d+)?$/i.test(input.host)) { + throw new InvalidUrlError({ input: input.href || input }); + } + return input; +} + +function spreadUrlObject(urlObject, target) { + var spread = target || {}; + for (var key of preservedUrlFields) { + spread[key] = urlObject[key]; + } + + // Fix IPv6 hostname + if (spread.hostname.startsWith("[")) { + spread.hostname = spread.hostname.slice(1, -1); + } + // Ensure port is a number + if (spread.port !== "") { + spread.port = Number(spread.port); + } + // Concatenate path + spread.path = spread.search ? spread.pathname + spread.search : spread.pathname; + + return spread; } function removeMatchingHeaders(regex, headers) { @@ -597,8 +623,16 @@ function createErrorType(code, message, baseClass) { // Attach constructor and set default properties CustomError.prototype = new (baseClass || Error)(); - CustomError.prototype.constructor = CustomError; - CustomError.prototype.name = "Error [" + code + "]"; + Object.defineProperties(CustomError.prototype, { + constructor: { + value: CustomError, + enumerable: false, + }, + name: { + value: "Error [" + code + "]", + enumerable: false, + }, + }); return CustomError; } @@ -628,6 +662,10 @@ function isBuffer(value) { return typeof value === "object" && ("length" in value); } +function isURL(value) { + return URL && value instanceof URL; +} + // Exports module.exports = wrap({ http: http, https: https }); module.exports.wrap = wrap; diff --git a/package-lock.json b/package-lock.json index b5fce58..9f47d97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "follow-redirects", - "version": "1.15.3", + "version": "1.15.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "follow-redirects", - "version": "1.15.3", + "version": "1.15.4", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index eb90372..f32466d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "follow-redirects", - "version": "1.15.3", + "version": "1.15.4", "description": "HTTP and HTTPS modules that follow redirects.", "license": "MIT", "main": "index.js", diff --git a/test/test.js b/test/test.js index 078926d..5e53695 100644 --- a/test/test.js +++ b/test/test.js @@ -177,6 +177,171 @@ describe("follow-redirects", function () { }); }); + it("http.get to IPv4 address", function () { + app.get("/a", redirectsTo("/b")); + app.get("/b", redirectsTo("/c")); + app.get("/c", redirectsTo("/d")); + app.get("/d", redirectsTo("/e")); + app.get("/e", redirectsTo("/f")); + app.get("/f", sendsJson({ a: "b" })); + + return server.start(app) + .then(asPromise(function (resolve, reject) { + http.get("http://127.0.0.1:3600/a", concatJson(resolve, reject)).on("error", reject); + })) + .then(function (res) { + assert.deepEqual(res.parsedJson, { a: "b" }); + assert.deepEqual(res.responseUrl, "http://127.0.0.1:3600/f"); + }); + }); + + it("http.get to IPv6 address", function () { + app.get("/a", redirectsTo("/b")); + app.get("/b", redirectsTo("/c")); + app.get("/c", redirectsTo("/d")); + app.get("/d", redirectsTo("/e")); + app.get("/e", redirectsTo("/f")); + app.get("/f", sendsJson({ a: "b" })); + + return server.start(app) + .then(asPromise(function (resolve, reject) { + http.get("http://[::1]:3600/a", concatJson(resolve, reject)).on("error", reject); + })) + .then(function (res) { + assert.deepEqual(res.parsedJson, { a: "b" }); + assert.deepEqual(res.responseUrl, "http://[::1]:3600/f"); + }); + }); + + it("http.get to bracketed IPv4 address", function () { + var error = null; + try { + http.get("http://[127.0.0.1]:3600/a"); + } + catch (err) { + error = err; + } + assert(error instanceof Error); + assert(error instanceof TypeError); + assert.equal(error.code, "ERR_INVALID_URL"); + assert.equal(error.input, "http://[127.0.0.1]:3600/a"); + }); + + it("http.get to bracketed IPv4 address specified as host", function () { + var error = null; + try { + http.get({ + host: "[127.0.0.1]:3600", + path: "/a", + }); + } + catch (err) { + error = err; + } + assert(error instanceof Error); + assert(error instanceof TypeError); + assert.equal(error.code, "ERR_INVALID_URL"); + }); + + it("http.get to bracketed IPv4 address specified as hostname", function () { + var error = null; + try { + http.get({ + hostname: "[127.0.0.1]", + port: 3600, + path: "/a", + }); + } + catch (err) { + error = err; + } + assert(error instanceof Error); + assert(error instanceof TypeError); + assert.equal(error.code, "ERR_INVALID_URL"); + }); + + it("http.get to bracketed hostname", function () { + var error = null; + try { + http.get("http://[localhost]:3600/a"); + } + catch (err) { + error = err; + } + assert(error instanceof Error); + assert(error instanceof TypeError); + assert.equal(error.code, "ERR_INVALID_URL"); + assert.equal(error.input, "http://[localhost]:3600/a"); + }); + + it("http.get redirecting to IPv4 address", function () { + app.get("/a", redirectsTo("http://127.0.0.1:3600/b")); + app.get("/b", sendsJson({ a: "b" })); + + return server.start(app) + .then(asPromise(function (resolve, reject) { + http.get("http://localhost:3600/a", concatJson(resolve, reject)).on("error", reject); + })) + .then(function (res) { + assert.deepEqual(res.parsedJson, { a: "b" }); + assert.deepEqual(res.responseUrl, "http://127.0.0.1:3600/b"); + }); + }); + + it("http.get redirecting to IPv6 address", function () { + app.get("/a", redirectsTo("http://[::1]:3600/b")); + app.get("/b", sendsJson({ a: "b" })); + + return server.start(app) + .then(asPromise(function (resolve, reject) { + http.get("http://localhost:3600/a", concatJson(resolve, reject)).on("error", reject); + })) + .then(function (res) { + assert.deepEqual(res.parsedJson, { a: "b" }); + assert.deepEqual(res.responseUrl, "http://[::1]:3600/b"); + }); + }); + + it("http.get redirecting to bracketed IPv4 address", function () { + app.get("/a", redirectsTo("http://[127.0.0.1]:3600/b")); + app.get("/b", sendsJson({ a: "b" })); + + return server.start(app) + .then(asPromise(function (resolve, reject) { + http.get("http://localhost:3600/a", concatJson(reject)).on("error", resolve); + })) + .then(function (error) { + assert(error instanceof Error); + assert.equal(error.code, "ERR_FR_REDIRECTION_FAILURE"); + + var cause = error.cause; + assert(cause instanceof Error); + assert(cause instanceof TypeError); + assert.equal(cause.code, "ERR_INVALID_URL"); + assert.equal(cause.input, "http://[127.0.0.1]:3600/b"); + }); + }); + + it("http.get redirecting to bracketed hostname", function () { + app.get("/a", redirectsTo("http://[localhost]:3600/b")); + app.get("/b", sendsJson({ a: "b" })); + + return server.start(app) + .then(asPromise(function (resolve, reject) { + http.get("http://localhost:3600/a", concatJson(reject)).on("error", resolve); + })) + .then(function (error) { + assert(error instanceof Error); + assert.equal(error.code, "ERR_FR_REDIRECTION_FAILURE"); + + var cause = error.cause; + assert(cause instanceof Error); + assert(cause instanceof TypeError); + assert.equal(cause.code, "ERR_INVALID_URL"); + assert.equal(cause.input, "http://[localhost]:3600/b"); + }); + }); + it("http.get with response event", function () { app.get("/a", redirectsTo("/b")); app.get("/b", redirectsTo("/c")); @@ -202,8 +367,8 @@ describe("follow-redirects", function () { try { http.get("/relative"); } - catch (e) { - error = e; + catch (err) { + error = err; } assert(error instanceof Error); assert(error instanceof TypeError); @@ -300,7 +465,7 @@ describe("follow-redirects", function () { switch (error.cause.code) { // Node 17+ case "ERR_INVALID_URL": - assert.equal(error.message, "Redirected request failed: Invalid URL"); + assert(/^Redirected request failed: Invalid URL/.test(error.message)); break; // Older Node versions case "ERR_UNESCAPED_CHARACTERS": @@ -312,23 +477,6 @@ describe("follow-redirects", function () { }); }); - it("emits an error when url.resolve fails", function () { - app.get("/a", redirectsTo("/b")); - var urlResolve = url.resolve; - url.resolve = function () { - throw new Error(); - }; - - return server.start(app) - .then(asPromise(function (resolve) { - http.get("http://localhost:3600/a").on("error", resolve); - })) - .then(function (error) { - url.resolve = urlResolve; - assert.equal(error.code, "ERR_FR_REDIRECTION_FAILURE"); - }); - }); - it("emits an error when the request fails for another reason", function () { app.get("/a", function (req, res) { res.socket.write("HTTP/1.1 301 Moved Permanently\r\n"); @@ -916,12 +1064,16 @@ describe("follow-redirects", function () { .then(asPromise(function (resolve, reject) { http.get("http://localhost:3600/a") .on("response", function () { return reject(new Error("unexpected response")); }) - .on("error", reject); + .on("error", resolve); })) - .catch(function (error) { + .then(function (error) { assert(error instanceof Error); - assert(error instanceof TypeError); - assert.equal(error.message, "Unsupported protocol about:"); + assert.equal(error.message, "Redirected request failed: Unsupported protocol about:"); + + var cause = error.cause; + assert(cause instanceof Error); + assert(cause instanceof TypeError); + assert.equal(cause.message, "Unsupported protocol about:"); }); }); }); @@ -1215,8 +1367,8 @@ describe("follow-redirects", function () { try { req.write(12345678); } - catch (e) { - error = e; + catch (err) { + error = err; } req.destroy(); assert(error instanceof Error); @@ -2081,15 +2233,14 @@ describe("follow-redirects", function () { throw new Error("no redirects!"); }, }; - http.get(options, concatJson(resolve, reject)).on("error", reject); + http.get(options, concatJson(reject)).on("error", resolve); })) - .then(function () { - assert.fail("request chain should have been aborted"); - }) - .catch(function (error) { + .then(function (error) { assert(!redirected); assert(error instanceof Error); - assert.equal(error.message, "no redirects!"); + assert.equal(error.message, "Redirected request failed: no redirects!"); + assert(error.cause instanceof Error); + assert.equal(error.cause.message, "no redirects!"); }); });