Skip to content
Merged
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
19 changes: 19 additions & 0 deletions lib/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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)) {

Expand Down Expand Up @@ -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;
}
Expand All @@ -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)) {

Expand Down
44 changes: 44 additions & 0 deletions test/messages_proto_poc.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading