diff --git a/ProxyServer.js b/ProxyServer.js index 52485dc..fa4882c 100644 --- a/ProxyServer.js +++ b/ProxyServer.js @@ -28,8 +28,9 @@ class ProxyServer extends net.createServer { auth, intercept, keys, handleSni, interceptOptions }, - logger || new Logger(verbose)) + logger || new Logger(verbose)); }); + this.bridgedConnections = bridgedConnections; //TODO this is an horrible workaround, but extending doesn't work diff --git a/core/HttpMirror.js b/core/HttpMirror.js new file mode 100644 index 0000000..aca1500 --- /dev/null +++ b/core/HttpMirror.js @@ -0,0 +1,156 @@ +const http = require('http'); +const net = require('net'); + +const {STRINGS} = require('../lib/constants'); +const {CRLF, TRANSFER_ENCODING, CONTENT_LENGTH, CHUNKED, ZERO} = STRINGS; + +module.exports = class HttpMirror { + constructor(session) { + this.server = http.createServer(); + // this.client = http.request; + this.isListening = false; + this._session = session; + this._mirror = { + server: {request: false, response: false}, + client: {request: false, response: false} + }; + } + + async close() { + return new Promise((resolve) => { + if (this.isListening) { + this.server.close(resolve); + return true; + } + resolve(false); + return false; + }); + + } + + listen() { + return new Promise((resolve, reject) => { + if (this.isListening) { + resolve(true); + return; + } + //starting http-server on random port + this.server.listen(0, 'localhost', () => { + this.isListening = true; + resolve(true); + }); + }); + } + + waitForRequest(data) { + const session = this._session; + + return new Promise(async (resolve) => { + const serverInfo = this.server.address(); + const config = {host: serverInfo.address, port: serverInfo.port}; + + if (!this._mirror?.client?.request) { + this._mirror.client.request = net.createConnection(config); + + this.server + .once('connect', (request, clientSocket) => { + const {method, url, headers, httpVersion, trailers} = request; + this._mirror.client.request = false; + clientSocket.destroy(); + resolve({method, url, headers, httpVersion, trailers}); + }) + .once('request', (request, response) => { + this._mirror.client.requesthandler = request; + this._mirror.client.response = response; + const {method, url, headers, httpVersion, trailers} = request; + + request.on('data', (chunk) => { + session._requestCounter++; + session._rawRequestBodyChunks.push(chunk); + }) + .once('data', () => { + resolve({method, url, headers, httpVersion, trailers}); + }) + .once('end', () => { + this._mirror.client.response.end(); + this._mirror.client.request.destroy(); + this._mirror.client.requesthandler.destroy(); + session._request.complete = true; + resolve({method, url, headers, httpVersion, trailers}); + }); + }); + return this._mirror.client.request.write(data); + } + else { + const {method, url, headers, httpVersion, trailers} = this._mirror.client.requestHandler; + + this._mirror.client.requestHandler + .once('data', () => { + resolve({method, url, headers, httpVersion, trailers}); + }) + .once('end', () => { + resolve({method, url, headers, httpVersion, trailers}); + }); + return this._mirror.client.request.write(data); + } + }); + } + + waitForResponse(data) { + const session = this._session; + return new Promise((resolve) => { + if (!this._mirror?.server?.response) { + this.server.once('request', (request, response) => { + this._mirror.server.response = response; + response.socket.write(data); //dumping TCP data + }); + + const {address, port} = this.server.address(); + const request = session._request; + const options = {method: request.method, headers: request.headers, path: '/', host: address, port}; + delete options.headers.host; + + http.request(options, (response) => { + const {headers, httpVersion, statusCode} = response; + this._mirror.server.request = response; + + response + .on('data', (chunk) => { + session._responseCounter++; + session._rawResponseBodyChunks.push(chunk); + if (headers?.[CONTENT_LENGTH]) { + const bodyBytes = Buffer.byteLength(session.rawResponse); + session._response.complete = parseInt(headers[CONTENT_LENGTH]) <= bodyBytes; + } + }) + .once('data', (chunk) => { + resolve({headers, httpVersion, statusCode}); + }) + .once('close', async () => { + session._response.complete = true; + this._mirror?.server?.request?.destroy(); + this._mirror?.server?.response?.end(); + await this.close(); //closing this instance at the end of response + resolve({headers, httpVersion, statusCode}); + }); + + setTimeout(() => { + resolve({headers, httpVersion, statusCode}); //resolving in case of no data after 10ms + }, 10); + }) + .end(); + } + else { + const {headers, httpVersion, statusCode} = this._mirror.server.request; + this._mirror.server.request + .once('data', () => { + resolve({headers, httpVersion, statusCode}); + }) + .once('close', () => { + resolve({headers, httpVersion, statusCode}); + }); + return this._mirror.server.response.socket.write(data); //dumping TCP data to existing response-Object + } + }); + } +}; \ No newline at end of file diff --git a/core/Session.js b/core/Session.js index b0ab5e6..a0cc813 100644 --- a/core/Session.js +++ b/core/Session.js @@ -1,23 +1,10 @@ +const net = require('net'); const tls = require('tls'); -const {EVENTS, DEFAULT_KEYS, STRINGS, HTTP_METHODS} = require('../lib/constants'); -const parseDataToObject = require('../lib/parseDataToObject'); +const {EVENTS, DEFAULT_KEYS} = require('../lib/constants'); const {CLOSE, DATA, ERROR} = EVENTS; -const {BINARY_ENCODING, CRLF, TRANSFER_ENCODING, CONTENT_LENGTH, CHUNKED, ZERO} = STRINGS; -const NOT_HEX_VALUE = /[^0-9A-Fa-f]/g; -/** - * Write data of given socket - * @param {net.Socket} socket - * @param data - */ -function socketWrite(socket, data) { - return new Promise(function (resolve, reject) { - if (socket && !socket.destroyed && data) { - return socket.write(data, null, resolve); - } - return resolve(false); - }); -} +const socketWrite = require('../lib/socketWrite'); +const HttpMirror = require('./HttpMirror'); /** * Destroy the socket @@ -52,7 +39,10 @@ class Session { this._isResponsePaused = false; this._rawResponseBodyChunks = []; + this._rawRequestBodyChunks = []; this._interceptOptions = interceptOptions; + + this._httpMirror = new HttpMirror(this); } /** @@ -114,6 +104,9 @@ class Session { * @returns {Session} */ destroy() { + if (this._httpMirror.isListening) { + this._httpMirror.close(); + } if (this._dst) { socketDestroy(this._dst); } @@ -151,6 +144,23 @@ class Session { return this; } + async sendToMirror(data, isResponse = false) { + await this._httpMirror.listen(); //this will happen only once + + if (!this.isHttps || this._updated) { + if (!isResponse) { + const request = await this._httpMirror.waitForRequest(data); //waiting for parsed request data + this._request = Object.assign(this._request, request); + } + else { + const response = await this._httpMirror.waitForResponse(data); //waiting for parsed response data + this._response = Object.assign(this._response, response); + } + return true; + } + return false; + } + /** * Get own id * @returns {string} @@ -159,96 +169,19 @@ class Session { return this._id; } - set request(buffer) { - if (!this.isHttps || this._updated) { //parse only if data is not encrypted - const parsedRequest = parseDataToObject(buffer, null, this._requestCounter > 0); - const body = parsedRequest.body; - delete parsedRequest.body; - - ++this._requestCounter; - if (parsedRequest.headers) { - this._request = parsedRequest; - } - if (this._request.method === HTTP_METHODS.CONNECT) { //ignore CONNECT method - --this._requestCounter; - } - - if (body) { - this._request.body = (this._request.body || '') + body; - } - } - - return this._request; + get response() { + return JSON.parse(JSON.stringify(Object.assign({}, this._response, { + body: this.rawResponse?.toString() || undefined, + }))); } get request() { - return this._request; - } - - set response(buffer) { - if (!this.isHttps || this._updated) { //parse only if data is not encrypted - const parsedResponse = parseDataToObject(buffer, true, this._responseCounter > 0); - - if (!parsedResponse.headers) { - this.rawResponse = buffer; //pushing whole buffer, because there aren't headers here - } - else { - //found body from buffer without converting - const DOUBLE_CRLF = CRLF + CRLF; - const splitAt = buffer.indexOf(DOUBLE_CRLF, 0); - this.rawResponse = buffer.slice(splitAt + DOUBLE_CRLF.length); - } - - ++this._responseCounter; - this._response = Object.assign({}, this._response, parsedResponse); - - if (this._response?.statusCode >= 300 - && this._response?.statusCode < 400) { - //redirects will use same session to do next requests - --this._requestCounter; //resetting request - --this._responseCounter; //resetting response - } - - if (this._response?.headers?.[CONTENT_LENGTH] - && this.rawResponse.length) { - const bodyBytes = Buffer.byteLength(this.rawResponse); - this._response.complete = parseInt(this._response.headers[CONTENT_LENGTH]) <= bodyBytes; - } - if (this._response?.headers?.[TRANSFER_ENCODING] === CHUNKED - && this.rawResponse.length) { - this._response.complete = buffer.indexOf(ZERO + CRLF + CRLF) > -1; - } - } - return this._response; - } - - set rawResponse(buffer) { - if (this._responseCounter === 0) { - this._rawResponseBodyChunks = []; //need to reset all possible body-chunks - } - const bufferToPush = Buffer.from(buffer, BINARY_ENCODING); - const splitAt = bufferToPush.indexOf(CRLF); - if (splitAt > -1) { - // handling transfer-encoding: chunked - // each chunk contains info like: - // chunk length in hex\r\n - // chunk\r\n - const [chunkLengthHex, chunk] = [bufferToPush.slice(0, splitAt), bufferToPush.slice(splitAt + CRLF.length)]; - const chunkLength = parseInt(chunkLengthHex, 16); - if (Number.isInteger(chunkLength) - && chunkLength !== 0) { - const [thisChunk, nextChunk] = [chunk.slice(0, chunkLength), chunk.slice(chunkLength + CRLF.length)]; - this._rawResponseBodyChunks.push(thisChunk); - if (nextChunk.length > 0) { - return this.rawResponse = nextChunk; //process next chunk in recursion - } - } - else if (!Number.isInteger(chunkLength)) { - return this.rawResponse = chunkLengthHex; //valid chunk is what we think could be the hex-number - } - return; //go out from this function - } - this._rawResponseBodyChunks.push(bufferToPush); + return JSON.parse(JSON.stringify(Object.assign({}, this._request, { + body: this.rawRequest?.toString() || undefined, + trailers: Object.keys(this._request.trailers || {}).length > 0 + ? this._request.trailers + : undefined + }))); } /** @@ -260,11 +193,11 @@ class Session { } /** - * Get response object. - * @returns {Object} + * Get the response body as Buffer. + * @returns {Buffer} */ - get response() { - return Object.assign({}, this._response, {body: this.rawResponse.toString()}); + get rawRequest() { + return Buffer.concat(this._rawRequestBodyChunks); } /** diff --git a/core/onConnectedClientHandling.js b/core/onConnectedClientHandling.js index 8b813e9..dd688f9 100644 --- a/core/onConnectedClientHandling.js +++ b/core/onConnectedClientHandling.js @@ -25,6 +25,7 @@ function sleep(ms) { }); } + /** * * @param clientSocket @@ -40,7 +41,6 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect handleSni, interceptOptions } = options; - const remotePort = clientSocket.remotePort; const remoteAddress = clientSocket.remoteAddress; const remoteID = remoteAddress + SEPARATOR + remotePort; @@ -97,8 +97,8 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect const thisTunnel = bridgedConnections[remoteID]; if (thisTunnel) { if (!thisTunnel._isResponsePaused) { - thisTunnel.response = dataFromUpStream; thisTunnel._pauseResponse(); + await thisTunnel.sendToMirror(dataFromUpStream, true); const responseData = isFunction(injectResponse) ? await injectResponse(dataFromUpStream, thisTunnel) : dataFromUpStream; @@ -139,7 +139,7 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect function updateSockets() { const thisTunnel = bridgedConnections[remoteID]; - if (intercept && thisTunnel && thisTunnel.isHttps && !thisTunnel._updated) { + if (intercept && thisTunnel?.isHttps && !thisTunnel._updated) { const keysObject = isFunction(keys) ? keys(thisTunnel) : false; @@ -154,13 +154,13 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect /** * @param {buffer} data - * @param {string} firstHeaderRow * @param {boolean} isConnectMethod - false as default. * @returns Promise{boolean|{host: string, port: number, protocol: string, credentials: string, upstreamed: boolean}} */ - async function prepareTunnel(data, firstHeaderRow, isConnectMethod = false) { + async function prepareTunnel(data, isConnectMethod = false) { const thisTunnel = bridgedConnections[remoteID]; - const upstreamHost = firstHeaderRow.split(BLANK)[1]; + + const upstreamHost = thisTunnel._request.headers.host; const initOpt = getConnectionOptions(false, upstreamHost); thisTunnel.setTunnelOpt(initOpt); //settings opt before callback @@ -192,7 +192,7 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect } if (connectionOpt.credentials) { - const headers = thisTunnel.request.headers; + const headers = thisTunnel._request.headers; const basedCredentials = Buffer.from(connectionOpt.credentials) .toString('base64'); //converting to base64 headers[PROXY_AUTH.toLowerCase()] = PROXY_AUTH_BASIC + BLANK + basedCredentials; @@ -215,7 +215,8 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect if (connectionOpt.upstreamed) { if (connectionOpt.credentials) { const headers = thisTunnel.request.headers; - const basedCredentials = Buffer.from(connectionOpt.credentials).toString('base64'); //converting to base64 + const basedCredentials = Buffer.from(connectionOpt.credentials) + .toString('base64'); //converting to base64 headers[PROXY_AUTH.toLowerCase()] = PROXY_AUTH_BASIC + BLANK + basedCredentials; const newData = rebuildHeaders(headers, data); await thisTunnel.clientRequestWrite(newData) @@ -249,21 +250,19 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect } /** - * @param {Array} split * @param {buffer} data */ - function handleProxyTunnel(split, data) { - const firstHeaderRow = split[0]; + function handleProxyTunnel(data) { const thisTunnel = bridgedConnections[remoteID]; - if (~firstHeaderRow.indexOf(CONNECT)) { //managing HTTP-Tunnel(upstream) & HTTPs - return prepareTunnel(data, firstHeaderRow, true); + if (thisTunnel._request.method === CONNECT) { //managing HTTP-Tunnel(upstream) & HTTPs + return prepareTunnel(data, true); } - else if (firstHeaderRow.indexOf(CONNECT) === -1 + else if (thisTunnel._request.method !== CONNECT && !thisTunnel._dst) { // managing http - return prepareTunnel(data, firstHeaderRow); + return prepareTunnel(data); } - else if (thisTunnel && thisTunnel._dst) { + else if (thisTunnel?._dst) { return onDirectConnectionOpen(data); } } @@ -274,14 +273,11 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect */ async function onDataFromClient(data) { const thisTunnel = bridgedConnections[remoteID]; - thisTunnel.request = data; - - const dataString = data.toString(); + await thisTunnel.sendToMirror(data); try { - if (dataString && dataString.length > 0) { - const headers = thisTunnel.request.headers; - const split = dataString.split(CRLF); + if (data?.length > 0) { + const headers = thisTunnel._request.headers; if (isFunction(auth) && !thisTunnel.isAuthenticated()) { @@ -302,10 +298,10 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect if (isLogged) { thisTunnel.setUserAuthentication(username); // cleaning data from headers because we dont need to leak this info - const headers = Object.assign({}, thisTunnel.request.headers); + const headers = Object.assign({}, thisTunnel._request.headers); delete headers[PROXY_AUTH.toLowerCase()]; const newData = rebuildHeaders(headers, data); - return handleProxyTunnel(split, newData); + return handleProxyTunnel(newData); } else { //return auth-error and close all @@ -319,7 +315,7 @@ module.exports = function onConnectedClientHandling(clientSocket, bridgedConnect } } else { - return handleProxyTunnel(split, data); + return handleProxyTunnel(data); } } } diff --git a/examples/handleGzip.js b/examples/handleGzip.js index c9ea126..81d1cb6 100644 --- a/examples/handleGzip.js +++ b/examples/handleGzip.js @@ -9,7 +9,7 @@ const server = new ProxyServer({ verbose: true, intercept: true, injectResponse: (data, session) => { - console.log(session.response.complete, session.response); + console.log('res', session.response); if (session.response.complete //if response is finished && session.response.headers['content-encoding'] === 'gzip') { //body is gzip const zlib = require('zlib'); diff --git a/examples/spoofRequest.js b/examples/spoofRequest.js index 6ffab1a..e4d5db5 100644 --- a/examples/spoofRequest.js +++ b/examples/spoofRequest.js @@ -9,13 +9,12 @@ const server = new ProxyServer({ intercept: true, verbose: true, injectData: (data, session) => { - if (session.isHttps) { - const modifiedData = data.toString() - .replace(session.request.headers['user-agent'], switchWith); //replacing UA-Header-Value + console.log('req before spoofing', session.request); - return Buffer.from(modifiedData); - } - return data; + const modifiedData = data.toString() + .replace(session.request.headers['user-agent'], switchWith); //replacing UA-Header-Value + + return Buffer.from(modifiedData); } }); @@ -31,7 +30,7 @@ server.listen(port, '0.0.0.0', async function () { .catch((err) => ({stdout: err.message})); console.log('Response =>', stdout); } - server.close(); + await server.close(); }); // curl -x localhost:10001 http://ifconfig.io/ua diff --git a/lib/socketWrite.js b/lib/socketWrite.js new file mode 100644 index 0000000..e17a537 --- /dev/null +++ b/lib/socketWrite.js @@ -0,0 +1,13 @@ +/** + * Write data of given socket + * @param {net.Socket} socket + * @param data + */ +module.exports = function socketWrite(socket, data) { + return new Promise(function (resolve, reject) { + if (socket && !socket.destroyed && data) { + return socket.write(data, null, resolve); + } + return resolve(false); + }); +}; \ No newline at end of file