Skip to content

Commit

Permalink
Fuzz the escape function, add escaping for interpolation characters (
Browse files Browse the repository at this point in the history
…#152)

* Fuzz `escape` function on unix

Update the fuzz target to test both the `quote()` and `escape()`
functions (instead of just `quote()`). Fuzzing the `escape()` function
is roughly identical to fuzzing the `quote()` function, except that the
provided buffer is stripped of whitespace before echoing to ensure all
characters in the string are part of the first argument.

* Refactor index.fuzz.cjs to reduce duplication and fix Windows args

- Define `WHITESPACE_REGEX` to avoid rewriting the regular expression to
  (really) capture all whitespace.
- Update the error message for unexpected output so it's different for
  the two tested functions.
- Define the `options` for `quote()` and `escape()` prior to the resp.
  fuzz function as the value is the same for both.
-  Fix `prepareArg` for Windows cmd.exe when quoted=false

* Update unix escaping based on fuzzing

Update escaping for unix platforms based on the existing fuzz corpus.
The unix escape logic now expects a boolean value indicating whether
interpolation is enabled and if so will escape more characters. This is
needed when the `escape()` API functions is used because the result may
be used in a context where interpolation is enabled. Hence, main assumes
interpolation is enabled unless specified otherwise.

* Escape ")" for unix with interpolation

This escape relates to the escaping of "(" in that shells (tested Zsh) on
unix with interpolation interpret parenthesis in a special way.

* Escape "<" and ">" for unix with interpolation

These characters are used for redirecting input/output streams so should
be escaped.

* Escape "#" at the start of args for unix with interpolation

The "#" character has a special meaning at the start of an argument.

* Escape "|" in args for unix with interpolation

The "|" character means a logical or.

* Escape ";" in args for unix with interpolation

The ";" character denotes the end of a singular command, after which a
new command starts.

* Escape "&" in args for unix with interpolation

The "&" character means a logical and.

* Escape "*" and "?" in args for unix with interpolation

The characters  "*" and "?" can be used for string expansion if
there are no quotes.

In Zsh these characters are always expanded (if unsuccessful the
command will error). On bash and dash these characters will only
be expanded if possible (otherwise they will appear literally). As
the context in which Shescape operates is unknown, for both shell
styles the characters always need to be escaped.

* Escape leading "~" in args for unix with interpolation

The "~" character has a special meaning (the home directory) either:
1. as the only character in the argument (bash, dash)
2. at the start of an argument (Zsh)
Both cases can be efficiently escaped by prefix "~" with a backslash,
this works regardless of whether or not the argument is more than one
string in bash and dash.

* Escape "[" and "]" for Zsh with interpolation

Specifically in Zsh, "[" and "]" can be used for string expansion if
there are no quotes. So, the err on the safe side, they will always be
escaped when `interpolation` is true.

* Escape leading "=" for Zsh with interpolation

The "=" character has a special meaning at the start of an argument in
Zsh.

* Escape "{" and "}" for Zsh with interpolation

Specifically in Zsh, "{" and "}" can be used for string expansion if
there are no quotes. So, the err on the safe side, they will always be
escaped when `interpolation` is true.

* Fuzz `escape` function on PowerShell

Run the fuzz target with the existing corpus on PowerShell and fix all
problems that are uncovered.

* Escape single quote variants in PowerShell on Windows

Escape the following characters for PowerShell on Windows because it
will interpret these as regular single quotes.

- U+2018 (Left Single Quotation Mark)
- U+2019 (Right Single Quotation Mark)
- U+201B (Single High-Reversed-9 Quotation Mark)
- U+201A (Single Low-9 Quotation Mark)

* Escape "<" and ">" for Windows PowerShell with interpolation

These characters have special meaning when they appear at the beginning
of an argument. In the case of ">", it also has this meaning when it is
prefixed by "1", "2", "3", "4", "5", "6", or "*".

* Escape "@" for Windows PowerShell with interpolation

This character has special meaning when it appears at the beginning of
an argument.

* Escape "]" for Windows PowerShell with interpolation

This character has a special meaning when it appears at the beginning of
an argument.

* Escape "," for Windows PowerShell with interpolation

This character is used to separate commands on PowerShell.

* Escape leading "-" in PowerShell on Windows

If "-" is the first character in an argument in PowerShell on Windows it
has a special meaning, so it's always escaped.

* Escape leading ":" in PowerShell on Windows

If ":" is the first character in an argument in PowerShell on Windows it
has a special meaning, so it's always escaped if it is the first
character.

* Fuzz `escape`  function on cmd.exe

Run the fuzz target with the existing corpus on cmd.exe and fix all
problems that are uncovered.

* Escape "^" for Windows cmd.exe with interpolation

This character is used for escaping, so it must be escaped itself. As it
is used for escaping, it should be escaped first to prevent escaping the
"^" instances inserted to escape other characters.

* Update unit test suites for changes to unix.js and win.js

* Update unit test suites for changes to main.js

* Update TypeScript type definitions

* Update documentation for `escape` and `escapeAll`

* Update CHANGELOG
  • Loading branch information
ericcornelissen committed Feb 8, 2022
1 parent c0976d3 commit fcba4ee
Show file tree
Hide file tree
Showing 38 changed files with 3,312 additions and 219 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Versioning].

