Merge pull request #9 from strongloop/initial-implementation

Initial implementation
This commit is contained in:
Miroslav Bajtoš 2016-05-13 13:14:30 +02:00
commit 0158d3a735
10 changed files with 716 additions and 1 deletions

3
.eslintrc Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "loopback"
}

7
.travis.yml Normal file
View File

@ -0,0 +1,7 @@
sudo: false
language: node_js
node_js:
- "0.10"
- "0.12"
- "4"
- "6"

25
LICENSE.md Normal file
View File

@ -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.

View File

@ -1 +1,72 @@
# strong-error-handler
# strong-error-handler
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');
};

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "strong-error-handler",
"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": "^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;
}