From 5a8c01c2f4f673f8f114690ad216e5d13674a9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 11 May 2016 09:29:31 +0200 Subject: [PATCH 1/2] Add project infrastructure - add license - scaffold basic package.json - setup eslint linter - setup Travis CI --- .eslintrc | 3 +++ .travis.yml | 7 +++++++ LICENSE.md | 25 +++++++++++++++++++++++++ README.md | 8 +++++++- package.json | 20 ++++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 .eslintrc create mode 100644 .travis.yml create mode 100644 LICENSE.md create mode 100644 package.json diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..70bcff8 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "loopback" +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ecf6d68 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +sudo: false +language: node_js +node_js: + - "0.10" + - "0.12" + - "4" + - "6" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..09b1735 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2016. All Rights Reserved. +Node module: strong-error-handler +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +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. diff --git a/README.md b/README.md index 79b3e55..d88f1c2 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# strong-error-handler \ No newline at end of file +# strong-error-handler + +# Install + +```bash +$ npm install strong-error-handler +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..06cfe0a --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "strong-error-handler", + "description": "Strong Loop Error Handler in Production", + "license": "MIT", + "version": "1.0.0", + "repository": { + "type": "git", + "url": "https://github.com/strongloop/strong-error-handler.git" + }, + "scripts": { + "lint": "eslint .", + "posttest": "npm run lint" + }, + "dependencies": { + }, + "devDependencies": { + "eslint": "^2.5.3", + "eslint-config-loopback": "^1.0.0" + } +} From 225d35994b5b2780d16d17bd5d7ffffa507e8a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 11 May 2016 13:53:28 +0200 Subject: [PATCH 2/2] Initial implementation The response is always JSON Options supported: log, debug --- README.md | 67 ++++++- lib/data-builder.js | 82 +++++++++ lib/handler.js | 58 +++++++ lib/logger.js | 24 +++ lib/send-json.js | 12 ++ package.json | 11 +- test/handler.test.js | 406 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 657 insertions(+), 3 deletions(-) create mode 100644 lib/data-builder.js create mode 100644 lib/handler.js create mode 100644 lib/logger.js create mode 100644 lib/send-json.js create mode 100644 test/handler.test.js diff --git a/README.md b/README.md index d88f1c2..2658fd6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,72 @@ # strong-error-handler -# Install +Error handler for use in development (debug) and production environments. + + - When run in production mode, error responses are purposely undetailed + in order to prevent leaking sensitive information. + + - When in debug mode, detailed information such as stack traces + are returned in the HTTP responses. + +JSON is the only supported response format at this time. + +*There are plans to support other formats such as Text, HTML, and XML.* + +## Install ```bash $ npm install strong-error-handler ``` + +## Usage + +In an express-based application: + +```js +var express = require('express'); +var errorHandler = require('strong-error-handler'); + +var app = express(); +// setup your routes +app.use(errorHandler({ /* options, see below */ })); + +app.listen(3000); +``` + +In LoopBack applications, add the following entry to your +`server/middleware.json` file. + +```json +{ + "final:after": { + "strong-error-handler": { + "params": { + } + } + } +} +``` + +## Options + +#### debug + +`boolean`, defaults to `false`. + +When enabled, HTTP responses include all error properties, including +sensitive data such as file paths, URLs and stack traces. + +#### log + +`boolean`, defaults to `true`. + +When enabled, all errors are printed via `console.error`. + +Customization of the log format is intentionally not allowed. If you would like +to use a different format/logger, disable this option and add your own custom +error-handling middleware. + +```js +app.use(myErrorLogger()); +app.use(errorHandler({ log: false })); +``` diff --git a/lib/data-builder.js b/lib/data-builder.js new file mode 100644 index 0000000..aad5a75 --- /dev/null +++ b/lib/data-builder.js @@ -0,0 +1,82 @@ +// Copyright IBM Corp. 2016. All Rights Reserved. +// Node module: strong-error-handler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var httpStatus = require('http-status'); + +module.exports = function buildResponseData(err, isDebugMode) { + if (Array.isArray(err) && isDebugMode) { + err = serializeArrayOfErrors(err); + } + + var data = Object.create(null); + fillStatusCode(data, err); + + if (typeof err !== 'object') { + data.statusCode = 500; + data.message = '' + err; + err = {}; + } + + if (isDebugMode) { + fillDebugData(data, err); + } else if (data.statusCode >= 400 && data.statusCode <= 499) { + fillBadRequestError(data, err); + } else { + fillInternalError(data, err); + } + + return data; +}; + +function serializeArrayOfErrors(errors) { + var details = []; + for (var ix in errors) { + var err = errors[ix]; + if (typeof err !== 'object') { + details.push('' + err); + continue; + } + + var data = {stack: err.stack}; + for (var p in err) { // eslint-disable-line one-var + data[p] = err[p]; + } + details.push(data); + } + + return { + name: 'ArrayOfErrors', + message: 'Failed with multiple errors, ' + + 'see `details` for more information.', + details: details, + }; +} + +function fillStatusCode(data, err) { + data.statusCode = err.statusCode || err.status; + if (!data.statusCode || data.statusCode < 400) + data.statusCode = 500; +} + +function fillDebugData(data, err) { + for (var p in err) { + if ((p in data)) continue; + data[p] = err[p]; + } + // NOTE err.stack is not an enumerable property + data.stack = err.stack; +} + +function fillBadRequestError(data, err) { + data.name = err.name; + data.message = err.message; + data.details = err.details; +} + +function fillInternalError(data, err) { + data.message = httpStatus[data.statusCode] || 'Unknown Error'; +} diff --git a/lib/handler.js b/lib/handler.js new file mode 100644 index 0000000..9859eed --- /dev/null +++ b/lib/handler.js @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2016. All Rights Reserved. +// Node module: strong-error-handler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var buildResponseData = require('./data-builder'); +var debug = require('debug')('strong-error-handler'); +var format = require('util').format; +var logToConsole = require('./logger'); +var sendJson = require('./send-json'); + +function noop() { +} + +/** + * Create a middleware error handler function. + * + * @param {Object} options + * @returns {Function} + */ +exports = module.exports = function createStrongErrorHandler(options) { + options = options || {}; + + debug('Initializing with options %j', options); + + // Debugging mode is disabled by default. When turned on (in dev), + // all error properties (including) stack traces are sent in the response + var isDebugMode = options.debug; + + // Log all errors via console.error (enabled by default) + var logError = options.log !== false ? logToConsole : noop; + + return function strongErrorHandler(err, req, res, next) { + debug('Handling %s', err.stack || err); + + logError(req, err); + + if (res._header) { + debug('Response was already sent, closing the underlying connection'); + return req.socket.destroy(); + } + + var data = buildResponseData(err, isDebugMode); + debug('Response status %s data %j', data.statusCode, data); + + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.statusCode = data.statusCode; + + // TODO: negotiate the content-type, take into account options.defaultType + // For now, we always return JSON. See + // - https://github.com/strongloop/strong-error-handler/issues/4 + // - https://github.com/strongloop/strong-error-handler/issues/5 + // - https://github.com/strongloop/strong-error-handler/issues/6 + sendJson(res, data); + }; +}; diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..1cbc0ba --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,24 @@ +// Copyright IBM Corp. 2016. All Rights Reserved. +// Node module: strong-error-handler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var format = require('util').format; + +module.exports = function logToConsole(req, err) { + if (!Array.isArray(err)) { + console.error('Unhandled error for request %s %s: %s', + req.method, req.url, err.stack || err); + return; + } + + var errors = err.map(formatError).join('\n'); + console.error('Unhandled array of errors for request %s %s\n', + req.method, req.url, errors); +}; + +function formatError(err) { + return format('%s', err.stack || err); +} diff --git a/lib/send-json.js b/lib/send-json.js new file mode 100644 index 0000000..e33886c --- /dev/null +++ b/lib/send-json.js @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2016. All Rights Reserved. +// Node module: strong-error-handler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +module.exports = function sendJson(res, data) { + var content = JSON.stringify({error: data}); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(content, 'utf-8'); +}; diff --git a/package.json b/package.json index 06cfe0a..7c1f415 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,27 @@ { "name": "strong-error-handler", - "description": "Strong Loop Error Handler in Production", + "description": "Error handler for use in development and production environments.", "license": "MIT", "version": "1.0.0", "repository": { "type": "git", "url": "https://github.com/strongloop/strong-error-handler.git" }, + "main": "lib/handler.js", "scripts": { "lint": "eslint .", + "test": "mocha", "posttest": "npm run lint" }, "dependencies": { + "debug": "^2.2.0", + "http-status": "^0.2.2" }, "devDependencies": { + "chai": "^2.1.1", "eslint": "^2.5.3", - "eslint-config-loopback": "^1.0.0" + "eslint-config-loopback": "^3.0.0", + "mocha": "^2.1.0", + "supertest": "^1.1.0" } } diff --git a/test/handler.test.js b/test/handler.test.js new file mode 100644 index 0000000..d28f77b --- /dev/null +++ b/test/handler.test.js @@ -0,0 +1,406 @@ +// Copyright IBM Corp. 2016. All Rights Reserved. +// Node module: strong-error-handler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var debug = require('debug')('test'); +var expect = require('chai').expect; +var http = require('http'); +var strongErrorHandler = require('..'); +var supertest = require('supertest'); +var util = require('util'); + +describe('strong-error-handler', function() { + before(setupHttpServerAndClient); + beforeEach(resetRequestHandler); + + it('sets nosniff header', function(done) { + givenErrorHandlerForError(); + request.get('/') + .expect('X-Content-Type-Options', 'nosniff') + .expect(500, done); + }); + + it('handles response headers already sent', function(done) { + givenErrorHandlerForError(); + var handler = _requestHandler; + _requestHandler = function(req, res, next) { + res.end('empty'); + process.nextTick(function() { + handler(req, res, next); + }); + }; + + request.get('/').expect(200, 'empty', done); + }); + + context('status code', function() { + it('converts non-error "err.status" to 500', function(done) { + givenErrorHandlerForError(new ErrorWithProps({status: 200})); + request.get('/').expect(500, done); + }); + + it('converts non-error "err.statusCode" to 500', function(done) { + givenErrorHandlerForError(new ErrorWithProps({statusCode: 200})); + request.get('/').expect(500, done); + }); + + it('uses the value from "err.status"', function(done) { + givenErrorHandlerForError(new ErrorWithProps({status: 404})); + request.get('/').expect(404, done); + }); + + it('uses the value from "err.statusCode"', function(done) { + givenErrorHandlerForError(new ErrorWithProps({statusCode: 404})); + request.get('/').expect(404, done); + }); + + it('prefers "err.statusCode" over "err.status"', function(done) { + givenErrorHandlerForError(new ErrorWithProps({ + statusCode: 400, + status: 404, + })); + + request.get('/').expect(400, done); + }); + }); + + context('logging', function() { + var logs; + + beforeEach(redirectConsoleError); + afterEach(restoreConsoleError); + + it('logs by default', function(done) { + givenErrorHandlerForError(new Error(), { + // explicitly set to undefined to prevent givenErrorHandlerForError + // from disabling this option + log: undefined, + }); + + request.get('/').end(function(err) { + if (err) return done(err); + expect(logs).to.have.length(1); + done(); + }); + }); + + it('honours options.log=false', function(done) { + givenErrorHandlerForError(new Error(), {log: false}); + + request.get('/api').end(function(err) { + if (err) return done(err); + expect(logs).to.have.length(0); + done(); + }); + }); + + it('honours options.log=true', function(done) { + givenErrorHandlerForError(new Error(), {log: true}); + + request.get('/api').end(function(err) { + if (err) return done(err); + expect(logs).to.have.length(1); + done(); + }); + }); + + it('includes relevant information in the log message', function(done) { + givenErrorHandlerForError(new TypeError('ERROR-NAME'), {log: true}); + + request.get('/api').end(function(err) { + if (err) return done(err); + + var msg = logs[0]; + // the request method + expect(msg).to.contain('GET'); + // the request path + expect(msg).to.contain('/api'); + // the error name & message + expect(msg).to.contain('TypeError: ERROR-NAME'); + // the stack + expect(msg).to.contain(__filename); + + done(); + }); + }); + + it('handles array argument', function(done) { + givenErrorHandlerForError( + [new TypeError('ERR1'), new Error('ERR2')], + {log: true}); + + request.get('/api').end(function(err) { + if (err) return done(err); + + var msg = logs[0]; + // the request method + expect(msg).to.contain('GET'); + // the request path + expect(msg).to.contain('/api'); + // the error name & message for all errors + expect(msg).to.contain('TypeError: ERR1'); + expect(msg).to.contain('Error: ERR2'); + // verify that stacks are included too + expect(msg).to.contain(__filename); + + done(); + }); + }); + + it('handles non-Error argument', function(done) { + givenErrorHandlerForError('STRING ERROR', {log: true}); + request.get('/').end(function(err) { + if (err) return done(err); + var msg = logs[0]; + expect(msg).to.contain('STRING ERROR'); + done(); + }); + }); + + var _consoleError = console.error; + function redirectConsoleError() { + logs = []; + console.error = function() { + var msg = util.format.apply(util, arguments); + logs.push(msg); + }; + } + + function restoreConsoleError() { + console.error = _consoleError; + logs = []; + } + }); + + context('JSON response', function() { + it('contains all error properties when debug=true', function(done) { + var error = new ErrorWithProps({ + details: 'some details', + extra: 'sensitive data', + }); + givenErrorHandlerForError(error, {debug: true}); + + requestJson().end(function(err, res) { + if (err) return done(err); + + var expectedData = {statusCode: 500, stack: error.stack}; + for (var key in error) expectedData[key] = error[key]; + + expect(res.body).to.have.property('error'); + expect(res.body.error).to.eql(expectedData); + done(); + }); + }); + + it('contains subset of properties when status=4xx', function(done) { + var error = new ErrorWithProps({ + name: 'ValidationError', + message: 'The model instance is not valid.', + statusCode: 422, + details: 'some details', + extra: 'sensitive data', + }); + givenErrorHandlerForError(error); + + requestJson().end(function(err, res) { + if (err) return done(err); + + expect(res.body).to.have.property('error'); + expect(res.body.error).to.eql({ + name: 'ValidationError', + message: 'The model instance is not valid.', + statusCode: 422, + details: 'some details', + // notice the property "extra" is not included + }); + done(); + }); + }); + + it('contains only safe info when status=5xx', function(done) { + // Mock an error reported by fs.readFile + var error = new ErrorWithProps({ + name: 'Error', + message: 'ENOENT: no such file or directory, open "/etc/passwd"', + errno: -2, + code: 'ENOENT', + syscall: 'open', + path: '/etc/password', + }); + givenErrorHandlerForError(error); + + requestJson().end(function(err, res) { + if (err) return done(err); + + expect(res.body).to.have.property('error'); + expect(res.body.error).to.eql({ + statusCode: 500, + message: 'Internal Server Error', + }); + + done(); + }); + }); + + it('handles array argument as 500 when debug=false', function(done) { + var errors = [new Error('ERR1'), new Error('ERR2'), 'ERR STRING']; + givenErrorHandlerForError(errors); + + requestJson().expect(500).end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql({ + error: { + statusCode: 500, + message: 'Internal Server Error', + }, + }); + done(); + }); + }); + + it('returns all array items when debug=true', function(done) { + var testError = new ErrorWithProps({ + message: 'expected test error', + statusCode: 400, + }); + var anotherError = new ErrorWithProps({ + message: 'another expected error', + statusCode: 500, + }); + var errors = [testError, anotherError, 'ERR STRING']; + givenErrorHandlerForError(errors, {debug: true}); + + requestJson().expect(500).end(function(err, res) { + if (err) return done(err); + + var data = res.body.error; + expect(data).to.have.property('message').that.match(/multiple errors/); + + var expectedDetails = [ + getExpectedErrorData(testError), + getExpectedErrorData(anotherError), + 'ERR STRING', + ]; + expect(data).to.have.property('details').to.eql(expectedDetails); + done(); + }); + }); + + it('handles non-Error argument as 500 when debug=false', function(done) { + givenErrorHandlerForError('Error Message', {debug: false}); + requestJson().expect(500).end(function(err, res) { + if (err) return done(err); + + expect(res.body.error).to.eql({ + statusCode: 500, + message: 'Internal Server Error', + }); + done(); + }); + }); + + it('returns non-Error argument in message when debug=true', function(done) { + givenErrorHandlerForError('Error Message', {debug: true}); + requestJson().expect(500).end(function(err, res) { + if (err) return done(err); + + expect(res.body.error).to.eql({ + statusCode: 500, + message: 'Error Message', + }); + done(); + }); + }); + + function requestJson(url) { + return request.get(url || '/') + .set('Accept', 'text/plain') + .expect('Content-Type', /^application\/json/); + } + }); +}); + +var _httpServer, _requestHandler, request; +function resetRequestHandler() { + _requestHandler = null; +} + +function givenErrorHandlerForError(error, options) { + if (!error) error = new Error('an error'); + + if (!options) options = {}; + if (!('log' in options)) { + // Disable logging to console by default, so that we don't spam + // console output. One can use "DEBUG=strong-error-handler" when + // troubleshooting. + options.log = false; + } + + var handler = strongErrorHandler(options); + _requestHandler = function(req, res, next) { + debug('Invoking strong-error-handler'); + handler(error, req, res, next); + }; +} + +function setupHttpServerAndClient(done) { + _httpServer = http.createServer(function(req, res) { + if (!_requestHandler) { + var msg = 'Error handler middleware was not setup in this test'; + console.error(msg); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(msg); + return; + } + + _requestHandler(req, res, function(err) { + console.log('unexpected: strong-error-handler called next with ' + (err && (err.stack || err)) || 'no error'); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(err ? + 'Unhandled strong-error-handler error:\n' + (err.stack || err) : + 'The error was silently discared by strong-error-handler'); + }); + }); + + _httpServer.once('error', function(err) { + debug('Cannot setup HTTP server: %s', err.stack); + done(err); + }); + + _httpServer.once('listening', function() { + var url = 'http://127.0.0.1:' + this.address().port; + debug('Test server listening on %s', url); + request = supertest(url); + done(); + }); + + _httpServer.listen(0, '127.0.0.1'); +} + +function ErrorWithProps(props) { + this.name = props.name || 'ErrorWithProps'; + for (var p in props) { + this[p] = props[p]; + } + + if (Error.captureStackTrace) { + // V8 (Chrome, Opera, Node) + Error.captureStackTrace(this, this.constructor); + } +} +util.inherits(ErrorWithProps, Error); + +function getExpectedErrorData(err) { + // "stack" is a non-enumerable property + var data = {stack: err.stack}; + for (var prop in err) { + data[prop] = err[prop]; + } + return data; +}