Initial implementation

The response is always JSON
Options supported: log, debug
This commit is contained in:
Miroslav Bajtoš 2016-05-11 13:53:28 +02:00
parent 5a8c01c2f4
commit 225d35994b
7 changed files with 657 additions and 3 deletions

View File

@ -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 }));
```

82
lib/data-builder.js Normal file
View File

@ -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';
}

58
lib/handler.js Normal file
View File

@ -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);
};
};

24
lib/logger.js Normal file
View File

@ -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);
}

12
lib/send-json.js Normal file
View File

@ -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');
};

View File

@ -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"
}
}

406
test/handler.test.js Normal file
View File

@ -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;
}