diff --git a/HISTORY.md b/HISTORY.md index 34b4fef9..84f446c0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,7 @@ unreleased * Fix JSON strict violation error to match native parse error * Include the `body` property on verify errors + * Include the `type` property on all generated errors * Use `http-errors` to set status code on errors * deps: bytes@3.0.0 * deps: debug@2.6.8 diff --git a/README.md b/README.md index f708d4d1..62221e47 100644 --- a/README.md +++ b/README.md @@ -275,8 +275,9 @@ encoding of the request. The parsing can be aborted by throwing an error. The middlewares provided by this module create errors depending on the error condition during parsing. The errors will typically have a `status`/`statusCode` property that contains the suggested HTTP response code, an `expose` property -to determine if the `message` property should be displayed to the client and a -`body` property containing the read body, if available. +to determine if the `message` property should be displayed to the client, a +`type` property to determine the type of error without matching against the +`message`, and a `body` property containing the read body, if available. The following are the common errors emitted, though any error can come through for various reasons. @@ -285,54 +286,62 @@ for various reasons. This error will occur when the request had a `Content-Encoding` header that contained an encoding but the "inflation" option was set to `false`. The -`status` property is set to `415`. +`status` property is set to `415`, the `type` property is set to +`'encoding.unsupported'`, and the `charset` property will be set to the +encoding that is unsupported. ### request aborted This error will occur when the request is aborted by the client before reading the body has finished. The `received` property will be set to the number of bytes received before the request was aborted and the `expected` property is -set to the number of expected bytes. The `status` property is set to `400`. +set to the number of expected bytes. The `status` property is set to `400` +and `type` property is set to `'request.aborted'`. ### request entity too large This error will occur when the request body's size is larger than the "limit" option. The `limit` property will be set to the byte limit and the `length` property will be set to the request body's length. The `status` property is -set to `413`. +set to `413` and the `type` property is set to `'entity.too.large'`. ### request size did not match content length This error will occur when the request's length did not match the length from the `Content-Length` header. This typically occurs when the request is malformed, typically when the `Content-Length` header was calculated based on characters -instead of bytes. The `status` property is set to `400`. +instead of bytes. The `status` property is set to `400` and the `type` property +is set to `'request.size.invalid'`. ### stream encoding should not be set This error will occur when something called the `req.setEncoding` method prior to this middleware. This module operates directly on bytes only and you cannot call `req.setEncoding` when using this module. The `status` property is set to -`500`. +`500` and the `type` property is set to `'stream.encoding.set'`. ### too many parameters This error will occur when the content of the request exceeds the configured `parameterLimit` for the `urlencoded` parser. The `status` property is set to -`413`. +`413` and the `type` property is set to `'parameters.too.many'`. ### unsupported charset "BOGUS" This error will occur when the request had a charset parameter in the `Content-Type` header, but the `iconv-lite` module does not support it OR the parser does not support it. The charset is contained in the message as well -as in the `charset` property. The `status` property is set to `415`. +as in the `charset` property. The `status` property is set to `415`, the +`type` property is set to `'charset.unsupported'`, and the `charset` property +is set to the charset that is unsupported. ### unsupported content encoding "bogus" This error will occur when the request had a `Content-Encoding` header that contained an unsupported encoding. The encoding is contained in the message -as well as in the `encoding` property. The `status` property is set to `415`. +as well as in the `encoding` property. The `status` property is set to `415`, +the `type` property is set to `'encoding.unsupported'`, and the `encoding` +property is set to the encoding that is unsupported. ## Examples diff --git a/lib/read.js b/lib/read.js index cbad08ce..77dadade 100644 --- a/lib/read.js +++ b/lib/read.js @@ -67,7 +67,8 @@ function read (req, res, next, parse, debug, options) { // assert charset is supported if (opts.encoding === null && encoding !== null && !iconv.encodingExists(encoding)) { return next(createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', { - charset: encoding.toLowerCase() + charset: encoding.toLowerCase(), + type: 'charset.unsupported' })) } @@ -78,7 +79,8 @@ function read (req, res, next, parse, debug, options) { // echo back charset if (err.type === 'encoding.unsupported') { err = createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', { - charset: encoding.toLowerCase() + charset: encoding.toLowerCase(), + type: 'charset.unsupported' }) } @@ -97,7 +99,8 @@ function read (req, res, next, parse, debug, options) { verify(req, res, body, encoding) } catch (err) { next(createError(403, err, { - body: body + body: body, + type: err.type || 'entity.verify.failed' })) return } @@ -113,7 +116,8 @@ function read (req, res, next, parse, debug, options) { req.body = parse(str) } catch (err) { next(createError(400, err, { - body: str + body: str, + type: err.type || 'entity.parse.failed' })) return } @@ -140,7 +144,10 @@ function contentstream (req, debug, inflate) { debug('content-encoding "%s"', encoding) if (inflate === false && encoding !== 'identity') { - throw createError(415, 'content encoding unsupported') + throw createError(415, 'content encoding unsupported', { + encoding: encoding, + type: 'encoding.unsupported' + }) } switch (encoding) { @@ -160,7 +167,8 @@ function contentstream (req, debug, inflate) { break default: throw createError(415, 'unsupported content encoding "' + encoding + '"', { - encoding: encoding + encoding: encoding, + type: 'encoding.unsupported' }) } diff --git a/lib/types/json.js b/lib/types/json.js index 9d1feb45..a7bc838c 100644 --- a/lib/types/json.js +++ b/lib/types/json.js @@ -84,8 +84,14 @@ function json (options) { } } - debug('parse json') - return JSON.parse(body, reviver) + try { + debug('parse json') + return JSON.parse(body, reviver) + } catch (e) { + throw normalizeJsonSyntaxError(e, { + stack: e.stack + }) + } } return function jsonParser (req, res, next) { @@ -118,7 +124,8 @@ function json (options) { if (charset.substr(0, 4) !== 'utf-') { debug('invalid charset') next(createError(415, 'unsupported charset "' + charset.toUpperCase() + '"', { - charset: charset + charset: charset, + type: 'charset.unsupported' })) return } @@ -149,8 +156,10 @@ function createStrictSyntaxError (str, char) { try { JSON.parse(partial); /* istanbul ignore next */ throw new SyntaxError('strict violation') } catch (e) { - e.message = e.message.replace('#', char) - return e + return normalizeJsonSyntaxError(e, { + message: e.message.replace('#', char), + stack: e.stack + }) } } @@ -181,6 +190,34 @@ function getCharset (req) { } } +/** + * Normalize a SyntaxError for JSON.parse. + * + * @param {SyntaxError} error + * @param {object} obj + * @return {SyntaxError} + */ + +function normalizeJsonSyntaxError (error, obj) { + var keys = Object.getOwnPropertyNames(error) + + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + if (key !== 'stack' && key !== 'message') { + delete error[key] + } + } + + var props = Object.keys(obj) + + for (var j = 0; j < props.length; j++) { + var prop = props[j] + error[prop] = obj[prop] + } + + return error +} + /** * Get the simple type checker. * diff --git a/lib/types/urlencoded.js b/lib/types/urlencoded.js index 763e556a..5ccda218 100644 --- a/lib/types/urlencoded.js +++ b/lib/types/urlencoded.js @@ -106,7 +106,8 @@ function urlencoded (options) { if (charset !== 'utf-8') { debug('invalid charset') next(createError(415, 'unsupported charset "' + charset.toUpperCase() + '"', { - charset: charset + charset: charset, + type: 'charset.unsupported' })) return } @@ -147,7 +148,9 @@ function extendedparser (options) { if (paramCount === undefined) { debug('too many parameters') - throw createError(413, 'too many parameters') + throw createError(413, 'too many parameters', { + type: 'parameters.too.many' + }) } var arrayLimit = Math.max(100, paramCount) @@ -257,7 +260,9 @@ function simpleparser (options) { if (paramCount === undefined) { debug('too many parameters') - throw createError(413, 'too many parameters') + throw createError(413, 'too many parameters', { + type: 'parameters.too.many' + }) } debug('parse urlencoding') diff --git a/test/json.js b/test/json.js index 06999413..0be9b20f 100644 --- a/test/json.js +++ b/test/json.js @@ -90,6 +90,15 @@ describe('bodyParser.json()', function () { .expect(400, parseError('{"user"'), done) }) + it('should error with type = "entity.parse.failed"', function (done) { + request(this.server) + .post('/') + .set('Content-Type', 'application/json') + .set('X-Error-Property', 'type') + .send(' {"user"') + .expect(400, 'entity.parse.failed', done) + }) + it('should include original body on error object', function (done) { request(this.server) .post('/') @@ -111,6 +120,17 @@ describe('bodyParser.json()', function () { .expect(413, done) }) + it('should error with type = "entity.too.large"', function (done) { + var buf = Buffer.alloc(1024, '.') + request(createServer({ limit: '1kb' })) + .post('/') + .set('Content-Type', 'application/json') + .set('Content-Length', '1034') + .set('X-Error-Property', 'type') + .send(JSON.stringify({ str: buf.toString() })) + .expect(413, 'entity.too.large', done) + }) + it('should 413 when over limit with chunked encoding', function (done) { var buf = Buffer.alloc(1024, '.') var server = createServer({ limit: '1kb' }) @@ -244,6 +264,15 @@ describe('bodyParser.json()', function () { .send(' { "user": "tobi" }') .expect(200, '{"user":"tobi"}', done) }) + + it('should error with type = "entity.parse.failed"', function (done) { + request(this.server) + .post('/') + .set('Content-Type', 'application/json') + .set('X-Error-Property', 'type') + .send('true') + .expect(400, 'entity.parse.failed', done) + }) }) }) @@ -329,6 +358,19 @@ describe('bodyParser.json()', function () { .expect(403, 'no arrays', done) }) + it('should error with type = "entity.verify.failed"', function (done) { + var server = createServer({verify: function (req, res, buf) { + if (buf[0] === 0x5b) throw new Error('no arrays') + }}) + + request(server) + .post('/') + .set('Content-Type', 'application/json') + .set('X-Error-Property', 'type') + .send('["tobi"]') + .expect(403, 'entity.verify.failed', done) + }) + it('should allow custom codes', function (done) { var server = createServer({verify: function (req, res, buf) { if (buf[0] !== 0x5b) return @@ -344,6 +386,22 @@ describe('bodyParser.json()', function () { .expect(400, 'no arrays', done) }) + it('should allow custom type', function (done) { + var server = createServer({verify: function (req, res, buf) { + if (buf[0] !== 0x5b) return + var err = new Error('no arrays') + err.type = 'foo.bar' + throw err + }}) + + request(server) + .post('/') + .set('Content-Type', 'application/json') + .set('X-Error-Property', 'type') + .send('["tobi"]') + .expect(403, 'foo.bar', done) + }) + it('should include original body on error object', function (done) { var server = createServer({verify: function (req, res, buf) { if (buf[0] === 0x5b) throw new Error('no arrays') @@ -432,6 +490,14 @@ describe('bodyParser.json()', function () { test.write(Buffer.from('7b226e616d65223a22cec5d4227d', 'hex')) test.expect(415, 'unsupported charset "KOI8-R"', done) }) + + it('should error with type = "charset.unsupported"', function (done) { + var test = request(this.server).post('/') + test.set('Content-Type', 'application/json; charset=koi8-r') + test.set('X-Error-Property', 'type') + test.write(Buffer.from('7b226e616d65223a22cec5d4227d', 'hex')) + test.expect(415, 'charset.unsupported', done) + }) }) describe('encoding', function () { @@ -486,6 +552,15 @@ describe('bodyParser.json()', function () { test.expect(415, 'unsupported content encoding "nulls"', done) }) + it('should error with type = "encoding.unsupported"', function (done) { + var test = request(this.server).post('/') + test.set('Content-Encoding', 'nulls') + test.set('Content-Type', 'application/json') + test.set('X-Error-Property', 'type') + test.write(Buffer.from('000000000000', 'hex')) + test.expect(415, 'encoding.unsupported', done) + }) + it('should 400 on malformed encoding', function (done) { var test = request(this.server).post('/') test.set('Content-Encoding', 'gzip') diff --git a/test/urlencoded.js b/test/urlencoded.js index 07927837..b475cd3f 100644 --- a/test/urlencoded.js +++ b/test/urlencoded.js @@ -306,6 +306,15 @@ describe('bodyParser.urlencoded()', function () { .expect(413, /too many parameters/, done) }) + it('should error with type = "parameters.too.many"', function (done) { + request(createServer({ extended: false, parameterLimit: 10 })) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('X-Error-Property', 'type') + .send(createManyParams(11)) + .expect(413, 'parameters.too.many', done) + }) + it('should work when at the limit', function (done) { request(createServer({ extended: false, parameterLimit: 10 })) .post('/') @@ -361,6 +370,15 @@ describe('bodyParser.urlencoded()', function () { .expect(413, /too many parameters/, done) }) + it('should error with type = "parameters.too.many"', function (done) { + request(createServer({ extended: true, parameterLimit: 10 })) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('X-Error-Property', 'type') + .send(createManyParams(11)) + .expect(413, 'parameters.too.many', done) + }) + it('should work when at the limit', function (done) { request(createServer({ extended: true, parameterLimit: 10 })) .post('/') @@ -480,6 +498,19 @@ describe('bodyParser.urlencoded()', function () { .expect(403, 'no leading space', done) }) + it('should error with type = "entity.verify.failed"', function (done) { + var server = createServer({verify: function (req, res, buf) { + if (buf[0] === 0x20) throw new Error('no leading space') + }}) + + request(server) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('X-Error-Property', 'type') + .send(' user=tobi') + .expect(403, 'entity.verify.failed', done) + }) + it('should allow custom codes', function (done) { var server = createServer({verify: function (req, res, buf) { if (buf[0] !== 0x20) return @@ -495,6 +526,22 @@ describe('bodyParser.urlencoded()', function () { .expect(400, 'no leading space', done) }) + it('should allow custom type', function (done) { + var server = createServer({verify: function (req, res, buf) { + if (buf[0] !== 0x20) return + var err = new Error('no leading space') + err.type = 'foo.bar' + throw err + }}) + + request(server) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('X-Error-Property', 'type') + .send(' user=tobi') + .expect(403, 'foo.bar', done) + }) + it('should allow pass-through', function (done) { var server = createServer({verify: function (req, res, buf) { if (buf[0] === 0x5b) throw new Error('no arrays') @@ -632,8 +679,13 @@ function createServer (opts) { return http.createServer(function (req, res) { _bodyParser(req, res, function (err) { - res.statusCode = err ? (err.status || 500) : 200 - res.end(err ? err.message : JSON.stringify(req.body)) + if (err) { + res.statusCode = err.status || 500 + res.end(err[req.headers['x-error-property'] || 'message']) + } else { + res.statusCode = 200 + res.end(JSON.stringify(req.body)) + } }) }) }