diff --git a/lib/plugins/bodyReader.js b/lib/plugins/bodyReader.js index eb6c10682..745c3669c 100644 --- a/lib/plugins/bodyReader.js +++ b/lib/plugins/bodyReader.js @@ -12,7 +12,7 @@ var errors = require('restify-errors'); ///--- Globals var BadDigestError = errors.BadDigestError; -var RequestEntityTooLargeError = errors.RequestEntityTooLargeError; +var InvalidContentError = errors.InvalidContentError; var PayloadTooLargeError = errors.PayloadTooLargeError; var UnsupportedMediaTypeError = errors.UnsupportedMediaTypeError; @@ -20,8 +20,14 @@ var MD5_MSG = "Content-MD5 '%s' didn't match '%s'"; ///--- Helpers -function createBodyWriter(req) { +function createPayloadTooLargeError(maxBodySize) { + var msg = 'Request body size exceeds ' + maxBodySize; + return new PayloadTooLargeError(msg); +} + +function createBodyWriter(req, maxBodySize) { var buffers = []; + var decodedBytes = 0; var contentType = req.contentType(); var isText = false; @@ -39,6 +45,12 @@ function createBodyWriter(req) { req.body = Buffer.alloc(0); return { write: function write(chunk) { + decodedBytes += chunk.length; + + if (maxBodySize && decodedBytes > maxBodySize) { + throw createPayloadTooLargeError(maxBodySize); + } + buffers.push(chunk); }, end: function end() { @@ -87,13 +99,15 @@ function bodyReader(options) { next(); return; } - var bodyWriter = createBodyWriter(req); + var bodyWriter = createBodyWriter(req, maxBodySize); + var bodyReadFailed = false; var bytesReceived = 0; var digest; var gz; var hash; var md5; + var onRequestEnd; var unsupportedContentEncoding; @@ -119,19 +133,7 @@ function bodyReader(options) { } if (maxBodySize && bytesReceived > maxBodySize) { - var msg = 'Request body size exceeds ' + maxBodySize; - var err; - - // Between Node 0.12 and 4 http status code messages changed - // RequestEntityTooLarge was changed to PayloadTooLarge - // this check is to maintain backwards compatibility - if (PayloadTooLargeError !== undefined) { - err = new PayloadTooLargeError(msg); - } else { - err = new RequestEntityTooLargeError(msg); - } - - next(err); + next(createPayloadTooLargeError(maxBodySize)); return; } @@ -148,14 +150,36 @@ function bodyReader(options) { next(); } + function failGzipRead(err) { + if (bodyReadFailed) { + return; + } + + bodyReadFailed = true; + req.removeListener('end', onRequestEnd); + gz.removeListener('end', done); + gz.destroy(); + next(err); + } + if (req.headers['content-encoding'] === undefined) { // This handles the original else branch req.once('end', done); } else if (req.headers['content-encoding'] === 'gzip') { gz = zlib.createGunzip(); - gz.on('data', bodyWriter.write); + onRequestEnd = gz.end.bind(gz); + gz.once('error', function onGzipError(err) { + failGzipRead(new InvalidContentError(err, 'Invalid gzip body')); + }); + gz.on('data', function onGzipData(chunk) { + try { + bodyWriter.write(chunk); + } catch (err) { + failGzipRead(err); + } + }); gz.once('end', done); - req.once('end', gz.end.bind(gz)); + req.once('end', onRequestEnd); } else { unsupportedContentEncoding = req.headers['content-encoding']; res.setHeader('Accept-Encoding', 'gzip'); @@ -163,10 +187,17 @@ function bodyReader(options) { } req.on('data', function onRequestData(chunk) { + if (bodyReadFailed) { + return; + } + if (maxBodySize) { bytesReceived += chunk.length; if (bytesReceived > maxBodySize) { + if (gz) { + failGzipRead(createPayloadTooLargeError(maxBodySize)); + } return; } } diff --git a/test/plugins/bodyReader.test.js b/test/plugins/bodyReader.test.js index cd20fb949..ef79c3433 100644 --- a/test/plugins/bodyReader.test.js +++ b/test/plugins/bodyReader.test.js @@ -2,7 +2,9 @@ /* eslint-disable func-names */ // core requires +var crypto = require('crypto'); var http = require('http'); +var zlib = require('zlib'); // external requires var assert = require('chai').assert; @@ -65,6 +67,120 @@ describe('body reader', function() { ); }); + it('should limit the decompressed body size', function(done) { + SERVER.use(restify.plugins.bodyParser({ maxBodySize: 1024 })); + + CLIENT = restifyClients.createJsonClient({ + url: 'http://127.0.0.1:' + PORT, + retry: false, + gzip: {} + }); + + SERVER.post('/compressed', function(req, res, next) { + assert.fail('route should not be called'); + res.send(); + next(); + }); + + CLIENT.post( + '/compressed', + { + value: new Array(2049).join('x') + }, + function(err, _, res) { + assert.isOk(err, 'should fail'); + assert.equal(res.statusCode, 413); + done(); + } + ); + }); + + it('should limit compressed gzip body size', function(done) { + SERVER.use(restify.plugins.bodyParser({ maxBodySize: 1024 })); + + CLIENT = restifyClients.createJsonClient({ + url: 'http://127.0.0.1:' + PORT, + retry: false + }); + + SERVER.post('/compressed', function(req, res, next) { + assert.fail('route should not be called'); + res.send(); + next(); + }); + + var compressedBody = zlib.gzipSync(crypto.randomBytes(2048)); + assert.isAbove(compressedBody.length, 1024); + + var request = http.request( + { + hostname: '127.0.0.1', + port: PORT, + path: '/compressed', + method: 'POST', + headers: { + 'content-encoding': 'gzip', + 'content-length': compressedBody.length, + 'content-type': 'text/plain' + } + }, + function(response) { + assert.equal(response.statusCode, 413); + response.resume(); + response.once('end', done); + } + ); + request.once('error', done); + request.end(compressedBody); + }); + + it('should reject malformed gzip and remain healthy', function(done) { + SERVER.use(restify.plugins.bodyParser({ maxBodySize: 1024 })); + + CLIENT = restifyClients.createJsonClient({ + url: 'http://127.0.0.1:' + PORT, + retry: false + }); + + SERVER.post('/compressed', function(req, res, next) { + assert.fail('route should not be called'); + res.send(); + next(); + }); + SERVER.get('/health', function(req, res, next) { + res.send({ healthy: true }); + next(); + }); + + var malformedBody = Buffer.from('not a gzip body'); + var request = http.request( + { + hostname: '127.0.0.1', + port: PORT, + path: '/compressed', + method: 'POST', + headers: { + 'content-encoding': 'gzip', + 'content-length': malformedBody.length, + 'content-type': 'text/plain' + } + }, + function(response) { + assert.equal(response.statusCode, 400); + response.resume(); + response.once('end', function() { + CLIENT.get('/health', function(err, _, healthResponse) { + assert.ifError(err); + assert.equal(healthResponse.statusCode, 200); + done(); + }); + }); + } + ); + request.once('error', done); + request.end(malformedBody); + }); + it('should not accept unsupported content encoding', function(done) { SERVER.use(restify.plugins.bodyParser());