Skip to content

Commit 4378a6e

Browse files
committed
[Fix] quote: validate object-token shapes
The per-character `.op` escape (`/(.)/g`) did not match line terminators (`\n`, `\r`, U+2028, U+2029), so a literal newline in `.op` passed through unescaped and acted as a shell command separator. Replace the regex with strict shape validation: - `{ op }`: `.op` must match the parser's control-operator allowlist - `{ op: 'glob', pattern }`: `.pattern` must be a string without line terminators; glob metas pass through, shell-special chars are escaped (Previously the field was discarded and the literal `\g\l\o\b` was emitted) - `{ comment }`: `.comment` must be a string without line terminators (Previously `quote` crashed on `{ comment }` tokens that `parse` emits) - Any other object shape: `TypeError`
1 parent 22ebec0 commit 4378a6e

3 files changed

Lines changed: 106 additions & 1 deletion

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ var parse = require('shell-quote/parse');
107107
Return a quoted string for the array `args` suitable for using in shell
108108
commands.
109109

110+
Each entry of `args` may be a string, or one of the object shapes that
111+
`parse` emits: `{ op }` (where `op` is one of the control operators
112+
`||`, `&&`, `;;`, `|&`, `<(`, `<<<`, `>>`, `>&`, `<&`, `&`, `;`, `(`,
113+
`)`, `|`, `<`, `>`), `{ op: 'glob', pattern }`, or `{ comment }`. Any
114+
other object shape, an unrecognized `op`, or a `pattern`/`comment`
115+
containing line terminators throws a `TypeError`.
116+
110117
## parse(cmd, env={})
111118

112119
Return an array of arguments from the quoted string `cmd`.

quote.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,54 @@
11
'use strict';
22

3+
var OPS = [
4+
'||',
5+
'&&',
6+
';;',
7+
'|&',
8+
'<(',
9+
'<<<',
10+
'>>',
11+
'>&',
12+
'<&',
13+
'&',
14+
';',
15+
'(',
16+
')',
17+
'|',
18+
'<',
19+
'>'
20+
];
21+
var LINE_TERMINATORS = /[\n\r\u2028\u2029]/;
22+
var GLOB_SHELL_SPECIAL = /[\s#!"$&'():;<=>@\\^`|]/g;
23+
324
module.exports = function quote(xs) {
425
return xs.map(function (s) {
526
if (s === '') {
627
return '\'\'';
728
}
829
if (s && typeof s === 'object') {
9-
return s.op.replace(/(.)/g, '\\$1');
30+
if (s.op === 'glob') {
31+
if (typeof s.pattern !== 'string') {
32+
throw new TypeError('glob token requires a string `pattern`');
33+
}
34+
if (LINE_TERMINATORS.test(s.pattern)) {
35+
throw new TypeError('glob `pattern` must not contain line terminators');
36+
}
37+
return s.pattern.replace(GLOB_SHELL_SPECIAL, '\\$&');
38+
}
39+
if (typeof s.op === 'string') {
40+
if (OPS.indexOf(s.op) < 0) {
41+
throw new TypeError('invalid `op` value: ' + JSON.stringify(s.op));
42+
}
43+
return s.op.replace(/[\s\S]/g, '\\$&');
44+
}
45+
if (typeof s.comment === 'string') {
46+
if (LINE_TERMINATORS.test(s.comment)) {
47+
throw new TypeError('`comment` must not contain line terminators');
48+
}
49+
return '#' + s.comment;
50+
}
51+
throw new TypeError('unrecognized object token shape');
1052
}
1153
if ((/["\s\\]/).test(s) && !(/'/).test(s)) {
1254
return "'" + s.replace(/(['])/g, '\\$1') + "'";

test/quote.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,59 @@ test('empty strings', function (t) {
5858

5959
t.end();
6060
});
61+
62+
test('quote ops: allowlist', function (t) {
63+
var ops = ['||', '&&', ';;', '|&', '<(', '<<<', '>>', '>&', '<&', '&', ';', '(', ')', '|', '<', '>'];
64+
for (var i = 0; i < ops.length; i++) {
65+
var op = ops[i];
66+
var expected = '';
67+
for (var j = 0; j < op.length; j++) { expected += '\\' + op.charAt(j); }
68+
t.equal(quote([{ op: op }]), expected, 'op ' + op);
69+
}
70+
t.end();
71+
});
72+
73+
test('quote ops: rejects line terminators (GHSA-w7jw-789q-3m8p)', function (t) {
74+
t['throws'](function () { quote([{ op: ';\nid' }]); }, TypeError, 'newline in op');
75+
t['throws'](function () { quote([{ op: ';\rid' }]); }, TypeError, 'carriage return in op');
76+
t['throws'](function () { quote([{ op: ';\u2028id' }]); }, TypeError, 'U+2028 in op');
77+
t['throws'](function () { quote([{ op: ';\u2029id' }]); }, TypeError, 'U+2029 in op');
78+
t.end();
79+
});
80+
81+
test('quote ops: rejects non-allowlisted values', function (t) {
82+
t['throws'](function () { quote([{ op: '' }]); }, TypeError, 'empty op');
83+
t['throws'](function () { quote([{ op: 'foo' }]); }, TypeError, 'arbitrary string');
84+
t['throws'](function () { quote([{ op: '|||' }]); }, TypeError, 'near-miss');
85+
t['throws'](function () { quote([{ op: 42 }]); }, TypeError, 'non-string op');
86+
t.end();
87+
});
88+
89+
test('quote glob pattern', function (t) {
90+
t.equal(quote([{ op: 'glob', pattern: 'test/*.test.js' }]), 'test/*.test.js');
91+
t.equal(quote([{ op: 'glob', pattern: '?ab' }]), '?ab');
92+
t.equal(quote([{ op: 'glob', pattern: '[ab]c' }]), '[ab]c');
93+
t.equal(quote([{ op: 'glob', pattern: '{a,b}' }]), '{a,b}');
94+
t.equal(quote([{ op: 'glob', pattern: 'my dir/*.txt' }]), 'my\\ dir/*.txt');
95+
t.equal(quote([{ op: 'glob', pattern: 'a$b' }]), 'a\\$b');
96+
t['throws'](function () { quote([{ op: 'glob' }]); }, TypeError, 'missing pattern');
97+
t['throws'](function () { quote([{ op: 'glob', pattern: 'a\nb' }]); }, TypeError, 'newline in pattern');
98+
t['throws'](function () { quote([{ op: 'glob', pattern: 'a\u2028b' }]); }, TypeError, 'U+2028 in pattern');
99+
t.end();
100+
});
101+
102+
test('quote comment', function (t) {
103+
t.equal(quote(['echo', 'hi', { comment: ' a comment' }]), 'echo hi # a comment');
104+
t.equal(quote([{ comment: '' }]), '#');
105+
t['throws'](function () { quote([{ comment: 'a\nb' }]); }, TypeError, 'newline in comment');
106+
t['throws'](function () { quote([{ comment: 'a\rb' }]); }, TypeError, 'CR in comment');
107+
t['throws'](function () { quote([{ comment: 'a\u2028b' }]); }, TypeError, 'U+2028 in comment');
108+
t.end();
109+
});
110+
111+
test('quote rejects unrecognized object shapes', function (t) {
112+
t['throws'](function () { quote([{}]); }, TypeError, 'empty object');
113+
t['throws'](function () { quote([{ foo: 'bar' }]); }, TypeError, 'unknown key');
114+
t['throws'](function () { quote([{ op: null }]); }, TypeError, 'null op');
115+
t.end();
116+
});

0 commit comments

Comments
 (0)