diff --git a/lib/node-http-proxy/http-proxy.js b/lib/node-http-proxy/http-proxy.js index 704eec903..c287fd5a8 100644 --- a/lib/node-http-proxy/http-proxy.js +++ b/lib/node-http-proxy/http-proxy.js @@ -1,33 +1,34 @@ /* - node-http-proxy.js: http proxy for node.js + node-http-proxy.js: http proxy for node.js - Copyright (c) 2010 Charlie Robbins, Mikeal Rogers, Marak Squires, Fedor Indutny + Copyright (c) 2010 Charlie Robbins, Mikeal Rogers, Marak Squires, Fedor Indutny - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ + */ var events = require('events'), - http = require('http'), - util = require('util'), - httpProxy = require('../node-http-proxy'); + http = require('http'), + util = require('util'), + httpProxy = require('../node-http-proxy'), + ReverseProxyHelper = require('./reverse-proxy-helper.js').ReverseProxyHelper; // // ### function HttpProxy (options) @@ -66,6 +67,8 @@ var HttpProxy = exports.HttpProxy = function (options) { this.forward = options.forward; this.target = options.target; + this.reverseProxyHelper = new ReverseProxyHelper(self.target); + // // Setup the necessary instances instance variables for // the `target` and `forward` `host:port` combinations @@ -104,6 +107,7 @@ var HttpProxy = exports.HttpProxy = function (options) { this.source = options.source || { host: 'localhost', port: 8000 }; this.source.https = this.source.https || options.https; this.changeOrigin = options.changeOrigin || false; + }; // Inherit from events.EventEmitter @@ -225,10 +229,11 @@ HttpProxy.prototype.proxyRequest = function (req, res, buffer) { // origin of the host header to the target URL! Please // don't revert this without documenting it! // + var originalHost = req.headers.host; if (this.changeOrigin) { outgoing.headers.host = this.target.host + ':' + this.target.port; } - + // // Open new HTTP request to internal resource with will act // as a reverse proxy pass @@ -247,14 +252,7 @@ HttpProxy.prototype.proxyRequest = function (req, res, buffer) { delete response.headers['transfer-encoding']; } - if ((response.statusCode === 301) || (response.statusCode === 302)) { - if (self.source.https && !self.target.https) { - response.headers.location = response.headers.location.replace(/^http\:/, 'https:'); - } - if (self.target.https && !self.source.https) { - response.headers.location = response.headers.location.replace(/^https\:/, 'http:'); - } - } + self.reverseProxyHelper.rewriteLocationHeader(req, response, originalHost); // Set the headers of the client response res.writeHead(response.statusCode, response.headers); diff --git a/lib/node-http-proxy/reverse-proxy-helper.js b/lib/node-http-proxy/reverse-proxy-helper.js new file mode 100644 index 000000000..3ec373f78 --- /dev/null +++ b/lib/node-http-proxy/reverse-proxy-helper.js @@ -0,0 +1,122 @@ +/* + reverse-proxy-helper.js: http reverse proxy helper methods. + + Copyright (c) 2012 Jo Voordeckers - @jovoordeckers - jo.voordeckers@gmail.com + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ + +// +// #### function ReverseProxyHelper(target) +// #### @target {Object} Proxy options, target (proxied) server +// Helper functions for reverse proxy mode. +var ReverseProxyHelper = exports.ReverseProxyHelper = function(target) { + var self = this; + self.target = target; +} + +// +// #### function httpRedirect(repsonse) +// #### @response {ServerResponse} Response from the target server +// Check if the response is a server-side HTTP 30x Redirect. +ReverseProxyHelper.prototype.isHttpRedirect = function(response) { + return (response.statusCode === 301 || response.statusCode === 302) && !!response.headers && !!response.headers.location; +} + +// +// #### function decomposeUrl(url) +// #### @url {String} absolute URL String +// Return an absolute URL in decomposed form { proto, host, port, path }. +ReverseProxyHelper.prototype.decomposeUrl = function (url) { + + if (url) { + + var urlMatch = url.match(/(https?)\:\/\/([a-zA-Z0-9\-\.]+)(?:\:(\d{1,5}))?((?:\/|\?).+)?/); + + if (urlMatch) { + + var decomp = { + proto: urlMatch[1], + host: urlMatch[2], + port: urlMatch[3] ? Number(urlMatch[3]) : (urlMatch[1] === "http" ? 80 : 443), + path: urlMatch[4] + }; + + return decomp; + + } + } + + return null; +} + +// +// #### function rewriteLocationHeader(request, response, originalHost) +// #### @request {ServerRequest} Incoming HTTP Request intercepted by the proxy +// #### @response {ServerResponse} Outgoing HTTP Request to write proxied data to +// #### @originalHost {String} Original Host header of the incoming request, before manipulation +// If needed rewrite the Location header to be consistent with the source and target configuration. +// This will only rewrite if a Host header is present in the original request and +// the X-Forwarded-Proto in the proxy request header. +ReverseProxyHelper.prototype.rewriteLocationHeader = function (request, response, originalHost) { + + var self = this, + decompConn; + + + function isRedirectToTarget(decomp) { // Check if the redirect URL assumes a redirect to the target server + + var sourceProto = request.headers["x-forwarded-proto"]; + + decompConn = self.decomposeUrl(sourceProto+"://"+originalHost); + + if (!decompConn) return false; + + var targetProto = (self.target.https ? "https" : "http") + var sameProto = targetProto == decomp.proto; + var samePort = Number(self.target.port) === decomp.port; + var isLocalHost = "127.0.0.1" === decomp.host || "localhost" === decomp.host; + + return sameProto && samePort && ( decompConn.host === decomp.host || self.target.host == decomp.host || isLocalHost); + } + + if (self.isHttpRedirect(response)) { + + var decomp = this.decomposeUrl(response.headers.location); + + if (decomp && isRedirectToTarget(decomp)) { + + var defaultPort = (decompConn.port === 80 || decompConn.port === 443); + + var proto = decompConn.proto + "://", + host = decompConn.host, + port = defaultPort ? "" : ":" + decompConn.port, + path = decomp.path; + + response.headers['x-reverse-proxy-location-rewritten-from'] = response.headers.location; + + response.headers.location = proto + host + port + path; + + response.headers['x-reverse-proxy-location-rewritten-to'] = response.headers.location; + + } + } +} diff --git a/test/http/reverse-proxy-helper-test.js b/test/http/reverse-proxy-helper-test.js new file mode 100644 index 000000000..cfa16b50d --- /dev/null +++ b/test/http/reverse-proxy-helper-test.js @@ -0,0 +1,339 @@ +/* + reverse-proxy-helper-test.js: test for http reverse proxy helper methods. + + Copyright (c) 2012 Jo Voordeckers - @jovoordeckers - jo.voordeckers@gmail.com + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ + +var assert = require('assert'), + vows = require('vows'), + ReverseProxyHelper = require('../../lib/node-http-proxy/reverse-proxy-helper').ReverseProxyHelper, + req = { headers: {} }, + reqHttps = { headers: {} }; + +req.headers["x-forwarded-proto"] = "http"; +reqHttps.headers["x-forwarded-proto"] = "https"; + +vows.describe("Reverse Proxy Helper").addBatch({ + + "when decomposing URL ":{ + + "http://server-host.com/myRequest":{ + topic:function () { + return new ReverseProxyHelper().decomposeUrl("http://server-host.com/myRequest"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "http"); + }, + "the port is 80":function (topic) { + assert.equal(topic.port, 80); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is /myRequest":function (topic) { + assert.equal(topic.path, "/myRequest"); + } + }, + + "https://server-host.com/myRequest":{ + topic:function () { + return new ReverseProxyHelper().decomposeUrl("https://server-host.com/myRequest"); + }, + "the proto is https":function (deco) { + assert.equal(deco.proto, "https"); + }, + "the port is 443":function (topic) { + assert.equal(topic.port, 443); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is /myRequest":function (topic) { + assert.equal(topic.path, "/myRequest"); + } + }, + + "http://server-host.com:8181/myRequest":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("http://server-host.com:8181/myRequest"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "http"); + }, + "the port is 8181":function (topic) { + assert.equal(topic.port, 8181); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is /myRequest":function (topic) { + assert.equal(topic.path, "/myRequest"); + } + }, + + "https://server-host.com:8181/myRequest":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("https://server-host.com:8181/myRequest"); + }, + "the proto is https":function (deco) { + assert.equal(deco.proto, "https"); + }, + "the port is 8181":function (topic) { + assert.equal(topic.port, 8181); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is /myRequest":function (topic) { + assert.equal(topic.path, "/myRequest"); + } + }, + + "http://server-host.com:8080":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("http://server-host.com:8080"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "http"); + }, + "the port is 8080":function (topic) { + assert.equal(topic.port, 8080); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is undefined":function (topic) { + assert.equal(topic.path, undefined); + } + }, + + "http://server-host.com:8080/?user=foo":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("http://server-host.com:8080?user=foo"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "http"); + }, + "the port is 8080":function (topic) { + assert.equal(topic.port, 8080); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is ?user=foo":function (topic) { + assert.equal(topic.path, "?user=foo"); + } + }, + + "http://server-host.com:8080/myRequest?user=foo":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("http://server-host.com:8080/myRequest?user=foo"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "http"); + }, + "the port is 8080":function (topic) { + assert.equal(topic.port, 8080); + }, + "the host is server-host.com":function (topic) { + assert.equal(topic.host, "server-host.com"); + }, + "the path is /myRequest?user=foo":function (topic) { + assert.equal(topic.path, "/myRequest?user=foo"); + } + }, + + "http://127.0.0.1:8080/myRequest?user=foo":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("http://127.0.0.1:8080/myRequest?user=foo"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "http"); + }, + "the port is 8080":function (topic) { + assert.equal(topic.port, 8080); + }, + "the host is 127.0.0.1":function (topic) { + assert.equal(topic.host, "127.0.0.1"); + }, + "the path is /myRequest?user=foo":function (topic) { + assert.equal(topic.path, "/myRequest?user=foo"); + } + }, + + "https://127.0.0.1:8080/myRequest?user=foo":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("https://127.0.0.1:8080/myRequest?user=foo"); + }, + "the proto is http":function (deco) { + assert.equal(deco.proto, "https"); + }, + "the port is 8080":function (topic) { + assert.equal(topic.port, 8080); + }, + "the host is 127.0.0.1":function (topic) { + assert.equal(topic.host, "127.0.0.1"); + }, + "the path is /myRequest?user=foo":function (topic) { + assert.equal(topic.path, "/myRequest?user=foo"); + } + }, + + "/someApp/foo?bar=baz":{ + + topic:function () { + return new ReverseProxyHelper().decomposeUrl("/someApp/foo?bar=baz"); + }, + "decomposes to undefined, not an absolute URL":function (deco) { + assert.equal(deco, undefined); + } + } + + }, + + "when response with statusCode":{ + "200":{ + topic:function () { + return new ReverseProxyHelper().isHttpRedirect({ statusCode:200 }); + }, + "no redirect":function (topic) { + assert.equal(topic, false); + } + }, + "301 and no headers":{ + topic:function () { + return new ReverseProxyHelper().isHttpRedirect({ statusCode:301 }); + }, + "no redirect - headers missing":function (topic) { + assert.equal(topic, false); + } + }, + "302 and no headers":{ + topic:function () { + return new ReverseProxyHelper().isHttpRedirect({ statusCode:302 }); + }, + "no redirect - headers missing":function (topic) { + assert.equal(topic, false); + } + }, + "301 and location headers":{ + topic:function () { + return new ReverseProxyHelper().isHttpRedirect({ statusCode:301, headers:{ location:'http://some/url' }}); + }, + "redirect - with location header":function (topic) { + assert.equal(topic, true); + } + }, + "302 and location headers":{ + topic:function () { + return new ReverseProxyHelper().isHttpRedirect({ statusCode:302, headers:{ location:'http://some/url' }}); + }, + "redirect - with location header":function (topic) { + assert.equal(topic, true); + } + } + }, + + "when location header":{ + "/someApp/foo?bar=baz":{ + topic:function () { + var origHost = "source.com"; + var target = { host:"target.com", port:8080 }; + var resp = { headers : { location: "/someApp/foo?bar=baz" }}; + new ReverseProxyHelper(target).rewriteLocationHeader(req, resp, origHost) + return resp; + }, + "don't rewrite (relative URL)":function (topic) { + assert.equal(topic.headers.location, "/someApp/foo?bar=baz"); + } + }, + "http://target.com:8080/someApp/foo?bar=baz":{ + topic:function () { + var origHost = "source.com"; + var target = { host:"target.com", port:8080 }; + var resp = { statusCode: 301, headers : { location: "http://target.com:8080/someApp/foo?bar=baz" }}; + new ReverseProxyHelper(target).rewriteLocationHeader(req, resp, origHost) + return resp; + }, + "rewrite to http://source.com/someApp/foo?bar=baz":function (topic) { + assert.equal(topic.headers.location, "http://source.com/someApp/foo?bar=baz"); + } + }, + "http://source.com:8080/someApp/foo?bar=baz and https source":{ + topic:function () { + var origHost = "source.com"; + var target = { host:"target.com", port:8080, https: false }; + var resp = { statusCode: 301, headers : { location: "http://source.com:8080/someApp/foo?bar=baz" }}; + new ReverseProxyHelper(target).rewriteLocationHeader(reqHttps, resp, origHost) + return resp; + }, + "rewrite to https://source.com/someApp/foo?bar=baz":function (topic) { + assert.equal(topic.headers.location, "https://source.com/someApp/foo?bar=baz"); + } + }, + "http://source.com/someApp/foo?bar=baz and https source same port source and target":{ + topic:function () { + var origHost = "source.com:80"; + var target = { host:"target.com", port:80, https: false }; + var resp = { statusCode: 301, headers : { location: "http://source.com/someApp/foo?bar=baz" }}; + new ReverseProxyHelper(target).rewriteLocationHeader(reqHttps, resp, origHost) + return resp; + }, + "rewrite to https://source.com/someApp/foo?bar=baz":function (topic) { + assert.equal(topic.headers.location, "https://source.com/someApp/foo?bar=baz"); + } + }, + "http://localhost:8080/someApp/foo?bar=baz":{ + topic:function () { + var origHost = "localhost:8181"; + var target = { host:"localhost", port:8080, https: false }; + var resp = { statusCode: 301, headers : { location: "http://localhost:8080/someApp/foo?bar=baz" }}; + new ReverseProxyHelper(target).rewriteLocationHeader(req, resp, origHost); + return resp; + }, + "rewrite to http://localhost:8181/someApp/foo?bar=baz":function (topic) { + assert.equal(topic.headers.location, "http://localhost:8181/someApp/foo?bar=baz"); + } + }, + "https://localhost:8080/someApp/foo?bar=baz and https target server":{ + topic:function () { + var origHost = "localhost:8181"; + var target = { host:"localhost", port:8080, https: true }; + var resp = { statusCode: 301, headers : { location: "https://localhost:8080/someApp/foo?bar=baz" }}; + new ReverseProxyHelper(target).rewriteLocationHeader(req, resp, origHost); + return resp; + }, + "rewrite to http://localhost:8181/someApp/foo?bar=baz":function (topic) { + assert.equal(topic.headers.location, "http://localhost:8181/someApp/foo?bar=baz"); + } + }, + } + +}).export(module);