Skip to content

Commit cc47981

Browse files
authored
BREAKING CHANGE: limit syntax for bracketed lookup strings to fix vuln (#145)
This restricts the supported syntax for *bracketed* parts of lookup strings to avoid the need to *eval* that string. The eval is a security vulnerability that allows command injection. CVE-2020-7712 Fixes #144
1 parent 8d3cf25 commit cc47981

10 files changed

Lines changed: 160 additions & 38 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/node_modules
33
/test/stream/stream.log
44
/test/in-place/*.json
5+
/package-lock.json

CHANGES.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,51 @@
55
(nothing yet)
66

77

8+
## 10.0.0
9+
10+
- **Backward incompatible** and **security-related** change to parsing "lookup" strings.
11+
12+
This version restricts the supported syntax for bracketed ["lookup"
13+
strings](https://trentm.com/json/#FEATURE-Lookups) to fix a possible
14+
vulnerability (CVE-2020-7712). With a carefully crafted lookup string,
15+
command injection was possible. See
16+
[#144](https://github.com/trentm/json/issues/144) for a repro. If you use
17+
`json` (the CLI or as a node.js module) and run arbitrary user-provided
18+
strings as a "lookup", then you should upgrade.
19+
20+
For the `json` CLI, a "lookup" string is the 'foo' in:
21+
22+
echo ...some json... | json foo
23+
24+
which allows you to lookup fields on the given JSON, e.g.:
25+
26+
$ echo '{"foo": {"bar": "baz"}}' | json foo.bar
27+
baz
28+
29+
If one of the lookup fields isn't a valid JS identifier, then the JS array
30+
notation is supported:
31+
32+
$ echo '{"http://example.com": "my-value"}' | json '["http://example.com"]'
33+
my-value
34+
35+
Before this change, `json` would effectively *exec* the string between the
36+
brackets as JS code such that things like the following were possible:
37+
38+
$ echo '{"foo3": "bar"}' | json '["foo" + 3]'
39+
bar
40+
41+
This change limits supported bracket syntax in lookups to a simple quoted
42+
string:
43+
44+
["..."]
45+
['...']
46+
[`...`] # no variable interpolation
47+
48+
Otherwise generating an error of the form:
49+
50+
json: error: invalid bracketed lookup string: "[\"foo\" + 3]" (must be of the form ['...'], ["..."], or [`...`])
51+
52+
853
## 9.0.6
954

1055
- [issue #107] Fix man page installation with `npm install -g json`.

lib/json.js

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
#!/usr/bin/env node
22
/**
3-
* Copyright (c) 2014 Trent Mick. All rights reserved.
4-
* Copyright (c) 2014 Joyent Inc. All rights reserved.
3+
* Copyright 2020 Trent Mick.
4+
* Copyright 2020 Joyent Inc.
55
*
66
* json -- JSON love for your command line.
77
*
88
* See <https://github.com/trentm/json> and <https://trentm.com/json/>
99
*/
1010

11-
var VERSION = '9.0.6';
11+
var VERSION = '10.0.0';
1212

1313
var p = console.warn;
1414
var util = require('util');
@@ -757,13 +757,22 @@ function isInteger(s) {
757757
*
758758
* 'a.b.c' -> ["a","b","c"]
759759
* 'b["a"]' -> ["b","a"]
760-
* 'b["a" + "c"]' -> ["b","ac"]
760+
*
761+
* Note: v10 made a backward incompatible change here that limits the supported
762+
* *bracketed* lookups. A bracketed section of a lookup must be of one of the
763+
* following forms:
764+
* ["..."]
765+
* ['...']
766+
* [`...`]
767+
* The quoted string is not evaluated, other than supporting a subset of JS
768+
* string escapes (e.g. \', \", \n; but not unicode char escapes).
769+
* See the long block comment below in this function for details.
761770
*
762771
* Optionally receives an alternative lookup delimiter (other than '.')
763772
*/
764773
function parseLookup(lookup, lookupDelim) {
765774
var debug = function () {};
766-
//var debug = console.warn;
775+
// var debug = console.warn;
767776

768777
var bits = [];
769778
debug('\n*** ' + lookup + ' ***');
@@ -775,15 +784,35 @@ function parseLookup(lookup, lookupDelim) {
775784
var escaped = false;
776785
var ch = null;
777786
for (var i = 0; i < lookup.length; ++i) {
778-
var escaped = (!escaped && ch === '\\');
779787
var ch = lookup[i];
780788
debug('-- i=' + i + ', ch=' + JSON.stringify(ch) + ' escaped=' +
781789
JSON.stringify(escaped));
782790
debug('states: ' + JSON.stringify(states));
783791

784-
if (escaped) {
785-
bit += ch;
786-
continue;
792+
// Handle a *limited subset* of JS string escapes.
793+
// JSSTYLED
794+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#Escape_notation
795+
var SUPPORTED_ESCAPES = {
796+
'\'': '\'',
797+
'\"': '\"',
798+
'\`': '\`',
799+
'\\': '\\',
800+
'n': '\n',
801+
'r': '\r',
802+
't': '\t',
803+
'v': '\v',
804+
'b': '\b',
805+
'f': '\f'
806+
};
807+
if (ch === '\\' && i+1 < lookup.length) {
808+
var nextCh = lookup[i+1];
809+
var escapedCh = SUPPORTED_ESCAPES[nextCh];
810+
if (escapedCh !== undefined) {
811+
debug('escaped: %j -> %j', ch+nextCh, escapedCh);
812+
bit += escapedCh;
813+
i++;
814+
continue;
815+
}
787816
}
788817

789818
switch (states[states.length - 1]) {
@@ -825,9 +854,47 @@ function parseLookup(lookup, lookupDelim) {
825854
case ']':
826855
states.pop();
827856
if (states[states.length - 1] === null) {
828-
var evaled = vm.runInNewContext(
829-
'(' + bit.slice(1, -1) + ')', {}, '<lookup>');
830-
bits.push(evaled);
857+
// `bit` is a bracketed string, `[...]`.
858+
//
859+
// The *intent* is to allow specifying an object key
860+
// that would otherwise get interpreted by `json`s
861+
// LOOKUP parsing -- typically if the key has a `.` in it.
862+
//
863+
// Up to and including json v9, this was handled by eval'ing
864+
// the given string inside the brackets (via
865+
// `vm.runInNewContext`). However, trentm/json#144 shows
866+
// that this is an avenue for command injection. It was
867+
// never made clear in `json` documentation that one
868+
// should never use user-provided strings for LOOKUPs, so
869+
// we should close this vulnerability.
870+
//
871+
// Expected usage and documented examples are like this:
872+
// ["foo.bar"]
873+
// ['foo.bar']
874+
// However, older implementation of eval'ing meant that
875+
// things like the following worked:
876+
// [42]
877+
// ["my" + "key"]
878+
// [(function () { return "mykey" })()]
879+
//
880+
// The documentation was never explicit about denying
881+
// expressions would work. v10 **breaks compatibility**
882+
// to only support a bracketed string:
883+
// ["..."]
884+
// ['...']
885+
// [`...`] # note: no var interpolation is done
886+
// and error otherwise.
887+
var VALID_QUOTES = '"\'`';
888+
var sQuote = bit[1];
889+
var eQuote = bit.slice(-2, -1);
890+
if (VALID_QUOTES.indexOf(sQuote) === -1 ||
891+
sQuote !== eQuote)
892+
{
893+
throw new Error(format('invalid bracketed lookup ' +
894+
'string: %j (must be of the form [\'...\'], ' +
895+
'["..."], or [`...`])', bit));
896+
}
897+
bits.push(bit.slice(2, -2));
831898
bit = ''
832899
}
833900
break;
@@ -1297,9 +1364,14 @@ function main(argv) {
12971364
}
12981365
var exe = Boolean(exeFuncs.length + exeScripts.length);
12991366

1300-
var lookups = lookupStrs.map(function (lookup) {
1301-
return parseLookup(lookup, opts.lookupDelim);
1302-
});
1367+
try {
1368+
var lookups = lookupStrs.map(function (lookup) {
1369+
return parseLookup(lookup, opts.lookupDelim);
1370+
});
1371+
} catch (e) {
1372+
warn('json: error: %s', e.message)
1373+
return drainStdoutAndExit(1);
1374+
}
13031375

13041376
if (opts.group && opts.array && opts.outputMode !== OM_JSON) {
13051377
// streaming

package.json

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
{
22
"name": "json",
33
"description": "a 'json' command for massaging and processing JSON on the command line",
4-
"version": "9.0.6",
4+
"version": "10.0.0",
55
"repository": {
66
"type": "git",
77
"url": "git://github.com/trentm/json.git"
88
},
99
"author": "Trent Mick <[email protected]> (http://trentm.com)",
1010
"main": "./lib/json.js",
11-
"man": ["./man/man1/json.1"],
12-
"bin": { "json": "./lib/json.js" },
11+
"man": [
12+
"./man/man1/json.1"
13+
],
14+
"bin": {
15+
"json": "./lib/json.js"
16+
},
1317
"scripts": {
1418
"test": "make test"
1519
},
1620
"engines": {
1721
"node": ">=0.10.0"
1822
},
19-
"keywords": ["json", "jsontool", "filter", "command", "shell"],
23+
"keywords": [
24+
"json",
25+
"jsontool",
26+
"filter",
27+
"command",
28+
"shell"
29+
],
2030
"devDependencies": {
2131
"uglify-js": "1.1.x",
2232
"nodeunit": "0.8.x",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Test the json v10 change to limit the allowe bracketed lookup syntax.
2+
JSON=../../lib/json.js
3+
4+
echo '{"foo42":"bar"}' | $JSON foo42
5+
6+
echo '{"foo42":"bar"}' | $JSON '["foo42"]'
7+
8+
# This one used to work in json <v10. No longer.
9+
echo '{"foo42":"bar"}' | $JSON '["foo" + 42]'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
json: error: invalid bracketed lookup string: "[\"foo\" + 42]" (must be of the form ['...'], ["..."], or [`...`])
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
bar
2+
bar

test/json7/cmd

Lines changed: 0 additions & 9 deletions
This file was deleted.

test/json7/expected.stdout

Lines changed: 0 additions & 3 deletions
This file was deleted.

test/test.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,15 @@ var data = {
3030

3131
parseLookup: function (test) {
3232
var parseLookup = require('../lib/json.js').parseLookup;
33-
test.deepEqual(parseLookup('42'), [42]);
3433
test.deepEqual(parseLookup('a'), ['a']);
3534
test.deepEqual(parseLookup('a.b'), ['a', 'b']);
3635
test.deepEqual(parseLookup('a.b.c'), ['a', 'b', 'c']);
3736

38-
test.deepEqual(parseLookup('[42]'), [42]);
39-
test.deepEqual(parseLookup('["a"]'), ['a']);
4037
test.deepEqual(parseLookup('["a"]'), ['a']);
4138

42-
test.deepEqual(parseLookup('b[42]'), ['b', 42]);
4339
test.deepEqual(parseLookup('b["a"]'), ['b', 'a']);
4440
test.deepEqual(parseLookup('b["a"]'), ['b', 'a']);
4541

46-
test.deepEqual(parseLookup('[42].b'), [42, 'b']);
47-
test.deepEqual(parseLookup('["a"].b'), ['a', 'b']);
4842
test.deepEqual(parseLookup('["a"].b'), ['a', 'b']);
4943

5044
test.deepEqual(parseLookup('["a-b"]'), ['a-b']);
@@ -63,7 +57,7 @@ var data = {
6357

6458
test.deepEqual(parseLookup('a/b', '/'), ['a', 'b']);
6559
test.deepEqual(parseLookup('a.b/c', '/'), ['a.b', 'c']);
66-
test.deepEqual(parseLookup('a.b/c[42]', '/'), ['a.b', 'c', 42]);
60+
test.deepEqual(parseLookup('a.b/c["d"]', '/'), ['a.b', 'c', 'd']);
6761
test.deepEqual(parseLookup('["a/b"]', '/'), ['a/b']);
6862

6963
test.done();

0 commit comments

Comments
 (0)