diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4304a344..2f5499ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ name: ci - on: -- pull_request -- push + push: + branches: + - master + pull_request: jobs: test: @@ -32,6 +33,9 @@ jobs: - Node.js 17.x - Node.js 18.x - Node.js 19.x + - Node.js 20.x + - Node.js 21.x + - Node.js 22.x include: - name: Node.js 0.8 @@ -48,77 +52,86 @@ jobs: npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - name: io.js 1.x - node-version: "1.8" + node-version: "1" npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - name: io.js 2.x - node-version: "2.5" + node-version: "2" npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - name: io.js 3.x - node-version: "3.3" + node-version: "3" npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - name: Node.js 4.x - node-version: "4.9" + node-version: "4" npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 - name: Node.js 5.x - node-version: "5.12" + node-version: "5" npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 - name: Node.js 6.x - node-version: "6.17" + node-version: "6" npm-i: mocha@6.2.2 nyc@14.1.1 supertest@6.1.6 - name: Node.js 7.x - node-version: "7.10" + node-version: "7" npm-i: mocha@6.2.2 nyc@14.1.1 supertest@6.1.6 - name: Node.js 8.x - node-version: "8.17" - npm-i: mocha@7.2.0 + node-version: "8" + npm-i: mocha@7.2.0 nyc@14.1.1 - name: Node.js 9.x - node-version: "9.11" - npm-i: mocha@7.2.0 + node-version: "9" + npm-i: mocha@7.2.0 nyc@14.1.1 - name: Node.js 10.x - node-version: "10.24" + node-version: "10" npm-i: mocha@8.4.0 - name: Node.js 11.x - node-version: "11.15" + node-version: "11" npm-i: mocha@8.4.0 - name: Node.js 12.x - node-version: "12.22" + node-version: "12" npm-i: mocha@9.2.2 - name: Node.js 13.x - node-version: "13.14" + node-version: "13" npm-i: mocha@9.2.2 - name: Node.js 14.x - node-version: "14.21" + node-version: "14" - name: Node.js 15.x - node-version: "15.14" + node-version: "15" - name: Node.js 16.x - node-version: "16.19" + node-version: "16" - name: Node.js 17.x - node-version: "17.9" + node-version: "17" - name: Node.js 18.x - node-version: "18.14" + node-version: "18" - name: Node.js 19.x - node-version: "19.7" + node-version: "19" + + - name: Node.js 20.x + node-version: "20" + + - name: Node.js 21.x + node-version: "21" + + - name: Node.js 22.x + node-version: "22.4.1" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Node.js ${{ matrix.node-version }} shell: bash -eo pipefail -l {0} @@ -209,7 +222,7 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install lcov shell: bash diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..39372a22 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,69 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '16 21 * * 1' + push: + branches: [ "master" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + + steps: + - name: "Checkout code" + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@2f93e4319b2f04a2efc38fa7f78bd681bc3f7b2f # v2.23.2 + with: + sarif_file: results.sarif diff --git a/HISTORY.md b/HISTORY.md index b8924919..81d23e06 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,10 @@ +1.20.3 / 2024-09-10 +=================== + + * deps: qs@6.13.0 + * add `depth` option to customize the depth level in the parser + * IMPORTANT: The default `depth` level for parsing URL-encoded data is now `32` (previously was `Infinity`) + 1.20.2 / 2023-02-21 =================== diff --git a/README.md b/README.md index 38553bf7..f6661b7d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![NPM Downloads][npm-downloads-image]][npm-url] [![Build Status][ci-image]][ci-url] [![Test Coverage][coveralls-image]][coveralls-url] +[![OpenSSF Scorecard Badge][ossf-scorecard-badge]][ossf-scorecard-visualizer] Node.js body parsing middleware. @@ -277,6 +278,10 @@ The `verify` option, if supplied, is called as `verify(req, res, buf, encoding)` where `buf` is a `Buffer` of the raw request body and `encoding` is the encoding of the request. The parsing can be aborted by throwing an error. +#### depth + +The `depth` option is used to configure the maximum depth of the `qs` library when `extended` is `true`. This allows you to limit the amount of keys that are parsed and can be useful to prevent certain types of abuse. Defaults to `32`. It is recommended to keep this value as low as possible. + ## Errors The middlewares provided by this module create errors using the @@ -373,6 +378,10 @@ 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. +### The input exceeded the depth + +This error occurs when using `bodyParser.urlencoded` with the `extended` property set to `true` and the input exceeds the configured `depth` option. The `status` property is set to `400`. It is recommended to review the `depth` option and evaluate if it requires a higher value. When the `depth` option is set to `32` (default value), the error will not be thrown. + ## Examples ### Express/Connect top-level generic @@ -463,3 +472,5 @@ app.use(bodyParser.text({ type: 'text/html' })) [npm-downloads-image]: https://badgen.net/npm/dm/body-parser [npm-url]: https://npmjs.org/package/body-parser [npm-version-image]: https://badgen.net/npm/v/body-parser +[ossf-scorecard-badge]: https://api.scorecard.dev/projects/github.com/expressjs/body-parser/badge +[ossf-scorecard-visualizer]: https://ossf.github.io/scorecard-visualizer/#/projects/github.com/expressjs/body-parser \ No newline at end of file diff --git a/lib/types/urlencoded.js b/lib/types/urlencoded.js index b2ca8f16..2bd4485f 100644 --- a/lib/types/urlencoded.js +++ b/lib/types/urlencoded.js @@ -55,6 +55,9 @@ function urlencoded (options) { : opts.limit var type = opts.type || 'application/x-www-form-urlencoded' var verify = opts.verify || false + var depth = typeof opts.depth !== 'number' + ? Number(opts.depth || 32) + : opts.depth if (verify !== false && typeof verify !== 'function') { throw new TypeError('option verify must be function') @@ -118,7 +121,8 @@ function urlencoded (options) { encoding: charset, inflate: inflate, limit: limit, - verify: verify + verify: verify, + depth: depth }) } } @@ -133,12 +137,20 @@ function extendedparser (options) { var parameterLimit = options.parameterLimit !== undefined ? options.parameterLimit : 1000 + + var depth = typeof options.depth !== 'number' + ? Number(options.depth || 32) + : options.depth var parse = parser('qs') if (isNaN(parameterLimit) || parameterLimit < 1) { throw new TypeError('option parameterLimit must be a positive number') } + if (isNaN(depth) || depth < 0) { + throw new TypeError('option depth must be a zero or a positive number') + } + if (isFinite(parameterLimit)) { parameterLimit = parameterLimit | 0 } @@ -156,12 +168,23 @@ function extendedparser (options) { var arrayLimit = Math.max(100, paramCount) debug('parse extended urlencoding') - return parse(body, { - allowPrototypes: true, - arrayLimit: arrayLimit, - depth: Infinity, - parameterLimit: parameterLimit - }) + try { + return parse(body, { + allowPrototypes: true, + arrayLimit: arrayLimit, + depth: depth, + strictDepth: true, + parameterLimit: parameterLimit + }) + } catch (err) { + if (err instanceof RangeError) { + throw createError(400, 'The input exceeded the depth', { + type: 'querystring.parse.rangeError' + }) + } else { + throw err + } + } } } diff --git a/package.json b/package.json index 46373043..3c9926fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "body-parser", "description": "Node.js body parsing middleware", - "version": "1.20.2", + "version": "1.20.3", "contributors": [ "Douglas Christopher Wilson ", "Jonathan Ong (http://jongleberry.com)" @@ -17,7 +17,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" diff --git a/test/body-parser.js b/test/body-parser.js index d46ea772..2d764d3a 100644 --- a/test/body-parser.js +++ b/test/body-parser.js @@ -73,13 +73,25 @@ describe('bodyParser()', function () { }) }) + function getMajorVersion (versionString) { + return versionString.split('.')[0] + } + + function shouldSkipQuery (versionString) { + // Skipping HTTP QUERY tests on Node 21, it is reported in http.METHODS on 21.7.2 but not supported + // update this implementation to run on supported versions of 21 once they exist + // upstream tracking https://github.com/nodejs/node/issues/51562 + // express tracking issue: https://github.com/expressjs/express/issues/5615 + return getMajorVersion(versionString) === '21' + } + methods.slice().sort().forEach(function (method) { - if (method === 'connect') { - // except CONNECT - return - } + if (method === 'connect') return it('should support ' + method.toUpperCase() + ' requests', function (done) { + if (method === 'query' && shouldSkipQuery(process.versions.node)) { + this.skip() + } request(this.server)[method]('/') .set('Content-Type', 'application/json') .set('Content-Length', '15') diff --git a/test/urlencoded.js b/test/urlencoded.js index 10b8c4d4..970c1e12 100644 --- a/test/urlencoded.js +++ b/test/urlencoded.js @@ -195,7 +195,7 @@ describe('bodyParser.urlencoded()', function () { it('should parse deep object', function (done) { var str = 'foo' - for (var i = 0; i < 500; i++) { + for (var i = 0; i < 32; i++) { str += '[p]' } @@ -213,13 +213,85 @@ describe('bodyParser.urlencoded()', function () { var depth = 0 var ref = obj.foo while ((ref = ref.p)) { depth++ } - assert.strictEqual(depth, 500) + assert.strictEqual(depth, 32) }) .expect(200, done) }) }) }) + describe('with depth option', function () { + describe('when custom value set', function () { + it('should reject non possitive numbers', function () { + assert.throws(createServer.bind(null, { extended: true, depth: -1 }), + /TypeError: option depth must be a zero or a positive number/) + assert.throws(createServer.bind(null, { extended: true, depth: NaN }), + /TypeError: option depth must be a zero or a positive number/) + assert.throws(createServer.bind(null, { extended: true, depth: 'beep' }), + /TypeError: option depth must be a zero or a positive number/) + }) + + it('should parse up to the specified depth', function (done) { + this.server = createServer({ extended: true, depth: 10 }) + request(this.server) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('a[b][c][d]=value') + .expect(200, '{"a":{"b":{"c":{"d":"value"}}}}', done) + }) + + it('should not parse beyond the specified depth', function (done) { + this.server = createServer({ extended: true, depth: 1 }) + request(this.server) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('a[b][c][d][e]=value') + .expect(400, '[querystring.parse.rangeError] The input exceeded the depth', done) + }) + }) + + describe('when default value', function () { + before(function () { + this.server = createServer({ }) + }) + + it('should parse deeply nested objects', function (done) { + var deepObject = 'a' + for (var i = 0; i < 32; i++) { + deepObject += '[p]' + } + deepObject += '=value' + + request(this.server) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(deepObject) + .expect(function (res) { + var obj = JSON.parse(res.text) + var depth = 0 + var ref = obj.a + while ((ref = ref.p)) { depth++ } + assert.strictEqual(depth, 32) + }) + .expect(200, done) + }) + + it('should not parse beyond the specified depth', function (done) { + var deepObject = 'a' + for (var i = 0; i < 33; i++) { + deepObject += '[p]' + } + deepObject += '=value' + + request(this.server) + .post('/') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(deepObject) + .expect(400, '[querystring.parse.rangeError] The input exceeded the depth', done) + }) + }) + }) + describe('with inflate option', function () { describe('when false', function () { before(function () {