diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 9edf3fb8..1ea68785 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -19,6 +19,7 @@ var platform = require('../platform'); var fromDataURI = require('../helpers/fromDataURI'); var stream = require('stream'); var estimateDataURLDecodedBytes = require('../helpers/estimateDataURLDecodedBytes.js'); +var shouldBypassProxy = require('../helpers/shouldBypassProxy'); var isHttps = /https:?/; @@ -46,9 +47,11 @@ function setProxy(options, configProxy, location) { if (!proxy && proxy !== false) { var proxyUrl = getProxyForUrl(location); if (proxyUrl) { - proxy = url.parse(proxyUrl); - // replace 'host' since the proxy object is not a URL object - proxy.host = proxy.hostname; + if (!shouldBypassProxy(location)) { + proxy = url.parse(proxyUrl); + // replace 'host' since the proxy object is not a URL object + proxy.host = proxy.hostname; + } } } if (proxy) { @@ -273,7 +276,7 @@ module.exports = function httpAdapter(config) { } else { options.hostname = parsed.hostname; options.port = parsed.port; - setProxy(options, config.proxy, protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path); + setProxy(options, config.proxy, protocol + '//' + parsed.host + options.path); } var transport; diff --git a/lib/core/dispatchRequest.js b/lib/core/dispatchRequest.js index 6ac7bc0e..37130312 100644 --- a/lib/core/dispatchRequest.js +++ b/lib/core/dispatchRequest.js @@ -6,6 +6,7 @@ var isCancel = require('../cancel/isCancel'); var defaults = require('../defaults'); var CanceledError = require('../cancel/CanceledError'); var normalizeHeaderName = require('../helpers/normalizeHeaderName'); +var sanitizeHeaderValue = require('../helpers/sanitizeHeaderValue'); /** * Throws a `CanceledError` if cancellation has been requested. @@ -58,6 +59,10 @@ module.exports = function dispatchRequest(config) { } ); + utils.forEach(config.headers, function sanitizeHeaderConfigValue(value, header) { + config.headers[header] = sanitizeHeaderValue(value); + }); + var adapter = config.adapter || defaults.adapter; return adapter(config).then(function onAdapterResolution(response) { diff --git a/lib/helpers/sanitizeHeaderValue.js b/lib/helpers/sanitizeHeaderValue.js new file mode 100644 index 00000000..fdef5d84 --- /dev/null +++ b/lib/helpers/sanitizeHeaderValue.js @@ -0,0 +1,22 @@ +'use strict'; + +var utils = require('../utils'); + +var INVALID_HEADER_VALUE_RE = /[^\x09\x20-\x7E\x80-\xFF]/g; +var BOUNDARY_WHITESPACE_RE = /^[\x09\x20]|[\x09\x20]$/g; + +function sanitizeHeaderValue(value) { + if (value === false || value == null) { + return value; + } + + if (utils.isArray(value)) { + return value.map(sanitizeHeaderValue); + } + + return String(value) + .replace(INVALID_HEADER_VALUE_RE, '') + .replace(BOUNDARY_WHITESPACE_RE, ''); +} + +module.exports = sanitizeHeaderValue; diff --git a/lib/helpers/shouldBypassProxy.js b/lib/helpers/shouldBypassProxy.js new file mode 100644 index 00000000..92fef63d --- /dev/null +++ b/lib/helpers/shouldBypassProxy.js @@ -0,0 +1,129 @@ +'use strict'; + +var URL = require('url').URL; + +var DEFAULT_PORTS = { + http: 80, + https: 443, + ws: 80, + wss: 443, + ftp: 21 +}; + +function parseNoProxyEntry(entry) { + var entryHost = entry; + var entryPort = 0; + + if (entryHost.charAt(0) === '[') { + var bracketIndex = entryHost.indexOf(']'); + + if (bracketIndex !== -1) { + var host = entryHost.slice(1, bracketIndex); + var rest = entryHost.slice(bracketIndex + 1); + + if (rest.charAt(0) === ':' && /^\d+$/.test(rest.slice(1))) { + entryPort = parseInt(rest.slice(1), 10); + } + + return [host, entryPort]; + } + } + + var firstColon = entryHost.indexOf(':'); + var lastColon = entryHost.lastIndexOf(':'); + + if (firstColon !== -1 && firstColon === lastColon && /^\d+$/.test(entryHost.slice(lastColon + 1))) { + entryPort = parseInt(entryHost.slice(lastColon + 1), 10); + entryHost = entryHost.slice(0, lastColon); + } + + return [entryHost, entryPort]; +} + +function normalizeNoProxyHost(hostname) { + if (!hostname) { + return hostname; + } + + if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') { + hostname = hostname.slice(1, -1); + } + + return hostname.replace(/\.+$/, ''); +} + +function isLoopbackIPv4(hostname) { + var octets = hostname.split('.'); + + if (octets.length !== 4) { + return false; + } + + if (octets[0] !== '127') { + return false; + } + + return octets.every(function testOctet(octet) { + return /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255; + }); +} + +function isLoopbackHost(hostname) { + return hostname === 'localhost' || hostname === '::1' || isLoopbackIPv4(hostname); +} + +module.exports = function shouldBypassProxy(location) { + var parsed; + + try { + parsed = new URL(location); + } catch (err) { + return false; + } + + var noProxy = (process.env.no_proxy || process.env.NO_PROXY || '').toLowerCase(); + + if (!noProxy) { + return false; + } + + if (noProxy === '*') { + return true; + } + + var protocol = parsed.protocol.split(':', 1)[0]; + var port = parseInt(parsed.port, 10) || DEFAULT_PORTS[protocol] || 0; + var hostname = normalizeNoProxyHost(parsed.hostname.toLowerCase()); + + return noProxy.split(/[\s,]+/).some(function testNoProxyEntry(entry) { + if (!entry) { + return false; + } + + var entryParts = parseNoProxyEntry(entry); + var entryHost = normalizeNoProxyHost(entryParts[0]); + var entryPort = entryParts[1]; + + if (!entryHost) { + return false; + } + + if (entryPort && entryPort !== port) { + return false; + } + + if (isLoopbackHost(hostname) && isLoopbackHost(entryHost)) { + return true; + } + + if (entryHost.charAt(0) === '*') { + entryHost = entryHost.slice(1); + } + + if (entryHost.charAt(0) === '.') { + return hostname.slice(-entryHost.length) === entryHost; + } + + return hostname === entryHost; + }); +}; diff --git a/test/specs/headers.spec.js b/test/specs/headers.spec.js index c1d10d7b..18b5d292 100644 --- a/test/specs/headers.spec.js +++ b/test/specs/headers.spec.js @@ -112,4 +112,17 @@ describe('headers', function () { done(); }); }); + + it('should sanitize headers containing invalid characters', function (done) { + axios('/foo', { + headers: { + 'x-test': ' ok\r\nInjected: yes\t' + } + }); + + getAjaxRequest().then(function (request) { + testHeaderValue(request.requestHeaders, 'x-test', 'okInjected: yes'); + done(); + }); + }); }); diff --git a/test/unit/adapters/http.js b/test/unit/adapters/http.js index ded712a4..c2c023fa 100644 --- a/test/unit/adapters/http.js +++ b/test/unit/adapters/http.js @@ -31,8 +31,39 @@ describe('supports http with nodejs', function () { proxy = null; } delete process.env.http_proxy; + delete process.env.HTTP_PROXY; delete process.env.https_proxy; delete process.env.no_proxy; + delete process.env.NO_PROXY; + }); + + it('should sanitize request headers containing invalid characters', function (done) { + server = http.createServer(function (req, res) { + res.setHeader('Content-Type', 'text/plain'); + res.end(req.headers['x-test']); + }).listen(4444, function () { + axios.get('http://localhost:4444/', { + headers: { + 'x-test': ' ok\r\nInjected: yes\t' + } + }).then(function (response) { + assert.equal(response.data, 'okInjected: yes'); + done(); + }).catch(done); + }); + }); + + it('should preserve request error for unavailable host with invalid characters', function (done) { + axios.get('http://localhost:1/', { + headers: { + 'x-test': 'ok\r\nInjected: yes' + } + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function (error) { + assert.notEqual(error.message, 'Invalid character in header content ["x-test"]'); + done(); + }); }); it('should throw an error if the timeout property is not parsable as a number', function (done) { @@ -967,6 +998,98 @@ describe('supports http with nodejs', function () { }); }); + it('should not use proxy for localhost with trailing dot when listed in no_proxy', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost,127.0.0.1,::1'; + process.env.NO_PROXY = 'localhost,127.0.0.1,::1'; + + axios.get('http://localhost.:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for localhost with trailing dot'); + done(); + }); + }); + }); + + it('should not use proxy for bracketed IPv6 loopback when listed in no_proxy', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost,127.0.0.1,::1'; + process.env.NO_PROXY = 'localhost,127.0.0.1,::1'; + + axios.get('http://[::1]:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for IPv6 loopback'); + done(); + }); + }); + }); + + it('should not use proxy for 127.0.0.1 when no_proxy is localhost', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost'; + process.env.NO_PROXY = 'localhost'; + + axios.get('http://127.0.0.1:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for IPv4 loopback alias'); + done(); + }); + }); + }); + + it('should not use proxy for [::1] when no_proxy is localhost', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost'; + process.env.NO_PROXY = 'localhost'; + + axios.get('http://[::1]:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for IPv6 loopback alias'); + done(); + }); + }); + }); + it('should use proxy for domains not in no_proxy', function (done) { server = http.createServer(function (req, res) { res.setHeader('Content-Type', 'text/html; charset=UTF-8'); diff --git a/test/unit/helpers/sanitizeHeaderValue.js b/test/unit/helpers/sanitizeHeaderValue.js new file mode 100644 index 00000000..ae735a13 --- /dev/null +++ b/test/unit/helpers/sanitizeHeaderValue.js @@ -0,0 +1,20 @@ +var assert = require('assert'); +var sanitizeHeaderValue = require('../../../lib/helpers/sanitizeHeaderValue'); + +describe('helpers::sanitizeHeaderValue', function () { + it('should remove invalid header characters', function () { + assert.strictEqual(sanitizeHeaderValue('ok\r\nInjected: yes'), 'okInjected: yes'); + assert.strictEqual(sanitizeHeaderValue('ok\x01bad'), 'okbad'); + }); + + it('should remove boundary whitespace', function () { + assert.strictEqual(sanitizeHeaderValue(' value\t'), 'value'); + }); + + it('should sanitize array values recursively', function () { + assert.deepStrictEqual( + sanitizeHeaderValue([' safe=1 ', 'unsafe=1\nInjected: true']), + ['safe=1', 'unsafe=1Injected: true'] + ); + }); +}); diff --git a/test/unit/helpers/shouldBypassProxy.js b/test/unit/helpers/shouldBypassProxy.js new file mode 100644 index 00000000..40e2839e --- /dev/null +++ b/test/unit/helpers/shouldBypassProxy.js @@ -0,0 +1,74 @@ +var assert = require('assert'); +var shouldBypassProxy = require('../../../lib/helpers/shouldBypassProxy'); + +var originalNoProxy = process.env.no_proxy; +var originalNOProxy = process.env.NO_PROXY; + +function setNoProxy(value) { + process.env.no_proxy = value; + process.env.NO_PROXY = value; +} + +describe('helpers::shouldBypassProxy', function () { + afterEach(function () { + if (originalNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalNoProxy; + } + + if (originalNOProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNOProxy; + } + }); + + it('should bypass proxy for localhost with a trailing dot', function () { + setNoProxy('localhost,127.0.0.1,::1'); + assert.strictEqual(shouldBypassProxy('http://localhost.:8080/'), true); + }); + + it('should bypass proxy for bracketed ipv6 loopback', function () { + setNoProxy('localhost,127.0.0.1,::1'); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + }); + + it('should support bracketed ipv6 entries in no_proxy', function () { + setNoProxy('[::1]'); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + }); + + it('should match wildcard and explicit ports', function () { + setNoProxy('*.example.com,localhost:8080'); + + assert.strictEqual(shouldBypassProxy('http://api.example.com/'), true); + assert.strictEqual(shouldBypassProxy('http://localhost:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://localhost:8081/'), false); + }); + + it('should treat localhost and loopback IP aliases as equivalent', function () { + setNoProxy('localhost'); + + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + + setNoProxy('127.0.0.1'); + + assert.strictEqual(shouldBypassProxy('http://localhost:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + + setNoProxy('::1'); + + assert.strictEqual(shouldBypassProxy('http://localhost:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8080/'), true); + }); + + it('should keep loopback alias matching port-aware', function () { + setNoProxy('localhost:8080'); + + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8081/'), false); + }); +});