diff --git a/lib/messages.js b/lib/messages.js index 10b692e8..a785dcc9 100755 --- a/lib/messages.js +++ b/lib/messages.js @@ -36,11 +36,19 @@ exports.compile = function (messages, target) { if (code === 'root' || Template.isTemplate(message)) { + // A flat code named __proto__ triggers the legacy accessor on plain-object + // assignment, replacing target's own prototype instead of creating an own property + + assert(code !== '__proto__', 'Cannot use __proto__ as a message code'); + target[code] = message; continue; } if (typeof message === 'string') { + + assert(code !== '__proto__', 'Cannot use __proto__ as a message code'); + target[code] = new Template(message); continue; } @@ -59,6 +67,8 @@ exports.compile = function (messages, target) { for (const key of Object.keys(message)) { const localized = message[key]; + assert(key !== '__proto__', 'Cannot use __proto__ as a message code'); + if (key === 'root' || Template.isTemplate(localized)) { @@ -146,11 +156,18 @@ exports.merge = function (base, extended) { if (code === 'root' || Template.isTemplate(message)) { + // Same as in compile(), a flat code named __proto__ replaces target's own prototype + + assert(code !== '__proto__', 'Cannot use __proto__ as a message code'); + target[code] = message; continue; } if (typeof message === 'string') { + + assert(code !== '__proto__', 'Cannot use __proto__ as a message code'); + target[code] = new Template(message); continue; } @@ -169,6 +186,8 @@ exports.merge = function (base, extended) { for (const key of Object.keys(message)) { const localized = message[key]; + assert(key !== '__proto__', 'Cannot use __proto__ as a message code'); + if (key === 'root' || Template.isTemplate(localized)) { diff --git a/test/messages_proto_poc.js b/test/messages_proto_poc.js new file mode 100644 index 00000000..80ad3072 --- /dev/null +++ b/test/messages_proto_poc.js @@ -0,0 +1,44 @@ +'use strict'; + +const Code = require('@hapi/code'); +const Lab = require('@hapi/lab'); + +const Joi = require('..'); +const Messages = require('../lib/messages'); + +const { describe, it } = exports.lab = Lab.script(); +const expect = Code.expect; + +// JSON.parse produces a genuine own "__proto__" property (unlike the { __proto__: ... } +// object literal shorthand, which the parser special-cases as prototype-setting syntax +// and never creates an own key), matching how an attacker delivers this in practice +// (a JSON request body or config file fed into .messages()/.prefs()). +const attackerJson = (message) => JSON.parse(`{"__proto__": ${JSON.stringify(message)}}`); + + +describe('messages() proto guard', () => { + + it('does not let a top-level __proto__ code hijack the compiled messages object prototype', () => { + + const before = Messages.compile({ 'number.min': 'too small' }); + expect(Object.getPrototypeOf(before)).to.equal(Object.prototype); + + expect(() => Messages.compile(attackerJson('pwned'))).to.throw(); + }); + + it('rejects __proto__ as a language-scoped error code', () => { + + expect(() => Messages.compile({ english: attackerJson('pwned') })).to.throw(); + }); + + it('rejects __proto__ via merge()', () => { + + expect(() => Messages.merge({ 'number.min': 'too small' }, attackerJson('pwned'))).to.throw(); + }); + + it('rejects __proto__ through the public .messages()/.prefs() schema API', () => { + + expect(() => Joi.any().messages(attackerJson('pwned'))).to.throw(); + expect(() => Joi.any().prefs({ messages: attackerJson('pwned') })).to.throw(); + }); +});