## [Unreleased]

- Add escaping for Unix interpolation characters to `escape`/`escapeAll`.
- Add escaping for Zsh wildcard characters to `escape`/`escapeAll`.
- Update TypeScript type definitions.
- Update type information in the documentation.

Expand Down
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ _dangerous_ characters.
Calling `escape()` directly is not recommended unless you know what you're
doing.

The `options.interpolation` value should be set to `true` if using this function
with the `exec` function, or when using `fork`, `spawn`, `execFile`, or similar,
and setting `{ shell: true }` in the call options. If in doubt, set it to `true`
explicitly.

#### Example

```js
Expand All @@ -212,11 +217,12 @@ console.log(safeArg);

#### Input-output

| Input | Type | Required | Description |
| --------------- | ------------------- | -------- | ---------------------------- |
| `arg` | `string` | Yes | The argument to escape. |
| `options` | `Object` | No | The escape options. |
| `options.shell` | `string`, `boolean` | No | The shell that will be used. |
| Input | Type | Required | Description |
| ----------------------- | ------------------- | -------- | ---------------------------- |
| `arg` | `string` | Yes | The argument to escape. |
| `options` | `Object` | No | The escape options. |
| `options.interpolation` | `boolean` | No | Is interpolation enabled. |
| `options.shell` | `string`, `boolean` | No | The shell that will be used. |

| Output | Type | Description |
| --------- | -------- | --------------------- |
Expand All @@ -231,6 +237,10 @@ console.log(safeArg);
The `escapeAll` function takes as input an array of values, the arguments, and
escapes any _dangerous_ characters in every argument.

The `options.interpolation` value should be set to `true` if using this function
with `fork`, `spawn`, `execFile`, or similar, and setting `{ shell: true }` in
the call options. If in doubt, set it to `true` explicitly.

#### Example

```js
Expand All @@ -244,11 +254,12 @@ console.log(safeArgs);

#### Input-output

| Input | Type | Required | Description |
| --------------- | ------------------- | -------- | ---------------------------- |
| `args` | `string[]` | Yes | The arguments to escape. |
| `options` | `Object` | No | The escape options. |
| `options.shell` | `string`, `boolean` | No | The shell that will be used. |
| Input | Type | Required | Description |
| ----------------------- | ------------------- | -------- | ---------------------------- |
| `args` | `string[]` | Yes | The arguments to escape. |
| `options` | `Object` | No | The escape options. |
| `options.interpolation` | `boolean` | No | Is interpolation enabled. |
| `options.shell` | `string`, `boolean` | No | The shell that will be used. |

| Output | Type | Description |
| ---------- | ---------- | ---------------------- |
Expand Down
15 changes: 10 additions & 5 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
* @author Eric Cornelissen <ericornelissen@gmail.com>
*/

interface Options {
interface EscapeOptions {
readonly interpolation?: boolean;
readonly shell?: boolean | string;
}

export function escape(arg: string, options?: Options): string;
interface QuoteOptions {
readonly shell?: boolean | string;
}

export function escape(arg: string, options?: EscapeOptions): string;

export function escapeAll(arg: string[], options?: Options): string[];
export function escapeAll(arg: string[], options?: EscapeOptions): string[];

export function quote(arg: string, options?: Options): string;
export function quote(arg: string, options?: QuoteOptions): string;

export function quoteAll(arg: string[], options?: Options): string[];
export function quoteAll(arg: string[], options?: QuoteOptions): string[];
22 changes: 18 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,23 @@ import * as main from "./src/main.js";
*
* @param {string} arg The argument to escape.
* @param {Object} [options] The escape options.
* @param {string} [options.interpolation=false] Is interpolation enabled.
* @param {string} [options.shell] The shell to escape the argument for.
* @returns {string} The escaped argument.
* @throws {TypeError} The argument is not stringable.
* @since 0.1.0
*/
export function escape(arg, options = {}) {
const shell = options.shell;
const { interpolation, shell } = options;
const env = process.env;
const platform = os.platform();
return main.escapeShellArgByPlatform(arg, platform, env, shell);
return main.escapeShellArgByPlatform(
arg,
platform,
env,
shell,
interpolation
);
}

/**
Expand All @@ -45,6 +52,7 @@ export function escape(arg, options = {}) {
*
* @param {string[]} args The arguments to escape.
* @param {Object} [options] The escape options.
* @param {string} [options.interpolation=false] Is interpolation enabled.
* @param {string} [options.shell] The shell to escape the arguments for.
* @returns {string[]} The escaped arguments.
* @throws {TypeError} One of the arguments is not stringable.
Expand All @@ -53,12 +61,18 @@ export function escape(arg, options = {}) {
export function escapeAll(args, options = {}) {
if (!Array.isArray(args)) args = [args];

const shell = options.shell;
const { interpolation, shell } = options;
const env = process.env;
const platform = os.platform();
const result = [];
for (const arg of args) {
const safeArg = main.escapeShellArgByPlatform(arg, platform, env, shell);
const safeArg = main.escapeShellArgByPlatform(
arg,
platform,
env,
shell,
interpolation
);
result.push(safeArg);
}

Expand Down
15 changes: 11 additions & 4 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,17 @@ function getShell(platform, env, shell) {
* @param {string} platform The platform to escape the argument for.
* @param {Object} env The environment variables.
* @param {string} [shell] The shell to escape the argument for, if any.
* @param {boolean} [interpolation=false] Is interpolation enabled.
* @returns {string} The escaped argument.
* @throws {TypeError} The argument is not stringable.
*/
export function escapeShellArgByPlatform(arg, platform, env, shell) {
export function escapeShellArgByPlatform(
arg,
platform,
env,
shell,
interpolation = false
) {
if (!isStringable(arg)) {
throw new TypeError(typeError);
}
Expand All @@ -65,9 +72,9 @@ export function escapeShellArgByPlatform(arg, platform, env, shell) {
const argAsString = arg.toString();
switch (platform) {
case win32:
return win.escapeShellArg(argAsString, shell);
return win.escapeShellArg(argAsString, shell, interpolation);
default:
return unix.escapeShellArg(argAsString, shell);
return unix.escapeShellArg(argAsString, shell, interpolation);
}
}

Expand All @@ -85,7 +92,7 @@ export function escapeShellArgByPlatform(arg, platform, env, shell) {
* @throws {TypeError} The argument is not stringable.
*/
export function quoteShellArgByPlatform(arg, platform, env, shell) {
const safeArg = escapeShellArgByPlatform(arg, platform, env, shell);
const safeArg = escapeShellArgByPlatform(arg, platform, env, shell, false);
switch (platform) {
case win32:
return `"${safeArg}"`;
Expand Down
45 changes: 43 additions & 2 deletions src/unix.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,58 @@

import { shellRequiredError } from "./constants.js";

/**
* Escape a shell argument when string interpolation is *disabled* (e.g. when
* the argument is surrounded by single quotes in bash-family shells).
*
* @param {string} arg The argument to escape.
* @returns {string} The escaped argument.
*/
function escapeShellArgNoInterpolation(arg) {
return arg.replace(/\u{0}/gu, "").replace(/'/g, `'\\''`);
}

/**
* Escape a shell argument when string interpolation is *enabled* (e.g. when
* the argument is surrounded by double quotes in bash-family shells).
*
* @param {string} arg The argument to escape.
* @param {string} shell The shell to escape the argument for.
* @returns {string} The escaped argument.
*/
function escapeShellArgWithInterpolation(arg, shell) {
let result = arg
.replace(/\u{0}/gu, "")
.replace(/\\/g, "\\\\")
.replace(/^(~|#)/g, "\\$1")
.replace(/(\*|\?)/gu, "\\$1")
.replace(/(\$|\;|\&|\|)/g, "\\$1")
.replace(/(\(|\)|\<|\>)/g, "\\$1")
.replace(/("|'|`)/g, "\\$1");

if (shell.endsWith("zsh")) {
result = result.replace(/^=/gu, "\\=").replace(/(\[|\]|\{|\})/g, "\\$1");
}

return result;
}

/**
* Escape a shell argument.
*
* @param {string} arg The argument to escape.
* @param {string} shell The shell to escape the argument for.
* @param {boolean} interpolation Is interpolation enabled.
* @returns {string} The escaped argument.
*/
export function escapeShellArg(arg, shell) {
export function escapeShellArg(arg, shell, interpolation) {
if (shell === undefined) throw new TypeError(shellRequiredError);

return arg.replace(/\u{0}/gu, "").replace(/'/g, `'\\''`);
if (interpolation) {
return escapeShellArgWithInterpolation(arg, shell);
} else {
return escapeShellArgNoInterpolation(arg);
}
}

/**
Expand Down
44 changes: 36 additions & 8 deletions src/win.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,68 @@ import { regexpPowerShell, shellRequiredError } from "./constants.js";
* Escape a shell argument for use in CMD.
*
* @param {string} arg The argument to escape.
* @param {boolean} interpolation Is interpolation enabled.
* @returns {string} The escaped argument.
*/
function escapeShellArgsForCmd(arg) {
return arg.replace(/\u{0}/gu, "").replace(/"/g, `""`);
function escapeShellArgsForCmd(arg, interpolation) {
let result = arg.replace(/\u{0}/gu, "");

if (interpolation) {
result = result
.replace(/\^/g, "^^")
.replace(/(<|>)/g, "^$1")
.replace(/(")/g, "^$1")
.replace(/(\&|\|)/g, "^$1");
} else {
result = result.replace(/"/g, `""`);
}

return result;
}

/**
* Escape a shell argument for use in PowerShell.
*
* @param {string} arg The argument to escape.
* @param {boolean} interpolation Is interpolation enabled.
* @returns {string} The escaped argument.
*/
function escapeShellArgsForPowerShell(arg) {
return arg
function escapeShellArgsForPowerShell(arg, interpolation) {
let result = arg
.replace(/\u{0}/gu, "")
.replace(/("|“|”|„)/g, `$1$1`)
.replace(/`/g, "``")
.replace(/\$/g, "`$");

if (interpolation) {
result = result
.replace(/^((?:\*|[1-6])?)(>)/g, "$1`$2")
.replace(/^(<|@|#|-|\:|\])/g, "`$1")
.replace(/(,|\;|\&|\|)/g, "`$1")
.replace(/(\(|\)|\{|\})/g, "`$1")
.replace(/('|’|‘|‛|‚)/g, "`$1")
.replace(/("|“|”|„)/g, "`$1");
} else {
result = result.replace(/("|“|”|„)/g, "$1$1");
}

return result;
}

/**
* Escape a shell argument.
*
* @param {string} arg The argument to escape.
* @param {string} shell The shell to escape the argument for.
* @param {boolean} interpolation Is interpolation enabled.
* @returns {string} The escaped argument.
*/
export function escapeShellArg(arg, shell) {
export function escapeShellArg(arg, shell, interpolation) {
if (shell === undefined) throw new TypeError(shellRequiredError);

if (regexpPowerShell.test(shell)) {
return escapeShellArgsForPowerShell(arg);
return escapeShellArgsForPowerShell(arg, interpolation);
} else {
return escapeShellArgsForCmd(arg);
return escapeShellArgsForCmd(arg, interpolation);
}
}

Expand Down
3 changes: 2 additions & 1 deletion test/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export const unixPlatform = "linux";

export const binSh = "/bin/sh";
export const binBash = "/bin/bash";
export const unixShells = [undefined, binSh, binBash];
export const binZsh = "/bin/zsh";
export const unixShells = [undefined, binSh, binBash, binZsh];

export const unixEnv = {};

Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
L:�\(;\
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
��$L:�>
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
�G��Q�L:�\(?}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:X|]5
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[9�\
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
~
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@L:^�I�\(?\
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#��(�"�3Ww��
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!*(?\ ��\
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package.j?on
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*>W�3w�3w��
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
p*
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
]5
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
L:�\(?\
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@L:�\(?\

0 comments on commit fcba4ee

Please sign in to comment.