UNPKG

cnn-routeomatic

Version:

A library for web server routing, redirecting and rewriting joy for ExpressJS.

870 lines (779 loc) 31.3 kB
/** * Route-o-matic Request object type * * @module rom-request */ 'use strict'; const Bourne = require('@hapi/bourne'), ContentType = require('content-type'), getRawBody = require('raw-body'), Http = require('http'), Http2 = require('http2'), Https = require('https'), HttpError = require('./http-error'), Mime = require('mime'), QS = require('qs'), Query = require('querystring'), redirectCodes = [301, 302, 303, 307, 308], Url = require('url'), utils = require('./utils'); /** * RomRequest object constructor * * @constructor * @param {object} settings - RouteOMatic settings object */ function RomRequest(settings) { // Initialize this object this.body = null; this.dnsLookup = settings.dnsLookup; this.headers = null; this.hostConfig = null; this.hostTable = settings.hostTable; this.logger = settings.requestLogger; this.logPrefix = ''; this.onSent = settings.onSent; this.routePass = 0; this.serverNext = null; this.serverResponse = null; this.serverRequest = null; this.settings = settings; this.timeout = settings.timeout; this.type = ''; // Setup the request logger. this.log = { silly: (msg) => this.logger.silly(this.logPrefix + msg, this.logMeta), debug: (msg) => this.logger.debug(this.logPrefix + msg, this.logMeta), verbose: (msg) => this.logger.verbose(this.logPrefix + msg, this.logMeta), info: (msg) => this.logger.info(this.logPrefix + msg, this.logMeta), warn: (msg) => this.logger.warn(this.logPrefix + msg, this.logMeta), error: (msg) => this.logger.error(this.logPrefix + msg, this.logMeta), fatal: (msg) => this.logger.fatal(this.logPrefix + msg, this.logMeta), important: (msg) => this.logger.important(this.logPrefix + msg, this.logMeta) }; } /** * Normalize and reduce the request URL, if necessary * * @memberof RomRequest * @private * @static * @param {string} url - Request URL * @returns {mixed} - Reduced URL string, null if bad URL */ RomRequest.normalizeAndReduce = function (url) { let nlFlag = false, ppos, qpos, newUrl = url.replace(/%[\dA-Fa-f]{2}/g, (mtch) => { let val = parseInt(mtch.slice(1), 16); if (val === 0x2D || val === 0x2E || val === 0x5F || val === 0x7E || (val >= 0x41 && val <= 0x5A) || (val >= 0x30 && val <= 0x39)) { return String.fromCharCode(val); } if (val === 0x0A || val === 0x0D) { nlFlag = true; } return mtch.toUpperCase(); }); // If the URL still contains percent characters, or if the URL and params contain linefeeds, return a null. if (nlFlag === true || ((ppos = newUrl.indexOf('%')) !== -1 && ((qpos = newUrl.indexOf('?')) === -1 || ppos < qpos))) { return null; } return newUrl; }; /** * Tweak the headers * * @memberof RomRequest * @private * @returns {object} - merged headers */ RomRequest.prototype.responseHeaders = function () { let headers = null; if (this.hostConfig.headers !== null) { if (this.headers !== null) { headers = utils.mergeHeaders(this.hostConfig.headers, this.headers); } else { headers = this.hostConfig.headers; } } else if (this.headers !== null) { headers = this.headers; } return headers; }; /** * Log whatever * * @memberof RomRequest * @public */ /** * End response and finish request * * @memberof RomRequest * @public * @param {number} code - Status code to send with, if any */ RomRequest.prototype.end = function (code) { if (code) { if (code > 309 && code < 600) { this.error(code); } else { this.serverResponse.status(code).end(); this.log.debug(`Request ended (${code}).`); } } else { this.serverResponse.end(); this.log.debug('Request ended.'); } if (this.onSent !== null) { this.onSent(this.serverRequest, this.serverResponse); } }; /** * Send an error * * @memberof RomRequest * @public * @param {number} [code] - Status code of error to send (default is 500) * @param {string} [message] - Optional message to attach to error */ RomRequest.prototype.error = function (code, message) { this.serverResponse.locals.isXhr = this.isXhr; this.serverNext(new HttpError(code || 500, message)); }; /** * Send normal content * * @memberof RomRequest * @private * @param {number} status - Status code to send with * @param {mixed} content - The string, buffer, or object to send. */ RomRequest.prototype.send = function (status, content) { try { let hdrs = this.responseHeaders(), resp = this.serverResponse; // Validate the status code if (typeof status !== 'number' || status < 100 || status > 599) { this.log.warn('Invalid status code passed to send function. Using default code 200.'); status = 200; } // Handle the headers if (hdrs !== null) { resp.set(hdrs); } if (this.type.length !== 0) { resp.type(this.type); } else { resp.type(Mime.getType(this.path) || 'text/html'); } // Send the status code and the response content resp.status(status).send(content); if (this.onSent !== null) { this.onSent(this.serverRequest, resp); } this.log.debug(`Response sent (${status}).`); } catch (err) { this.log.error(`Error sending response: ${err.message}`); this.error(500); } }; /** * Send json content * * @memberof RomRequest * @public * @param {number} status - Status code to send with * @param {object|array|string|boolean|number} content - Content to JSON encode and send */ RomRequest.prototype.json = function (status, content) { try { let data = JSON.stringify(content); this.type = 'json'; this.send(status, data); } catch (err) { this.log.error(`Error sending JSON response: ${err.message}`); this.error(500); } }; /** * Send jsonp content * * @memberof RomRequest * @public * @param {number} status - Status code to send with * @param {object|array|string|boolean|number} content - Content to JSON encode and send * @param {string} [callback] - Optional callback query param name (default is "callback") */ RomRequest.prototype.jsonp = function (status, content, callback) { callback = callback || 'callback'; try { let data = JSON.stringify(content); this.type = 'json'; if (typeof this.queryParams[callback] === 'string' && this.queryParams[callback].length !== 0) { this.headers = this.headers || {}; this.headers['x-content-type-options'] = 'nosniff'; data = this.queryParams[callback] + '(' + data + ')'; } this.send(status, data); } catch (err) { this.log.error(`Error sending JSON response: ${err.message}`); this.error(500); } }; /** * Send a file * * @memberof RomRequest * @private * @param {string} filepath - The full path of the file to send * @param {object} options - sendFile options */ RomRequest.prototype.sendFile = function (filepath, options) { try { let hdrs, resp = this.serverResponse; options = options || {}; // Handle the headers hdrs = this.responseHeaders(); if (hdrs !== null) { options.headers = options.headers ? utils.mergeHeaders(hdrs, options.headers) : hdrs; } if (this.type) { resp.type(this.type); // This may or may not work here } // else handled by Express response sendFile // Send the file resp.sendFile(filepath, options, (err) => { if (err) { if (err.status === 404 || err.code === 'EISDIR') { this.log.debug(`Unable to send file, not found: "${filepath}"`); } else { this.log.error(`Error sending file "${filepath}": ${err.status} - ${err.message}`); } this.error(err.status || 500); } else { if (this.onSent !== null) { this.onSent(this.serverRequest, resp); } this.log.debug(`Sent file "${filepath}"`); } }); } catch (err) { this.log.error(`Error sending response: ${err.message}`); this.error(500); } }; /** * Proxy the response through another server with Express * * @memberof RomRequest * @private * @param {object} options - Route options object */ RomRequest.prototype.proxy = function (options) { try { let handleProxyResponse, reqHeaders = {}, reqLib, newReq, proto, protoVer, proxy = (options && options.proxy) || null, pUrl, resp = this.serverResponse; // Handle bad proxy host if (proxy === null || typeof proxy !== 'object' || typeof proxy.hostname !== 'string' || proxy.hostname.length <= 0) { this.log.error('Proxy hostname not set'); if (resp.headersSent !== true) { this.error(502, 'Proxy hostname not set'); return; } } // Figure out the proto to use, if any protoVer = proxy.protoVer || this.protoVer || '1.1'; proto = (proxy.proto || (protoVer === '2.0' && 'https') || this.proto) + ':'; // Setup proxy destination options.fullUrl = { auth: proxy.auth || this.auth || null, hash: proxy.hash || this.hash || null, hostname: proxy.hostname, pathname: proxy.path || this.path, port: proxy.port || null, protocol: proto, search: proxy.query || null }; // If we are doing path replacement, do it now... if (typeof proxy.pathReplace === 'string' && (typeof proxy.pathMatch === 'string' || proxy.pathMatch instanceof RegExp)) { options.fullUrl.pathname = options.fullUrl.pathname.replace(proxy.pathMatch, proxy.pathReplace); } pUrl = Url.format(options.fullUrl); options.fullUrl.href = pUrl; this.log.debug(`Proxying request to ${pUrl}`); // Shallow copy the request headers reqHeaders = utils.mergeHeaders({}, this.serverRequest.headers); if (typeof proxy.headers === 'object') { reqHeaders = utils.mergeHeaders(reqHeaders, proxy.headers); if (!proxy.headers.host) { reqHeaders.host = options.fullUrl.hostname; } } else { reqHeaders.host = options.fullUrl.hostname; } // Tweak the X-Forwarded-For header if (typeof reqHeaders['x-forwarded-for'] === 'string' && reqHeaders['x-forwarded-for'].length !== 0) { reqHeaders['x-forwarded-for'] += ', ' + this.serverRequest.connection.localAddress; } else { reqHeaders['x-forwarded-for'] = this.serverRequest.ip; } if (proto !== this.proto && !reqHeaders['x-forwarded-proto']) { reqHeaders['x-forwarded-proto'] = this.proto; } if (!reqHeaders['x-forwarded-host']) { reqHeaders['x-forwarded-host'] = this.headerHost; } // Setup the proxy HTTP options options.httpOpts = { auth: options.fullUrl.auth, headers: reqHeaders, hostname: options.fullUrl.hostname, method: this.method || 'GET', path: options.fullUrl.pathname + (options.fullUrl.search ? (options.fullUrl.search.charAt(0) === '?' ? '' : '?') + options.fullUrl.search : ''), port: options.fullUrl.port, protocol: options.fullUrl.protocol }; options.timeout = (typeof options.timeout === 'number') ? options.timeout : this.timeout; options._rom = this; options._newRequest = null; // What kind of request is this... if (options.fullUrl.protocol === 'https:') { reqLib = (protoVer.charAt(0) === '2') ? Http2 : Https; } else { reqLib = Http; } // Prep the agent options.agent = this.settings.proxyAgent; // Handle the proxy headers if (this.hostConfig.proxyHeaders !== null) { if (this.headers !== null) { options.httpOpts.proxyHeaders = utils.mergeHeaders(this.hostConfig.proxyHeaders, this.headers); } else { options.httpOpts.proxyHeaders = this.hostConfig.proxyHeaders; } if (options.headers) { options.httpOpts.proxyHeaders = utils.mergeHeaders(this.httpOpts.proxyHeaders, options.headers); } } else if (this.headers !== null) { if (options.headers) { options.httpOpts.proxyHeaders = utils.mergeHeaders(this.headers, options.headers); } else { options.httpOpts.proxyHeaders = this.headers; } } else if (options.headers) { options.httpOpts.proxyHeaders = options.headers; } else { options.httpOpts.proxyHeaders = null; } // Support alternate DNS lookup if (this.dnsLookup !== null) { options.httpOpts.lookup = this.dnsLookup; } // Function to handle the initial proxy response handleProxyResponse = function (opts, proxyResp) { let proxyRespCode = Number(proxyResp.statusCode), servResp = opts._rom.serverResponse; if (!proxyResp) { opts._rom.log.error(`Proxy request (${opts.fullUrl.href}) failed with no response`); proxyResp.resume(); opts._rom.error(502); return; } proxyResp.on('error', (error) => { opts._rom.log.error(`Proxy request response error (${opts.fullUrl.href}): ${error.message}\n${error.stack}`); if (opts._newRequest !== null) { opts._newRequest.abort(); opts._newRequest = null; } opts._rom.error(502); }); if (opts._rom.onSent !== null) { proxyResp.on('end', () => { opts._rom.onSent(opts._rom.serverRequest, servResp); }); } // Clone response headers utils.cloneResponseHeaders(servResp, proxyResp.headers); servResp.statusCode = proxyRespCode; if (proxyRespCode < 200 || proxyRespCode >= 300) { // Explicit handling of redirects. If the 'location' header // contains the proxy host, replace it with empty string to // get the the redirect to go to THIS server. if (redirectCodes.indexOf(proxyRespCode) !== -1) { try { let loc = servResp.getHeader('location'); if (typeof loc === 'string') { let locUrl = Url.parse(loc); if (locUrl.host === opts.fullUrl.host && locUrl.port === opts.fullUrl.port) { servResp.setHeader('location', loc.replace(/^http(s)?:\/\/[\w\.\-]+(\:\d+)?/, '')); } } } catch (e) { opts._rom.log.error(`Error attempting to set 301/302 headers for proxied request: ${e.message}`); } } } else { opts._rom.log.debug(`Proxy response status code ${proxyRespCode}`); servResp.statusCode = proxyRespCode; // Merge in the relevant proxy response headers, if any if (opts.httpOpts.proxyHeaders !== null) { try { utils.cloneResponseHeaders(servResp, opts.httpOpts.proxyHeaders); } catch (e) { opts._rom.log.error(`Error attempting to set headers for proxied request: ${e.message}`); } } } // Pipe new connection to existing response proxyResp.pipe(servResp, {end: true}); proxyResp.on('response', (res) => { if (res && res.statusCode >= 500) { opts._rom.log.debug('Proxy connection returned status ' + res.statusCode); } proxyResp.unpipe(servResp); // Important to prevent unexpected errors. }); opts._rom.log.debug('Connection proxied to client.'); }.bind(this, options); // Make the request to the back-end server newReq = reqLib.request(options.httpOpts, handleProxyResponse); options._newRequest = newReq; // Handle events on the new request newReq.on('socket', (socket) => { // Handle socket timeout, if set if (options.timeout > 0) { socket.on('timeout', () => { this.log.debug(`Proxy request took over ${options.timeout}ms to return; request timed-out.`); socket.destroy(); }); socket.setTimeout(options.timeout); } // Handle socket error socket.on('error', (error) => { this.log.error(`Proxy socket error handling request to ${options.fullUrl.href}: ${error.message}`); socket.destroy(); }); }); newReq.on('error', (error) => { this.log.error(`Proxy error for request "${options.fullUrl.href}": ${error.message}`); this.error(500); }); // Handle a POST/PUT correctly if (utils.isWriteMethod(this.method) && this.rawBody) { newReq.write(this.rawBody); } newReq.end(); } catch (err) { this.log.error(`Error proxying request: ${err.message}`); this.error(500); } }; /** * Do a redirect through Express * * @memberof RomRequest * @private * @param {number} code - Redirect code to use as status * @param {string} location - New location URL string to redirect to */ RomRequest.prototype.redirect = function (code, location) { try { let resp = this.serverResponse; // Validate the redirect code if (typeof code !== 'number' || code < 300 || code > 310) { this.log.warn(`Invalid status code passed to "redirect" function. Using default redirect code ${this.settings.redirectCode}.`); code = this.settings.redirectCode; } // Handle the redirect headers if (this.hostConfig.redirectHeaders !== null) { if (this.headers !== null) { resp.set(utils.mergeHeaders(this.hostConfig.redirectHeaders, this.headers)); } else { resp.set(this.hostConfig.redirectHeaders); } } else if (this.headers !== null) { resp.set(this.headers); } // Send the redirect resp.redirect(code, location); if (this.onSent !== null) { this.onSent(this.serverRequest, resp); } this.log.info(`Redirected request to ${location}`); } catch (err) { this.log.error(`Error sending redirect: ${err.message}`); this.error(500); } }; /** * Do a rewrite * * @memberof RomRequest * @private * @param {string} newUrl - New URL to rewrite request to. */ RomRequest.prototype.rewrite = function (newUrl) { try { // Pull apart new URL, check for redirecting changes let url = Url.parse(newUrl); if (url.hostname !== null && ((url.hostname !== this.hostname) || (url.protocol !== null && url.protocol !== (this.proto + ':')) || (url.port !== null && parseInt(url.port, 10) !== this.port))) { // Change in host, protocol, or port, so requires redirecting this.redirect(this.settings.redirectCode, newUrl); } else { // Normalize and reduce, if necessary if (this.settings.normalizeUrls === true) { let normed = RomRequest.normalizeAndReduce(newUrl); if (normed === null) { // If the URL still contains percent characters, or if the URL and params contain linefeeds, throw an error throw new Error(`Rewritten request "${newUrl}" now contains invalid characters`); } if (normed.length < this.url.length) { // Modified, so deal with the path and update the request values url = Url.parse(normed); } } if (this.settings.removeDoubleSlashes === true && url.pathname.indexOf('//') !== -1) { let newPath = url.pathname.replace(/\/\/+/g, '/'); this.log.debug(`Reduced the rewritten request path "{$url.pathname}" to "${newPath}"`); url.pathname = newPath; url.path = newPath + (typeof url.search === 'string' ? url.search : ''); } // Update the request object this.path = (typeof url.pathname === 'string' && url.pathname.length !== 0) ? url.pathname : '/'; this.normalizedPath = this.path.toLowerCase(); this.url = (typeof url.path === 'string' && url.path.length !== 0) ? url.path : '/'; if ((this.port === 80 && this.proto === 'http') || (this.port === 443 && this.proto === 'https')) { this.href = this.proto + '://' + this.hostname + url.path; } else { this.href = this.proto + '://' + this.hostname + ':' + this.port.toString(10) + this.url; } // Continue route processing this.log.info(`Rewrote request to ${newUrl}`); this.doRoute(); } } catch (err) { this.log.error(`Error handling rewrite of request to "${newUrl}": ${err.message}`); this.error(500); } }; /** * Route or re-route a request * * @memberof RomRequest * @private */ RomRequest.prototype.doRoute = function () { try { let host, result, routes; if (this.routePass++ > this.settings.retryLimit) { throw new Error(`Exceeded routing retry limit (${this.settings.retryLimit}) with original URL "${this.serverRequest.originalUrl}"`); } // Get the host details host = this.hostTable.getHost(this.hostname); if (host === null) { // Host not found, return error this.log.info(`Invalid hostname "${this.host}", sending error 503.`); this.error(503, `Invalid server hostname "${this.host}".`); return; } // Set request settings from host config this.hostConfig = host.config; this.timeout = host.config.timeout; // Get routes routes = host.routeResolvers; // Check each route resolver result = false; for (let i = 0, rl = routes.length; result === false && i < rl; i++) { result = routes[i](this); } // Handle 404 if no route found if (result === false) { this.log.debug('No route found for request'); this.error(404); } } catch (err) { this.log.error(`Critical error in routing request: ${err.message}`, err); this.error(500); } }; /** * Process RomRequest request * * @memberof RomRequest * @param {object} req - Request object (Express) * @param {object} res - Response object (HTTP/HTTPS/HTTP2) * @param {function} next - Continuation function (Express) */ RomRequest.prototype.process = function (req, res, next) { this.logPrefix = res.locals.logPrefix || ''; if (typeof res.locals.logMeta === 'object' && res.locals.logMeta !== null) { this.logMeta = res.locals.logMeta; } this.log.debug(`Initializing ROM Request for ${req.path}`); try { let curAddr = req.socket.address(), curPort = this.settings.ports[curAddr.port] || null, hname = req.hostname.toLowerCase().replace(/[\s,]+.*$/, ''), hh = (req.headers && req.headers.host) || hname, prot = (curPort && curPort.origProto) || req.protocol, pstring = '', url = Url.parse(prot + '://' + hh + req.url, false, false); // Initialize this request this.auth = url.auth; this.hash = url.hash; this.headerHost = hh; this.hostname = hname; this.href = url.href; this.isXhr = req.xhr; this.method = req.method; this.normalizedPath = url.pathname.toLowerCase(); this.path = url.pathname; this.port = utils.portFromUrlObject(url); this.proto = prot; // Hopefully, original request protocol this.protoVer = (curPort && curPort.origProtoVer) || (prot === 'https' && req.httpVersion.charAt(0) === '2' ? '2.0' : '1.1'); this.query = url.query; this.serverNext = next; this.serverResponse = res; this.serverRequest = req; this.serverPort = curAddr.port; this.serverProto = req.protocol; this.url = req.url; if (typeof req.headers.host !== 'string') { this.log.error('No host header in request!'); next(new HttpError('No host header in request!', 400)); return; } if ((this.proto === 'http' && this.port !== 80) || (this.proto === 'https' && this.port !== 443)) { pstring = ':' + this.port.toString(10); } // Remove double slashes and redirect, if we should if (this.settings.removeDoubleSlashes === true && req.path.indexOf('//') !== -1) { let qp = req.url.indexOf('?'), newUrl = req.path.replace(/\/\/+/g, '/') + (qp === -1 ? '' : req.url.substr(qp)); this.log.debug('Reduced the request path and redirecting.'); res.redirect(this.settings.reduceRedirectCode, newUrl); if (this.onSent !== null) { this.onSent(req, res); } return; } // Normalize and reduce, if necessary if (this.settings.normalizeUrls === true) { let normed = RomRequest.normalizeAndReduce(req.url); if (normed === null) { // If the URL still contains percent characters, or if the URL and params contain linefeeds, return a 404. this.log.debug('URL contains invalid characters, sending 404.'); next(new HttpError(404)); return; } // Modified, so deal with the path and update the request values if (normed.length !== req.url.length) { req.url = normed; // No setter for req.path, but the getter extracts it from the url pathname // req.path = RomRequest.normalizeAndReduce(req.path); url = Url.parse(prot + '://' + hh + pstring + req.url, false, false); this.auth = url.auth; this.hash = url.hash; this.href = url.href; this.normalizedPath = url.pathname.toLowerCase(); this.path = url.pathname; this.query = url.query; this.url = req.url; } } // Parse query parameters this.queryParams = Query.parse(url.query) || {}; // If "write" request, check for a body if (utils.isWriteMethod(req.method)) { let clh = req.get('content-length'), cth = req.get('content-type'); if (typeof cth === 'string' && cth.length !== 0 && typeof clh === 'string') { let cl = parseInt(clh, 10), ct = ContentType.parse(cth), en = (ct.parameters && ct.parameters.charset) || 'utf8'; switch (ct.type) { case 'application/json': getRawBody(req, { encoding: en, length: cl, limit: '200kb' }, (err, body) => { if (err) { this.log.debug(`Failed to process request body: ${err}`); next(new HttpError(err.statusCode)); } else { try { this.rawBody = body; this.body = Bourne.parse(body); // Safe version of JSON.parse } catch (e) { next(new HttpError(400)); return; } this.doRoute(); } }); break; case 'application/x-www-form-urlencoded': getRawBody(req, { encoding: en, length: cl, limit: '200kb' }, (err, body) => { if (err) { this.log.debug(`Failed to process request body: ${err}`); next(new HttpError(err.statusCode)); } else { try { this.rawBody = body; this.body = QS.parse(body); } catch (e) { next(new HttpError(400)); return; } this.doRoute(); } }); break; default: getRawBody(req, { encoding: en, length: cl, limit: '200kb' }, (err, body) => { if (err) { this.log.debug(`Failed to process request body: ${err}`); next(new HttpError(err.statusCode)); } else { this.rawBody = body; this.body = body; this.doRoute(); } }); } } else { this.body = null; this.doRoute(); } } else { // Done processing the request and creating the new request object, now route the thing this.doRoute(); } } catch (err) { this.log.error(`Error processing request: ${err.message}`); next(new HttpError(500)); } }; module.exports = RomRequest;