Skip to content

feat: add secure JSON parsing with prototype poisoning handling #603

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ 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.

##### onProto

###### onProtoPoisoning

Defines what action must be taken when parsing a JSON object with `__proto__`

###### onConstructorPoisoning

Defines what action must be taken when parsing a JSON object with `constructor.prototype` key

### bodyParser.raw([options])

Returns middleware that parses all bodies as a `Buffer` and only looks at
Expand Down
4 changes: 3 additions & 1 deletion lib/types/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var isFinished = require('on-finished').isFinished
var read = require('../read')
var typeis = require('type-is')
var { getCharset, normalizeOptions } = require('../utils')
const secureJson = require('secure-json-parse')

/**
* Module exports.
Expand Down Expand Up @@ -55,6 +56,7 @@ function json (options) {

var reviver = options?.reviver
var strict = options?.strict !== false
const poisoningOptions = { onProtoPoisoning: options?.onProto?.onProtoPoisoning || 'ignore', onConstructorPoisoning: options?.onProto?.onConstructorPoisoning || 'ignore' }

function parse (body) {
if (body.length === 0) {
Expand All @@ -74,7 +76,7 @@ function json (options) {

try {
debug('parse json')
return JSON.parse(body, reviver)
return secureJson.parse(body, reviver, { protoAction: poisoningOptions.onProtoPoisoning, constructorAction: poisoningOptions.onConstructorPoisoning })
} catch (e) {
throw normalizeJsonSyntaxError(e, {
message: e.message,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"secure-json-parse": "^3.0.2",
Copy link
Member Author

@bjohansebas bjohansebas Apr 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a version 4, but they theoretically support from node 20 onwards, so to avoid any potential issues, we’re sticking with version 3, which supports Node 18+

"type-is": "^2.0.1"
},
"devDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions test/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,58 @@ describe('bodyParser.json()', function () {
test.expect(413, done)
})
})

describe('prototype poisoning', function () {
it('should parse __proto__ when protoAction is set to ignore', function (done) {
request(createServer({ onProto: { onProtoPoisoning: 'ignore' } }))
.post('/')
.set('Content-Type', 'application/json')
.send('{"user":"tobi","__proto__":{"x":7}}')
.expect(200, '{"user":"tobi","__proto__":{"x":7}}', done)
})

it('should throw when protoAction is set to error', function (done) {
request(createServer({ onProto: { onProtoPoisoning: 'error' } }))
.post('/')
.set('Content-Type', 'application/json')
.send('{"user":"tobi","__proto__":{"x":7}}')
.expect(400, '[entity.parse.failed] Object contains forbidden prototype property', done)
})

it('should remove prototype poisoning when protoAction is set to remove', function (done) {
request(createServer({ onProto: { onProtoPoisoning: 'remove' } }))
.post('/')
.set('Content-Type', 'application/json')
.send('{"user":"tobi","__proto__":{"x":7}}')
.expect(200, '{"user":"tobi"}', done)
})
})

describe('constructor poisoning', function () {
it('should parse constructor when protoAction is set to ignore', function (done) {
request(createServer({ onProto: { onConstructorPoisoning: 'ignore' } }))
.post('/')
.set('Content-Type', 'application/json')
.send('{"user":"tobi","constructor":{"prototype":{"bar":"baz"}}}')
.expect(200, '{"user":"tobi","constructor":{"prototype":{"bar":"baz"}}}', done)
})

it('should throw when protoAction is set to error', function (done) {
request(createServer({ onProto: { onConstructorPoisoning: 'error' } }))
.post('/')
.set('Content-Type', 'application/json')
.send('{"user":"tobi","constructor":{"prototype":{"bar":"baz"}}}')
.expect(400, '[entity.parse.failed] Object contains forbidden prototype property', done)
})

it('should remove prototype poisoning when protoAction is set to remove', function (done) {
request(createServer({ onProto: { onConstructorPoisoning: 'remove' } }))
.post('/')
.set('Content-Type', 'application/json')
.send('{"user":"tobi","constructor":{"prototype":{"bar":"baz"}}}')
.expect(200, '{"user":"tobi"}', done)
})
})
})

function createServer (opts) {
Expand Down